"""``Co4EWorkflowService`` — nửa "hành vi" tách ra từ ``Co4ERunManager`` cũ. Bối cảnh: ``core/co4e_run_manager.py::Co4ERunManager`` là một ``QObject`` gộp chung dữ liệu run (nay là ``domain/workflows/run_record.py::RunRecord``), logic chạy job trên ``AgentWorker``/``QThread``, và logic đọc/ghi lịch sử ra đĩa. File này là phần còn lại sau khi tách DTO: quản lý vòng đời nhiều run cùng lúc, các hook nhận sự kiện từ worker, và lưu/nạp lịch sử — nhưng THUẦN PYTHON, không kế thừa ``QObject`` và không tự dựng ``QThread`` (``application/`` cấm PySide6). Hai điều thay ``Signal`` cũ: * ``changed = Signal()`` -> danh sách callback ``self._changed_callbacks`` + ``on_changed(cb)`` để đăng ký; mọi chỗ code cũ gọi ``self.changed.emit()`` nay gọi ``self._emit_changed()``, gọi callback theo ĐÚNG thứ tự đã đăng ký. * ``event = Signal(str, dict)`` -> ``self._event_callbacks`` + ``on_event(cb)``, tương tự, thay ``self.event.emit(rid, ev)`` bằng ``self._emit_event(rid, ev)``. * ``self.changed.connect(self._save_history)`` (lớp cũ tự nối signal của chính nó vào slot riêng, trong ``__init__``) -> ở đây gọi thẳng ``self._save_history()`` làm bước ĐẦU TIÊN bên trong ``_emit_changed()``, trước khi chạy các callback đã đăng ký từ bên ngoài. Chọn cách "gọi thẳng" (thay vì "đăng ký như callback đầu tiên") vì nó khớp với thứ tự nối cũ (``_save_history`` luôn được nối sớm nhất trong ``__init__`` nên luôn chạy trước mọi slot ngoài nối sau) mà không cần một danh sách callback nội bộ riêng chỉ để chứa đúng một phần tử cố định. ``start()`` KHÔNG tự tạo ``AgentWorker``/``QThread`` — nó nhận một ``runner`` (``WorkflowRunner`` Protocol, mặc định ``None``) tiêm qua constructor. Adapter Qt thật (bọc ``AgentWorker`` — xem ``core/worker.py``) là việc của widget ở ``presentation/``, không viết ở đây; test dùng fake chạy đồng bộ (``tests/fakes/fake_co4e_workflow_service.py`` hoặc fake cục bộ trong ``tests/test_co4e_workflow_service.py``). 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. """ 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 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 _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: return datetime.now().strftime("%Y-%m-%d %H:%M") def _current_user() -> str: """Best-effort creator name for a run (signed-in MS365 identity -> OS user).""" return os.environ.get("USERNAME") or os.environ.get("USER") or "you" # ---- ports (Protocol) — thay QThread thật bằng thứ tiêm được --------------- class RunnerJob(Protocol): """Bề mặt tối thiểu mà job workflow cần từ 'worker' của nó. Tương ứng ``AgentWorker.emit_event``/``AgentWorker.is_cancelled`` cũ (``core/worker.py``) — giữ nguyên chữ ký đó để hàm job bên trong ``co4e_runner.run_workflow`` không phải đổi khi runner đứng sau là ``AgentWorker``/``QThread`` thật (adapter ở presentation/) hay là fake đồng bộ trong test. """ def emit_event(self, ev: dict) -> None: ... def is_cancelled(self) -> bool: ... 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: ... class WorkflowRunner(Protocol): """Cổng chạy một job nền, tiêm qua constructor ``Co4EWorkflowService``. Thay cho việc service tự ``AgentWorker(job); worker.start()`` (cần ``QThread`` -> cấm ở ``application/``). Bên gọi ``start()`` truyền vào ``job`` với đúng chữ ký cũ (``job(worker) -> Optional[dict]``); runner chịu trách nhiệm chạy nó (nền thật hay đồng bộ) và gọi lại ba callback tương ứng ba signal cũ của ``AgentWorker`` (``event``/``finished_ok``/``failed``). """ 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: ... class Co4EWorkflowService: """Tầng application: vòng đời nhiều run Co4E cùng lúc, thuần Python. Vai trò: đây là nơi ``build_co4e_tab(ctx, workflow_service)`` (``presentation/co4e/co4e_tab.py``) sẽ lấy ``workflow_service`` thật một khi widget Co4E Studio được lắp lại để dùng nó — hiện widget thật (``ui/co4e_tab.py``) vẫn dùng ``Co4ERunManager`` cũ song song. """ def __init__(self, ctx, *, history_path: Optional[Path] = None, runner: Optional[WorkflowRunner] = None): self.ctx = ctx self._runs: Dict[str, RunRecord] = {} self._worker_handles: Dict[str, RunWorkerHandle] = {} self._seq = 0 self._output_root: Optional[Path] = None # thư mục output co4e của workspace đang chọn 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. self._history_path_value = ( Path(history_path) if history_path is not None else (CO4E_DIR / "run_history.json") ) 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 # giu du lich su qua cac lan restart # ---- callback thay Signal --------------------------------------------- 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: 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: 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 # ".bad-" (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 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 # ---- lifecycle ---------------------------------------------------- def _next_id(self) -> str: self._seq += 1 return f"run{self._seq}" def start(self, wf: Workflow, *, skill_map: Optional[Dict[str, str]] = None, plan_mode: bool = False, only_nodes: Optional[set] = None, seed_outputs: Optional[Dict[str, str]] = None, manual: bool = False, label: Optional[str] = None) -> str: """Đăng ký một run mới và giao job cho ``self._runner`` (nếu có). Không tự thực thi AI thật ở đây: khi ``self._runner`` là ``None`` (mặc định), run được ghi nhận nhưng không job nào được giao đi — dùng cho test/khi chưa lắp adapter Qt thật. """ run_id = self._next_id() total = len(only_nodes) if only_nodes else len(wf.nodes) record = RunRecord(run_id, wf.id, label or wf.name, total, plan_mode, manual, created_by=_current_user(), created_at=_now_str(), project_id=self._project_id) # workflow_to_dict() tu dung dataclasses.asdict() de dung ca cay (node, # step, sub-agent) -> ban than no da la mot "deep copy" sang dict moi, # khong con giu tham chieu toi wf.nodes/wf.edges song. Vi vay KHONG can # deepcopy(wf) truoc nhu ban Qt cu (RunHandle.wf giu nguyen doi tuong # Workflow) -- xem doc string dau file domain/workflows/run_record.py # ve ly do snapshot o day la dict tho chu khong phai doi tuong. record.wf = workflow_to_dict(wf) nodes = list(wf.nodes) edges = list(wf.edges) out_dir = self._out_dir(wf) record.out_dir = str(out_dir) ctx = self.ctx sk = dict(skill_map or {}) only: Optional[Set[str]] = set(only_nodes) if only_nodes else None seed = dict(seed_outputs or {}) run_label = record.name self._runs[run_id] = record if self._runner is not None: def job(worker: RunnerJob): from ...core import co4e_runner return co4e_runner.run_workflow( ctx, nodes, edges, out_dir, worker.emit_event, worker.is_cancelled, plan_mode=plan_mode, skill_map=sk, only_nodes=only, seed_outputs=seed, usage_label=run_label) self._worker_handles[run_id] = self._runner.start( run_id, job, on_event=lambda ev, rid=run_id: self._on_event(rid, ev), on_finished=lambda _r=None, rid=run_id: self._on_finished(rid), on_failed=lambda e, rid=run_id: self._on_failed(rid, e), ) self._emit_changed() return run_id # ---- worker callbacks (goi tu runner, thay slot Qt cu) ----------------- def _on_event(self, run_id: str, ev) -> None: 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() # quirk co y giu nguyen (xem test_on_event_unknown_run_id... trong ca # test cu lan test moi): re-emit VO DIEU KIEN, ke ca run_id la hoac ev # khong phai dict/None -- khac _on_finished/_on_failed la no-op hoan # toan khi run_id la. # # Khac biet CO CHU Y so voi ban Qt cu: Signal(str, dict) cua PySide6 ep # ev=None thanh {} khi giao cho slot (tac dung phu cua kieu Signal khai # bao cung). O day khong con Signal nen callback nhan DUNG gia tri ev # goc (None neu goi voi None) -- khong gia lap lai viec ep kieu do vi # no la tac dung phu cua Qt, khong phai quy tac nghiep vu can giu. self._emit_event(run_id, ev) def _on_finished(self, run_id: str) -> None: 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 record.status = "done" self._emit_changed() def _on_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) worker = self._worker_handles.get(run_id) if record is not None and worker is not None and record.running: worker.request_stop() record.status = "stopped" self._emit_changed() def stop_all(self) -> None: # 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) def rename(self, run_id: str, new_name: str) -> None: """Rename a run in the Flow Status history (and its kept workflow snapshot), then persist + refresh views. No-op on a blank name / unknown run.""" 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 # DTO doi: RunHandle.wf cu la doi tuong Workflow (gan record.wf.name), # RunRecord.wf o day la dict tho (xem domain/workflows/run_record.py) # nen doi truc tiep khoa "name" cua dict thay vi thuoc tinh doi tuong. if record.wf is not None: record.wf["name"] = new_name self._emit_changed() def remove(self, run_id: str) -> None: record = self._runs.get(run_id) if record is not None and record.running: self.stop(run_id) self._runs.pop(run_id, None) self._worker_handles.pop(run_id, None) self._emit_changed() def clear_finished(self) -> None: # 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) self._worker_handles.pop(run_id, None) self._emit_changed() # ---- queries ---------------------------------------------------------- def _belongs(self, r: RunRecord) -> bool: """Whether a run belongs to the currently-selected workspace.""" return getattr(r, "project_id", "") == self._project_id def runs(self) -> List[RunRecord]: """Runs of the CURRENT workspace only — Flow Status is per-project.""" return [r for r in self._runs.values() if self._belongs(r)] def all_runs(self) -> List[RunRecord]: """Every tracked run across all workspaces (background tracking).""" 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: """Filter Flow Status (and new runs) to this workspace. Runs started while this is set are tagged with it; the Runs view shows only matching runs.""" pid = project_id or "" if pid != self._project_id: self._project_id = pid self._emit_changed() # re-render Flow Status for the new workspace def set_output_root(self, root: Optional[Path]) -> None: """Point flow outputs at the SELECTED workspace's co4e folder (set by the Co4E tab when a project is chosen). ``None`` → fall back to the global Cowork output dir.""" self._output_root = Path(root) if root else None def _out_dir(self, wf: Workflow) -> Path: # 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 # runs tidy. Falls back to the global Cowork output dir when no workspace # is selected. base = self._output_root if base is None: try: base = self.ctx.config.cowork_output_dir() / "co4e" except Exception: # noqa: BLE001 - fall back to the config dir if unavailable base = CO4E_DIR / "runs" / "co4e" d = Path(base) / slugify(wf.name or "flow") d.mkdir(parents=True, exist_ok=True) return d