CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
389 lines
19 KiB
Python
389 lines
19 KiB
Python
"""``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.
|
|
|
|
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
|
|
|
|
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
|
|
from .co4e_run_history import RunHistoryStore
|
|
|
|
_TERMINAL_NODE = {STEP_DONE, STEP_ERROR, STEP_PLANNED}
|
|
|
|
|
|
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")
|
|
|
|
|
|
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:
|
|
"""Đẩ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:
|
|
"""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):
|
|
"""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:
|
|
"""Chạy ``job`` và trả về tay cầm để dừng nó."""
|
|
...
|
|
|
|
|
|
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):
|
|
"""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] = {}
|
|
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 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
|
|
# giu du lich su qua cac lan restart
|
|
|
|
# ---- 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 --------------------------------------------------
|
|
def _load_history(self) -> None:
|
|
"""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:
|
|
"""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}"
|
|
|
|
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:
|
|
"""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")
|
|
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:
|
|
"""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
|
|
record.status = "done"
|
|
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"
|
|
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:
|
|
"""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:
|
|
worker.request_stop()
|
|
record.status = "stopped"
|
|
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)
|
|
|
|
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:
|
|
"""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)
|
|
self._runs.pop(run_id, None)
|
|
self._worker_handles.pop(run_id, None)
|
|
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)
|
|
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]:
|
|
"""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:
|
|
"""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:
|
|
"""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
|
|
# 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
|