Soát lại plan.md thì thấy CASAN là NĂM tiêu chí C-A-S-A-N, không phải ba. Tiêu chí A có hai vế, tôi mới đạt vế đầu: vế 1 0 API key plaintext trong JSON -> đã đạt từ 25/08 vế 2 MỌI thao tác ghi tệp đi qua AtomicJsonFile -> CHƯA Toàn repo còn 15 chỗ ghi JSON thẳng. Bốn trong đó là của Gamma (vùng Co4E): core/co4e.py:236 lưu workflow ghi thẳng, không nguyên tử gì cả core/co4e.py:310 lưu agent ghi thẳng core/co4e_run_manager.py:156 tmp + replace tự viết application/workflows/co4e_workflow_service.py:178 tmp + replace tự viết Hai chỗ đầu nguy hơn: tắt máy giữa lúc lưu là mất luôn workflow hoặc agent. Hai chỗ sau nhìn thì có vẻ ổn vì đã tmp + replace, nhưng thiếu hai thứ: * không fsync — dữ liệu có thể còn nằm trong bộ đệm ổ đĩa khi mất điện, nên "nguyên tử" chỉ đúng với crash tiến trình, không đúng với mất điện; * dùng thẳng Path.replace, đúng chỗ dính PermissionError [WinError 5] mà tôi vá hôm 25/08 — Defender giữ handle file vừa tạo. Tần suất đo được khoảng 1/140 lần lưu, nhân với số lần lưu lịch sử chạy flow. 11 chỗ còn lại thuộc team khác (accounts, admin_agents, custom_agents, flows, groups, history, projects, skills, tasks). Không đụng vào; cần báo lên vì tiêu chí A là tiêu chí TOÀN DỰ ÁN, Gamma sạch không cứu được cổng. Đã kiểm application/ vẫn không kéo PySide6 vào sau khi thêm import mới (tiêu chí C). 714 test xanh, 24/24 checker qua. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
380 lines
18 KiB
Python
380 lines
18 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.
|
|
"""
|
|
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
|
|
# "<ten>.bad-<timestamp>" (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
|