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
+174
View File
@@ -0,0 +1,174 @@
"""``Co4EWorkflowService`` giả — cho widget Co4E Studio (presentation/) và cho
test khác dùng khi service thật
(``application/workflows/co4e_workflow_service.py``) chưa được ``bootstrap.py``
lắp vào, hoặc khi test không muốn chạm đĩa/AI thật.
Chạy hoàn toàn trong bộ nhớ, đồng bộ, không cần ``runner`` thật (không
``AgentWorker``/``QThread`` nào được tạo): ``start()`` ghi nhận run ở trạng
thái "running" rồi đứng yên — muốn mô phỏng tiến trình thì test tự gọi
``deliver_event``/``mark_finished``/``mark_failed``, giống hệt cách
``tests/characterization/test_co4e_run_manager_behavior.py`` seed tay vào
``Co4ERunManager`` thật rồi gọi ``_on_event``/``_on_finished``/``_on_failed``.
Ví dụ dùng::
>>> from tests.fakes.fake_co4e_workflow_service import FakeCo4EWorkflowService
>>> class _Wf:
... id = "wf1"; name = "Flow"; nodes = []; edges = []
>>> svc = FakeCo4EWorkflowService()
>>> run_id = svc.start(_Wf())
>>> svc.started_workflows[0].id
'wf1'
>>> svc.runs()[0].status
'running'
>>> svc.mark_finished(run_id)
>>> svc.runs()[0].status
'done'
"""
from __future__ import annotations
from pathlib import Path
from typing import Callable, Dict, List, Optional
from cowork_local.domain.workflows.run_record import RunRecord
# Mirror dung gia tri cua STEP_DONE/STEP_ERROR/STEP_PLANNED (core/co4e.py) ma
# khong import core/ o day -- fake nay chi phu thuoc domain/, giu no nhe va
# nhanh de import trong test cua team khac.
_TERMINAL_NODE = {"done", "error", "planned"}
class FakeCo4EWorkflowService:
"""Bản giả của ``Co4EWorkflowService`` — cùng API công khai, ghi lại mọi
lời gọi để test khẳng định được "có gọi service không" và "gọi với gì"."""
def __init__(self):
self._runs: Dict[str, RunRecord] = {}
self._seq = 0
self._project_id: str = ""
self._output_root: Optional[Path] = None
self._changed_callbacks: List[Callable[[], None]] = []
self._event_callbacks: List[Callable[[str, dict], None]] = []
#: moi workflow da duoc start(), dung thu tu goi -- test khang dinh
#: "co goi service.start() khong" ma khong can thuc thi that.
self.started_workflows: list = []
self.stopped_run_ids: List[str] = []
self.removed_run_ids: List[str] = []
self.renamed: List[tuple] = []
# ---- callback thay Signal (giong Co4EWorkflowService that) -------------
def on_changed(self, cb: Callable[[], None]) -> None:
self._changed_callbacks.append(cb)
def on_event(self, cb: Callable[[str, dict], None]) -> None:
self._event_callbacks.append(cb)
def _emit_changed(self) -> None:
for cb in self._changed_callbacks:
cb()
def _emit_event(self, run_id: str, ev: dict) -> None:
for cb in self._event_callbacks:
cb(run_id, ev)
# ---- lifecycle ----------------------------------------------------
def start(self, wf, *, skill_map=None, plan_mode: bool = False, only_nodes=None,
seed_outputs=None, manual: bool = False, label: Optional[str] = None) -> str:
self._seq += 1
run_id = f"run{self._seq}"
nodes = getattr(wf, "nodes", None) or []
total = len(only_nodes) if only_nodes else len(nodes)
record = RunRecord(run_id, getattr(wf, "id", ""), label or getattr(wf, "name", ""),
total, plan_mode, manual, project_id=self._project_id)
self._runs[run_id] = record
self.started_workflows.append(wf)
self._emit_changed()
return run_id
# ---- hook gia lap tien trinh (goi TU TEST, khong phai tu runner that) --
def deliver_event(self, run_id: str, ev: dict) -> None:
"""Mo phong dung ``Co4EWorkflowService._on_event`` that."""
record = self._runs.get(run_id)
if record is not None and isinstance(ev, dict):
t = ev.get("type")
if t == "node_status":
record.node_status[ev.get("node_id")] = ev.get("status")
record.done = sum(1 for s in record.node_status.values() if s in _TERMINAL_NODE)
self._emit_changed()
elif t == "run_done":
if record.status == "running":
record.status = "done" if ev.get("ok", True) else "error"
self._emit_changed()
self._emit_event(run_id, ev)
def mark_finished(self, run_id: str) -> None:
record = self._runs.get(run_id)
if record is not None and record.status == "running":
record.status = "done"
self._emit_changed()
def mark_failed(self, run_id: str, err: str) -> None:
record = self._runs.get(run_id)
if record is not None:
record.status = "error"
record.error = str(err)
self._emit_event(run_id, {"type": "run_error", "error": str(err)})
self._emit_changed()
# ---- control --------------------------------------------------------
def stop(self, run_id: str) -> None:
record = self._runs.get(run_id)
if record is not None and record.running:
record.status = "stopped"
self.stopped_run_ids.append(run_id)
self._emit_changed()
def stop_all(self) -> None:
for run_id in [r for r, rec in self._runs.items() if self._belongs(rec)]:
self.stop(run_id)
def rename(self, run_id: str, new_name: str) -> None:
record = self._runs.get(run_id)
new_name = (new_name or "").strip()
if record is None or not new_name or new_name == record.name:
return
record.name = new_name
if record.wf is not None:
record.wf["name"] = new_name
self.renamed.append((run_id, new_name))
self._emit_changed()
def remove(self, run_id: str) -> None:
self._runs.pop(run_id, None)
self.removed_run_ids.append(run_id)
self._emit_changed()
def clear_finished(self) -> None:
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)
self._emit_changed()
# ---- queries ----------------------------------------------------------
def _belongs(self, r: RunRecord) -> bool:
return getattr(r, "project_id", "") == self._project_id
def runs(self) -> List[RunRecord]:
return [r for r in self._runs.values() if self._belongs(r)]
def all_runs(self) -> List[RunRecord]:
return list(self._runs.values())
def get(self, run_id: str) -> Optional[RunRecord]:
return self._runs.get(run_id)
def active_count(self) -> int:
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:
pid = project_id or ""
if pid != self._project_id:
self._project_id = pid
self._emit_changed()
def set_output_root(self, root) -> None:
self._output_root = Path(root) if root else None