diff --git a/config.py b/config.py index 6c96a4f..9e6cea2 100644 --- a/config.py +++ b/config.py @@ -16,6 +16,8 @@ import json import os from dataclasses import dataclass, field from pathlib import Path + +from .infrastructure.config.json_config_repository import JsonConfigRepository from typing import Any, Dict, List CONFIG_DIR = Path.home() / ".cowork_local" @@ -343,281 +345,33 @@ def _migrate_connectors(data: Dict[str, Any]) -> None: data["mcp_servers"] = [] # migrated — the UI no longer manages this -@dataclass -class AppConfig: - """In-memory view of the configuration with load/save helpers.""" +class AppConfig(JsonConfigRepository): + """Vỏ tương thích — R02 đã thay lớp này bằng :class:`JsonConfigRepository`. - data: Dict[str, Any] = field(default_factory=lambda: copy.deepcopy(DEFAULT_CONFIG)) - path: Path = CONFIG_PATH + Ngày 25/08 app chuyển hẳn sang repository (ghi nguyên tử, khoá nằm trong + kho bí mật của hệ điều hành). Nhưng cái tên ``AppConfig`` còn nằm ở 41 file + — 23 checker trong ``tools/`` và 18 file test, trong đó có test của cả ba + người. Sửa hết 41 chỗ trong một commit là đổi thứ không cần đổi và làm + review không đọc nổi. + + Nên giữ tên, đổi ruột: mọi lối vào đều dẫn tới repository. + + Bỏ hẳn được khi ``tools/`` và ``tests/`` chuyển sang gọi + ``presentation.shell.bootstrap.build_context()``. + """ + + def __init__(self, data=None, path: Path = CONFIG_PATH, **kw): + if data is None: + super().__init__(Path(path), **kw) + return + # Dạng AppConfig(data=..., path=...) mà 13 file test đang dùng: dựng + # thẳng từ dict, không đụng đĩa. + built = JsonConfigRepository.from_data(data, Path(path)) + self.__dict__.update(built.__dict__) - # ---- persistence ------------------------------------------------- @classmethod - def load(cls, path: Path = CONFIG_PATH) -> "AppConfig": - merged = copy.deepcopy(DEFAULT_CONFIG) - if path.exists(): - try: - stored = json.loads(path.read_text(encoding="utf-8")) - merged = _deep_merge(merged, stored) - except (json.JSONDecodeError, OSError): - # Corrupt config should never block startup. - merged = copy.deepcopy(DEFAULT_CONFIG) - merged = _apply_env_overrides(merged) - # "unlocked" is a runtime-only Settings-panel state (see the "ms365" - # comment in DEFAULT_CONFIG) — never trust a stored/hand-edited value, - # every launch starts locked. - merged.setdefault("ms365", {})["unlocked"] = False - _migrate_connectors(merged) # office→ms365 + legacy mcp_servers→other - return cls(data=merged, path=path) - - def save(self) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - to_write = self.data - if self.data.get("ms365", {}).get("unlocked"): - # Defense in depth: even if some caller saves without having gone - # through the Settings dialog's own auto-lock-after-save flow, the - # unlock state must never reach disk. - to_write = copy.deepcopy(self.data) - to_write["ms365"]["unlocked"] = False - self.path.write_text( - json.dumps(to_write, indent=2, ensure_ascii=False), encoding="utf-8" - ) - - # ---- convenience accessors -------------------------------------- - @property - def active_provider(self) -> str: - # Migrate configs that still point at a removed provider (e.g. an older - # install saved "ollama") to a supported one, so the app never tries to - # build an unknown provider. - val = self.data.get("active_provider", "openai_compat") - return val if val in PROVIDER_LABELS else "openai_compat" - - @active_provider.setter - def active_provider(self, value: str) -> None: - self.data["active_provider"] = value - - def provider_conf(self, name: str | None = None) -> Dict[str, Any]: - name = name or self.active_provider - return self.data["providers"].get(name, {}) - - @property - def ca_bundle(self) -> str: - """Path to a custom CA/certificate PEM file, or '' for normal validation. - - Used as ``requests``' ``verify=`` argument for every outbound HTTPS call - — see the "tls_ca_bundle" comment above for when this is needed.""" - return (self.data.get("tls_ca_bundle") or "").strip() - - @ca_bundle.setter - def ca_bundle(self, value: str) -> None: - self.data["tls_ca_bundle"] = (value or "").strip() - - # ---- Microsoft 365 connections (Settings-panel lock, see DEFAULT_CONFIG) -- - @property - def ms365(self) -> Dict[str, Any]: - return self.data.setdefault("ms365", copy.deepcopy(DEFAULT_CONFIG["ms365"])) - - # ---- Login / RBAC / shared cross-machine store (see DEFAULT_CONFIG) ------ - @property - def auth(self) -> Dict[str, Any]: - return self.data.setdefault("auth", copy.deepcopy(DEFAULT_CONFIG["auth"])) - - @property - def shared_dir(self) -> str: - return (self.auth.get("shared_dir") or "").strip() - - def ms365_try_unlock(self, code: str) -> bool: - """Unlock the MS365 Settings group for this session if ``code`` matches. - - This is a client-side UI lock (prevents casually toggling a sensitive - section), NOT Microsoft authentication — see the DEFAULT_CONFIG - comment. Never persisted as unlocked; see ``save()``.""" - if (code or "") and code == self.ms365.get("unlock_code", ""): - self.data["ms365"]["unlocked"] = True - return True - return False - - def ms365_lock(self) -> None: - self.data.setdefault("ms365", {})["unlocked"] = False - - @property - def theme(self) -> str: - return self.data.get("theme", "dark") - - @theme.setter - def theme(self, value: str) -> None: - self.data["theme"] = value - - @property - def language(self) -> str: - from .i18n import DEFAULT_LANGUAGE, LANGUAGES - val = self.data.get("language", DEFAULT_LANGUAGE) - return val if val in LANGUAGES else DEFAULT_LANGUAGE - - @language.setter - def language(self, value: str) -> None: - self.data["language"] = value - - @property - def code(self) -> Dict[str, Any]: - return self.data["code"] - - @property - def tools_disabled(self) -> list: - """Built-in agent tool names the admin has turned off (Monitoring → Tools).""" - return self.data.setdefault("tools", {}).setdefault("disabled", []) - - def set_tool_enabled(self, name: str, enabled: bool) -> None: - """Enable/disable a built-in agent tool by name and persist it.""" - disabled = set(self.tools_disabled) - if enabled: - disabled.discard(name) - else: - disabled.add(name) - self.data.setdefault("tools", {})["disabled"] = sorted(disabled) - self.save() - - @property - def connect_external(self) -> bool: - """Master switch (Monitoring → Tools → Connector): when off, the agent - connects to NO external connectors (CAD/CAE/MS365/Other MCP + REST). - Defaults ON so existing setups keep working.""" - return bool(self.data.setdefault("tools", {}).get("connect_external", True)) - - def set_connect_external(self, enabled: bool) -> None: - self.data.setdefault("tools", {})["connect_external"] = bool(enabled) - self.save() - - # ---- one-time seeding bookkeeping (built-in skill library / flows) ------- - @property - def seeded_library_skills(self) -> List[str]: - """Slugs of bundled library skills already seeded into the user's Skill - Manager — so a user-deleted one is never silently re-seeded.""" - return list(self.data.setdefault("seeded_library_skills", [])) - - @seeded_library_skills.setter - def seeded_library_skills(self, slugs) -> None: - self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or [])) - - @property - def seeded_builtin_flows(self) -> List[str]: - """Ids of built-in Co4E flows already seeded (same respect-user-deletion - rule as seeded_library_skills).""" - return list(self.data.setdefault("seeded_builtin_flows", [])) - - @seeded_builtin_flows.setter - def seeded_builtin_flows(self, ids) -> None: - self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or [])) - - @property - def teams(self) -> Dict[str, Any]: - return self.data["teams"] - - @property - def history(self) -> Dict[str, Any]: - return self.data["history"] - - @property - def codebase_memory(self) -> Dict[str, Any]: - return self.data["codebase_memory"] - - @property - def agent_security(self) -> Dict[str, Any]: - return self.data["agent_security"] - - @property - def mcp_servers(self) -> List[Dict[str, Any]]: - return self.data.setdefault("mcp_servers", []) - - @property - def ext_connectors(self) -> Dict[str, List[Dict[str, Any]]]: - """Unified Connectors (MCP), grouped by category CAD/CAE/MS365/Other — - see core/ext_connectors.py for the per-entry shape and CATEGORIES.""" - d = self.data.setdefault("ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []}) - for cat in ("cad", "cae", "ms365", "other"): - d.setdefault(cat, []) - return d - - @property - def cowork(self) -> Dict[str, Any]: - return self.data["cowork"] - - @property - def routing(self) -> Dict[str, Any]: - """Auto Model Assessment & Routing behaviour config (see DEFAULT_CONFIG). - - Always returns a dict with every expected key present, backfilling any - missing sub-keys from the defaults so older configs upgrade seamlessly.""" - d = self.data.setdefault("routing", copy.deepcopy(DEFAULT_CONFIG["routing"])) - for k, v in DEFAULT_CONFIG["routing"].items(): - d.setdefault(k, copy.deepcopy(v)) - d.setdefault("surface_modes", {}) - for surface in ("cowork", "co4e", "ai_edit"): - d["surface_modes"].setdefault(surface, "") - return d - - # The routing modes a surface may be in. "fallback" joined the set in - # R03-T03 (keep the selected model; re-route only when it cannot serve the - # turn) — see application/model_routing/routing_models.py::RoutingMode, - # which is the authority on what each mode means. - ROUTING_MODES = ("off", "auto", "manual", "fallback") - - def routing_mode_for(self, surface: str) -> str: - """Effective Off/Auto/Manual/Fallback mode for a chat surface. - - A per-surface override wins; an empty override falls back to the global - ``switch_mode``. Anything unrecognised degrades to "off" so routing - stays opt-in even with a hand-edited config.""" - routing = self.routing - override = (routing.get("surface_modes", {}) or {}).get(surface, "") - mode = override or routing.get("switch_mode", "off") - return mode if mode in self.ROUTING_MODES else "off" - - def set_routing_mode_for(self, surface: str, mode: str) -> None: - """Persist a chat surface's routing toggle selection.""" - mode = mode if mode in self.ROUTING_MODES else "off" - self.routing.setdefault("surface_modes", {})[surface] = mode - self.save() - - @property - def structure(self) -> Dict[str, Any]: - return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400}) - - @property - def monitoring_visibility(self) -> Dict[str, bool]: - return self.data.setdefault( - "monitoring_visibility", copy.deepcopy(DEFAULT_CONFIG["monitoring_visibility"])) - - def cowork_output_dir(self) -> Path: - """Where Cowork saves generated files (OneDrive folder by default).""" - custom = (self.cowork.get("output_dir") or "").strip() - if custom: - return Path(custom).expanduser() - from . import paths # local import avoids any import cycle - root = paths.primary_onedrive_root() - if root is not None: - return root / "CoworkLocal" / "output" - return CONFIG_DIR / "output" / "cowork" - - def history_dir(self) -> Path: - """Resolve where conversation history is stored. - - When a project is open, its history is stored INSIDE the project's - workspace folder (``_project_history_dir``, set by the Workspace screen) - so that sharing/syncing that folder shares the history — another machine - opening the same folder sees the conversations and can continue them. - Otherwise: Local (default) or OneDrive.""" - rt = getattr(self, "_project_history_dir", None) - if rt: - return Path(rt) - custom = (self.history.get("custom_dir") or "").strip() - if custom: - return Path(custom).expanduser() - if self.history.get("location") == "onedrive": - from . import paths # local import avoids any import cycle - root = paths.primary_onedrive_root() - if root is not None: - return root / "CoworkLocal" / "history" - return HISTORY_DIR - - def model_label(self) -> str: - return str(self.provider_conf().get("model", "?")) + def load(cls, path: Path = CONFIG_PATH) -> "JsonConfigRepository": + """Điểm vào cũ. Giờ đi qua Composition Root nên checker và app dùng + chung một đường dựng — kể cả phần ráp kho bí mật.""" + from .presentation.shell.bootstrap import build_config + return build_config(Path(path)) diff --git a/infrastructure/config/json_config_repository.py b/infrastructure/config/json_config_repository.py index cbb2094..bd9b60b 100644 --- a/infrastructure/config/json_config_repository.py +++ b/infrastructure/config/json_config_repository.py @@ -47,6 +47,23 @@ class JsonConfigRepository: self._env_overrides = env_overrides self.data: Dict[str, Any] = self._load() + @classmethod + def from_data(cls, data: Dict[str, Any], path: Path): + """Dựng từ dict có sẵn — KHÔNG đọc đĩa, KHÔNG nâng cấp schema. + + Dành cho test: chúng dựng cấu hình trong bộ nhớ rồi mới ghi. Đi qua + ``__init__`` thường thì nó đọc file (chưa có) và có thể chạy migration + trên dữ liệu test, tức là test đo nhầm thứ khác. + """ + obj = cls.__new__(cls) + obj._file = AtomicJsonFile(Path(path)) + obj._secrets = None + from ... import config as legacy + obj._defaults = legacy.DEFAULT_CONFIG + obj._env_overrides = legacy._apply_env_overrides + obj.data = data + return obj + # ---- nạp ------------------------------------------------------------ def _load(self) -> Dict[str, Any]: merged = copy.deepcopy(self._defaults) @@ -74,6 +91,14 @@ class JsonConfigRepository: def active_provider(self) -> str: return self.data.get("active_provider", "") + @active_provider.setter + def active_provider(self, name: str) -> None: + """``AppConfig`` cũ cho gán thẳng, và 3 chỗ trong app.py đang gán. Bỏ + setter đi thì Qt nuốt AttributeError trong slot và triệu chứng là + "bấm không ăn", không có lỗi nào hiện ra — mất hẳn một buổi mới truy + ra. Refactor thì hành vi nhìn từ ngoài phải y hệt.""" + self.data["active_provider"] = name + def set_active_provider(self, name: str) -> None: self.data["active_provider"] = name @@ -135,6 +160,10 @@ class JsonConfigRepository: def theme(self) -> str: return self.data.get("theme", "dark") + @theme.setter + def theme(self, value: str) -> None: + self.data["theme"] = value + def set_theme(self, value: str) -> None: self.data["theme"] = value @@ -142,6 +171,10 @@ class JsonConfigRepository: def language(self) -> str: return self.data.get("language", "vi") + @language.setter + def language(self, value: str) -> None: + self.data["language"] = value + def set_language(self, value: str) -> None: self.data["language"] = value diff --git a/presentation/monitoring/monitoring_tab.py b/presentation/monitoring/monitoring_tab.py index 4c773ee..fc20357 100644 --- a/presentation/monitoring/monitoring_tab.py +++ b/presentation/monitoring/monitoring_tab.py @@ -35,6 +35,54 @@ _UNBOUNDED_PAGE_SIZE = 100_000 class MonitoringTab(QWidget): status_message = Signal(str) + # ---- cầu tương thích sau khi tách 8 tab (R08-T08) -------------------- + # Trước khi tách, 55 control của Tổng quan treo thẳng trên MonitoringTab với + # tiền tố ov_. Tách xong mỗi cái về đúng tab/thẻ của nó và rụng tiền tố. + # + # tools/check_controls_alive.py so với mốc git 291a611 và đòi cả 55 cái phải + # còn tới được — đó chính là việc của nó: bắt control biến mất trong lúc bóc + # tách. Lần này control còn đủ, chỉ đổi chỗ ở, nên bắc cầu theo tiền tố. + # + # Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card + # có những tên trùng nhau (network_lbl nằm ở cả hai — một cái là mức dùng + # mạng, một cái là quyền truy cập mạng). Dò mờ vớ nhầm cái đầu tiên tìm thấy. + _OV_TIEN_TO = ( + ("ov_perm_", lambda s: s.overview_tab.sandbox_card.permissions_card), + ("ov_sbx_", lambda s: s.overview_tab.sandbox_card), + ("ov_price_", lambda s: s.overview_tab.pricing_panel), + ("ov_pricing_", lambda s: s.overview_tab.pricing_panel), + ("ov_", lambda s: s.overview_tab), + ) + #: Ba hộp nhóm: bản thân widget con CHÍNH LÀ hộp đó, không phải thuộc tính. + _OV_CHINH_NO = { + "ov_pricing_group": lambda s: s.overview_tab.pricing_panel, + "ov_sandbox_details_group": lambda s: s.overview_tab.sandbox_card, + "ov_permissions_group": lambda s: s.overview_tab.sandbox_card.permissions_card, + } + + #: Vài control không mang tiền tố ov_ nhưng cũng đã dời đi. + _KHAC = { + "status_table": lambda s: s.status_tab.table, + } + + def __getattr__(self, name): + lay_khac = self._KHAC.get(name) + if lay_khac is not None: + return lay_khac(self) + # Qt gọi __getattr__ rất nhiều lúc khởi tạo; chặn sớm cho rẻ. + if not name.startswith("ov_"): + raise AttributeError(name) + lay = self._OV_CHINH_NO.get(name) + if lay is not None: + return lay(self) + for tien_to, chu in self._OV_TIEN_TO: + if name.startswith(tien_to): + try: + return getattr(chu(self), name[len(tien_to):]) + except AttributeError: + continue + raise AttributeError(name) + def __init__(self, ctx: AppContext, cowork=None, structure=None, task_scheduler=None): super().__init__() self.ctx = ctx diff --git a/tests/test_config_gan_duoc.py b/tests/test_config_gan_duoc.py new file mode 100644 index 0000000..a71f2d5 --- /dev/null +++ b/tests/test_config_gan_duoc.py @@ -0,0 +1,97 @@ +"""Mọi chỗ trong repo gán ``config.X = ...`` thì repository phải nhận được. + +Bài này sinh ra từ một lỗi thật ngày 26/08. + +R02 tráo ``config.py::AppConfig`` bằng ``JsonConfigRepository``. Trước khi +tráo tôi có đối chiếu API: đếm đủ 34/34 thành viên công khai, không thiếu cái +nào, nên kết luận là thay được. Đối chiếu đó **chỉ so tên**, không so việc một +``property`` có setter hay không. + +``AppConfig`` cũ là dataclass, ``config.language = "vi"`` chạy bình thường. +Repository để ``language`` là property chỉ đọc, gán vào là ``AttributeError``. +Ba chỗ trong ``app.py`` đang gán như thế: đổi ngôn ngữ, đổi giao diện, đổi +provider trên thanh bên. + +Điều làm nó khó thấy: cả ba đều nằm trong slot của Qt, mà Qt **nuốt ngoại lệ +trong slot**. Không có traceback, không có thông báo — người dùng bấm đổi ngôn +ngữ thì không có gì xảy ra. Bộ test đơn vị vẫn 709 xanh; chỉ ``check_nav`` bắt +được vì nó bấm thật vào combo rồi kiểm ngôn ngữ có đổi không. + +Nên bài này đi ngược từ mã nguồn: tìm mọi chỗ gán, rồi thử gán thật. +""" +from __future__ import annotations + +import copy +import re +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent + +#: ``config.X = ...`` nhưng không phải ``==``. +GAN = re.compile(r"\bconfig\.([a-z_][a-z_0-9]*)\s*=(?!=)") + +#: Không phải thuộc tính cấu hình — là chỗ chứa chính đối tượng config. +BO_QUA = {"data", "config"} + + +def _cho_gan() -> set[str]: + out = subprocess.run(["git", "ls-files", "*.py"], cwd=REPO, + capture_output=True, text=True, encoding="utf-8", + errors="replace").stdout.split() + ten: set[str] = set() + for f in out: + p = REPO / f + if not p.is_file(): + continue + for m in GAN.finditer(p.read_text(encoding="utf-8", errors="replace")): + if m.group(1) not in BO_QUA and not m.group(1).startswith("_"): + ten.add(m.group(1)) + return ten + + +def _repo(tmp_path): + from cowork_local.config import DEFAULT_CONFIG + from cowork_local.infrastructure.config.json_config_repository import ( + JsonConfigRepository, + ) + return JsonConfigRepository.from_data(copy.deepcopy(DEFAULT_CONFIG), + tmp_path / "config.json") + + +def test_tim_duoc_cho_gan(): + """Bảo vệ chính bài test: biểu thức tìm kiếm hỏng thì nó lặng lẽ xanh.""" + ten = _cho_gan() + assert ten, "không tìm thấy chỗ nào gán config.X — kiểm lại GAN" + assert "language" in ten, f"phải thấy config.language (thấy: {sorted(ten)})" + + +def test_moi_thuoc_tinh_bi_gan_deu_gan_duoc(tmp_path): + cfg = _repo(tmp_path) + hong = [] + for ten in sorted(_cho_gan()): + if not hasattr(type(cfg), ten) and not hasattr(cfg, ten): + continue # thuộc tính của lớp khác, không phải config + cu = getattr(cfg, ten, None) + try: + setattr(cfg, ten, cu) + except AttributeError: + hong.append(ten) + + assert not hong, ( + "Repository không nhận gán, nhưng trong mã nguồn có chỗ gán:\n " + + "\n ".join("config.%s = ..." % t for t in hong) + + "\nQt nuốt AttributeError trong slot, nên chỗ đó sẽ im lặng không " + "làm gì. Thêm @.setter vào JsonConfigRepository." + ) + + +@pytest.mark.parametrize("ten,gia_tri", [("language", "ja"), ("theme", "light"), + ("active_provider", "ollama")]) +def test_ba_cho_app_py_dang_gan(tmp_path, ten, gia_tri): + """Chốt riêng ba cái app.py gán, để bài trên có hỏng thì vẫn còn lưới.""" + cfg = _repo(tmp_path) + setattr(cfg, ten, gia_tri) + assert getattr(cfg, ten) == gia_tri