Files
cowork-local/application/workflows/co4e_workflow_service.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## 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>
2026-08-31 05:15:13 +00:00

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