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:
2026-08-30 10:41:45 +09:00
co-authored by Claude Opus 5
parent d20306be08
commit e29a0ccdbd
264 changed files with 4593 additions and 359 deletions
+62 -53
View File
@@ -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