feat(co4e): tách 6 widget UI khỏi ui/co4e_tab.py sang presentation/co4e/*

Lane N3 (Co4E Studio) — dùng bộ workflow refactor-god-file, mỗi bước có
characterization test trước khi tách, hậu kiểm ranh giới tầng sau mỗi bước:

- skills_list_panel.py / agent_list_panel.py — 2 khu vực sidebar
- co4e_canvas_widget.py + canvas_items.py + canvas_interaction_mixin.py —
  Co4ECanvas tách 3 file (vượt 400 dòng nếu đứng một mình)
- node_property_panel.py + node_property_actions_mixin.py +
  step_config_section.py — StepConfigPanel, cùng lý do
- co4e_run_control_widget.py — RunsPagePanel (trang Flow Status)
- co4e_chat_view.py — ChatPanel + _ChatInput + helper autocomplete
- palette_list.py — _PaletteList dời khỏi ui/co4e_tab.py, hết import ngược
  presentation -> ui (agent/skills panel giờ import top-level)

ui/co4e_tab.py giảm 2089 -> 1878 dòng, chỉ còn phần wiring + business logic
(Co4ERunManager/AgentWorker chưa đổi — nằm ngoài phạm vi này, xem docstring
presentation/co4e/co4e_tab.py). ui/co4e_canvas.py và ui/co4e_config_panel.py
còn lại là compat shim re-export, không đổi API cho bên gọi.

Thêm tests/test_co4e_integration.py — dựng thật Co4ETab qua build_co4e_tab(),
lái luồng qua nhiều panel trong cùng instance (thêm node, mở/gập chat, chuyển
trang Flow Status rồi quay lại không mất state canvas) — bắt lỗi wiring
xuyên-panel mà characterization test từng panel riêng không thấy được.

Đã xác minh: pytest 348 passed/1 skipped, tools/check_co4e.py sạch, không
file nào >400 dòng, domain/application không import PySide6, và so pixel
before/after (git worktree tại HEAD cũ) ra 0/1.125.000 pixel khác biệt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 18:43:42 +09:00
co-authored by Claude Sonnet 5
parent ca7ea1479d
commit 0631abf85f
45 changed files with 22739 additions and 1578 deletions
+114
View File
@@ -0,0 +1,114 @@
"""Bản ghi "một lần chạy flow" — DTO thuần Python cho tầng domain.
Bối cảnh: ``Co4ERunManager``/``RunHandle`` cũ (``core/co4e_run_manager.py``)
trộn ba việc vào một ``QObject``: (1) dữ liệu một run cần nhớ để hiện Flow
Status, (2) logic chạy job trên ``AgentWorker``/``QThread``, và (3) logic
đọc/ghi lịch sử ra đĩa. Tách phần (1) ra thành ``RunRecord`` ở đây giúp nó độc
lập với Qt và với việc đọc/ghi đĩa — đúng quy ước ``domain/__init__.py``: domain
không được biết PySide6 tồn tại và không được chạm đĩa/mạng. Phần (2) và (3)
chuyển sang ``application/workflows/co4e_workflow_service.py``
(``Co4EWorkflowService``), nơi được phép import ``core/`` và làm việc với đĩa.
Vì sao trường ``wf`` là dict thô chứ không phải đối tượng ``Workflow``: lớp
``Workflow`` sống ở ``core/co4e.py``, và việc dựng nó từ/thành dict
(``workflow_to_dict``/``workflow_from_dict``) nằm trong module đó. Domain
không được import ``cowork_local.core.*``, nên ``RunRecord`` giữ nguyên đúng
hình dạng dữ liệu mà bản ghi lịch sử đã có sẵn trên đĩa hôm nay: một dict thô
(kết quả ``workflow_to_dict``) hoặc ``None``. Việc quy đổi dict <-> đối tượng
``Workflow`` là việc của tầng application, nơi được phép import ``core``.
Quirk giữ nguyên có chủ ý — đã bị "đóng đinh" bởi
``tests/characterization/test_co4e_run_manager_behavior.py`` (quirk #1 và #7
trong docstring đầu file đó, xem thêm ``RunHandle.to_record``/``from_record``
gốc) — ĐỪNG "dọn" các chỗ này khi đọc code dưới đây, chúng trông như bug nhưng
là hành vi đã được test khẳng định:
* ``total`` âm bị ``max(0, total)`` kẹp về 0 ngay lúc khởi tạo, không giữ
nguyên giá trị âm.
* ``from_dict()`` đổi ``status == "running"`` đọc từ đĩa thành ``"stopped"``
(lý do: app tắt giữa lúc một run đang "running" thì worker của nó đã mất
theo, nên đọc lại không còn coi là đang chạy) — nhưng ``to_dict()`` vẫn ghi
đúng ``"running"`` xuống đĩa tại thời điểm lưu. Đây là một round-trip
*không đối xứng* có chủ ý.
* ``from_dict({})``/``from_dict(None)`` mặc định ``status`` là ``"done"``
(không phải ``"running"``) — nên KHÔNG bị nhánh phía trên đổi thành
"stopped".
"""
from __future__ import annotations
from typing import Dict, Optional
class RunRecord:
"""DTO domain: trạng thái sống của một lần chạy flow, thuần dữ liệu.
Vai trò: đây là "danh từ" mà ``Co4EWorkflowService`` (application/) đọc/ghi
và mà UI Flow Status hiển thị — không có hành vi chạy worker, không đọc/ghi
đĩa. Nó ở tầng domain vì đây là quy tắc nghiệp vụ ổn định (hình dạng một
lần chạy flow cần nhớ những gì) độc lập với Qt lẫn với cơ chế lưu trữ.
"""
def __init__(self, run_id: str, wf_id: str, name: str, total: int,
plan_mode: bool, manual: bool, created_by: str = "", created_at: str = "",
project_id: str = ""):
self.id = run_id
self.wf_id = wf_id
self.name = name
self.project_id = project_id # workspace run này thuộc về (Flow Status lọc theo project)
self.total = max(0, total) # quirk cố ý: total âm bị kẹp về 0, xem docstring đầu file
self.done = 0
self.status = "running" # running | done | error | stopped
self.plan_mode = plan_mode
self.manual = manual
self.created_by = created_by
self.created_at = created_at
self.error = ""
self.node_status: Dict[str, str] = {}
self.wf: Optional[dict] = None # snapshot workflow dạng dict thô (xem docstring đầu file)
self.out_dir = "" # thư mục workspace mà run này ghi file vào
@property
def running(self) -> bool:
return self.status == "running"
def progress_text(self) -> str:
return f"{self.done}/{self.total}" if self.total else self.status
# ---- (de)serialization --------------------------------------------
def to_dict(self) -> dict:
"""Hình dạng bản ghi lịch sử trên đĩa.
PHẢI khớp đúng bộ khoá mà ``RunHandle.to_record()`` gốc
(``core/co4e_run_manager.py``) đang ghi hôm nay — file JSON lịch sử cũ
và mới dùng chung một định dạng trong lúc cả hai lớp còn chạy song
song (bản cũ chưa bị xoá).
"""
return {
"id": self.id, "wf_id": self.wf_id, "name": self.name,
"total": self.total, "done": self.done, "status": self.status,
"plan_mode": self.plan_mode, "manual": self.manual,
"created_by": self.created_by, "created_at": self.created_at,
"error": self.error, "node_status": dict(self.node_status),
"wf": self.wf, "out_dir": self.out_dir, "project_id": self.project_id,
}
@classmethod
def from_dict(cls, rec: dict) -> "RunRecord":
rec = dict(rec or {})
r = cls(str(rec.get("id", "")), str(rec.get("wf_id", "")),
rec.get("name", ""), int(rec.get("total", 0) or 0),
bool(rec.get("plan_mode")), bool(rec.get("manual")),
created_by=rec.get("created_by", ""), created_at=rec.get("created_at", ""))
r.done = int(rec.get("done", 0) or 0)
r.status = rec.get("status", "done")
# quirk cố ý (xem docstring đầu file): round-trip không đối xứng —
# "running" đọc lại từ đĩa luôn bị chốt thành "stopped".
if r.status == "running":
r.status = "stopped"
r.error = rec.get("error", "")
r.node_status = dict(rec.get("node_status") or {})
r.out_dir = rec.get("out_dir", "")
r.project_id = rec.get("project_id", "")
# Giữ nguyên dict thô -- KHONG parse thanh doi tuong Workflow o day (do
# la viec cua tang application, xem docstring dau file).
r.wf = rec.get("wf")
return r