Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## 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:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+7
View File
@@ -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/``.
"""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure config package: ConfigRepository and typed settings facades."""
+119
View File
@@ -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).
"""
...
+128
View File
@@ -0,0 +1,128 @@
"""Các nhóm cấu hình đọc thẳng từ dict — tách khỏi ``json_config_repository.py``.
Mỗi thành viên ở đây chỉ làm đúng một việc: đặt tên cho một khoá trong file
cấu hình và nói rõ giá trị mặc định khi khoá đó chưa có. Không có hành vi nào
đáng bàn, nhưng có đến hơn hai mươi cái — để chung với phần có logic thật
(trộn mặc định, kho bí mật, di trú schema, ghi nguyên tử) thì phần ấy bị chìm.
Dùng ``setdefault`` chứ không ``get``: bên gọi sửa thẳng vào dict trả về
(``cfg.routing["switch_mode"] = ...``) rồi mới ``save()``, nên cái trả về phải
là dict THẬT nằm trong ``data``, không phải một bản sao rồi bị vứt đi.
Mixin chứ không phải lớp riêng: 29 file đang gọi ``ctx.config.<tên>`` thẳng,
nên tách thành ``ctx.config.sections.<tên>`` sẽ là đổi API công khai — việc
này chỉ chia file, không chia bề mặt.
"""
from __future__ import annotations
import copy
from typing import Any, Dict
class ConfigSectionsMixin:
"""Phần truy cập nhóm cấu hình của :class:`JsonConfigRepository`.
Chỉ trông vào hai thứ của lớp chủ: ``self.data`` (dict cấu hình đã trộn
mặc định) và ``self.save()``. Không tự đứng một mình được — và cũng không
cần, vì không có ai khác dùng.
"""
@property
def code(self) -> Dict[str, Any]:
"""Nhóm ``code``: thiết lập của agent lập trình (thư mục làm việc, model)."""
return self.data["code"]
@property
def teams(self) -> Dict[str, Any]:
"""Nhóm ``teams``: thiết lập tích hợp Microsoft Teams."""
return self.data["teams"]
@property
def history(self) -> Dict[str, Any]:
"""Nhóm ``history``: lưu lịch sử hội thoại (bật/tắt, giới hạn)."""
return self.data["history"]
@property
def codebase_memory(self) -> Dict[str, Any]:
"""Nhóm ``codebase_memory``: bộ nhớ mã nguồn cho agent lập trình."""
return self.data["codebase_memory"]
@property
def cowork(self) -> Dict[str, Any]:
"""Nhóm ``cowork``: thiết lập màn Cowork (thư mục kết quả, model mặc định)."""
return self.data["cowork"]
@property
def mcp_servers(self) -> list:
"""Danh sách máy chủ MCP đã khai báo; rỗng nếu chưa có cái nào."""
return self.data.setdefault("mcp_servers", [])
@property
def structure(self) -> Dict[str, Any]:
"""Nhóm ``structure``: trần số node/cạnh khi vẽ đồ thị GraphRAG.
Mặc định 400/400 — vượt ngưỡng đó thì đồ thị vừa vẽ chậm vừa rối, không
còn đọc được nữa.
"""
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
@property
def monitoring_visibility(self) -> Dict[str, bool]:
"""Nhóm ``monitoring_visibility``: tab nào của màn Giám sát được hiện.
Lấy bản sao sâu của mặc định khi khoá chưa có, để người dùng tắt một tab
không vô tình sửa luôn bộ mặc định dùng chung.
"""
return self.data.setdefault(
"monitoring_visibility",
copy.deepcopy(self._defaults["monitoring_visibility"]))
@property
def ext_connectors(self) -> Dict[str, list]:
"""Connector (MCP) gom theo nhóm CAD/CAE/MS365/Other."""
d = self.data.setdefault(
"ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
for cat in ("cad", "cae", "ms365", "other"):
d.setdefault(cat, [])
return d
# ---- công tắc tổng cho connector -------------------------------------
@property
def connect_external(self) -> bool:
"""Tắt cái này là agent không nối tới connector ngoài nào cả. Mặc định
BẬT để cấu hình đang chạy không đổi hành vi."""
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
def set_connect_external(self, enabled: bool) -> None:
"""Bật/tắt công tắc tổng cho connector ngoài, ghi đĩa ngay."""
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
self.save()
# ---- những thứ đã gieo sẵn -------------------------------------------
@property
def seeded_library_skills(self) -> list:
"""Slug của skill thư viện đã gieo — để cái người dùng xoá đi không bị
lặng lẽ gieo lại."""
return list(self.data.setdefault("seeded_library_skills", []))
@seeded_library_skills.setter
def seeded_library_skills(self, slugs) -> None:
"""Ghi lại danh sách slug đã gieo, bỏ trùng và giữ nguyên thứ tự."""
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
@property
def seeded_builtin_flows(self) -> list:
"""Id của flow Co4E dựng sẵn đã gieo (cùng quy tắc tôn trọng việc người
dùng đã xoá như seeded_library_skills)."""
return list(self.data.setdefault("seeded_builtin_flows", []))
@seeded_builtin_flows.setter
def seeded_builtin_flows(self, ids) -> None:
"""Ghi lại danh sách id flow đã gieo, bỏ trùng và giữ nguyên thứ tự."""
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
__all__ = ["ConfigSectionsMixin"]
@@ -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
+139
View File
@@ -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
+216
View File
@@ -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)
+6
View File
@@ -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"]
+120
View File
@@ -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"]
+57
View File
@@ -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"]
+146
View File
@@ -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"]
+68
View 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"]
+5
View File
@@ -0,0 +1,5 @@
"""MCP server connection lifecycle management (EPIC R05)."""
from .mcp_source_manager import McpToolSourceManager
__all__ = ["McpToolSourceManager"]
+119
View File
@@ -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"]
+1
View File
@@ -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"]
+13
View File
@@ -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",
]
+18
View File
@@ -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"]
+75
View File
@@ -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"]
+1
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Kho bí mật: đọc/ghi khoá API qua keyring của hệ điều hành thay vì để trong
file cấu hình (R02).
"""
+99
View File
@@ -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
+46
View File
@@ -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."""
...
+32
View File
@@ -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",
]
+177
View File
@@ -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
+304
View File
@@ -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",
]