## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""Infrastructure layer - adapters to the outside world.
|
||||
|
||||
Concrete implementations of what the inner layers only describe: HTTP calls to
|
||||
model gateways, the OS keyring, the filesystem, subprocesses, telemetry sinks.
|
||||
May import ``domain/`` (to speak its types) and third-party libraries, but never
|
||||
``presentation/``/``ui/``.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure config package: ConfigRepository and typed settings facades."""
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Cấu hình ứng dụng — interface, chưa phải cài đặt.
|
||||
|
||||
Hợp đồng số 2 của mục chung. Đây là thứ gỡ chốt lớn nhất: **156 lời gọi
|
||||
``ctx.config.*`` nằm rải trong 29 file**, nên nếu N2 và N3 phải đợi
|
||||
``ConfigRepository`` bản thật (R02-T02, hạn 23/08) thì hai người mất mấy ngày
|
||||
đầu ngồi không.
|
||||
|
||||
Danh sách thuộc tính dưới đây không bịa ra: đếm trực tiếp chỗ đang gọi trong
|
||||
``core/``, ``ui/``, ``providers/`` và ``app.py`` rồi lấy những cái được dùng
|
||||
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
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConfigRepository(Protocol):
|
||||
"""Đọc/ghi cấu hình. Cài đặt thật dùng ``AtomicJsonFile`` (R02-T01/T02)."""
|
||||
|
||||
# ---- provider ------------------------------------------------------
|
||||
@property
|
||||
def active_provider(self) -> str:
|
||||
"""Tên provider đang chọn (24 lời gọi)."""
|
||||
...
|
||||
|
||||
def set_active_provider(self, name: str) -> None:
|
||||
"""Đổi provider đang dùng."""
|
||||
...
|
||||
|
||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||
"""Cấu hình của một provider (9 lời gọi).
|
||||
|
||||
CHÚ Ý — điểm còn bỏ ngỏ, xem ``docs/refactor/GammaTeam_decisions.md``:
|
||||
dict này còn chứa ``api_key`` hay không là quyết định chưa chốt. Có 5
|
||||
nơi đang đọc trực tiếp, 3 trong số đó thuộc ``providers/`` của Team Duy.
|
||||
"""
|
||||
...
|
||||
|
||||
# ---- đường dẫn -----------------------------------------------------
|
||||
@property
|
||||
def shared_dir(self) -> str:
|
||||
"""Thư mục dùng chung cho telemetry nhiều máy (10 lời gọi)."""
|
||||
...
|
||||
|
||||
def history_dir(self) -> Path:
|
||||
"""Thư mục lịch sử chat của project đang chọn (7 lời gọi)."""
|
||||
...
|
||||
|
||||
def cowork_output_dir(self) -> Path:
|
||||
"""Thư mục Cowork ghi kết quả ra (6 lời gọi)."""
|
||||
...
|
||||
|
||||
# ---- giao diện -----------------------------------------------------
|
||||
@property
|
||||
def theme(self) -> str:
|
||||
"""``"dark"`` | ``"light"`` | ``"system"`` (8 lời gọi)."""
|
||||
...
|
||||
|
||||
def set_theme(self, value: str) -> None:
|
||||
"""Đổi giao diện sáng/tối."""
|
||||
...
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
"""``"vi"`` | ``"en"`` | ``"ja"`` (4 lời gọi)."""
|
||||
...
|
||||
|
||||
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 -------------------------------------
|
||||
@property
|
||||
def routing(self) -> Dict[str, Any]:
|
||||
"""Cấu hình định tuyến model (7 lời gọi)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def auth(self) -> Dict[str, Any]:
|
||||
"""Cấu hình đăng nhập (6 lời gọi)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def agent_security(self) -> Dict[str, Any]:
|
||||
"""Chính sách an toàn cho agent (5 lời gọi)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def tools_disabled(self) -> list[str]:
|
||||
"""Tool bị tắt (2 lời gọi)."""
|
||||
...
|
||||
|
||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||
"""Bật/tắt một tool theo tên."""
|
||||
...
|
||||
|
||||
# ---- ghi ------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
"""Ghi xuống đĩa. Bản thật ghi atomic — tạm + fsync + thay thế —
|
||||
nên tắt máy giữa chừng không làm hỏng file (R02-T01).
|
||||
"""
|
||||
...
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,359 @@
|
||||
"""ConfigRepository chạy trên file JSON — R02-T02.
|
||||
|
||||
Thay cho ``config.py::AppConfig``. Hai khác biệt duy nhất về hành vi, cả hai
|
||||
đều là thứ ta muốn:
|
||||
|
||||
1. Ghi qua :class:`AtomicJsonFile` — mất điện giữa lúc lưu không còn làm hỏng
|
||||
cấu hình (R02-T01).
|
||||
2. API key đọc từ :class:`SecretStore` rồi **ghép vào** dict do
|
||||
``provider_conf()`` trả về — đúng đường A đã chốt 21/08
|
||||
(``docs/refactor/GammaTeam_decisions.md``). Nhờ vậy 5 nơi đang đọc
|
||||
``conf["api_key"]`` không phải sửa dòng nào, trong đó 3 nơi thuộc Team Duy.
|
||||
|
||||
Mọi thứ còn lại giữ nguyên có chủ đích: trộn sâu với mặc định, đọc biến môi
|
||||
trường, ``ms365.unlocked`` không bao giờ chạm đĩa. Đây là refactor — hành vi
|
||||
nhìn từ ngoài phải y hệt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
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(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.
|
||||
if defaults is None or env_overrides is None:
|
||||
from ... import config as legacy
|
||||
defaults = defaults if defaults is not None else legacy.DEFAULT_CONFIG
|
||||
env_overrides = env_overrides or legacy._apply_env_overrides
|
||||
self._defaults = defaults
|
||||
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]:
|
||||
"""Đọ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):
|
||||
# Nâng cấp TRƯỚC khi trộn với mặc định: bước v1→v2 gỡ api_key khỏi
|
||||
# đĩa, mà mặc định thì không có khoá nào để gỡ.
|
||||
stored, changed = migrate(stored, secrets=self._secrets,
|
||||
path=self._file.path)
|
||||
merged = _deep_merge(merged, stored)
|
||||
if changed:
|
||||
self.data = merged
|
||||
self.save() # ghi ngay, để lần sau khỏi chuyển lại
|
||||
merged = self._env_overrides(merged)
|
||||
# Trạng thái mở khoá ms365 chỉ tồn tại lúc chạy — mỗi lần mở app đều
|
||||
# bắt đầu ở trạng thái khoá, không tin giá trị đọc từ đĩa.
|
||||
merged.setdefault("ms365", {})["unlocked"] = False
|
||||
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
|
||||
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:
|
||||
"""Đổ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]:
|
||||
"""Cấu hình provider, có sẵn ``api_key``.
|
||||
|
||||
Trả về BẢN SAO: chỗ gọi sửa dict này thì không được âm thầm ghi ngược
|
||||
vào cấu hình — và quan trọng hơn, khoá vừa ghép vào không được lẫn
|
||||
ngược vào ``self.data`` rồi theo ``save()`` xuống đĩa.
|
||||
"""
|
||||
name = name or self.active_provider
|
||||
conf = dict(self.data.get("providers", {}).get(name, {}))
|
||||
if self._secrets is not None:
|
||||
stored = self._secrets.get(provider_key(name))
|
||||
if stored:
|
||||
conf["api_key"] = stored
|
||||
return conf
|
||||
|
||||
def set_api_key(self, name: str, value: str) -> None:
|
||||
"""Lưu khoá vào kho bí mật, và xoá khỏi cấu hình trên đĩa.
|
||||
|
||||
Đây là nửa còn lại của đường A: dict *đọc ra* vẫn có ``api_key``,
|
||||
nhưng file JSON *trên đĩa* thì không — điều kiện để qua CASAN Check 1.
|
||||
"""
|
||||
if self._secrets is not None:
|
||||
self._secrets.set(provider_key(name), value)
|
||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = ""
|
||||
else:
|
||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = value
|
||||
|
||||
# ---- đườ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)
|
||||
custom = (self.data.get("history", {}).get("custom_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
from ...config import CONFIG_DIR
|
||||
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()
|
||||
from ... import paths
|
||||
from ...config import CONFIG_DIR
|
||||
root = paths.primary_onedrive_root()
|
||||
if root is not None:
|
||||
return root / "CoworkLocal" / "output"
|
||||
return CONFIG_DIR / "output" / "cowork"
|
||||
|
||||
# ---- 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]
|
||||
elif name not in disabled:
|
||||
disabled.append(name)
|
||||
self.data["tools_disabled"] = disabled
|
||||
|
||||
|
||||
# ---- phần bù để thay được AppConfig ----------------------------------
|
||||
# 21 thành viên dưới đây chép nguyên ngữ nghĩa từ ``config.py::AppConfig``.
|
||||
# Không phải thiết kế mới: chừng nào 29 file còn gọi qua ``ctx.config`` thì
|
||||
# repository phải trả lời được đúng những câu hỏi cũ, nếu không thì không
|
||||
# tráo được. Dọn lại là việc của các R sau, không phải của R02.
|
||||
|
||||
#: Các chế độ định tuyến. Delta thêm "fallback" ở R03-T03. Định nghĩa ở đây
|
||||
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
|
||||
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
|
||||
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
|
||||
"""Dựng repository từ đường dẫn mặc định — thay ``AppConfig.load()``."""
|
||||
if path is None:
|
||||
from ... import config as legacy
|
||||
path = legacy.CONFIG_PATH
|
||||
return cls(Path(path), secrets=secrets)
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Đường dẫn file config.json đang dùng."""
|
||||
return self._file.path
|
||||
|
||||
# ---- TLS -------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def ca_bundle(self) -> str:
|
||||
"""Đường dẫn file PEM riêng, hoặc '' để kiểm chứng chỉ như bình thường.
|
||||
|
||||
Dùng làm tham số ``verify=`` của ``requests`` cho mọi lượt gọi HTTPS."""
|
||||
return (self.data.get("tls_ca_bundle") or "").strip()
|
||||
|
||||
@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:
|
||||
"""Mở khoá nhóm MS365 trong Cài đặt cho phiên này.
|
||||
|
||||
Đây là khoá phía giao diện (chặn bấm nhầm vào một mục nhạy cảm), KHÔNG
|
||||
phải xác thực Microsoft. Không bao giờ được lưu ở trạng thái đã mở."""
|
||||
if (code or "") and code == self.ms365.get("unlock_code", ""):
|
||||
self.data["ms365"]["unlocked"] = True
|
||||
return True
|
||||
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 tuyến theo từng bề mặt chat --------------------------------
|
||||
|
||||
def routing_mode_for(self, surface: str) -> str:
|
||||
"""Chế độ có hiệu lực cho một bề mặt chat.
|
||||
|
||||
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
|
||||
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi
|
||||
có người sửa tay file cấu hình."""
|
||||
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:
|
||||
"""Đặ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()
|
||||
|
||||
# ---- 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 -------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
|
||||
to_write = self.data
|
||||
if self.data.get("ms365", {}).get("unlocked"):
|
||||
to_write = copy.deepcopy(self.data)
|
||||
to_write["ms365"]["unlocked"] = False
|
||||
to_write.pop("_project_history_dir", None)
|
||||
to_write["schema_version"] = CURRENT_VERSION
|
||||
self._file.write(to_write)
|
||||
|
||||
|
||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Trộn sâu — giống hệt ``config.py::_deep_merge``.
|
||||
|
||||
Không import lại từ đó vì file này phải sống được sau khi ``config.py``
|
||||
biến mất; giữ bản sao 6 dòng còn hơn giữ một sợi dây phụ thuộc.
|
||||
"""
|
||||
out = copy.deepcopy(base)
|
||||
for key, value in (override or {}).items():
|
||||
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
||||
out[key] = _deep_merge(out[key], value)
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Đánh số phiên bản và chuyển đổi cấu hình — R02-T06.
|
||||
|
||||
Hôm nay ``config.json`` không có số phiên bản. Nghĩa là không có cách nào biết
|
||||
file trên đĩa thuộc thời nào, và mọi thay đổi hình dạng phải xử lý bằng cách
|
||||
đoán — ``config.py::_migrate_connectors()`` chính là một ví dụ: nó đoán "có
|
||||
khoá ``office`` nghĩa là file cũ".
|
||||
|
||||
Ở đây đặt luật rõ:
|
||||
|
||||
* File có ``schema_version``. Thiếu ⇒ coi là **1** (mọi file đang tồn tại).
|
||||
* Mỗi bước nâng cấp là một hàm ``v1 -> v2``, chạy tuần tự, không nhảy cóc.
|
||||
* **Sao lưu trước khi nâng cấp.** Người dùng lùi về bản app cũ thì bản cũ đọc
|
||||
file mới có thể hỏng — phải còn đường về.
|
||||
* Chỉ nâng, không hạ. File mới hơn app thì báo và dùng nguyên trạng, không cố
|
||||
đoán ngược.
|
||||
|
||||
Bước v1→v2 đầu tiên đi kèm R02-T05: gỡ ``api_key`` khỏi đĩa, đẩy vào
|
||||
``SecretStore``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from ..secrets.secret_store import SecretStore, provider_key
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: Phiên bản app hiện đang ghi ra.
|
||||
CURRENT_VERSION = 2
|
||||
|
||||
#: Thiếu ``schema_version`` ⇒ file có từ trước khi đánh số.
|
||||
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):
|
||||
return ASSUMED_VERSION
|
||||
|
||||
|
||||
def _v1_to_v2(data: Dict[str, Any], secrets: SecretStore | None) -> Dict[str, Any]:
|
||||
"""Chuyển API key từ file sang kho bí mật — R02-T05.
|
||||
|
||||
Không có kho bí mật thì **không chuyển**: thà để khoá nằm nguyên trong file
|
||||
còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao. File giữ
|
||||
nguyên phiên bản 1, lần chạy sau trên máy có keyring sẽ chuyển.
|
||||
"""
|
||||
if secrets is None or not getattr(secrets, "available", True):
|
||||
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
|
||||
return data
|
||||
|
||||
out = copy.deepcopy(data)
|
||||
moved = []
|
||||
for name, conf in (out.get("providers") or {}).items():
|
||||
if not isinstance(conf, dict):
|
||||
continue
|
||||
key = (conf.get("api_key") or "").strip()
|
||||
# "ollama" là giá trị bù nhìn — Ollama đòi có api_key nhưng bỏ qua nội
|
||||
# dung. Đẩy nó vào keyring chỉ tổ rác.
|
||||
if not key or key == "ollama":
|
||||
continue
|
||||
secrets.set(provider_key(name), key)
|
||||
conf["api_key"] = ""
|
||||
moved.append(name)
|
||||
|
||||
out["schema_version"] = 2
|
||||
if moved:
|
||||
log.info("đã chuyển API key sang kho bí mật: %s", ", ".join(moved))
|
||||
return out
|
||||
|
||||
|
||||
#: {phiên bản nguồn: hàm nâng lên phiên bản kế tiếp}
|
||||
STEPS: Dict[int, Callable[[Dict[str, Any], SecretStore | None], Dict[str, Any]]] = {
|
||||
1: _v1_to_v2,
|
||||
}
|
||||
|
||||
|
||||
def backup(path: Path) -> Path | None:
|
||||
"""Chép file trước khi nâng cấp. Trả về đường dẫn bản sao."""
|
||||
if not path.exists():
|
||||
return None
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = path.with_suffix(path.suffix + f".v{stamp}.bak")
|
||||
try:
|
||||
shutil.copy2(path, target)
|
||||
return target
|
||||
except OSError as exc:
|
||||
log.warning("không sao lưu được %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def migrate(data: Dict[str, Any], *, secrets: SecretStore | None = None,
|
||||
path: Path | None = None) -> tuple[Dict[str, Any], bool]:
|
||||
"""Nâng ``data`` lên :data:`CURRENT_VERSION`.
|
||||
|
||||
Trả về ``(dữ_liệu, có_đổi_không)``. ``có_đổi_không`` là False thì chỗ gọi
|
||||
khỏi phải ghi lại đĩa.
|
||||
"""
|
||||
version = read_version(data)
|
||||
|
||||
if version > CURRENT_VERSION:
|
||||
# App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu.
|
||||
log.warning("config phiên bản %s mới hơn app (%s) — dùng nguyên trạng",
|
||||
version, CURRENT_VERSION)
|
||||
return data, False
|
||||
|
||||
if version == CURRENT_VERSION:
|
||||
return data, False
|
||||
|
||||
if path is not None:
|
||||
backup(path)
|
||||
|
||||
changed = False
|
||||
while version < CURRENT_VERSION:
|
||||
step = STEPS.get(version)
|
||||
if step is None:
|
||||
log.warning("thiếu bước nâng cấp từ phiên bản %s — dừng", version)
|
||||
break
|
||||
data = step(data, secrets)
|
||||
new_version = read_version(data)
|
||||
if new_version <= version:
|
||||
# Bước không nâng được phiên bản (ví dụ v1→v2 bỏ qua vì chưa có
|
||||
# keyring). Dừng, đừng lặp vô hạn.
|
||||
break
|
||||
version = new_version
|
||||
changed = True
|
||||
|
||||
return data, changed
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Khung nhìn có kiểu cho từng nhóm cấu hình — R02-T03.
|
||||
|
||||
Vấn đề đang có: khắp nơi viết ``ctx.config.routing.get("switch_mode", "off")``.
|
||||
Gõ sai một chữ thì lặng lẽ nhận giá trị mặc định, không ai biết cho tới khi
|
||||
tính năng "không hiểu sao không chạy". Đếm được **156 lời gọi ``ctx.config.*``
|
||||
trong 29 file** kiểu đó.
|
||||
|
||||
Ở đây mỗi nhóm cấu hình có một lớp: gõ sai tên thuộc tính là lỗi ngay, và kiểu
|
||||
dữ liệu ghi rõ ràng nên đọc code là biết ``confirm_timeout_sec`` là số giây
|
||||
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
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
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
|
||||
|
||||
def raw(self) -> Dict[str, Any]:
|
||||
"""Dict gốc — dùng khi cần đọc khoá chưa được đưa vào khung nhìn.
|
||||
|
||||
Có mặt để không ai bị kẹt: thiếu thuộc tính thì dùng tạm ``raw()`` rồi
|
||||
mở issue bổ sung, chứ đừng vòng lại ``ctx.config.data``.
|
||||
"""
|
||||
return self._d
|
||||
|
||||
|
||||
class ProviderSettings(_View):
|
||||
"""Một provider: đi đâu, model nào, khoá nào.
|
||||
|
||||
``api_key`` ở đây là thứ ``JsonConfigRepository.provider_conf()`` đã ghép
|
||||
sẵn từ kho bí mật — xem đường A trong ``GammaTeam_decisions.md``.
|
||||
"""
|
||||
|
||||
@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
|
||||
def configured(self) -> bool:
|
||||
"""Đủ thông tin để gọi được chưa.
|
||||
|
||||
Ollama chạy cục bộ nên không cần khoá — đó là lý do điều kiện là
|
||||
"có base_url và model", không phải "có api_key".
|
||||
"""
|
||||
return bool(self.base_url and self.model)
|
||||
|
||||
|
||||
class RoutingSettings(_View):
|
||||
"""Định tuyến model tự động (``core/routing/``)."""
|
||||
|
||||
@property
|
||||
def switch_mode(self) -> str:
|
||||
"""``"off"`` | ``"auto"`` | ``"manual"``."""
|
||||
return str(self._get("switch_mode", "off"))
|
||||
|
||||
@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
|
||||
def policy(self) -> str:
|
||||
"""``"balanced"`` | ``"cheap"`` | ``"quality"``…"""
|
||||
return str(self._get("policy", "balanced"))
|
||||
|
||||
@property
|
||||
def min_score_gain(self) -> float:
|
||||
"""Phải hơn model hiện tại bao nhiêu điểm mới đáng đổi."""
|
||||
return float(self._get("min_score_gain", 0.05))
|
||||
|
||||
@property
|
||||
def confirm_timeout_sec(self) -> int:
|
||||
"""GIÂY, không phải mili giây — đọc tên là biết, khỏi phải mò."""
|
||||
return int(self._get("confirm_timeout_sec", 60))
|
||||
|
||||
@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", ""))
|
||||
|
||||
|
||||
class SecuritySettings(_View):
|
||||
"""Chính sách an toàn cho agent (``core/agent_security.py``)."""
|
||||
|
||||
@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
|
||||
def cowork_confirm_commands(self) -> bool:
|
||||
"""Có hỏi trước khi chạy lệnh không.
|
||||
|
||||
Ứng với ``PolicyOutcome.ASK`` trong
|
||||
``domain/security/tool_policy.py``.
|
||||
"""
|
||||
return bool(self._get("cowork_confirm_commands", True))
|
||||
|
||||
@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", ""))
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Cửa vào duy nhất cho các nhóm cấu hình có kiểu.
|
||||
|
||||
>>> s = Settings(repo)
|
||||
>>> if s.routing.enabled and s.provider().configured:
|
||||
... ...
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Filesystem/process/network tool adapters split out of ``core/tools.py``
|
||||
(EPIC R05) and the sandbox execution context they share."""
|
||||
|
||||
from .tool_context import CancelFn, ToolContext, ToolError
|
||||
|
||||
__all__ = ["CancelFn", "ToolContext", "ToolError"]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Command tools - run_command, install_package (R05-T02).
|
||||
|
||||
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). These
|
||||
two are the ones today's hand-written permission gate in
|
||||
``core/chat_agent.py`` singles out by literal name
|
||||
(``name in ("run_command", "install_package")``) — R05-T03 replaces that
|
||||
tuple with a capability lookup, but the tools themselves are unchanged here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .tool_context import CancelFn, ToolContext
|
||||
|
||||
COMMAND_TIMEOUT = 120 # seconds
|
||||
|
||||
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
|
||||
".idea", ".mypy_cache", ".pytest_cache"}
|
||||
|
||||
|
||||
def _snapshot(workdir: Path) -> Dict[str, Any]:
|
||||
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
|
||||
snap: Dict[str, Any] = {}
|
||||
try:
|
||||
for dirpath, dirnames, filenames in os.walk(str(workdir)):
|
||||
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
|
||||
for fn in filenames:
|
||||
full = os.path.join(dirpath, fn)
|
||||
try:
|
||||
st = os.stat(full)
|
||||
snap[full] = (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
if len(snap) > 5000:
|
||||
return snap
|
||||
except OSError:
|
||||
pass
|
||||
return snap
|
||||
|
||||
|
||||
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
|
||||
on_output=None) -> Optional[str]:
|
||||
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
|
||||
``ctx.sandbox``); returns its python path, or None to use the app's own."""
|
||||
if not ctx.sandbox:
|
||||
return None
|
||||
from cowork_local.core.deps import ensure_project_venv
|
||||
|
||||
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
|
||||
return str(py) if py else 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
|
||||
|
||||
command = str(args.get("command", "")).strip()
|
||||
if not command:
|
||||
return {"ok": False, "output": "Empty command."}
|
||||
|
||||
# --- Security validation pipeline ---
|
||||
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
|
||||
if risk.blocked:
|
||||
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
|
||||
return {"ok": False, "output": denial}
|
||||
|
||||
# Route through SandboxManager for risk-based isolation
|
||||
mgr = SandboxManager(ExecutionConfig(
|
||||
enabled=True,
|
||||
block_network_by_default=ctx.block_network,
|
||||
is_cowork_mode=ctx.flatten_writes,
|
||||
))
|
||||
sandbox_result = mgr.run(
|
||||
command=command,
|
||||
workdir=str(ctx.workdir),
|
||||
block_network=ctx.block_network,
|
||||
timeout_sec=COMMAND_TIMEOUT,
|
||||
cancel=cancel,
|
||||
)
|
||||
# Sandbox ALWAYS executes (never double-run). Return its result directly.
|
||||
if sandbox_result.get("sandbox") == "blocked":
|
||||
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
|
||||
out = sandbox_result.get("stdout", "").strip() or "(no output)"
|
||||
err = sandbox_result.get("stderr", "")
|
||||
rc = sandbox_result.get("returncode", -1)
|
||||
if err:
|
||||
out = f"{out}\n{err}" if out else err
|
||||
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
|
||||
|
||||
|
||||
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()
|
||||
if not package:
|
||||
return {"ok": False, "output": "No package specified."}
|
||||
python = _sandbox_python(ctx, cancel, on_output)
|
||||
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
|
||||
head = f"Installed {package}." if ok else f"Could not install {package}."
|
||||
return {"ok": ok, "output": f"{head}\n{detail}"}
|
||||
|
||||
|
||||
__all__ = ["COMMAND_TIMEOUT", "run_command", "install_package", "_snapshot"]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""ExecutionWorkspace - the output folder vs. the scratch folder for one
|
||||
turn, as two distinct properties instead of a name convention (R06-T03).
|
||||
|
||||
Today the ``.scratch`` subtree is a special case buried inside
|
||||
``_flatten_rel`` (``infrastructure/filesystem/file_tools.py``): a generator
|
||||
script writes there, the deliverable lands in the output root, and
|
||||
``core/chat_agent.py`` cleans ``.scratch`` up after the turn — but nothing
|
||||
NAMES "the scratch folder" as a thing; every call site re-derives
|
||||
``workdir / ".scratch"`` (or checks ``Path(rel).parts[0] == ".scratch"``) by
|
||||
hand. This class gives that convention one home.
|
||||
|
||||
It does not change WHERE files land - ``workspace_root/.scratch`` stays
|
||||
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
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cowork_local.domain.workspaces import WorkspaceSession
|
||||
|
||||
SCRATCH_DIRNAME = ".scratch"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionWorkspace:
|
||||
"""The two folders a turn actually writes to, derived from a
|
||||
:class:`WorkspaceSession`.
|
||||
|
||||
``output_dir`` is always the session's ``workspace_root`` itself, not a
|
||||
per-turn subfolder - Cowork's whole design is that every deliverable lands
|
||||
directly in the one configured Output folder (see
|
||||
``infrastructure/filesystem/file_tools.py::_flatten_rel``'s docstring).
|
||||
``scratch_dir`` is the SAME flat ``workspace_root/.scratch`` every turn on
|
||||
that workspace already shares today (``core/chat_agent.py``'s
|
||||
``_cleanup_cowork_intermediates`` operates on that exact path) - this
|
||||
class does not introduce per-turn namespacing that doesn't exist in the
|
||||
engine yet, only names the existing convention.
|
||||
|
||||
``turn_id`` is kept as metadata for callers that want to attribute a
|
||||
workspace to the turn that used it (logging, future per-turn scratch
|
||||
namespacing); it does not affect either path today.
|
||||
"""
|
||||
|
||||
session: WorkspaceSession
|
||||
turn_id: str
|
||||
|
||||
@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:
|
||||
"""Create both folders if they don't exist yet. Callers that only
|
||||
need one (most do) can skip this and let ``write_file`` create parents
|
||||
on demand, same as today."""
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.scratch_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def cleanup_scratch(self) -> None:
|
||||
"""Unconditionally remove the scratch subtree.
|
||||
|
||||
Coarser than ``core/chat_agent.py::_cleanup_cowork_intermediates``,
|
||||
which rescues any real deliverable a generator script wrote INSIDE
|
||||
``.scratch`` before wiping it - that rescue logic stays there. This
|
||||
is for callers that only need "make the scratch folder go away"
|
||||
(e.g. before starting a fresh run) and know it holds nothing worth
|
||||
saving.
|
||||
"""
|
||||
shutil.rmtree(self.scratch_dir, ignore_errors=True)
|
||||
|
||||
|
||||
__all__ = ["ExecutionWorkspace", "SCRATCH_DIRNAME"]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Fetch tools - fetch_url, jira_search, jira_get_issue (R05-T02).
|
||||
|
||||
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). The
|
||||
network access these three carry is exactly what the ``ToolCapability.NETWORK``
|
||||
tag added in R05-T01/domain/tools/tool_registry.py describes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .tool_context import ToolContext
|
||||
|
||||
|
||||
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fetch a URL's text content (web page / online document / SharePoint-
|
||||
OneDrive share link) via link_fetch — the same parser task-link attachments
|
||||
use. Honors the Sandbox Security Layer's "Block network" policy."""
|
||||
url = str(args.get("url", "")).strip()
|
||||
if not url:
|
||||
return {"ok": False, "output": "fetch_url: 'url' is required."}
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
|
||||
if not ctx.allow_url_fetch:
|
||||
return {"ok": False,
|
||||
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
|
||||
"(\"Allow the agent to fetch URLs\").")}
|
||||
# A pasted Jira issue link on the CONNECTED Jira host is read via the
|
||||
# authenticated API (so private issues resolve, not a login page). Public
|
||||
# links / any other URL fall through to the normal fetcher below.
|
||||
from cowork_local.core import jira_tool
|
||||
if jira_tool.is_jira_issue_url(ctx.jira, url):
|
||||
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
|
||||
from cowork_local.core.link_fetch import fetch_link_preview
|
||||
|
||||
return {"ok": True, "output": fetch_link_preview(url)}
|
||||
|
||||
|
||||
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", "")),
|
||||
int(args.get("max_results", 25) or 25))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
|
||||
"output": out}
|
||||
|
||||
|
||||
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", "")))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
|
||||
"output": out}
|
||||
|
||||
|
||||
__all__ = ["fetch_url", "jira_search", "jira_get_issue"]
|
||||
@@ -0,0 +1,146 @@
|
||||
"""File tools - read_file, list_dir, write_file, edit_file (R05-T02).
|
||||
|
||||
Moved verbatim out of ``core/tools.py``, whose ``execute_tool`` used to
|
||||
dispatch to these via a hand-written if/elif chain over every tool name it
|
||||
knew about. Splitting the built-in handlers into per-concern modules
|
||||
(this one, ``command_tools.py``, ``fetch_tools.py``) means adding a tool no
|
||||
longer means growing that one function; ``core/tools.py::execute_tool`` now
|
||||
looks the name up in a dict built from these modules instead.
|
||||
|
||||
Behavior is unchanged from before the split - this is a pure move, not a
|
||||
rewrite. Every existing characterization/contract test that exercises
|
||||
read_file/write_file/edit_file/list_dir through ``core.tools.execute_tool``
|
||||
still exercises the exact same code, just imported from here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from .tool_context import ToolContext
|
||||
|
||||
MAX_READ_BYTES = 200_000
|
||||
|
||||
|
||||
def _flatten_rel(rel: str) -> str:
|
||||
"""Collapse a sub-folder path down to a bare filename so the file lands in the
|
||||
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
|
||||
|
||||
Used by the Cowork agent (flatten_writes=True) so it can never create a
|
||||
per-session / per-chat / per-task output sub-folder: every deliverable stays
|
||||
directly in the single configured Output folder."""
|
||||
parts = Path(rel).parts
|
||||
if parts and parts[0] == ".scratch":
|
||||
return rel # temporary sandbox is allowed (and cleaned up afterwards)
|
||||
return Path(rel).name or rel
|
||||
|
||||
|
||||
def _check_python_syntax(target: Path, content: str) -> str:
|
||||
"""Return a short warning if ``content`` is invalid Python, else ''.
|
||||
|
||||
Catches syntax errors the instant a .py file is written/edited — before the
|
||||
agent wastes a whole run_command round-trip just to get the same error back
|
||||
from a traceback."""
|
||||
if target.suffix.lower() not in (".py", ".pyw"):
|
||||
return ""
|
||||
try:
|
||||
ast.parse(content, filename=str(target))
|
||||
return ""
|
||||
except SyntaxError as exc:
|
||||
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
|
||||
|
||||
|
||||
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')}"}
|
||||
data = target.read_bytes()[:MAX_READ_BYTES]
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
return {"ok": True, "output": text}
|
||||
|
||||
|
||||
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
|
||||
# ordinary result so the agent can create it or pick another path and keep
|
||||
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
|
||||
# Co4E flows and could stall a step on a recoverable situation.
|
||||
if not target.exists():
|
||||
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
|
||||
if target.is_file():
|
||||
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
|
||||
entries = []
|
||||
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
marker = "/" if child.is_dir() else ""
|
||||
entries.append(f"{child.name}{marker}")
|
||||
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
|
||||
|
||||
|
||||
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)
|
||||
target = ctx.resolve(rel)
|
||||
content = str(args.get("content", ""))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A .xlsx is a binary package — build a REAL workbook from the content
|
||||
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
|
||||
if target.suffix.lower() in (".xlsx", ".xlsm"):
|
||||
from cowork_local.core import xlsx_write
|
||||
if xlsx_write.build_xlsx_from_text(target, content):
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote spreadsheet {rel} ({target.name})."}
|
||||
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
|
||||
"write a .csv instead, or use a generator script."}
|
||||
target.write_text(content, encoding="utf-8")
|
||||
warning = _check_python_syntax(target, content)
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
|
||||
|
||||
|
||||
def edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Replace an exact snippet inside an existing file (precise patch edit)."""
|
||||
rel = str(args.get("path", ""))
|
||||
if ctx.flatten_writes:
|
||||
rel = _flatten_rel(rel)
|
||||
target = ctx.resolve(rel)
|
||||
if not target.exists():
|
||||
return {"ok": False,
|
||||
"output": f"File not found: {rel} — use write_file to create it."}
|
||||
old = str(args.get("old_string", ""))
|
||||
new = str(args.get("new_string", ""))
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
if not old:
|
||||
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
|
||||
try:
|
||||
text = target.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return {"ok": False, "output": f"Could not read file: {exc}"}
|
||||
count = text.count(old)
|
||||
if count == 0:
|
||||
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
|
||||
"text to replace, including indentation/whitespace.")}
|
||||
if count > 1 and not replace_all:
|
||||
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
|
||||
"context to make it unique, or set replace_all=true.")}
|
||||
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
|
||||
target.write_text(updated, encoding="utf-8")
|
||||
n = count if replace_all else 1
|
||||
warning = _check_python_syntax(target, updated)
|
||||
return {"ok": True,
|
||||
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
|
||||
|
||||
|
||||
__all__ = ["MAX_READ_BYTES", "read_file", "list_dir", "write_file", "edit_file"]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""ToolContext / ToolError / CancelFn - the sandboxed execution context every
|
||||
built-in tool runs against (moved out of ``core/tools.py`` in R05-T02).
|
||||
|
||||
Kept as its own leaf module (no dependency on any sibling in this package) so
|
||||
``file_tools.py``, ``command_tools.py`` and ``fetch_tools.py`` can each import
|
||||
it without creating an import cycle back through ``core/tools.py``, which
|
||||
itself re-exports ``ToolContext``/``ToolError`` from here for the existing
|
||||
callers (``core/chat_agent.py``, ``core/code_agent.py``,
|
||||
``core/task_executors.py``) that do ``from .tools import ToolContext``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
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
|
||||
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
|
||||
# disk_mb), applied to every run_command/install_package this context runs.
|
||||
# None (default) = no limits, matching pre-existing behavior.
|
||||
resource_limits: Optional[Dict[str, float]] = None
|
||||
# Sandbox Security Layer — Settings' "Block network for agent commands"
|
||||
# (policy-level, see deps.py::network_blocked_env). False (default) =
|
||||
# unrestricted, matching pre-existing behavior.
|
||||
block_network: bool = False
|
||||
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
|
||||
# (reading a web page/share link for info is safe; running networked shell
|
||||
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
|
||||
allow_url_fetch: bool = True
|
||||
# Jira read connector config (base_url/email/api_token) — None disables the
|
||||
# jira_* tools' ability to connect. Populated from config.data["jira"].
|
||||
jira: Optional[Dict[str, Any]] = None
|
||||
|
||||
def resolve(self, rel: str) -> Path:
|
||||
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
|
||||
if rel in ("", "."):
|
||||
return self.workdir
|
||||
candidate = (self.workdir / rel).expanduser()
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Invalid path: {rel} ({exc})")
|
||||
root = self.workdir.resolve()
|
||||
if resolved != root and root not in resolved.parents:
|
||||
raise ToolError(
|
||||
f"Refused: '{rel}' is outside the working folder ({root})."
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
__all__ = ["CancelFn", "ToolError", "ToolContext"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""MCP server connection lifecycle management (EPIC R05)."""
|
||||
|
||||
from .mcp_source_manager import McpToolSourceManager
|
||||
|
||||
__all__ = ["McpToolSourceManager"]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""McpToolSourceManager - the MCP server connection lifecycle, extracted out
|
||||
of ``state.py::AppContext`` (R05-T05).
|
||||
|
||||
Today ``AppContext.build_mcp_tools`` inlines all of this: a ``_mcp_connections``
|
||||
dict, a ``_conn_lock`` guarding check-then-create against concurrent turns (a
|
||||
Cowork tab, a Co4E flow and a Scheduled Task can all call it at once), and a
|
||||
"start it, cache it, skip it on failure" loop repeated for both the
|
||||
admin-configured servers AND the built-in MS365 server
|
||||
(``_ms365_builtin_connection``). None of that logic touches Qt; it was only
|
||||
ever inline because ``AppContext`` is where the config lived.
|
||||
|
||||
This class owns the SAME cache/lock/start-or-skip behavior as a standalone,
|
||||
directly testable object — ``AppContext`` becomes a thin caller (one instance
|
||||
per app, same as it holds one ``RoutingApplicationService``).
|
||||
|
||||
Pure Python: no Qt. It DOES touch the network/filesystem via
|
||||
``core.mcp_client.McpServerConnection`` (a subprocess + asyncio loop), which is
|
||||
exactly what makes it infrastructure rather than domain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from cowork_local.core.mcp_client import McpServerConnection
|
||||
|
||||
|
||||
class McpToolSourceManager:
|
||||
"""Caches and supervises one :class:`McpServerConnection` per server name.
|
||||
|
||||
``connection_factory`` defaults to ``McpServerConnection`` itself; tests
|
||||
substitute a fake so no real subprocess is spawned (see
|
||||
``tests/unit/test_mcp_source_manager.py``).
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
def ensure(self, name: str, command: str, args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
|
||||
"""Return a live connection for ``name``, starting one if there is
|
||||
none cached or the cached one's subprocess has died.
|
||||
|
||||
Serialized under one lock so two turns racing to build their tool
|
||||
list at the same moment share one subprocess per server instead of
|
||||
each spawning their own (the bug this replaces:
|
||||
``AppContext._conn_lock``'s original docstring). Returns ``None`` -
|
||||
never raises - when the server fails to start, matching the existing
|
||||
"one broken server must not block the turn" behavior.
|
||||
"""
|
||||
with self._lock:
|
||||
existing = self._connections.get(name)
|
||||
if existing is not None and existing.is_alive():
|
||||
return existing
|
||||
if existing is not None:
|
||||
self._connections.pop(name, None)
|
||||
connection = self._connection_factory(name, command, args or [], env)
|
||||
try:
|
||||
connection.start()
|
||||
except Exception: # noqa: BLE001 - one broken server must not block the turn
|
||||
return None
|
||||
self._connections[name] = connection
|
||||
return connection
|
||||
|
||||
def get(self, name: str) -> Optional[McpServerConnection]:
|
||||
"""The cached connection for ``name``, without starting one."""
|
||||
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()
|
||||
|
||||
def restart(self, name: str, command: str, args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
|
||||
"""Force a fresh connection for ``name`` even if the cached one still
|
||||
looks alive - for a server the caller knows is misbehaving."""
|
||||
with self._lock:
|
||||
self._connections.pop(name, None)
|
||||
return self.ensure(name, command, args, env)
|
||||
|
||||
def stop(self, name: str) -> None:
|
||||
"""Stop and forget one connection - used when a server becomes
|
||||
unavailable by configuration (e.g. MS365 signed out) rather than by
|
||||
crashing."""
|
||||
with self._lock:
|
||||
connection = self._connections.pop(name, None)
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.stop()
|
||||
except Exception: # noqa: BLE001 - shutdown must never raise into the caller
|
||||
pass
|
||||
|
||||
def active(self) -> List[McpServerConnection]:
|
||||
"""Every currently cached connection - what
|
||||
``core/mcp_client.py::build_mcp_tools`` merges tool specs from."""
|
||||
return list(self._connections.values())
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Terminate every connection's subprocess - called on app shutdown
|
||||
so none of them linger as orphan processes."""
|
||||
with self._lock:
|
||||
connections = list(self._connections.values())
|
||||
self._connections.clear()
|
||||
for connection in connections:
|
||||
try:
|
||||
connection.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["McpToolSourceManager"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Persistence adapters (EPIC R02/R06)."""
|
||||
@@ -0,0 +1,16 @@
|
||||
"""JSON-file persistence adapters: crash-safe writes and the workspace/
|
||||
conversation/task repositories built on them (EPIC R06, R07)."""
|
||||
|
||||
from .atomic_json_file import AtomicJsonFile
|
||||
from .atomic_write import write_json
|
||||
from .conversation_repository_impl import ConversationRepository
|
||||
from .task_repository_impl import TaskRepository
|
||||
from .workspace_repository_impl import WorkspaceRepository
|
||||
|
||||
__all__ = [
|
||||
"AtomicJsonFile",
|
||||
"write_json",
|
||||
"WorkspaceRepository",
|
||||
"ConversationRepository",
|
||||
"TaskRepository",
|
||||
]
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Ghi JSON kiểu không-hỏng-file — R02-T01.
|
||||
|
||||
Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm
|
||||
đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay
|
||||
process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở**
|
||||
— và người dùng mất toàn bộ cấu hình.
|
||||
|
||||
Cách làm ở đây theo đúng thứ tự bắt buộc:
|
||||
|
||||
1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử)
|
||||
2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong
|
||||
bộ đệm của hệ điều hành
|
||||
3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX
|
||||
|
||||
Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không
|
||||
bao giờ có trạng thái ghi dở.
|
||||
|
||||
Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn
|
||||
cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động,
|
||||
đúng như ``config.py`` hiện tại đang làm.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AtomicJsonFile:
|
||||
"""Một file JSON, đọc ghi an toàn.
|
||||
|
||||
>>> f = AtomicJsonFile(Path("cau_hinh.json"))
|
||||
>>> f.write({"theme": "dark"})
|
||||
>>> f.read(default={})
|
||||
{'theme': 'dark'}
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# ---- đọc ------------------------------------------------------------
|
||||
def read(self, default: Any = None) -> Any:
|
||||
"""Nội dung file, hoặc ``default`` nếu chưa có / hỏng.
|
||||
|
||||
Không ném lỗi. File hỏng được đổi tên thành ``<tên>.bad-<thời điểm>``
|
||||
rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng.
|
||||
"""
|
||||
if not self.path.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
self._quarantine()
|
||||
return default
|
||||
except OSError:
|
||||
# Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file
|
||||
# có thể vẫn tốt nguyên.
|
||||
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:
|
||||
os.replace(self.path, target)
|
||||
return target
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# ---- ghi ------------------------------------------------------------
|
||||
#: Số lần thử lại ``os.replace`` và khoảng nghỉ giữa các lần (giây).
|
||||
_REPLACE_TRIES = 6
|
||||
_REPLACE_BACKOFF = 0.02
|
||||
|
||||
@classmethod
|
||||
def _replace_ben_bi(cls, src: Path, dst: Path) -> None:
|
||||
"""``os.replace`` có thử lại — bắt buộc trên Windows.
|
||||
|
||||
MoveFileEx trả ERROR_ACCESS_DENIED khi có tiến trình khác đang giữ
|
||||
handle lên nguồn hoặc đích. Trên Windows thật thì gần như luôn là
|
||||
Defender hoặc Search Indexer quét file vừa tạo, giữ handle vài chục
|
||||
mili-giây rồi nhả. Không phải lỗi quyền thật, thử lại là hết.
|
||||
|
||||
Đo trên máy dev 25/08: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức
|
||||
khoảng 1 trên 140 lần lưu. Không có vòng này thì người dùng thỉnh
|
||||
thoảng bấm Lưu là văng lỗi mà không tài nào tái hiện.
|
||||
|
||||
POSIX không có kiểu hỏng này nên vòng lặp chạy đúng một lượt.
|
||||
"""
|
||||
for lan in range(cls._REPLACE_TRIES):
|
||||
try:
|
||||
os.replace(src, dst)
|
||||
return
|
||||
except PermissionError:
|
||||
if lan == cls._REPLACE_TRIES - 1:
|
||||
raise
|
||||
time.sleep(cls._REPLACE_BACKOFF * (2 ** lan))
|
||||
|
||||
def write(self, data: Any) -> None:
|
||||
"""Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(data, indent=self.indent, ensure_ascii=False)
|
||||
|
||||
# File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng
|
||||
# một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến
|
||||
# thành copy + delete — mất luôn tính nguyên tử.
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp")
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm
|
||||
self._replace_ben_bi(tmp, self.path) # nguyên tử, có thử lại
|
||||
except BaseException:
|
||||
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng
|
||||
# để rác .tmp nằm lại cạnh file cấu hình.
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
# ---- 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})"
|
||||
@@ -0,0 +1,56 @@
|
||||
"""write_json - crash-safe JSON writes (R06-T02).
|
||||
|
||||
``core/projects.py::save_project`` and ``core/history.py``'s
|
||||
``save_conversation``/``rename_conversation``/``set_pinned`` all do a plain
|
||||
``path.write_text(json.dumps(...))`` today. That is two syscalls with a gap in
|
||||
between: a crash, a killed process, or a full disk between the truncate and
|
||||
the write leaves a half-written, unparseable JSON file - the NEXT read of
|
||||
that project/conversation then fails outright (``load_project`` /
|
||||
``load_conversation`` already treat a parse error as "missing", so this isn't
|
||||
even a loud failure - a project can silently vanish).
|
||||
|
||||
``write_json`` fixes this the standard way: write the full content to a
|
||||
temporary file in the SAME directory (so the following replace is on one
|
||||
filesystem, not crossing a mount point), then atomically rename it over the
|
||||
target. Either the old file is still there, or the new one is fully there -
|
||||
never a partial one.
|
||||
|
||||
Transitional note: EPIC R02 (Team Nam, ``docs/refactor/Refactoring_Checklist.md``
|
||||
R02-T01) plans a shared ``infrastructure/persistence/json/atomic_json_file.py``
|
||||
for the SAME purpose across the whole app (config, secrets, ...). This module
|
||||
is deliberately named differently and scoped to R06's two repositories only,
|
||||
so the two EPICs don't edit the same file in parallel; once R02-T01 lands,
|
||||
``WorkspaceRepository``/``ConversationRepository`` should switch to it and
|
||||
this module can go away.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> None:
|
||||
"""Serialize ``data`` as indented UTF-8 JSON and write it to ``path``
|
||||
atomically. Creates parent directories if needed."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_name, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
__all__ = ["write_json"]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""ConversationRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/history.py`` (R06-T02).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cowork_local.core.history import (
|
||||
delete_conversation,
|
||||
list_conversations,
|
||||
load_conversation,
|
||||
new_session_id,
|
||||
rename_conversation,
|
||||
save_conversation,
|
||||
set_pinned,
|
||||
)
|
||||
|
||||
|
||||
class ConversationRepository:
|
||||
"""CRUD + search over conversation JSON files, scoped to one
|
||||
``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:
|
||||
from cowork_local.config import HISTORY_DIR
|
||||
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))
|
||||
if p.exists() or p.is_absolute():
|
||||
return p
|
||||
for file in self._directory.glob(f"*__{target}.json"):
|
||||
return file
|
||||
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)
|
||||
|
||||
|
||||
__all__ = ["ConversationRepository"]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""TaskRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/tasks.py`` (R07-T01).
|
||||
|
||||
``core/tasks.py``'s module-level functions (``list_tasks``, ``load_task``,
|
||||
``save_task``, ``delete_task``, ``new_task``, ``duplicate_task``) are still
|
||||
what every existing call site (``core/task_scheduler.py``,
|
||||
``core/task_executors.py``, ``ui/schedule_task_tab.py``) uses, and stay that
|
||||
way - ``save_task`` now writes through :func:`atomic_write.write_json`
|
||||
itself (R07-T01, same class of durability fix already applied to
|
||||
``core/projects.py``/``core/history.py`` at R06-T02), so the fix applies
|
||||
whether or not a caller ever touches this class.
|
||||
|
||||
This repository exists for the application layer
|
||||
(``application/scheduling``, R07-T04) to depend on an interface instead of
|
||||
reaching into ``core/`` directly. It is a thin pass-through today, not a
|
||||
re-implementation: same on-disk format, same directory, same functions
|
||||
underneath.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class TaskRepository:
|
||||
"""CRUD over task dicts (see ``core/tasks.py::DEFAULT_TASK`` for shape),
|
||||
scoped to one ``directory`` (defaults to the app's real ``TASKS_DIR``;
|
||||
tests pass a ``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 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
|
||||
except ImportError:
|
||||
from ...core.tasks import TASKS_DIR
|
||||
self._directory = TASKS_DIR
|
||||
else:
|
||||
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:
|
||||
from ...core.tasks import list_tasks
|
||||
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:
|
||||
from ...core.tasks import load_task
|
||||
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:
|
||||
from ...core.tasks import save_task
|
||||
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:
|
||||
from ...core.tasks import new_task
|
||||
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:
|
||||
from ...core.tasks import duplicate_task
|
||||
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:
|
||||
from ...core.tasks import delete_task
|
||||
delete_task(task_id, self._directory)
|
||||
|
||||
|
||||
__all__ = ["TaskRepository"]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""WorkspaceRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/projects.py`` (R06-T02).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cowork_local.core.projects import Project
|
||||
|
||||
|
||||
class WorkspaceRepository:
|
||||
"""CRUD over :class:`~cowork_local.core.projects.Project`, scoped to one
|
||||
``directory`` (defaults to the app's real ``PROJECTS_DIR``; tests pass a
|
||||
``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:
|
||||
from cowork_local.core.projects import PROJECTS_DIR
|
||||
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)
|
||||
|
||||
|
||||
__all__ = ["WorkspaceRepository"]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Provider adapters and the central provider catalogue (EPIC R03)."""
|
||||
|
||||
from .provider_registry import (
|
||||
BUILTIN_DESCRIPTORS,
|
||||
ProviderRegistry,
|
||||
default_registry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BUILTIN_DESCRIPTORS",
|
||||
"ProviderRegistry",
|
||||
"default_registry",
|
||||
]
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Central registry of every LLM provider the app can talk to.
|
||||
|
||||
Replaces the bare ``{name: class}`` dict in ``providers/factory.py`` as the
|
||||
single catalogue of providers. Two responsibilities, kept deliberately narrow:
|
||||
|
||||
1. **Lookup** — resolve a provider id (or one of its aliases, or a bare model
|
||||
id) to its :class:`~domain.models.provider_descriptor.ProviderDescriptor`.
|
||||
2. **Construction** — instantiate the concrete adapter class that speaks the
|
||||
descriptor's wire protocol.
|
||||
|
||||
This is infrastructure, not domain: it is allowed to import the concrete
|
||||
``providers/*`` adapters (which pull in ``requests``). The adapters are imported
|
||||
lazily inside :meth:`build` so that merely *reading the catalogue* — which the
|
||||
pure routing service does on every turn — never drags the HTTP stack into the
|
||||
process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from ...domain.models.provider_descriptor import (
|
||||
AuthKind,
|
||||
ProviderDescriptor,
|
||||
WireProtocol,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Built-in catalogue.
|
||||
#
|
||||
# Mirrors DEFAULT_CONFIG["providers"] in config.py (ids + default models) and
|
||||
# providers/factory.py (id -> wire protocol). Prices are intentionally absent:
|
||||
# core/routing/metadata.py owns cost, and a guessed price is worse than a
|
||||
# known-unknown (see that module's docstring).
|
||||
# --------------------------------------------------------------------------- #
|
||||
BUILTIN_DESCRIPTORS: tuple = (
|
||||
ProviderDescriptor(
|
||||
provider_id="openai_compat",
|
||||
display_name="OpenAI-compatible gateway",
|
||||
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||
auth_kind=AuthKind.API_KEY,
|
||||
default_model="gpt-4o-mini",
|
||||
supports_vision=True,
|
||||
# A generic gateway has no fixed host, so the endpoint MUST be
|
||||
# configured before the provider can be used at all.
|
||||
requires_base_url=True,
|
||||
),
|
||||
ProviderDescriptor(
|
||||
provider_id="anthropic",
|
||||
display_name="Anthropic Claude",
|
||||
wire_protocol=WireProtocol.ANTHROPIC,
|
||||
auth_kind=AuthKind.API_KEY,
|
||||
default_model="claude-sonnet-4-6",
|
||||
# Kept in sync with AnthropicProvider._FALLBACK_MODELS — the list the
|
||||
# provider itself falls back to when /v1/models cannot be reached.
|
||||
models=("claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"),
|
||||
max_context=200000,
|
||||
supports_vision=True,
|
||||
),
|
||||
ProviderDescriptor(
|
||||
provider_id="ollama",
|
||||
display_name="Ollama (local)",
|
||||
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||
# A local runtime needs no credential; Settings must not demand one.
|
||||
auth_kind=AuthKind.NONE,
|
||||
default_model="llama3.1",
|
||||
supports_vision=False,
|
||||
requires_base_url=True,
|
||||
),
|
||||
ProviderDescriptor(
|
||||
provider_id="github_copilot",
|
||||
display_name="GitHub Copilot",
|
||||
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||
# The credential is a Copilot token minted by an external login flow,
|
||||
# not a self-service API key.
|
||||
auth_kind=AuthKind.OAUTH_TOKEN,
|
||||
default_model="gpt-4o",
|
||||
models=("gpt-4o", "gpt-4o-mini"),
|
||||
max_context=128000,
|
||||
supports_vision=True,
|
||||
),
|
||||
ProviderDescriptor(
|
||||
provider_id="codex",
|
||||
display_name="OpenAI",
|
||||
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||
auth_kind=AuthKind.API_KEY,
|
||||
default_model="gpt-4o-mini",
|
||||
models=("gpt-4o", "gpt-4o-mini", "o1", "o3"),
|
||||
max_context=128000,
|
||||
supports_vision=True,
|
||||
# Historic config key: early builds stored this provider as "openai".
|
||||
aliases=("openai",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ProviderNotFoundError(LookupError):
|
||||
"""Raised when no descriptor answers to the requested provider id.
|
||||
|
||||
A dedicated type (rather than bare ``KeyError``) lets callers distinguish
|
||||
"this provider is not in the catalogue" from an unrelated dict miss, and
|
||||
keeps the message actionable by listing what IS registered.
|
||||
"""
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""Thread-safe catalogue of :class:`ProviderDescriptor` records.
|
||||
|
||||
Thread-safety matters because model discovery runs on background worker
|
||||
threads (the routing prober, Settings' "Load models") and republishes an
|
||||
updated descriptor via :meth:`replace`, while chat turns on other threads
|
||||
are reading the catalogue concurrently.
|
||||
"""
|
||||
|
||||
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 ():
|
||||
self.register(descriptor)
|
||||
|
||||
# -- registration --------------------------------------------------- #
|
||||
def register(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
|
||||
"""Add a descriptor. Refuses to silently overwrite an existing id so a
|
||||
typo in a plugin cannot hijack a built-in provider; use :meth:`replace`
|
||||
when an update is the actual intent."""
|
||||
with self._lock:
|
||||
existing = self._by_id.get(descriptor.provider_id)
|
||||
if existing is not None and existing != descriptor:
|
||||
raise ValueError(
|
||||
f"Provider '{descriptor.provider_id}' is already registered; "
|
||||
"call replace() to update it."
|
||||
)
|
||||
self._by_id[descriptor.provider_id] = descriptor
|
||||
return descriptor
|
||||
|
||||
def replace(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
|
||||
"""Register or update a descriptor unconditionally — the path model
|
||||
discovery uses to publish a freshly enumerated model list."""
|
||||
with self._lock:
|
||||
self._by_id[descriptor.provider_id] = descriptor
|
||||
return descriptor
|
||||
|
||||
# -- lookup ---------------------------------------------------------- #
|
||||
def get(self, provider_id: str) -> ProviderDescriptor:
|
||||
"""Descriptor for ``provider_id`` (canonical id or alias).
|
||||
|
||||
Raises :class:`ProviderNotFoundError` rather than returning ``None`` so
|
||||
a misconfigured provider fails loudly at the call site instead of
|
||||
surfacing later as an ``AttributeError`` on ``None``.
|
||||
"""
|
||||
found = self.find(provider_id)
|
||||
if found is None:
|
||||
known = ", ".join(sorted(self._by_id)) or "<empty registry>"
|
||||
raise ProviderNotFoundError(
|
||||
f"Unsupported provider: {provider_id!r}. Registered: {known}"
|
||||
)
|
||||
return found
|
||||
|
||||
def find(self, provider_id: str) -> Optional[ProviderDescriptor]:
|
||||
"""Non-raising :meth:`get` — ``None`` when nothing matches."""
|
||||
needle = (provider_id or "").strip()
|
||||
if not needle:
|
||||
return None
|
||||
with self._lock:
|
||||
direct = self._by_id.get(needle)
|
||||
if direct is not None:
|
||||
return direct
|
||||
# Fall back to a case-insensitive id/alias scan; order is stable
|
||||
# because dicts preserve insertion order, so the earliest-registered
|
||||
# provider wins a tie.
|
||||
for descriptor in self._by_id.values():
|
||||
if descriptor.matches(needle):
|
||||
return descriptor
|
||||
return None
|
||||
|
||||
def find_by_model(self, model_id: str) -> Optional[ProviderDescriptor]:
|
||||
"""Resolve a bare model id back to the provider that serves it.
|
||||
|
||||
This is the "dynamic lookup by model ID" R03-T02 calls for: routing
|
||||
decisions and saved conversations sometimes carry only a model name, and
|
||||
the caller still needs to know which provider to build. Returns ``None``
|
||||
when the model belongs to a gateway whose catalogue we cannot enumerate
|
||||
offline — callers then fall back to the configured active provider.
|
||||
"""
|
||||
needle = (model_id or "").strip()
|
||||
if not needle:
|
||||
return None
|
||||
with self._lock:
|
||||
for descriptor in self._by_id.values():
|
||||
if descriptor.knows_model(needle):
|
||||
return descriptor
|
||||
return None
|
||||
|
||||
def all(self) -> List[ProviderDescriptor]:
|
||||
"""Every registered descriptor, in registration order (snapshot copy —
|
||||
safe to iterate while another thread registers)."""
|
||||
with self._lock:
|
||||
return list(self._by_id.values())
|
||||
|
||||
def ids(self) -> List[str]:
|
||||
"""Canonical provider ids, sorted for stable UI/reporting output."""
|
||||
with self._lock:
|
||||
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)
|
||||
|
||||
# -- construction ---------------------------------------------------- #
|
||||
def adapter_class(self, provider_id: str):
|
||||
"""Concrete ``Provider`` subclass implementing this provider's protocol.
|
||||
|
||||
The adapters are imported here (not at module import) so the pure
|
||||
routing/domain code can consult the catalogue without loading
|
||||
``requests`` and the whole HTTP stack.
|
||||
"""
|
||||
descriptor = self.get(provider_id)
|
||||
from ...providers.anthropic import AnthropicProvider
|
||||
from ...providers.openai_compat import OpenAICompatProvider
|
||||
|
||||
protocol_to_class = {
|
||||
WireProtocol.OPENAI_COMPAT: OpenAICompatProvider,
|
||||
WireProtocol.ANTHROPIC: AnthropicProvider,
|
||||
}
|
||||
adapter = protocol_to_class.get(descriptor.wire_protocol)
|
||||
if adapter is None: # pragma: no cover — unreachable while the map is total
|
||||
raise ProviderNotFoundError(
|
||||
f"No adapter implements wire protocol {descriptor.wire_protocol!r}"
|
||||
)
|
||||
return adapter
|
||||
|
||||
def build(self, provider_id: str, conf: Dict[str, Any]):
|
||||
"""Instantiate a ready-to-use provider adapter.
|
||||
|
||||
The descriptor's ``default_model`` fills in a missing/blank ``model`` so
|
||||
a half-written config still produces a working provider instead of an
|
||||
empty model id that only fails once the request hits the gateway.
|
||||
"""
|
||||
descriptor = self.get(provider_id)
|
||||
adapter = self.adapter_class(descriptor.provider_id)
|
||||
merged = dict(conf or {})
|
||||
merged["model"] = descriptor.resolve_model(merged.get("model", ""))
|
||||
return adapter(merged)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Process-wide default registry.
|
||||
#
|
||||
# Built lazily under a lock: several UI screens can ask for it during startup
|
||||
# from different threads, and double-construction would hand out two catalogues
|
||||
# whose discovered model lists then drift apart.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_default_registry: Optional[ProviderRegistry] = None
|
||||
_default_lock = threading.Lock()
|
||||
|
||||
|
||||
def default_registry() -> ProviderRegistry:
|
||||
"""The shared registry seeded with :data:`BUILTIN_DESCRIPTORS`."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
with _default_lock:
|
||||
if _default_registry is None:
|
||||
_default_registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
|
||||
return _default_registry
|
||||
|
||||
|
||||
def reset_default_registry() -> None:
|
||||
"""Drop the cached registry — test-support hook so one test's registrations
|
||||
cannot leak into the next."""
|
||||
global _default_registry
|
||||
with _default_lock:
|
||||
_default_registry = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BUILTIN_DESCRIPTORS",
|
||||
"ProviderNotFoundError",
|
||||
"ProviderRegistry",
|
||||
"default_registry",
|
||||
"reset_default_registry",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Qt-backed adapters for pure interfaces used elsewhere in the app (EPIC R07).
|
||||
|
||||
Note: the original plan (``docs/refactor/Feature_Architecture_Proposal.md``)
|
||||
placed this adapter at a new top-level ``platform/qt/`` package. That name
|
||||
was dropped after it was shown to actually shadow the stdlib ``platform``
|
||||
module (used by ``core/windows_sandbox_vm.py``/``core/appcontainer_sandbox.
|
||||
py``) whenever the repo root ends up on ``sys.path`` directly - e.g. running
|
||||
``python -c "..."`` (or any script) with the repo root as the working
|
||||
directory, which resolves a bare ``import platform`` to this package instead
|
||||
of the standard library one. ``infrastructure/`` already exists as a layer
|
||||
for exactly this kind of toolkit-specific implementation
|
||||
(``infrastructure/filesystem/``, ``infrastructure/mcp/``, ...), so the
|
||||
adapter lives here instead - same content, safer location.
|
||||
"""
|
||||
|
||||
from .qt_scheduler_clock import QtSchedulerClock
|
||||
|
||||
__all__ = ["QtSchedulerClock"]
|
||||
@@ -0,0 +1,75 @@
|
||||
"""QtSchedulerClock - the ``QTimer``-backed periodic ticker `TaskScheduler`
|
||||
needs, pulled out from ``core/task_scheduler.py`` into its own adapter
|
||||
(R07-T03).
|
||||
|
||||
``core/task_scheduler.py::TaskScheduler`` is the only file in the scheduling
|
||||
stack that imports Qt at all (confirmed by grep — ``core/tasks.py`` and
|
||||
``core/task_executors.py`` are Qt-free). Everything it needs Qt FOR is small
|
||||
and mechanical: an interval timer that calls back into ``tick()`` every
|
||||
``TICK_MS``, plus, during ``stop()``, a way to pump the event loop so a
|
||||
worker thread's queued ``finished_ok``/``failed`` signal still gets delivered
|
||||
while draining running tasks (see the long comment on ``TaskScheduler.stop()``
|
||||
for why that pump matters).
|
||||
|
||||
Wrapping exactly that surface — ``start(interval_ms, callback)``, ``stop()``,
|
||||
``pump()`` — behind :class:`QtSchedulerClock` lets ``TaskScheduler`` take a
|
||||
clock as a constructor parameter instead of constructing a ``QTimer``
|
||||
itself. Production wiring is unchanged (``TaskScheduler`` defaults to a real
|
||||
``QtSchedulerClock`` when no clock is passed); tests can inject
|
||||
``tests/fakes/fake_clock.py::FakeClock`` to control ticks by hand with no Qt
|
||||
event loop running at all.
|
||||
|
||||
See ``infrastructure/qt/__init__.py`` for why this lives under
|
||||
``infrastructure/qt/`` and not the ``platform/qt/`` path the original plan
|
||||
named.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QTimer
|
||||
|
||||
|
||||
class QtSchedulerClock:
|
||||
"""Owns one ``QTimer``. Not itself a ``QObject`` subclass — it OWNS a
|
||||
``QObject``-parented timer instead of inheriting from one, so callers
|
||||
(like ``FakeClock`` in tests) can satisfy the same duck-typed interface
|
||||
without any Qt base class at all."""
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||
# 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()
|
||||
|
||||
def start(self, interval_ms: int, callback: Callable[[], None]) -> None:
|
||||
"""Arm and start the timer. Calling this again while already
|
||||
running re-arms it with the new interval/callback (matches
|
||||
``QTimer.start()``'s own restart-on-repeat-call behaviour)."""
|
||||
self._callback = callback
|
||||
self._timer.setInterval(interval_ms)
|
||||
self._timer.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Dừng nhịp đếm."""
|
||||
self._timer.stop()
|
||||
|
||||
def pump(self) -> None:
|
||||
"""Process one batch of pending Qt events — used by
|
||||
``TaskScheduler.stop()``'s bounded drain loop so a worker thread's
|
||||
queued completion signal can still be delivered while we wait for it
|
||||
to exit."""
|
||||
QCoreApplication.processEvents()
|
||||
|
||||
|
||||
__all__ = ["QtSchedulerClock"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure sandbox package: OS-specific sandbox capability adapters."""
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Sandbox capability matrix — which isolation backends exist on which OS,
|
||||
and which one a given risk tier should prefer.
|
||||
|
||||
Pure policy/data: no subprocess execution, no PySide6, no dependency on
|
||||
``core/sandbox_manager.py`` (that module owns the actual execution and isn't
|
||||
in this task's editable scope — this matrix is a standalone, independently
|
||||
testable module ready for that module's owner to wire in later).
|
||||
|
||||
The Windows entries mirror what ``core/sandbox_manager.py`` +
|
||||
``core/appcontainer_sandbox.py``/``core/windows_sandbox_vm.py``/
|
||||
``core/integrity_sandbox.py`` already implement today. Linux/macOS entries
|
||||
are declared but marked ``implemented=False`` — today those platforms have no
|
||||
real isolation backend (confirmed: ``core/appcontainer_sandbox.py`` and
|
||||
``core/windows_sandbox_vm.py`` both hard-return ``False`` off Windows) — so
|
||||
this matrix reports that honestly instead of pretending capabilities that
|
||||
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
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
# Plain string constants (like core/audit_log.py's ``Kind``) rather than an
|
||||
# Enum, so a brand-new OS can be registered without editing a closed type.
|
||||
WINDOWS = "windows"
|
||||
LINUX = "linux"
|
||||
MACOS = "macos"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
# Risk tiers — same vocabulary as security/command_risk_classifier.RiskLevel,
|
||||
# kept as plain strings here so this module has zero dependency on the
|
||||
# ``security/`` package (out of scope for this task).
|
||||
SAFE = "SAFE"
|
||||
MODERATE = "MODERATE"
|
||||
HIGH = "HIGH"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
BLOCKED = "blocked"
|
||||
DIRECT = "direct"
|
||||
|
||||
|
||||
def detect_os(platform_name: Optional[str] = None) -> str:
|
||||
"""``platform_name`` defaults to ``sys.platform`` but can be injected for
|
||||
testing (e.g. ``detect_os("linux")``, ``detect_os("darwin")``)."""
|
||||
name = platform_name if platform_name is not None else sys.platform
|
||||
if name.startswith("win"):
|
||||
return WINDOWS
|
||||
if name.startswith("linux"):
|
||||
return LINUX
|
||||
if name.startswith("darwin"):
|
||||
return MACOS
|
||||
return UNKNOWN
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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)
|
||||
routing: Dict[str, Tuple[str, ...]]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
_WINDOWS_PROFILE = _profile(
|
||||
WINDOWS,
|
||||
backends=(
|
||||
SandboxBackend(DIRECT, "none", True),
|
||||
SandboxBackend("integrity_job_wfp", "resource_limits", True),
|
||||
SandboxBackend("appcontainer", "restricted_token", True),
|
||||
SandboxBackend("windows_sandbox", "full_vm", True),
|
||||
),
|
||||
routing={
|
||||
SAFE: ("integrity_job_wfp", DIRECT),
|
||||
MODERATE: ("integrity_job_wfp", DIRECT),
|
||||
HIGH: ("appcontainer", "integrity_job_wfp"),
|
||||
CRITICAL: ("windows_sandbox", "appcontainer", BLOCKED),
|
||||
},
|
||||
)
|
||||
|
||||
_LINUX_PROFILE = _profile(
|
||||
LINUX,
|
||||
backends=(
|
||||
SandboxBackend(DIRECT, "none", True),
|
||||
SandboxBackend("namespaces_bubblewrap", "namespace", False), # not implemented yet
|
||||
),
|
||||
routing={
|
||||
SAFE: (DIRECT,),
|
||||
MODERATE: (DIRECT,),
|
||||
HIGH: ("namespaces_bubblewrap", BLOCKED),
|
||||
CRITICAL: (BLOCKED,),
|
||||
},
|
||||
)
|
||||
|
||||
_MACOS_PROFILE = _profile(
|
||||
MACOS,
|
||||
backends=(
|
||||
SandboxBackend(DIRECT, "none", True),
|
||||
SandboxBackend("sandbox_exec", "seatbelt", False), # not implemented yet
|
||||
),
|
||||
routing={
|
||||
SAFE: (DIRECT,),
|
||||
MODERATE: (DIRECT,),
|
||||
HIGH: ("sandbox_exec", BLOCKED),
|
||||
CRITICAL: (BLOCKED,),
|
||||
},
|
||||
)
|
||||
|
||||
_UNKNOWN_PROFILE = _profile(
|
||||
UNKNOWN,
|
||||
backends=(),
|
||||
routing={SAFE: (BLOCKED,), MODERATE: (BLOCKED,), HIGH: (BLOCKED,), CRITICAL: (BLOCKED,)},
|
||||
)
|
||||
|
||||
_PROFILES: Dict[str, OsSandboxProfile] = {
|
||||
WINDOWS: _WINDOWS_PROFILE,
|
||||
LINUX: _LINUX_PROFILE,
|
||||
MACOS: _MACOS_PROFILE,
|
||||
UNKNOWN: _UNKNOWN_PROFILE,
|
||||
}
|
||||
|
||||
|
||||
def register_profile(profile: OsSandboxProfile) -> None:
|
||||
"""Extension point for a brand-new OS: build an :class:`OsSandboxProfile`
|
||||
and register it once — no change to :class:`SandboxCapabilityMatrix`
|
||||
needed. Overwrites any existing profile for the same
|
||||
``operating_system`` name (lets a caller override the built-in Windows/
|
||||
Linux/macOS profiles too, e.g. once a real Linux backend ships)."""
|
||||
_PROFILES[profile.operating_system] = profile
|
||||
|
||||
|
||||
class SandboxCapabilityMatrix:
|
||||
"""Answers, for one OS: which backends are actually available today, and
|
||||
which one a given risk tier should prefer. Read-only policy — does not
|
||||
execute anything."""
|
||||
|
||||
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
|
||||
|
||||
def all_backends(self) -> Tuple[SandboxBackend, ...]:
|
||||
"""Every backend declared for this OS, implemented or not."""
|
||||
return self._profile.backends
|
||||
|
||||
def available_backends(self) -> Tuple[SandboxBackend, ...]:
|
||||
"""Only backends with a real implementation today."""
|
||||
return tuple(b for b in self._profile.backends if b.implemented)
|
||||
|
||||
def select_backend(self, risk_level: str) -> str:
|
||||
"""The backend name to use for ``risk_level`` on this OS — the first
|
||||
available (implemented) backend in that tier's preference order, else
|
||||
``"direct"`` when allowed for a non-CRITICAL tier, else ``"blocked"``."""
|
||||
available_names = {b.name for b in self.available_backends()}
|
||||
preferred = self._profile.routing.get(risk_level.upper(), ())
|
||||
for name in preferred:
|
||||
if name == BLOCKED:
|
||||
return BLOCKED
|
||||
if name in available_names:
|
||||
return name
|
||||
if (self.allow_direct_fallback and DIRECT in available_names
|
||||
and risk_level.upper() != CRITICAL):
|
||||
return DIRECT
|
||||
return BLOCKED
|
||||
@@ -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).
|
||||
"""
|
||||
@@ -0,0 +1,99 @@
|
||||
"""SecretStore chạy trên OS Keyring — R02-T04.
|
||||
|
||||
Windows dùng Credential Manager, macOS dùng Keychain, Linux dùng Secret
|
||||
Service. Người dùng cuối không thấy gì khác, nhưng API key thôi nằm trong
|
||||
``config.json`` — đó là điều kiện để qua CASAN Check 1.
|
||||
|
||||
Không phải máy nào cũng có keyring dùng được: Linux chạy headless không có
|
||||
Secret Service, và CI thì gần như chắc chắn không. Nên adapter này **không bao
|
||||
giờ ném lỗi** — không dùng được thì tự báo ``available = False`` và trả về
|
||||
None, để tầng trên hiển thị "chưa lưu được khoá" thay vì sập cả app.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: Tên "dịch vụ" trong keyring — mọi khoá của app nằm dưới đây.
|
||||
SERVICE = "cowork-local"
|
||||
|
||||
|
||||
class KeyringAdapter:
|
||||
"""Cài đặt :class:`SecretStore` bằng thư viện ``keyring``.
|
||||
|
||||
>>> store = KeyringAdapter()
|
||||
>>> if store.available:
|
||||
... store.set("provider:openai", "sk-...")
|
||||
"""
|
||||
|
||||
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
|
||||
try:
|
||||
import keyring
|
||||
from keyring.backends.fail import Keyring as FailKeyring
|
||||
|
||||
backend = keyring.get_keyring()
|
||||
# backend "fail" là cái keyring trả về khi không tìm được kho nào
|
||||
# dùng được — gọi vào chỉ tổ ném lỗi.
|
||||
if not isinstance(backend, FailKeyring):
|
||||
self._backend = keyring
|
||||
self._available = True
|
||||
else:
|
||||
log.info("keyring không có kho khả dụng trên máy này")
|
||||
except Exception as exc: # noqa: BLE001 — thiếu thư viện, thiếu DBus…
|
||||
log.info("keyring không dùng được: %s", exc)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Có kho bí mật dùng được không.
|
||||
|
||||
Tầng giao diện đọc cờ này để nói cho người dùng biết vì sao ô API key
|
||||
không lưu được, thay vì im lặng làm mất khoá họ vừa nhập.
|
||||
"""
|
||||
return self._available
|
||||
|
||||
# ---- 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:
|
||||
return self._backend.get_password(self.service, key)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("đọc khoá %r thất bại: %s", key, exc)
|
||||
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
|
||||
try:
|
||||
self._backend.set_password(self.service, key, value)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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:
|
||||
self._backend.delete_password(self.service, key)
|
||||
except Exception: # noqa: BLE001 — xoá cái không có: bỏ qua
|
||||
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
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Nơi cất credential — interface, chưa phải cài đặt.
|
||||
|
||||
Hợp đồng số 1 của mục chung: chốt hôm nay để N2 và N3 code được ngay, không
|
||||
phải đợi bản Keyring thật (R02-T04, hạn 26/08).
|
||||
|
||||
Vì sao là interface chứ không phải hàm tiện ích: bản thật sẽ gọi OS Keyring —
|
||||
chậm, có thể ném lỗi, và trong test thì không được đụng vào keyring máy thật.
|
||||
Có interface thì test tiêm ``FakeSecretStore`` vào, chạy trong bộ nhớ.
|
||||
|
||||
Quy ước đặt key: ``"provider:<tên>"`` cho API key của provider, ví dụ
|
||||
``"provider:openai"``. Đặt sẵn để không mỗi người tự nghĩ một kiểu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
def provider_key(name: str) -> str:
|
||||
"""Key chuẩn cho API key của một provider."""
|
||||
return f"provider:{name}"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SecretStore(Protocol):
|
||||
"""Đọc/ghi bí mật. Cài đặt thật: ``KeyringAdapter`` (R02-T04)."""
|
||||
|
||||
def get(self, key: str) -> str | None:
|
||||
"""Giá trị của ``key``, hoặc None nếu chưa có.
|
||||
|
||||
Không được ném lỗi khi thiếu key — thiếu là chuyện bình thường (người
|
||||
dùng chưa nhập API key), không phải sự cố.
|
||||
"""
|
||||
...
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
"""Lưu ``value``. Ghi đè nếu key đã tồn tại."""
|
||||
...
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Xoá ``key``. Không có sẵn thì im lặng bỏ qua, không ném lỗi."""
|
||||
...
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Có key này chưa — dùng cho màn Cài đặt hiển thị trạng thái mà không
|
||||
cần đọc chính giá trị bí mật ra."""
|
||||
...
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Infrastructure telemetry package: CanonicalAuditLogger and token usage sinks."""
|
||||
|
||||
from .audit_logger import CanonicalAuditEvent, CanonicalAuditLogger
|
||||
from .usage_sink import (
|
||||
CompositeUsageSink,
|
||||
InMemoryUsageSink,
|
||||
UsageEvent,
|
||||
UsageEventSink,
|
||||
UsageTrackerSink,
|
||||
estimate_tokens,
|
||||
get_usage_sink,
|
||||
publish,
|
||||
set_usage_sink,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CanonicalAuditEvent",
|
||||
"CanonicalAuditLogger",
|
||||
"CompositeUsageSink",
|
||||
"InMemoryUsageSink",
|
||||
"UsageEvent",
|
||||
"UsageEventSink",
|
||||
"UsageTrackerSink",
|
||||
"estimate_tokens",
|
||||
"get_usage_sink",
|
||||
"publish",
|
||||
"set_usage_sink",
|
||||
"subscribe",
|
||||
"unsubscribe",
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Canonical audit event logging — the infrastructure behind
|
||||
``core/audit_log.py``'s ``set_identity``/``record``/``load_events`` free
|
||||
functions (kept as thin wrappers over a module-level singleton for backward
|
||||
compatibility with every existing call site).
|
||||
|
||||
Same on-disk shape as before: one JSON line per event, one file per day
|
||||
under ``~/.cowork_local/audit/`` (plus a best-effort mirror into a shared
|
||||
cross-machine folder when an identity's ``shared_dir`` is set). ``record()``
|
||||
never raises — audit logging must never break a chat turn, a permission
|
||||
decision, or a tool call.
|
||||
|
||||
The event schema is unchanged (same field names, same order) so every
|
||||
``.jsonl`` file written before this refactor remains fully readable. New
|
||||
event kinds can be added by defining another ``KIND_*`` constant — nothing
|
||||
about the schema itself needs to change to support one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Known kinds today. ``kind`` stays a plain str (not an enum) so a caller can
|
||||
# always pass a new value without editing this module — these constants are
|
||||
# just the documented, current vocabulary.
|
||||
KIND_TOOL_CALL = "tool_call"
|
||||
KIND_PERMISSION = "permission"
|
||||
KIND_SECURITY_BLOCK = "security_block"
|
||||
KIND_MCP_CALL = "mcp_call"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanonicalAuditEvent:
|
||||
"""One audit log entry. Field order matches the pre-refactor
|
||||
``core/audit_log.py`` schema exactly, for byte-compatible JSON output."""
|
||||
|
||||
ts: str
|
||||
kind: str
|
||||
agent_role: str
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str
|
||||
account: str
|
||||
role: str
|
||||
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,
|
||||
"agent_role": self.agent_role,
|
||||
"name": self.name,
|
||||
"ok": self.ok,
|
||||
"detail": self.detail,
|
||||
"account": self.account,
|
||||
"role": self.role,
|
||||
"machine": self.machine,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: Dict[str, Any]) -> "CanonicalAuditEvent":
|
||||
"""Tolerant of missing keys, so old/partial rows never fail to load."""
|
||||
return cls(
|
||||
ts=str(raw.get("ts", "")),
|
||||
kind=str(raw.get("kind", "")),
|
||||
agent_role=str(raw.get("agent_role", "")),
|
||||
name=str(raw.get("name", "")),
|
||||
ok=bool(raw.get("ok", False)),
|
||||
detail=str(raw.get("detail", "")),
|
||||
account=str(raw.get("account", "")),
|
||||
role=str(raw.get("role", "")),
|
||||
machine=str(raw.get("machine", "")),
|
||||
)
|
||||
|
||||
|
||||
@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 = ""
|
||||
shared_dir: str = ""
|
||||
|
||||
|
||||
class CanonicalAuditLogger:
|
||||
"""Day-sharded JSONL audit writer/reader. Process identity (who's logged
|
||||
in, this machine's name) is set once via :meth:`set_identity`, mirroring
|
||||
the pre-refactor module-global pattern but held as instance state so this
|
||||
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()
|
||||
|
||||
def set_identity(self, account: str, machine: str, role: str = "",
|
||||
shared_dir: str = "") -> None:
|
||||
"""Called once after login succeeds. ``shared_dir``, when reachable,
|
||||
makes every subsequent :meth:`record` ALSO best-effort-append to the
|
||||
shared cross-machine telemetry store."""
|
||||
self._identity = _Identity(account=account or "", role=role or "",
|
||||
machine=machine or "", shared_dir=shared_dir or "")
|
||||
|
||||
def record(self, kind: str, name: str, ok: bool, detail: str = "",
|
||||
agent_role: str = "") -> None:
|
||||
"""Append one audit event. Never raises."""
|
||||
try:
|
||||
now = datetime.now()
|
||||
event = CanonicalAuditEvent(
|
||||
ts=now.isoformat(timespec="seconds"),
|
||||
kind=kind,
|
||||
agent_role=agent_role or "",
|
||||
name=name or "",
|
||||
ok=bool(ok),
|
||||
detail=(detail or "")[:2000],
|
||||
account=self._identity.account,
|
||||
role=self._identity.role,
|
||||
machine=self._identity.machine,
|
||||
)
|
||||
self.audit_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = self.audit_dir / f"{now.strftime('%Y-%m-%d')}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event.to_dict(), ensure_ascii=False) + "\n")
|
||||
self._write_shared(event, now)
|
||||
except Exception: # noqa: BLE001
|
||||
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
|
||||
try:
|
||||
shared = Path(identity.shared_dir).expanduser() / "telemetry" / "audit"
|
||||
shared.mkdir(parents=True, exist_ok=True)
|
||||
path = shared / f"{identity.machine}-{now.strftime('%Y-%m-%d')}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event.to_dict(), ensure_ascii=False) + "\n")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def load_events(self, start: Optional[date] = None, end: Optional[date] = None,
|
||||
kind: Optional[str] = None,
|
||||
directory: Optional[Path] = None) -> List[CanonicalAuditEvent]:
|
||||
"""Events between ``start``/``end`` (inclusive; None = unbounded),
|
||||
optionally filtered to one ``kind``."""
|
||||
directory = directory or self.audit_dir
|
||||
if not directory.exists():
|
||||
return []
|
||||
events: List[CanonicalAuditEvent] = []
|
||||
for path in sorted(directory.glob("*.jsonl")):
|
||||
try:
|
||||
day = datetime.strptime(path.stem, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
if (start and day < start) or (end and day > end):
|
||||
continue
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
raw = json.loads(line)
|
||||
if kind is not None and raw.get("kind") != kind:
|
||||
continue
|
||||
events.append(CanonicalAuditEvent.from_dict(raw))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
return events
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Token-usage telemetry as a publish/subscribe seam (R03-T06).
|
||||
|
||||
Before this module every provider adapter reached straight into
|
||||
``core/usage_tracker.py`` and wrote a dashboard row itself, which meant the
|
||||
provider layer owned a telemetry policy decision ("where do usage numbers go?")
|
||||
and no test could observe a turn's token accounting without touching the real
|
||||
``~/.cowork_local/usage/`` files.
|
||||
|
||||
Now a provider only *describes what happened* — it publishes an immutable
|
||||
:class:`UsageEvent` — and subscribers decide what to do with it. The default
|
||||
subscriber, :class:`UsageTrackerSink`, forwards to the existing usage tracker so
|
||||
the Dashboard keeps working byte-for-byte; tests swap in
|
||||
:class:`InMemoryUsageSink` and assert on the events directly.
|
||||
|
||||
Every publish path is failure-tolerant on purpose: telemetry must never be the
|
||||
reason a chat turn dies, which is the same contract
|
||||
``usage_tracker.record()`` already documents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
logger = logging.getLogger("cowork_local.telemetry.usage")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageEvent:
|
||||
"""One provider turn's token accounting.
|
||||
|
||||
Frozen so a subscriber cannot mutate an event the next subscriber in the
|
||||
chain is about to receive. ``source``/``label`` stay optional: the usage
|
||||
tracker already derives them from thread-local context set by whoever ran
|
||||
the turn, and a provider adapter has no business knowing which UI surface
|
||||
invoked it.
|
||||
"""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cached_tokens: int = 0
|
||||
# True when the counts are a ~4-chars-per-token approximation because the
|
||||
# gateway never sent a usage block. Surfaced in the Dashboard so users know
|
||||
# which rows are measured and which are guessed.
|
||||
estimated: bool = False
|
||||
source: Optional[str] = None # None -> tracker's thread-local context
|
||||
label: Optional[str] = None # None -> tracker's thread-local context
|
||||
extras: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
"""Billable token count for this turn (cached tokens are already part
|
||||
of the input count reported by every gateway we support, so adding them
|
||||
again would double-count)."""
|
||||
return int(self.input_tokens) + int(self.output_tokens)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON-friendly view, using the same short keys as the usage tracker's
|
||||
on-disk rows so a caller can diff an event against a stored row."""
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"in": int(self.input_tokens),
|
||||
"out": int(self.output_tokens),
|
||||
"cache": int(self.cached_tokens),
|
||||
"estimated": bool(self.estimated),
|
||||
"source": self.source or "",
|
||||
"label": self.label or "",
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UsageEventSink(Protocol):
|
||||
"""Anything that can receive :class:`UsageEvent`s.
|
||||
|
||||
A ``Protocol`` rather than a base class so a plain object (or a test double,
|
||||
or a Qt-side adapter that re-emits a signal) qualifies without inheriting
|
||||
from infrastructure code.
|
||||
"""
|
||||
|
||||
def emit(self, event: UsageEvent) -> None:
|
||||
"""Handle one usage event. Implementations MUST NOT raise."""
|
||||
|
||||
|
||||
class UsageTrackerSink:
|
||||
"""Default subscriber: writes each event through ``core/usage_tracker.py``.
|
||||
|
||||
Keeps the existing Dashboard/telemetry pipeline (daily JSONL files, shared
|
||||
cross-machine mirror, per-thread accumulator) as the single writer, so
|
||||
routing this through an event seam changed the plumbing without changing
|
||||
a single stored byte.
|
||||
"""
|
||||
|
||||
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):
|
||||
"""Late-bind ``usage_tracker.record``.
|
||||
|
||||
Imported on first use rather than at module import so telemetry stays
|
||||
out of the import graph of anything that merely *declares* a sink.
|
||||
"""
|
||||
if self._recorder is None:
|
||||
from ...core import usage_tracker as tracker
|
||||
|
||||
self._recorder = tracker.record
|
||||
return self._recorder
|
||||
|
||||
def emit(self, event: UsageEvent) -> None:
|
||||
"""Forward one event; swallow every failure (telemetry is never fatal)."""
|
||||
try:
|
||||
record = self._resolve_recorder()
|
||||
if event.source is None:
|
||||
# Normal path: the worker thread already tagged its own
|
||||
# source/label via set_context(), so record() attributes the row.
|
||||
record(
|
||||
event.provider, event.model,
|
||||
int(event.input_tokens), int(event.output_tokens),
|
||||
int(event.cached_tokens), estimated=bool(event.estimated),
|
||||
)
|
||||
return
|
||||
|
||||
# Event carries its own attribution: apply it for this single write
|
||||
# and restore the thread's previous context afterwards, so a
|
||||
# re-attributed event cannot silently relabel every later turn that
|
||||
# runs on the same worker thread.
|
||||
from ...core import usage_tracker as tracker
|
||||
|
||||
previous_source, previous_label = tracker.current_context()
|
||||
tracker.set_context(event.source, event.label or "")
|
||||
try:
|
||||
record(
|
||||
event.provider, event.model,
|
||||
int(event.input_tokens), int(event.output_tokens),
|
||||
int(event.cached_tokens), estimated=bool(event.estimated),
|
||||
)
|
||||
finally:
|
||||
tracker.set_context(previous_source, previous_label)
|
||||
except Exception: # noqa: BLE001 — usage tracking must never break a turn
|
||||
logger.debug("usage sink: forwarding to usage_tracker failed", exc_info=True)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
def emit(self, event: UsageEvent) -> None:
|
||||
"""Append under a lock: parallel Co4E flows publish from several worker
|
||||
threads at once and ``list.append`` alone would still be atomic, but the
|
||||
lock also makes :meth:`snapshot` a consistent read."""
|
||||
with self._lock:
|
||||
self.events.append(event)
|
||||
|
||||
def snapshot(self) -> List[UsageEvent]:
|
||||
"""A copy of everything received so far."""
|
||||
with self._lock:
|
||||
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())
|
||||
|
||||
|
||||
class CompositeUsageSink:
|
||||
"""Fans one event out to several subscribers.
|
||||
|
||||
This is what makes the seam useful beyond the Dashboard: a future consumer
|
||||
(per-workspace budget guard, live cost meter) subscribes alongside the
|
||||
tracker instead of patching provider code again. One failing subscriber is
|
||||
logged and skipped so it cannot starve the others.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
def remove(self, sink: UsageEventSink) -> None:
|
||||
"""Detach a subscriber; a sink that was never added is ignored so
|
||||
teardown code can call this unconditionally."""
|
||||
with self._lock:
|
||||
if sink in self._sinks:
|
||||
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)
|
||||
except Exception: # noqa: BLE001 — one bad subscriber must not stop the rest
|
||||
logger.debug("usage sink: subscriber %r failed", sink, exc_info=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Process-wide sink.
|
||||
#
|
||||
# Providers publish through the module-level helpers below rather than holding a
|
||||
# sink reference, because a provider instance is created fresh for every turn
|
||||
# (see AppContext.build_provider_for) and would otherwise have to be handed the
|
||||
# telemetry wiring on every construction.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_sink_lock = threading.RLock()
|
||||
_sink: Optional[CompositeUsageSink] = None
|
||||
|
||||
|
||||
def get_usage_sink() -> CompositeUsageSink:
|
||||
"""The shared sink, seeded with :class:`UsageTrackerSink` on first use."""
|
||||
global _sink
|
||||
if _sink is None:
|
||||
with _sink_lock:
|
||||
if _sink is None:
|
||||
_sink = CompositeUsageSink([UsageTrackerSink()])
|
||||
return _sink
|
||||
|
||||
|
||||
def set_usage_sink(sink: Optional[CompositeUsageSink]) -> None:
|
||||
"""Replace the shared sink (``None`` restores the default on next use).
|
||||
|
||||
Used by tests and by the app shell when it wants a different fan-out; kept
|
||||
explicit so nothing silently reconfigures telemetry mid-run.
|
||||
"""
|
||||
global _sink
|
||||
with _sink_lock:
|
||||
_sink = sink
|
||||
|
||||
|
||||
def subscribe(sink: UsageEventSink) -> UsageEventSink:
|
||||
"""Attach an extra subscriber to the shared sink and return it (so callers
|
||||
can keep the handle for a later :func:`unsubscribe`)."""
|
||||
get_usage_sink().add(sink)
|
||||
return sink
|
||||
|
||||
|
||||
def unsubscribe(sink: UsageEventSink) -> None:
|
||||
"""Detach a subscriber previously passed to :func:`subscribe`."""
|
||||
get_usage_sink().remove(sink)
|
||||
|
||||
|
||||
def publish(event: UsageEvent) -> None:
|
||||
"""Publish one usage event to every subscriber.
|
||||
|
||||
Never raises: called from inside a provider's streaming loop, where an
|
||||
exception would abort an otherwise successful turn.
|
||||
"""
|
||||
try:
|
||||
get_usage_sink().emit(event)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("usage sink: publish failed", exc_info=True)
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""~4 chars per token approximation, re-exported so provider adapters need
|
||||
exactly ONE telemetry import instead of also importing the tracker."""
|
||||
return max(0, len(text or "") // 4)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UsageEvent",
|
||||
"UsageEventSink",
|
||||
"UsageTrackerSink",
|
||||
"InMemoryUsageSink",
|
||||
"CompositeUsageSink",
|
||||
"get_usage_sink",
|
||||
"set_usage_sink",
|
||||
"subscribe",
|
||||
"unsubscribe",
|
||||
"publish",
|
||||
"estimate_tokens",
|
||||
]
|
||||
Reference in New Issue
Block a user