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>
578 lines
22 KiB
Python
578 lines
22 KiB
Python
"""Test đặc tả cho phần vừa tách khỏi ``core/co4e_run_manager.py``:
|
|
|
|
* ``domain/workflows/run_record.py::RunRecord`` — DTO thuần domain.
|
|
* ``application/workflows/co4e_workflow_service.py::Co4EWorkflowService`` —
|
|
phần hành vi (hook + lifecycle + lưu lịch sử), thuần Python.
|
|
|
|
Khác với ``tests/characterization/test_co4e_run_manager_behavior.py`` (bọc lớp
|
|
CŨ, không được sửa), file này bọc lớp MỚI, và có thêm một test bắt buộc theo
|
|
yêu cầu tách: ``test_new_service_produces_same_json_record_as_old_manager`` —
|
|
chạy CÙNG một chuỗi thao tác trên CẢ HAI lớp (cũ và mới) với cùng input, rồi so
|
|
JSON ghi ra đĩa của chúng bằng nhau. Đây là bằng chứng "hành vi không lệch"
|
|
chạy được, không phải suy luận bằng mắt.
|
|
|
|
Không gọi ``Co4EWorkflowService.start()`` với ``runner=None`` bỏ qua — luôn
|
|
truyền ``runner`` fake không thực thi job thật (không gọi AI thật), giống lý do
|
|
``test_co4e_run_manager_behavior.py`` không bao giờ gọi ``Co4ERunManager.start()``
|
|
thật.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cowork_local.application.workflows.co4e_workflow_service import Co4EWorkflowService
|
|
from cowork_local.core.co4e import Node, Step, Workflow
|
|
from cowork_local.domain.workflows.run_record import RunRecord
|
|
|
|
|
|
class _FakeConfig:
|
|
def __init__(self, output_dir: Path):
|
|
self._output_dir = output_dir
|
|
|
|
def cowork_output_dir(self) -> Path:
|
|
return self._output_dir
|
|
|
|
|
|
class _Ctx:
|
|
"""Stub ctx: chỉ ``start()``/``_out_dir()`` mới đụng ``ctx.config``."""
|
|
|
|
def __init__(self, output_dir: Path):
|
|
self.config = _FakeConfig(output_dir)
|
|
|
|
|
|
class _RecordingRunner:
|
|
"""Fake ``WorkflowRunner`` — ghi lại lời gọi ``start()``, KHÔNG thực thi
|
|
``job`` (job thật gọi ``core.co4e_runner.run_workflow`` -> AI thật, tốn
|
|
tiền/ghi file thật, đúng lý do old characterization test tránh gọi
|
|
``Co4ERunManager.start()``). Trả một handle giả để test ``stop()``."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def start(self, run_id, job, on_event, on_finished, on_failed):
|
|
handle = _FakeWorkerHandle()
|
|
self.calls.append((run_id, job, on_event, on_finished, on_failed, handle))
|
|
return handle
|
|
|
|
|
|
class _FakeWorkerHandle:
|
|
def __init__(self):
|
|
self.stop_requested = False
|
|
|
|
def request_stop(self):
|
|
self.stop_requested = True
|
|
|
|
|
|
def _make_workflow(node_count: int = 3, wf_id: str = "wf1", name: str = "Flow") -> Workflow:
|
|
# Dung dung dataclass that (core/co4e.py) thay vi stub -- workflow_to_dict()
|
|
# trong Co4EWorkflowService.start() doc n.id/n.x/n.y/n.data tren tung node
|
|
# va wf.is_template tren workflow, khong the gia lap bang string/duck-type
|
|
# thieu thuoc tinh.
|
|
nodes = [Node(id=f"n{i}", x=0.0, y=0.0, data=Step(label=f"Step{i}")) for i in range(1, node_count + 1)]
|
|
return Workflow(id=wf_id, name=name, nodes=nodes, edges=[])
|
|
|
|
|
|
@pytest.fixture
|
|
def service(tmp_path):
|
|
return Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "run_history.json")
|
|
|
|
|
|
def _seed(service: Co4EWorkflowService, run_id: str, **kw) -> RunRecord:
|
|
defaults = dict(wf_id="wf1", name="Flow", total=3, plan_mode=False, manual=False)
|
|
defaults.update(kw)
|
|
r = RunRecord(run_id, **defaults)
|
|
service._runs[run_id] = r
|
|
return r
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RunRecord: gia tri mac dinh / kep bien / round trip (khop ban cu)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_run_record_defaults_on_construction():
|
|
r = RunRecord("run1", "wf1", "My Flow", 3, False, False)
|
|
assert r.status == "running"
|
|
assert r.done == 0
|
|
assert r.progress_text() == "0/3"
|
|
assert r.running is True
|
|
|
|
|
|
def test_run_record_negative_total_clamped_to_zero():
|
|
r = RunRecord("run2", "wf2", "Flow2", -5, False, False)
|
|
assert r.total == 0
|
|
|
|
|
|
def test_run_record_zero_total_progress_text_falls_back_to_status():
|
|
r = RunRecord("run3", "wf3", "Flow3", 0, False, False)
|
|
assert r.progress_text() == "running"
|
|
|
|
|
|
def test_to_dict_contains_expected_keys_and_values():
|
|
r = RunRecord("run1", "wf1", "My Flow", 3, False, False,
|
|
created_by="alice", created_at="2026-08-23 10:00", project_id="p1")
|
|
rec = r.to_dict()
|
|
assert sorted(rec.keys()) == [
|
|
"created_at", "created_by", "done", "error", "id", "manual", "name",
|
|
"node_status", "out_dir", "plan_mode", "project_id", "status", "total",
|
|
"wf", "wf_id",
|
|
]
|
|
assert rec["id"] == "run1"
|
|
assert rec["status"] == "running"
|
|
assert rec["wf"] is None
|
|
|
|
|
|
def test_round_trip_status_running_becomes_stopped():
|
|
r = RunRecord("run1", "wf1", "My Flow", 3, False, False)
|
|
rec = r.to_dict()
|
|
assert rec["status"] == "running"
|
|
back = RunRecord.from_dict(rec)
|
|
assert back.status == "stopped"
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["done", "error", "stopped"])
|
|
def test_round_trip_non_running_statuses_are_preserved(status):
|
|
r = RunRecord("run1", "wf1", "My Flow", 3, False, False)
|
|
r.status = status
|
|
back = RunRecord.from_dict(r.to_dict())
|
|
assert back.status == status
|
|
|
|
|
|
def test_from_dict_empty_dict_uses_documented_defaults():
|
|
r = RunRecord.from_dict({})
|
|
assert r.id == ""
|
|
assert r.status == "done"
|
|
assert r.wf is None
|
|
assert r.node_status == {}
|
|
|
|
|
|
def test_from_dict_none_treated_same_as_empty_dict():
|
|
assert RunRecord.from_dict(None).id == RunRecord.from_dict({}).id
|
|
assert RunRecord.from_dict(None).status == RunRecord.from_dict({}).status
|
|
|
|
|
|
def test_round_trip_preserves_raw_workflow_snapshot_dict():
|
|
# domain khong parse "wf" thanh doi tuong -- giu nguyen dict tho (khac
|
|
# RunHandle cu, xem docstring domain/workflows/run_record.py).
|
|
r = RunRecord("run4", "wf-x", "Flow X run", 1, False, False)
|
|
r.wf = {"id": "wf-x", "name": "Flow X", "nodes": [{"id": "n1"}], "edges": []}
|
|
back = RunRecord.from_dict(r.to_dict())
|
|
assert back.wf == r.wf
|
|
assert isinstance(back.wf, dict)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _on_event / _on_finished / _on_failed (hanh vi khop ban cu, callback thay Signal)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_on_event_node_status_done_increments_progress_and_emits_changed(service):
|
|
r = _seed(service, "run1")
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
|
|
assert r.node_status == {"n1": "done"}
|
|
assert r.done == 1
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_on_event_node_status_planned_counts_as_terminal_too(service):
|
|
r = _seed(service, "run1")
|
|
service._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "planned"})
|
|
assert r.done == 1
|
|
|
|
|
|
def test_on_event_node_status_running_is_not_terminal(service):
|
|
r = _seed(service, "run1")
|
|
service._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "running"})
|
|
assert r.done == 0
|
|
|
|
|
|
def test_on_event_node_status_missing_keys_stores_none_key(service):
|
|
r = _seed(service, "run1")
|
|
service._on_event("run1", {"type": "node_status"})
|
|
assert r.node_status == {None: None}
|
|
|
|
|
|
def test_on_event_run_done_default_ok_marks_done(service):
|
|
r = _seed(service, "run1")
|
|
service._on_event("run1", {"type": "run_done"})
|
|
assert r.status == "done"
|
|
|
|
|
|
def test_on_event_run_done_ok_false_marks_error(service):
|
|
r = _seed(service, "run1")
|
|
service._on_event("run1", {"type": "run_done", "ok": False})
|
|
assert r.status == "error"
|
|
|
|
|
|
def test_on_event_run_done_ignored_when_not_running(service):
|
|
r = _seed(service, "run1")
|
|
r.status = "stopped"
|
|
service._on_event("run1", {"type": "run_done", "ok": False})
|
|
assert r.status == "stopped"
|
|
|
|
|
|
def test_on_event_unknown_run_id_does_not_raise_and_still_reemits_event(service):
|
|
received = []
|
|
service.on_event(lambda rid, ev: received.append((rid, ev)))
|
|
service._on_event("no-such-run", {"type": "node_status", "node_id": "n1", "status": "done"})
|
|
assert received == [("no-such-run", {"type": "node_status", "node_id": "n1", "status": "done"})]
|
|
|
|
|
|
def test_on_event_none_payload_does_not_raise_and_reemits_none(service):
|
|
# Khac ban cu (Qt ep None -> {} do Signal(str, dict)): o day khong con
|
|
# Signal nen callback nhan DUNG gia tri goc None. Xem comment trong
|
|
# co4e_workflow_service.py::_on_event ve ly do khong gia lap lai viec ep
|
|
# kieu do.
|
|
_seed(service, "run1")
|
|
received = []
|
|
service.on_event(lambda rid, ev: received.append((rid, ev)))
|
|
service._on_event("run1", None)
|
|
assert received == [("run1", None)]
|
|
|
|
|
|
def test_on_finished_while_running_settles_to_done(service):
|
|
r = _seed(service, "run1")
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service._on_finished("run1")
|
|
assert r.status == "done"
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_on_finished_when_already_settled_is_a_noop(service):
|
|
r = _seed(service, "run1")
|
|
r.status = "error"
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service._on_finished("run1")
|
|
assert r.status == "error"
|
|
assert len(changed) == 0
|
|
|
|
|
|
def test_on_finished_unknown_run_id_is_a_total_noop(service):
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service._on_finished("no-such-run")
|
|
assert service._runs == {}
|
|
assert changed == []
|
|
|
|
|
|
def test_on_failed_marks_error_with_message_and_emits_run_error_event(service):
|
|
r = _seed(service, "run1")
|
|
events = []
|
|
changed = []
|
|
service.on_event(lambda rid, ev: events.append((rid, ev)))
|
|
service.on_changed(lambda: changed.append(1))
|
|
service._on_failed("run1", "boom")
|
|
assert r.status == "error"
|
|
assert r.error == "boom"
|
|
assert events == [("run1", {"type": "run_error", "error": "boom"})]
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_on_failed_overrides_status_even_when_already_settled(service):
|
|
r = _seed(service, "run1")
|
|
r.status = "done"
|
|
service._on_failed("run1", "late failure")
|
|
assert r.status == "error"
|
|
|
|
|
|
def test_on_failed_unknown_run_id_is_a_total_noop(service):
|
|
events = []
|
|
changed = []
|
|
service.on_event(lambda rid, ev: events.append((rid, ev)))
|
|
service.on_changed(lambda: changed.append(1))
|
|
service._on_failed("no-such-run", "err")
|
|
assert events == []
|
|
assert changed == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# persistence: hook -> dia -> from_dict round trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_changed_hook_persists_to_history_file(service, tmp_path):
|
|
_seed(service, "run1")
|
|
service._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
|
|
path = tmp_path / "run_history.json"
|
|
assert path.exists()
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
assert len(data["runs"]) == 1
|
|
assert data["runs"][0]["id"] == "run1"
|
|
assert data["runs"][0]["status"] == "running"
|
|
|
|
|
|
def test_reloading_service_after_hook_settles_running_to_stopped(tmp_path):
|
|
history_path = tmp_path / "run_history.json"
|
|
s1 = Co4EWorkflowService(_Ctx(tmp_path), history_path=history_path)
|
|
_seed(s1, "run1")
|
|
s1._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
|
|
|
|
s2 = Co4EWorkflowService(_Ctx(tmp_path), history_path=history_path)
|
|
assert "run1" in s2._runs
|
|
assert s2._runs["run1"].status == "stopped"
|
|
assert s2._seq == 1
|
|
|
|
|
|
def test_reloaded_seq_avoids_colliding_with_history_ids(tmp_path):
|
|
history_path = tmp_path / "run_history.json"
|
|
s1 = Co4EWorkflowService(_Ctx(tmp_path), history_path=history_path)
|
|
_seed(s1, "run7")
|
|
s1._on_event("run7", {"type": "node_status", "node_id": "n1", "status": "done"})
|
|
|
|
s2 = Co4EWorkflowService(_Ctx(tmp_path), history_path=history_path)
|
|
assert s2._seq == 7
|
|
assert s2._next_id() == "run8"
|
|
|
|
|
|
def test_load_history_missing_file_is_silent_noop(tmp_path):
|
|
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "does-not-exist.json")
|
|
assert s._runs == {}
|
|
assert s._seq == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start() qua WorkflowRunner Protocol (khong QThread, khong AI that)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_start_registers_run_and_delegates_to_injected_runner(tmp_path):
|
|
runner = _RecordingRunner()
|
|
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
|
|
changed = []
|
|
s.on_changed(lambda: changed.append(1))
|
|
|
|
run_id = s.start(_make_workflow(node_count=2))
|
|
|
|
assert run_id == "run1"
|
|
record = s.get(run_id)
|
|
assert record is not None
|
|
assert record.status == "running"
|
|
assert record.total == 2
|
|
assert record.wf["id"] == "wf1"
|
|
assert record.wf["name"] == "Flow"
|
|
assert len(record.wf["nodes"]) == 2
|
|
assert len(runner.calls) == 1
|
|
assert runner.calls[0][0] == run_id
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_start_with_only_nodes_uses_its_length_as_total(tmp_path):
|
|
runner = _RecordingRunner()
|
|
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
|
|
run_id = s.start(_make_workflow(node_count=3), only_nodes={"n1", "n2"})
|
|
assert s.get(run_id).total == 2
|
|
|
|
|
|
def test_start_without_runner_still_registers_run_but_no_job_delegated(tmp_path):
|
|
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json") # runner=None mac dinh
|
|
run_id = s.start(_make_workflow())
|
|
assert s.get(run_id) is not None
|
|
assert s.get(run_id).status == "running"
|
|
|
|
|
|
def test_stop_calls_runner_handle_request_stop(tmp_path):
|
|
runner = _RecordingRunner()
|
|
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
|
|
run_id = s.start(_make_workflow())
|
|
handle = runner.calls[0][5]
|
|
s.stop(run_id)
|
|
assert handle.stop_requested is True
|
|
assert s.get(run_id).status == "stopped"
|
|
|
|
|
|
def test_stop_running_run_emits_changed(tmp_path):
|
|
# Bite-test: neu ai xoa self._emit_changed() ben trong stop(), test nay
|
|
# phai do (khac assertion ve status/stop_requested o test ben tren, von
|
|
# khong dung toi len goi on_changed()).
|
|
runner = _RecordingRunner()
|
|
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
|
|
run_id = s.start(_make_workflow())
|
|
changed = []
|
|
s.on_changed(lambda: changed.append(1))
|
|
s.stop(run_id)
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_stop_non_running_run_is_noop_and_does_not_emit_changed(tmp_path):
|
|
runner = _RecordingRunner()
|
|
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
|
|
run_id = s.start(_make_workflow())
|
|
s.get(run_id).status = "done"
|
|
changed = []
|
|
s.on_changed(lambda: changed.append(1))
|
|
s.stop(run_id)
|
|
assert changed == []
|
|
|
|
|
|
def test_rename_updates_name_and_wf_dict_and_emits_changed(service):
|
|
r = _seed(service, "run1")
|
|
r.wf = {"id": "wf1", "name": "Old", "nodes": [], "edges": []}
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service.rename("run1", "New Name")
|
|
assert r.name == "New Name"
|
|
assert r.wf["name"] == "New Name"
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_rename_blank_name_is_noop_and_does_not_emit_changed(service):
|
|
r = _seed(service, "run1", name="Flow")
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service.rename("run1", " ")
|
|
assert r.name == "Flow"
|
|
assert changed == []
|
|
|
|
|
|
def test_rename_same_name_is_noop_and_does_not_emit_changed(service):
|
|
_seed(service, "run1", name="Flow")
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service.rename("run1", "Flow")
|
|
assert changed == []
|
|
|
|
|
|
def test_rename_unknown_run_id_is_noop_and_does_not_emit_changed(service):
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service.rename("no-such-run", "New Name")
|
|
assert changed == []
|
|
|
|
|
|
def test_remove_running_run_stops_it_then_removes_and_emits_changed_twice(tmp_path):
|
|
# remove() goi stop() (rieng no da emit mot lan) roi tu emit them mot lan
|
|
# sau khi pop -- 2 la con so dung khop ban cu (core/co4e_run_manager.py::
|
|
# remove), khong phai 1.
|
|
runner = _RecordingRunner()
|
|
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
|
|
run_id = s.start(_make_workflow())
|
|
handle = runner.calls[0][5]
|
|
changed = []
|
|
s.on_changed(lambda: changed.append(1))
|
|
s.remove(run_id)
|
|
assert handle.stop_requested is True
|
|
assert s.get(run_id) is None
|
|
assert len(changed) == 2
|
|
|
|
|
|
def test_remove_non_running_run_emits_changed_once(service):
|
|
r = _seed(service, "run1")
|
|
r.status = "done"
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service.remove("run1")
|
|
assert service.get("run1") is None
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_clear_finished_emits_changed_even_with_no_matching_runs(service):
|
|
# Ban cu luon emit sau vong lap, ke ca khi khong xoa gi -- giu quirk nay.
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service.clear_finished()
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_clear_finished_removes_only_finished_runs_of_current_project(service):
|
|
r1 = _seed(service, "run1")
|
|
r1.status = "done"
|
|
r2 = _seed(service, "run2")
|
|
r2.status = "running"
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service.clear_finished()
|
|
assert "run1" not in service._runs
|
|
assert "run2" in service._runs
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_set_current_project_changes_pid_and_emits_changed(service):
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service.set_current_project("proj1")
|
|
assert service._project_id == "proj1"
|
|
assert len(changed) == 1
|
|
|
|
|
|
def test_set_current_project_same_pid_is_noop_and_does_not_emit_changed(service):
|
|
service.set_current_project("proj1")
|
|
changed = []
|
|
service.on_changed(lambda: changed.append(1))
|
|
service.set_current_project("proj1")
|
|
assert changed == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bang chung "hanh vi khong lech": cung input -> cung JSON tren dia, ca lop
|
|
# cu (core/co4e_run_manager.py) lan lop moi (application/workflows/...).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_new_service_produces_same_json_record_as_old_manager(tmp_path, monkeypatch):
|
|
from cowork_local.core import co4e as _co4e_module
|
|
from cowork_local.core.co4e_run_manager import Co4ERunManager, RunHandle as OldRunHandle
|
|
|
|
# Co lap CO4E_DIR cho manager cu bang cach patch THUOC TINH MODULE (dung ky
|
|
# thuat cua tests/characterization/test_co4e_run_manager_behavior.py, xem
|
|
# docstring dau file do ve ly do KHONG dung bien moi truong truoc luc
|
|
# import: _history_path() doc lai CO4E_DIR tuoi ngay luc goi ham).
|
|
monkeypatch.setattr(_co4e_module, "CO4E_DIR", tmp_path / "old_home" / ".cowork_local" / "co4e")
|
|
old_history = tmp_path / "old_history.json"
|
|
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: old_history)
|
|
|
|
class _OldCtx:
|
|
pass
|
|
|
|
old_mgr = Co4ERunManager(_OldCtx())
|
|
old_mgr._runs["run1"] = OldRunHandle(
|
|
"run1", "wf1", "Flow", 3, False, False,
|
|
created_by="alice", created_at="2026-08-23 10:00", project_id="p1",
|
|
)
|
|
old_mgr._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
|
|
old_mgr._on_event("run1", {"type": "node_status", "node_id": "n2", "status": "running"})
|
|
old_mgr._on_event("run1", {"type": "run_done", "ok": True})
|
|
old_record = json.loads(old_history.read_text(encoding="utf-8"))["runs"][0]
|
|
|
|
new_history = tmp_path / "new_history.json"
|
|
new_svc = Co4EWorkflowService(_Ctx(tmp_path), history_path=new_history)
|
|
new_svc._runs["run1"] = RunRecord(
|
|
"run1", "wf1", "Flow", 3, False, False,
|
|
created_by="alice", created_at="2026-08-23 10:00", project_id="p1",
|
|
)
|
|
new_svc._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
|
|
new_svc._on_event("run1", {"type": "node_status", "node_id": "n2", "status": "running"})
|
|
new_svc._on_event("run1", {"type": "run_done", "ok": True})
|
|
new_record = json.loads(new_history.read_text(encoding="utf-8"))["runs"][0]
|
|
|
|
assert new_record == old_record
|
|
|
|
|
|
def test_new_service_reload_quirk_matches_old_manager_reload_quirk(tmp_path, monkeypatch):
|
|
"""Cung quirk round-trip khong doi xung ('running' -> 'stopped' sau khi
|
|
doc lai tu dia) phai xay ra giong het nhau tren ca hai lop."""
|
|
from cowork_local.core import co4e as _co4e_module
|
|
from cowork_local.core.co4e_run_manager import Co4ERunManager, RunHandle as OldRunHandle
|
|
|
|
monkeypatch.setattr(_co4e_module, "CO4E_DIR", tmp_path / "old_home2" / ".cowork_local" / "co4e")
|
|
old_history = tmp_path / "old_history2.json"
|
|
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: old_history)
|
|
|
|
class _OldCtx:
|
|
pass
|
|
|
|
old_mgr = Co4ERunManager(_OldCtx())
|
|
old_mgr._runs["run7"] = OldRunHandle("run7", "wf1", "Flow", 2, False, False)
|
|
old_mgr._on_event("run7", {"type": "node_status", "node_id": "n1", "status": "done"})
|
|
old_mgr2 = Co4ERunManager(_OldCtx())
|
|
|
|
new_history = tmp_path / "new_history2.json"
|
|
new_svc = Co4EWorkflowService(_Ctx(tmp_path), history_path=new_history)
|
|
new_svc._runs["run7"] = RunRecord("run7", "wf1", "Flow", 2, False, False)
|
|
new_svc._on_event("run7", {"type": "node_status", "node_id": "n1", "status": "done"})
|
|
new_svc2 = Co4EWorkflowService(_Ctx(tmp_path), history_path=new_history)
|
|
|
|
assert old_mgr2._runs["run7"].status == new_svc2._runs["run7"].status == "stopped"
|
|
assert old_mgr2._seq == new_svc2._seq == 7
|