refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,12 @@ class ConversationApplicationService:
|
||||
permission_request: Optional[PermissionRequest] = None,
|
||||
attachment_reader: Optional[AttachmentReader] = None,
|
||||
) -> None:
|
||||
"""Nhận vào các cổng (port) thay vì tự dựng phụ thuộc.
|
||||
|
||||
``model`` và ``tools`` bắt buộc; mọi thứ còn lại là tuỳ chọn và để None thì
|
||||
bỏ qua bước đó. Nhờ vậy test dựng được service với đúng phần nó cần kiểm,
|
||||
không phải dựng cả provider thật lẫn sandbox.
|
||||
"""
|
||||
self._model = model
|
||||
self._tools = tools
|
||||
# Every hook is optional so the service degrades to a plain chat turn.
|
||||
|
||||
@@ -51,9 +51,11 @@ class CoreModelCall:
|
||||
"""
|
||||
|
||||
def __init__(self, provider: Any) -> None:
|
||||
"""Bọc một provider của ``core/`` vào cổng ``ModelCallPort``."""
|
||||
self._provider = provider
|
||||
|
||||
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
|
||||
"""Gọi model một lượt, có tự phục hồi khi tràn context hoặc bị giới hạn tốc độ."""
|
||||
from ...core.code_agent import _call_provider_with_recovery
|
||||
|
||||
return _call_provider_with_recovery(self._provider, messages, tools, on_text,
|
||||
@@ -66,6 +68,11 @@ class CoreToolRuntime:
|
||||
def __init__(self, output_dir: Path, *, title: str = "",
|
||||
extra_tools: Optional[Sequence[Any]] = None, extra_executor=None,
|
||||
security_config: Any = None, agent_role: str = "") -> None:
|
||||
"""Bọc bộ tool của ``core/`` vào cổng ``ToolRuntimePort``.
|
||||
|
||||
Tên các tool phụ được gom sẵn vào một ``set`` ngay tại đây: mỗi lượt gọi tool
|
||||
đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool.
|
||||
"""
|
||||
self._output_dir = Path(output_dir)
|
||||
self._title = title
|
||||
self._extra_tools = list(extra_tools or ())
|
||||
@@ -80,6 +87,7 @@ class CoreToolRuntime:
|
||||
# -- the configured extra tools, for the system-prompt hints ---------- #
|
||||
@property
|
||||
def extra_names(self) -> frozenset:
|
||||
"""Tên các tool bổ sung (MCP, connector) ngoài bộ dựng sẵn."""
|
||||
return frozenset(self._extra_names)
|
||||
|
||||
def _tool_context(self):
|
||||
@@ -206,6 +214,7 @@ class CoreToolRuntime:
|
||||
"plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]}
|
||||
|
||||
def snapshot(self) -> Any:
|
||||
"""Ảnh chụp thư mục kết quả trước lượt chạy — dùng để biết tệp nào mới sinh ra."""
|
||||
from ...core.tools import _snapshot
|
||||
|
||||
return _snapshot(self._output_dir)
|
||||
@@ -294,16 +303,19 @@ def build_cowork_conversation_service(
|
||||
_apply_project_context(messages, project_context)
|
||||
|
||||
def prompt_guard(messages: List[Dict[str, Any]]) -> None:
|
||||
"""Chốt an toàn cho prompt trước khi gửi: quét dấu hiệu tiêm lệnh."""
|
||||
from ...core import agent_security
|
||||
|
||||
agent_security.enforce_prompt(provider, messages, security_config, emit)
|
||||
|
||||
def command_guard(name: str, args: Dict[str, Any]) -> None:
|
||||
"""Chốt an toàn cho lệnh shell trước khi chạy: phân loại rủi ro và chặn/hỏi."""
|
||||
from ...core import agent_security
|
||||
|
||||
agent_security.enforce_command(provider, name, args, security_config, emit)
|
||||
|
||||
def compact(messages: List[Dict[str, Any]], cancel) -> None:
|
||||
"""Nén lịch sử hội thoại khi gần đầy cửa sổ ngữ cảnh."""
|
||||
from ...core import context_budget
|
||||
|
||||
context_budget.maybe_compact(provider, messages, security_config,
|
||||
|
||||
@@ -39,7 +39,10 @@ from cowork_local.domain.tools import ToolCapability, ToolRegistry
|
||||
class ConfirmGate(Protocol):
|
||||
"""Shape of the existing ``PermissionGate`` both engines already use."""
|
||||
|
||||
def request(self, payload: Dict[str, Any]) -> bool: ...
|
||||
"""Hỏi người dùng; trả về ``True`` nếu được đồng ý."""
|
||||
def request(self, payload: Dict[str, Any]) -> bool:
|
||||
"""Hỏi người dùng về một lời gọi tool; trả về ``True`` nếu được đồng ý."""
|
||||
...
|
||||
|
||||
|
||||
class ToolPolicyGateway:
|
||||
@@ -54,6 +57,11 @@ class ToolPolicyGateway:
|
||||
"""
|
||||
|
||||
def __init__(self, registry: ToolRegistry, gated_capabilities: ToolCapability) -> None:
|
||||
"""Nhận sổ đăng ký tool và tập năng lực cần xin phép.
|
||||
|
||||
Truyền vào chứ không viết cứng: mỗi bề mặt chat có ngưỡng riêng, và test đặt
|
||||
được ngưỡng của mình mà không đụng cấu hình thật.
|
||||
"""
|
||||
self._registry = registry
|
||||
self._gated_capabilities = gated_capabilities
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class CoreRoutingEngine:
|
||||
"""
|
||||
|
||||
def __init__(self, routing_service: Any) -> None:
|
||||
"""Bọc ``core/routing/service.py`` vào cổng quyết định định tuyến."""
|
||||
self._routing_service = routing_service
|
||||
|
||||
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
||||
@@ -126,6 +127,9 @@ class AppContextModeResolver:
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: Any) -> None:
|
||||
"""Đọc chế độ định tuyến từ ``AppContext``, để tầng application không phải biết
|
||||
hình dạng của context.
|
||||
"""
|
||||
self._ctx = ctx
|
||||
|
||||
def mode_for(self, surface: str) -> RoutingMode:
|
||||
|
||||
@@ -78,6 +78,10 @@ class RoutingApplicationService:
|
||||
*,
|
||||
confirm_timeout_sec: Optional[Callable[[], float]] = None,
|
||||
) -> None:
|
||||
"""``mode_resolver`` để None thì mọi bề mặt đều coi như đang ở chế độ mặc định.
|
||||
``confirm_timeout_sec`` là hàm chứ không phải số: người dùng đổi thiết lập
|
||||
giữa chừng thì lần hỏi sau phải theo giá trị mới.
|
||||
"""
|
||||
self._decision_port = decision_port
|
||||
self._mode_resolver = mode_resolver
|
||||
# A callable rather than a number: the timeout lives in mutable config
|
||||
|
||||
@@ -32,6 +32,9 @@ class DashboardQueryService:
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: Any, directory: Optional[Path] = None) -> None:
|
||||
"""``directory`` để None thì đọc thư mục telemetry mặc định; test trỏ nó vào
|
||||
``tmp_path`` để không chạm dữ liệu thật.
|
||||
"""
|
||||
self.ctx = ctx
|
||||
self._directory = directory
|
||||
|
||||
@@ -94,16 +97,21 @@ class DashboardQueryService:
|
||||
return ut.period_totals(events, granularity, self.pricing(), offset)
|
||||
|
||||
def period_range_label(self, granularity: str, offset: int) -> str:
|
||||
"""Nhãn hiển thị của một kỳ (tuần/tháng/năm cộng độ lệch)."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
return ut.period_range_label(granularity, offset)
|
||||
|
||||
def budget_status(self):
|
||||
"""Tình trạng ngân sách: đã dùng bao nhiêu, còn lại bao nhiêu, có vượt ngưỡng chưa."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
return ut.budget_status(self.ctx.config)
|
||||
|
||||
def set_budget(self, amount: float, currency: str) -> None:
|
||||
"""Đặt hạn mức ngân sách mới — mở một chu kỳ đếm mới, chi tiêu trước đó không
|
||||
còn được tính vào.
|
||||
"""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
ut.set_budget(self.ctx.config, amount, currency)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""DTO của phân hệ Giám sát: hình dạng dữ liệu mà tầng application trả cho
|
||||
giao diện, không phụ thuộc nguồn đọc.
|
||||
"""
|
||||
|
||||
@@ -12,6 +12,9 @@ from typing import Any, Dict
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditEventDTO:
|
||||
"""Một sự kiện kiểm toán ở dạng tầng application dùng — không phụ thuộc khuôn
|
||||
lưu trên đĩa, nên đổi định dạng nhật ký không kéo theo sửa giao diện.
|
||||
"""
|
||||
ts: str
|
||||
kind: str
|
||||
name: str
|
||||
@@ -40,6 +43,7 @@ class AuditEventDTO:
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Bản ghi dưới dạng dict cho lớp giao diện."""
|
||||
return {
|
||||
"ts": self.ts, "kind": self.kind, "agent_role": self.agent_role,
|
||||
"name": self.name, "ok": self.ok, "detail": self.detail,
|
||||
|
||||
@@ -16,6 +16,7 @@ from .repository.audit_event_repository import AuditEventRepository
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Page:
|
||||
"""Một trang kết quả truy vấn nhật ký: các mục, tổng số, số trang và cỡ trang."""
|
||||
items: List[AuditEventDTO]
|
||||
total: int
|
||||
page: int
|
||||
@@ -23,6 +24,7 @@ class Page:
|
||||
|
||||
@property
|
||||
def has_more(self) -> bool:
|
||||
"""Còn trang sau nữa không."""
|
||||
return self.page * self.page_size < self.total
|
||||
|
||||
|
||||
@@ -31,11 +33,15 @@ class MonitoringQueryService:
|
||||
audit log; this service never writes anything."""
|
||||
|
||||
def __init__(self, repository: AuditEventRepository) -> None:
|
||||
"""Nhận kho sự kiện kiểm toán qua tham số — bản thật đọc đĩa, bản test nằm
|
||||
trong bộ nhớ.
|
||||
"""
|
||||
self._repository = repository
|
||||
|
||||
def query(self, kind: Optional[str] = None, ok: Optional[bool] = None,
|
||||
text: Optional[str] = None, sort_by: str = "ts",
|
||||
descending: bool = True, page: int = 1, page_size: int = 50) -> Page:
|
||||
"""Lọc theo loại/kết quả/từ khoá, sắp xếp rồi cắt thành một trang."""
|
||||
events = self._repository.load(kind=kind)
|
||||
|
||||
if ok is not None:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Cổng đọc dữ liệu của phân hệ Giám sát — hợp đồng, không phải cài đặt."""
|
||||
|
||||
@@ -13,7 +13,13 @@ from ..dto.audit_event_dto import AuditEventDTO
|
||||
|
||||
|
||||
class AuditEventRepository(Protocol):
|
||||
"""Cổng đọc nhật ký kiểm toán mà tầng application dùng.
|
||||
|
||||
Chỉ là hợp đồng: bản cài đặt thật đọc từ file cục bộ hoặc thư mục chia sẻ,
|
||||
còn test truyền vào bộ giả.
|
||||
"""
|
||||
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
|
||||
"""Đọc sự kiện kiểm toán, lọc theo loại nếu có."""
|
||||
...
|
||||
|
||||
|
||||
@@ -22,9 +28,11 @@ class CanonicalAuditEventRepository:
|
||||
— the only place this application service reaches into infrastructure."""
|
||||
|
||||
def __init__(self, audit_logger) -> None:
|
||||
"""Bọc bộ ghi nhật ký kiểm toán chuẩn để đọc sự kiện ra."""
|
||||
self._audit_logger = audit_logger
|
||||
|
||||
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
|
||||
"""Đọc sự kiện từ nhật ký và đổi sang DTO của tầng application."""
|
||||
events = self._audit_logger.load_events(kind=kind)
|
||||
return [AuditEventDTO.from_raw(e.to_dict()) for e in events]
|
||||
|
||||
@@ -33,9 +41,13 @@ class InMemoryAuditEventRepository:
|
||||
"""Test double — holds a fixed list of events, no file I/O."""
|
||||
|
||||
def __init__(self, events: List[AuditEventDTO]) -> None:
|
||||
"""Nhận sẵn danh sách sự kiện. Chép lại chứ không giữ tham chiếu: bên gọi sửa
|
||||
danh sách gốc thì kết quả test không được đổi theo.
|
||||
"""
|
||||
self._events = list(events)
|
||||
|
||||
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
|
||||
"""Trả về danh sách đã nạp sẵn, lọc theo loại nếu có."""
|
||||
if kind is None:
|
||||
return list(self._events)
|
||||
return [e for e in self._events if e.kind == kind]
|
||||
|
||||
@@ -43,6 +43,9 @@ class AiTaskPlannerService:
|
||||
"""
|
||||
|
||||
def __init__(self, provider_factory: Optional[ProviderFactory] = None) -> None:
|
||||
"""``provider_factory`` là hàm dựng provider, gọi lúc cần chứ không dựng sẵn —
|
||||
provider có thể bị đổi giữa hai lần lập kế hoạch.
|
||||
"""
|
||||
self._provider_factory = provider_factory
|
||||
|
||||
def plan(
|
||||
@@ -86,6 +89,9 @@ class AiTaskPlannerService:
|
||||
return import_tasks(path)
|
||||
|
||||
def _resolve_provider(self) -> Any:
|
||||
"""Provider dùng để lập kế hoạch; chưa cấu hình thì báo lỗi rõ ràng ngay tại
|
||||
đây thay vì để lỗi nổ ra ở tận tầng HTTP.
|
||||
"""
|
||||
if self._provider_factory is None:
|
||||
raise RuntimeError("No provider available to plan tasks.")
|
||||
return self._provider_factory()
|
||||
|
||||
@@ -75,6 +75,9 @@ class TaskApplicationService:
|
||||
"""
|
||||
|
||||
def __init__(self, repository: Any, run_now: Optional[RunNowFn] = None) -> None:
|
||||
"""``run_now`` để None thì service chỉ đọc/ghi task, không chạy được cái nào —
|
||||
đúng cho ngữ cảnh không có scheduler (test, hay màn chỉ xem).
|
||||
"""
|
||||
self._repository = repository
|
||||
self._run_now = run_now
|
||||
|
||||
@@ -118,6 +121,7 @@ class TaskApplicationService:
|
||||
return task
|
||||
|
||||
def delete(self, task_id: str) -> bool:
|
||||
"""Xoá một task; trả về ``False`` nếu id không tồn tại."""
|
||||
if self._repository.get(task_id) is None:
|
||||
return False
|
||||
self._repository.delete(task_id)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Đọc/ghi file lịch sử run của Co4E — tách khỏi ``co4e_workflow_service.py``.
|
||||
|
||||
``Co4EWorkflowService`` lo vòng đời các run đang chạy; chỗ này lo đúng một
|
||||
việc: đưa ``RunRecord`` ra đĩa và lấy lại được. Tách ra vì hành vi đọc/ghi ở
|
||||
đây có những ràng buộc rất riêng — được ghi lại nguyên vẹn bên dưới — mà trộn
|
||||
lẫn vào file điều phối thì không ai đọc tới.
|
||||
|
||||
DTO ở ``domain/workflows/run_record.py`` không được chạm đĩa, nên việc này
|
||||
nằm ở tầng application chứ không nằm trong domain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from ...domain.workflows.run_record import RunRecord
|
||||
from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
|
||||
|
||||
#: Giữ N run gần nhất trên đĩa. Lịch sử chỉ để người dùng nhìn lại, không
|
||||
#: phải sổ kiểm toán — để nó lớn vô hạn thì mỗi lần lưu lại phải tuần tự hoá
|
||||
#: cả file, và lần lưu ấy nằm ngay trên đường đi của mọi sự kiện tiến độ.
|
||||
HISTORY_CAP = 500
|
||||
|
||||
|
||||
class RunHistoryStore:
|
||||
"""Một file JSON chứa lịch sử run, kèm hai quy ước phải giữ nguyên.
|
||||
|
||||
**Không cách ly file hỏng.** Bản đầu dùng ``AtomicJsonFile.read()``, nhưng
|
||||
review thấy nó đổi hành vi thật so với ``Co4ERunManager`` cũ: gặp JSON
|
||||
hỏng, ``AtomicJsonFile.read()`` ĐỔI TÊN file thành ``<tên>.bad-<mốc>`` rồi
|
||||
mới trả về mặc định, trong khi bản cũ chỉ bắt lỗi và ĐỂ NGUYÊN file tại
|
||||
chỗ. Đó là thay đổi quan sát được trên đĩa mà không test nào khoá lại và
|
||||
không có chú thích báo trước — Lâm (N3) quyết ngày 24/08: giữ hành vi cũ.
|
||||
Vì thế :meth:`load` đọc thủ công bằng ``json.loads``.
|
||||
|
||||
**Ghi hỏng không được làm vỡ luồng gọi.** :meth:`save` nuốt ``OSError``,
|
||||
đúng như ``core/co4e_run_manager.py::_save_history``. Nó nằm trên đường đi
|
||||
của mọi hook tiến độ (``_on_event``/``_on_finished``/``_on_failed``); để
|
||||
lỗi ghi đĩa (đầy đĩa, mất quyền) ném ra là vỡ cả lượt xử lý sự kiện đang
|
||||
chạy, chỉ vì lịch sử lần này không lưu được. Người dùng vẫn thấy Flow
|
||||
Status đúng trong phiên hiện tại, chỉ là bản ghi trên đĩa lùi một bước.
|
||||
|
||||
Ghi thì vẫn qua ``AtomicJsonFile``: bản tự viết bằng tmp + ``replace``
|
||||
thiếu ``fsync`` (dữ liệu có thể còn trong bộ đệm khi mất điện) và
|
||||
``Path.replace`` thỉnh thoảng bị Defender từ chối trên Windows.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
"""Trỏ vào một file JSON. Chưa tồn tại cũng không sao — :meth:`load` coi như
|
||||
lịch sử rỗng và :meth:`save` tự tạo thư mục cha.
|
||||
"""
|
||||
self.path = Path(path)
|
||||
|
||||
def load(self) -> Tuple[Dict[str, RunRecord], int]:
|
||||
"""Đọc lịch sử; trả về ``({id: RunRecord}, số thứ tự lớn nhất đã dùng)``.
|
||||
|
||||
Số thứ tự trả kèm để bên gọi sinh id tiếp theo không đụng vào id đã có
|
||||
trong lịch sử — không có nó thì sau mỗi lần khởi động lại, ``run1``
|
||||
mới sẽ ghi đè ``run1`` cũ.
|
||||
|
||||
File không có, không đọc được, hay JSON hỏng đều trả về rỗng: mất lịch
|
||||
sử là chuyện chấp nhận được, chặn ứng dụng khởi động thì không. Từng
|
||||
bản ghi hỏng cũng bị bỏ riêng lẻ, để một dòng lỗi không kéo theo cả
|
||||
file.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}, 0
|
||||
|
||||
runs: Dict[str, RunRecord] = {}
|
||||
max_seq = 0
|
||||
for rec in data.get("runs", []):
|
||||
try:
|
||||
record = RunRecord.from_dict(rec)
|
||||
except Exception:
|
||||
continue
|
||||
if not record.id:
|
||||
continue
|
||||
runs[record.id] = record
|
||||
if record.id.startswith("run") and record.id[3:].isdigit():
|
||||
max_seq = max(max_seq, int(record.id[3:]))
|
||||
return runs, max_seq
|
||||
|
||||
def save(self, runs: List[RunRecord]) -> None:
|
||||
"""Ghi ``HISTORY_CAP`` run gần nhất xuống đĩa, ghi nguyên tử.
|
||||
|
||||
Lỗi ghi bị nuốt có chủ ý — xem docstring của lớp.
|
||||
"""
|
||||
payload = {"runs": [r.to_dict() for r in runs[-HISTORY_CAP:]]}
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
AtomicJsonFile(self.path).write(payload)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["RunHistoryStore", "HISTORY_CAP"]
|
||||
@@ -32,12 +32,20 @@ Qt thật (bọc ``AgentWorker`` — xem ``core/worker.py``) là việc của wi
|
||||
KHÔNG xoá/sửa ``core/co4e_run_manager.py`` — lớp cũ tiếp tục chạy song song
|
||||
cho tới khi widget Co4E Studio thật (``ui/co4e_tab.py``) chuyển hẳn sang dùng
|
||||
service này.
|
||||
|
||||
SEAM · dựng 2026-08-25 · chưa nối dây (F-05)
|
||||
------------------------------------------------------------
|
||||
Được nối khi: ``ui/co4e_tab.py`` bỏ ``Co4ERunManager`` và nhận service này qua ``build_co4e_tab(ctx, workflow_service)``.
|
||||
Để dormant thì sao: Hai bản cùng giữ vòng đời run đang chạy song song. Càng
|
||||
để lâu thì sửa một lỗi lại phải sửa hai nơi — và đến một lúc sẽ có người
|
||||
quên nơi thứ hai.
|
||||
|
||||
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 ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -45,12 +53,13 @@ from typing import Callable, Dict, List, Optional, Protocol, Set
|
||||
|
||||
from ...core.co4e import CO4E_DIR, STEP_DONE, STEP_ERROR, STEP_PLANNED, Workflow, slugify, workflow_to_dict
|
||||
from ...domain.workflows.run_record import RunRecord
|
||||
from .co4e_run_history import RunHistoryStore
|
||||
|
||||
_TERMINAL_NODE = {STEP_DONE, STEP_ERROR, STEP_PLANNED}
|
||||
_HISTORY_CAP = 500 # giữ N run gần nhất trên đĩa
|
||||
|
||||
|
||||
def _now_str() -> str:
|
||||
"""Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' — đúng định dạng lịch sử run đang lưu."""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
@@ -70,15 +79,24 @@ class RunnerJob(Protocol):
|
||||
đồng bộ trong test.
|
||||
"""
|
||||
|
||||
def emit_event(self, ev: dict) -> None: ...
|
||||
def is_cancelled(self) -> bool: ...
|
||||
def emit_event(self, ev: dict) -> None:
|
||||
"""Đẩy một sự kiện tiến độ từ luồng nền về service."""
|
||||
...
|
||||
|
||||
def is_cancelled(self) -> bool:
|
||||
"""``True`` khi người dùng đã bấm dừng — thân job phải tự kiểm để thoát sớm."""
|
||||
...
|
||||
|
||||
|
||||
class RunWorkerHandle(Protocol):
|
||||
"""Điều khiển một job đang chạy nền — tương ứng phần
|
||||
``AgentWorker.request_stop()`` cũ mà ``Co4ERunManager.stop()`` gọi."""
|
||||
|
||||
def request_stop(self) -> None: ...
|
||||
def request_stop(self) -> None:
|
||||
"""Xin dừng run. Chỉ là yêu cầu: job đang chạy phải tự thấy qua
|
||||
``is_cancelled()`` rồi thoát, không ai giết luồng giữa chừng.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class WorkflowRunner(Protocol):
|
||||
@@ -94,7 +112,9 @@ class WorkflowRunner(Protocol):
|
||||
def start(self, run_id: str, job: Callable[[RunnerJob], Optional[dict]],
|
||||
on_event: Callable[[dict], None],
|
||||
on_finished: Callable[[Optional[dict]], None],
|
||||
on_failed: Callable[[str], None]) -> RunWorkerHandle: ...
|
||||
on_failed: Callable[[str], None]) -> RunWorkerHandle:
|
||||
"""Chạy ``job`` và trả về tay cầm để dừng nó."""
|
||||
...
|
||||
|
||||
|
||||
class Co4EWorkflowService:
|
||||
@@ -108,6 +128,12 @@ class Co4EWorkflowService:
|
||||
|
||||
def __init__(self, ctx, *, history_path: Optional[Path] = None,
|
||||
runner: Optional[WorkflowRunner] = None):
|
||||
"""Dựng service.
|
||||
|
||||
``runner`` để None nghĩa là chưa có ai chạy được run — đúng trạng thái hiện
|
||||
nay, vì adapter Qt thật thuộc về tầng ``presentation/`` và chưa được nối.
|
||||
Test tiêm runner chạy đồng bộ vào đây.
|
||||
"""
|
||||
self.ctx = ctx
|
||||
self._runs: Dict[str, RunRecord] = {}
|
||||
self._worker_handles: Dict[str, RunWorkerHandle] = {}
|
||||
@@ -116,10 +142,12 @@ class Co4EWorkflowService:
|
||||
self._project_id: str = "" # workspace đang chọn — Flow Status lọc theo no
|
||||
self._runner = runner
|
||||
# DTO domain khong duoc cham dia (xem domain/workflows/run_record.py),
|
||||
# nen viec doc/ghi file lich su nam o day, tang application.
|
||||
# nen viec doc/ghi file lich su nam o tang application — cu the la
|
||||
# co4e_run_history.py::RunHistoryStore.
|
||||
self._history_path_value = (
|
||||
Path(history_path) if history_path is not None else (CO4E_DIR / "run_history.json")
|
||||
)
|
||||
self._history = RunHistoryStore(self._history_path_value)
|
||||
self._changed_callbacks: List[Callable[[], None]] = []
|
||||
self._event_callbacks: List[Callable[[str, dict], None]] = []
|
||||
self._load_history() # khoi phuc lich su cu de Flow Status
|
||||
@@ -127,71 +155,42 @@ class Co4EWorkflowService:
|
||||
|
||||
# ---- callback thay Signal ---------------------------------------------
|
||||
def on_changed(self, cb: Callable[[], None]) -> None:
|
||||
"""Đăng ký callback gọi mỗi khi danh sách run đổi — thay cho signal Qt cũ."""
|
||||
self._changed_callbacks.append(cb)
|
||||
|
||||
def on_event(self, cb: Callable[[str, dict], None]) -> None:
|
||||
"""Đăng ký callback nhận sự kiện tiến độ của từng run — thay cho signal Qt cũ."""
|
||||
self._event_callbacks.append(cb)
|
||||
|
||||
def _emit_changed(self) -> None:
|
||||
"""Lưu lịch sử rồi báo mọi người đăng ký."""
|
||||
self._save_history() # xem docstring dau file: giu dung thu tu ban Qt cu
|
||||
for cb in self._changed_callbacks:
|
||||
cb()
|
||||
|
||||
def _emit_event(self, run_id: str, ev) -> None:
|
||||
"""Chuyển một sự kiện tiến độ tới mọi callback đã đăng ký."""
|
||||
for cb in self._event_callbacks:
|
||||
cb(run_id, ev)
|
||||
|
||||
# ---- persistence --------------------------------------------------
|
||||
# Doc/ghi thu cong (json.loads/write_text + tmp.replace), KHONG dung
|
||||
# AtomicJsonFile — ban dau file nay dung AtomicJsonFile.read(), nhung
|
||||
# review phat hien no doi hanh vi that so voi Co4ERunManager cu: gap
|
||||
# JSON hong, AtomicJsonFile.read() ĐOI TEN file hong thanh
|
||||
# "<ten>.bad-<timestamp>" (quarantine) roi moi tra ve mac dinh, trong
|
||||
# khi ban cu chi bat loi va ĐE NGUYEN file hong tai cho, khong dong gi
|
||||
# vao no. Day la mot thay doi quan sat duoc tren dia ma khong test nao
|
||||
# khoa lai va khong co comment bao truoc — Lam (N3) da quyet 24/08:
|
||||
# GIU HANH VI CU nguyen van (khong quarantine), vi day la buoc tach
|
||||
# chi duoc phep doi hanh vi khi da noi ra ro rang va co lưới an toan,
|
||||
# khong phai luc nay.
|
||||
def _load_history(self) -> None:
|
||||
try:
|
||||
data = json.loads(self._history_path_value.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
max_seq = 0
|
||||
for rec in data.get("runs", []):
|
||||
try:
|
||||
record = RunRecord.from_dict(rec)
|
||||
except Exception:
|
||||
continue
|
||||
if not record.id:
|
||||
continue
|
||||
self._runs[record.id] = record
|
||||
if record.id.startswith("run") and record.id[3:].isdigit():
|
||||
max_seq = max(max_seq, int(record.id[3:]))
|
||||
self._seq = max_seq # tranh sinh id trung voi lich su
|
||||
"""Khôi phục lịch sử run từ đĩa lúc khởi động.
|
||||
|
||||
Lấy luôn số thứ tự lớn nhất đã dùng để ``_next_id()`` không sinh trùng
|
||||
id với run cũ.
|
||||
"""
|
||||
self._runs, self._seq = self._history.load()
|
||||
|
||||
def _save_history(self) -> None:
|
||||
runs = list(self._runs.values())[-_HISTORY_CAP:]
|
||||
payload = {"runs": [r.to_dict() for r in runs]}
|
||||
try:
|
||||
self._history_path_value.parent.mkdir(parents=True, exist_ok=True)
|
||||
# AtomicJsonFile thay cho tmp+replace tự viết: bản cũ thiếu fsync
|
||||
# (dữ liệu có thể còn trong bộ đệm khi mất điện) và dùng thẳng
|
||||
# Path.replace, vốn thỉnh thoảng bị Defender từ chối trên Windows.
|
||||
AtomicJsonFile(self._history_path_value).write(payload)
|
||||
except OSError:
|
||||
# Giu dung hanh vi cu (core/co4e_run_manager.py::_save_history):
|
||||
# mot lan luu that bai (day dia, mat quyen...) KHONG duoc phep
|
||||
# chan luong goi cua moi hook (_on_event/_on_finished/_on_failed)
|
||||
# dang di qua _emit_changed(). Bo try/except nay se lam mot loi
|
||||
# ghi dia lam vo ca luot xu ly su kien dang chay, chi vi lich su
|
||||
# khong luu duoc lan nay -- nguoi dung van thay Flow Status dung
|
||||
# trong phien hien tai, chi la ban ghi tren dia lui lai mot buoc.
|
||||
pass
|
||||
"""Ghi lịch sử xuống đĩa. Lỗi ghi bị nuốt có chủ ý — xem
|
||||
``co4e_run_history.py::RunHistoryStore``.
|
||||
"""
|
||||
self._history.save(list(self._runs.values()))
|
||||
|
||||
# ---- lifecycle ----------------------------------------------------
|
||||
def _next_id(self) -> str:
|
||||
"""Sinh id run kế tiếp ('run1', 'run2', ...), không đụng id đã có trong lịch sử."""
|
||||
self._seq += 1
|
||||
return f"run{self._seq}"
|
||||
|
||||
@@ -247,6 +246,7 @@ class Co4EWorkflowService:
|
||||
|
||||
# ---- worker callbacks (goi tu runner, thay slot Qt cu) -----------------
|
||||
def _on_event(self, run_id: str, ev) -> None:
|
||||
"""Nhận sự kiện từ job đang chạy và cập nhật bản ghi run."""
|
||||
record = self._runs.get(run_id)
|
||||
if record is not None and isinstance(ev, dict):
|
||||
t = ev.get("type")
|
||||
@@ -271,6 +271,7 @@ class Co4EWorkflowService:
|
||||
self._emit_event(run_id, ev)
|
||||
|
||||
def _on_finished(self, run_id: str) -> None:
|
||||
"""Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'."""
|
||||
record = self._runs.get(run_id)
|
||||
if record is not None and record.status == "running":
|
||||
# job returned without a run_done event (shouldn't happen) — settle it
|
||||
@@ -278,6 +279,7 @@ class Co4EWorkflowService:
|
||||
self._emit_changed()
|
||||
|
||||
def _on_failed(self, run_id: str, err: str) -> None:
|
||||
"""Job ném lỗi: ghi lỗi vào bản ghi và báo ra ngoài một sự kiện ``run_error``."""
|
||||
record = self._runs.get(run_id)
|
||||
if record is not None:
|
||||
record.status = "error"
|
||||
@@ -287,6 +289,7 @@ class Co4EWorkflowService:
|
||||
|
||||
# ---- control --------------------------------------------------------
|
||||
def stop(self, run_id: str) -> None:
|
||||
"""Yêu cầu dừng một run đang chạy và đánh dấu 'stopped'."""
|
||||
record = self._runs.get(run_id)
|
||||
worker = self._worker_handles.get(run_id)
|
||||
if record is not None and worker is not None and record.running:
|
||||
@@ -295,6 +298,7 @@ class Co4EWorkflowService:
|
||||
self._emit_changed()
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Dừng mọi run của workspace đang chọn (Flow Status vốn lọc theo project)."""
|
||||
# Only the CURRENT workspace's runs (Flow Status is per-project).
|
||||
for run_id in [r for r, rec in self._runs.items() if self._belongs(rec)]:
|
||||
self.stop(run_id)
|
||||
@@ -315,6 +319,7 @@ class Co4EWorkflowService:
|
||||
self._emit_changed()
|
||||
|
||||
def remove(self, run_id: str) -> None:
|
||||
"""Xoá một run khỏi lịch sử; đang chạy thì dừng trước."""
|
||||
record = self._runs.get(run_id)
|
||||
if record is not None and record.running:
|
||||
self.stop(run_id)
|
||||
@@ -323,6 +328,7 @@ class Co4EWorkflowService:
|
||||
self._emit_changed()
|
||||
|
||||
def clear_finished(self) -> None:
|
||||
"""Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy."""
|
||||
# Only clear finished runs of the CURRENT workspace.
|
||||
for run_id in [r for r, rec in self._runs.items() if not rec.running and self._belongs(rec)]:
|
||||
self._runs.pop(run_id, None)
|
||||
@@ -343,9 +349,11 @@ class Co4EWorkflowService:
|
||||
return list(self._runs.values())
|
||||
|
||||
def get(self, run_id: str) -> Optional[RunRecord]:
|
||||
"""Lấy một run theo id; ``None`` nếu không có."""
|
||||
return self._runs.get(run_id)
|
||||
|
||||
def active_count(self) -> int:
|
||||
"""Số run đang chạy của workspace đang chọn — dùng cho huy hiệu trên tab."""
|
||||
return sum(1 for r in self._runs.values() if r.running and self._belongs(r))
|
||||
|
||||
def set_current_project(self, project_id: str) -> None:
|
||||
@@ -363,6 +371,7 @@ class Co4EWorkflowService:
|
||||
self._output_root = Path(root) if root else None
|
||||
|
||||
def _out_dir(self, wf: Workflow) -> Path:
|
||||
"""Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có."""
|
||||
# Flow deliverables are written into the SELECTED workspace (the active
|
||||
# project's folder) so they land where the user works with files (Folder
|
||||
# tab), not in the config/install folder. One subfolder per flow keeps
|
||||
|
||||
@@ -28,6 +28,9 @@ def pptx_available() -> bool:
|
||||
|
||||
|
||||
def read_text(path: str) -> str:
|
||||
"""Đọc tệp dạng văn bản, thay ký tự hỏng thay vì ném lỗi; không đọc được thì
|
||||
trả về chuỗi rỗng.
|
||||
"""
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
@@ -35,6 +38,11 @@ def read_text(path: str) -> str:
|
||||
|
||||
|
||||
def is_probably_text(path: str) -> bool:
|
||||
"""Đoán tệp này có phải văn bản không, bằng cách tìm byte NUL trong phần đầu.
|
||||
|
||||
Đoán sai theo hướng "là văn bản" sẽ hiện một màn hình ký tự rác, nên phép
|
||||
thử cố tình bảo thủ.
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
chunk = f.read(4096)
|
||||
|
||||
@@ -32,6 +32,9 @@ class FileWorkspaceService:
|
||||
"""
|
||||
|
||||
def __init__(self, session) -> None: # WorkspaceSession - see module docstring
|
||||
"""Nhận một ``WorkspaceSession`` — mọi đường dẫn về sau đều bị nó chặn trong
|
||||
phạm vi cho phép.
|
||||
"""
|
||||
self._session = session
|
||||
|
||||
def list_tree(self, rel: str = ".") -> Dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user