Files
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

137 lines
7.4 KiB
Python

"""Bản ghi "một lần chạy flow" — DTO thuần Python cho tầng domain.
Bối cảnh: ``Co4ERunManager``/``RunHandle`` cũ (``core/co4e_run_manager.py``)
trộn ba việc vào một ``QObject``: (1) dữ liệu một run cần nhớ để hiện Flow
Status, (2) logic chạy job trên ``AgentWorker``/``QThread``, và (3) logic
đọc/ghi lịch sử ra đĩa. Tách phần (1) ra thành ``RunRecord`` ở đây giúp nó độc
lập với Qt và với việc đọc/ghi đĩa — đúng quy ước ``domain/__init__.py``: domain
không được biết PySide6 tồn tại và không được chạm đĩa/mạng. Phần (2) và (3)
chuyển sang ``application/workflows/co4e_workflow_service.py``
(``Co4EWorkflowService``), nơi được phép import ``core/`` và làm việc với đĩa.
Vì sao trường ``wf`` là dict thô chứ không phải đối tượng ``Workflow``: lớp
``Workflow`` sống ở ``core/co4e.py``, và việc dựng nó từ/thành dict
(``workflow_to_dict``/``workflow_from_dict``) nằm trong module đó. Domain
không được import ``cowork_local.core.*``, nên ``RunRecord`` giữ nguyên đúng
hình dạng dữ liệu mà bản ghi lịch sử đã có sẵn trên đĩa hôm nay: một dict thô
(kết quả ``workflow_to_dict``) hoặc ``None``. Việc quy đổi dict <-> đối tượng
``Workflow`` là việc của tầng application, nơi được phép import ``core``.
Quirk giữ nguyên có chủ ý — đã bị "đóng đinh" bởi
``tests/characterization/test_co4e_run_manager_behavior.py`` (quirk #1 và #7
trong docstring đầu file đó, xem thêm ``RunHandle.to_record``/``from_record``
gốc) — ĐỪNG "dọn" các chỗ này khi đọc code dưới đây, chúng trông như bug nhưng
là hành vi đã được test khẳng định:
* ``total`` âm bị ``max(0, total)`` kẹp về 0 ngay lúc khởi tạo, không giữ
nguyên giá trị âm.
* ``from_dict()`` đổi ``status == "running"`` đọc từ đĩa thành ``"stopped"``
(lý do: app tắt giữa lúc một run đang "running" thì worker của nó đã mất
theo, nên đọc lại không còn coi là đang chạy) — nhưng ``to_dict()`` vẫn ghi
đúng ``"running"`` xuống đĩa tại thời điểm lưu. Đây là một round-trip
*không đối xứng* có chủ ý.
* ``from_dict({})``/``from_dict(None)`` mặc định ``status`` là ``"done"``
(không phải ``"running"``) — nên KHÔNG bị nhánh phía trên đổi thành
"stopped".
SEAM · dựng 2026-08-25 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ``Co4EWorkflowService`` được nối dây — cùng điều kiện với seam ấy.
Để dormant thì sao: DTO này và ``core/co4e_run_manager.py::RunHandle`` là
hai bản của cùng một thứ; chỉ một bản được phép ở lại.
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
from typing import Dict, Optional
class RunRecord:
"""DTO domain: trạng thái sống của một lần chạy flow, thuần dữ liệu.
Vai trò: đây là "danh từ" mà ``Co4EWorkflowService`` (application/) đọc/ghi
và mà UI Flow Status hiển thị — không có hành vi chạy worker, không đọc/ghi
đĩa. Nó ở tầng domain vì đây là quy tắc nghiệp vụ ổn định (hình dạng một
lần chạy flow cần nhớ những gì) độc lập với Qt lẫn với cơ chế lưu trữ.
"""
def __init__(self, run_id: str, wf_id: str, name: str, total: int,
plan_mode: bool, manual: bool, created_by: str = "", created_at: str = "",
project_id: str = ""):
"""Dựng một bản ghi run. ``total`` âm bị kẹp về 0 — quirk cố ý giữ nguyên từ
``Co4ERunManager`` cũ, xem docstring đầu file.
"""
self.id = run_id
self.wf_id = wf_id
self.name = name
self.project_id = project_id # workspace run này thuộc về (Flow Status lọc theo project)
self.total = max(0, total) # quirk cố ý: total âm bị kẹp về 0, xem docstring đầu file
self.done = 0
self.status = "running" # running | done | error | stopped
self.plan_mode = plan_mode
self.manual = manual
self.created_by = created_by
self.created_at = created_at
self.error = ""
self.node_status: Dict[str, str] = {}
self.wf: Optional[dict] = None # snapshot workflow dạng dict thô (xem docstring đầu file)
self.out_dir = "" # thư mục workspace mà run này ghi file vào
@property
def running(self) -> bool:
"""Run này còn đang chạy không."""
return self.status == "running"
def progress_text(self) -> str:
"""Chuỗi tiến độ để hiện lên bảng: "3/7" khi biết tổng số bước, còn không thì
hiện trạng thái.
"""
return f"{self.done}/{self.total}" if self.total else self.status
# ---- (de)serialization --------------------------------------------
def to_dict(self) -> dict:
"""Hình dạng bản ghi lịch sử trên đĩa.
PHẢI khớp đúng bộ khoá mà ``RunHandle.to_record()`` gốc
(``core/co4e_run_manager.py``) đang ghi hôm nay — file JSON lịch sử cũ
và mới dùng chung một định dạng trong lúc cả hai lớp còn chạy song
song (bản cũ chưa bị xoá).
"""
return {
"id": self.id, "wf_id": self.wf_id, "name": self.name,
"total": self.total, "done": self.done, "status": self.status,
"plan_mode": self.plan_mode, "manual": self.manual,
"created_by": self.created_by, "created_at": self.created_at,
"error": self.error, "node_status": dict(self.node_status),
"wf": self.wf, "out_dir": self.out_dir, "project_id": self.project_id,
}
@classmethod
def from_dict(cls, rec: dict) -> "RunRecord":
"""Dựng lại một run từ dict đọc ở file lịch sử.
Mọi trường đều có mặc định và được ép kiểu: file lịch sử là dữ liệu cũ có
thể thiếu trường mà bản mới đã thêm.
"""
rec = dict(rec or {})
r = cls(str(rec.get("id", "")), str(rec.get("wf_id", "")),
rec.get("name", ""), int(rec.get("total", 0) or 0),
bool(rec.get("plan_mode")), bool(rec.get("manual")),
created_by=rec.get("created_by", ""), created_at=rec.get("created_at", ""))
r.done = int(rec.get("done", 0) or 0)
r.status = rec.get("status", "done")
# quirk cố ý (xem docstring đầu file): round-trip không đối xứng —
# "running" đọc lại từ đĩa luôn bị chốt thành "stopped".
if r.status == "running":
r.status = "stopped"
r.error = rec.get("error", "")
r.node_status = dict(rec.get("node_status") or {})
r.out_dir = rec.get("out_dir", "")
r.project_id = rec.get("project_id", "")
# Giữ nguyên dict thô -- KHONG parse thanh doi tuong Workflow o day (do
# la viec cua tang application, xem docstring dau file).
r.wf = rec.get("wf")
return r