Files
cowork-local/core/flows.py
T
anhtnm1andClaude Opus 5 e29a0ccdbd refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
Hồi quy đã vá
-------------
F-12  Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
      `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
      `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
      tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
      là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
      gắn 1 tệp, khớp bản trước refactor.

F-01  Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
      `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
      `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
      tiên hỏng thì đổi provider chính là lúc phải thử lại.

F-07  Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
      lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
      người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
      theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
      mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
      `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.

F-11  Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
      mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
      và không bao giờ chạy.

Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:

    graph_renderer.py         -> graph_scene_builder.py + graph_export.py
    co4e_workflow_service.py  -> co4e_run_history.py
    json_config_repository.py -> config_sections.py
    agents_admin_tab.py       -> shared/agent_kind_visuals.py

File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.

Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.

Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.

859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:41:45 +09:00

359 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Flows: multi-step "Requirement → Demo" pipelines for the Code tab.
A flow is an ordered list of steps. Each step carries a prompt, an optional
skill to apply, an optional AI agent (provider), and a hint. Flows can be saved
as reusable templates and executed step-by-step by the Code agent.
Stored as one JSON file per flow under ``~/.cowork_local/flows/``.
"""
from __future__ import annotations
import json
import re
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import List, Optional
from ..config import CONFIG_DIR
FLOWS_DIR = CONFIG_DIR / "flows"
@dataclass
class SubAgent:
"""One concurrent worker inside a "parallel" stage (see FlowStep.parallel_agents)."""
name: str
prompt: str = "" # task for this sub-agent; falls back to the step's own prompt if empty
agent: str = "" # AI provider key override ("" = use the step's/default provider)
model: str = "" # model override within that provider ("" = provider default)
@dataclass
class FlowStep:
"""Một bước trong luồng cũ: prompt, skill áp dụng, và danh sách agent chạy song song."""
name: str
prompt: str = ""
skill: str = "" # skill name to apply on this step ("" = none)
agent: str = "" # AI provider key ("" = dùng provider đang chọn)
model: str = "" # model (Agent) within the provider ("" = provider default)
hint: str = "" # gợi ý để thực thi
attachments: List[str] = field(default_factory=list) # files fed to this stage's prompt
compact_after_run: bool = False # trim old history before the NEXT stage starts
self_verify: bool = False # ask the agent to confirm completeness before handoff
review_retries: int = 0 # re-run this stage up to N times if self-verify fails
parallel_agents: List[SubAgent] = field(default_factory=list) # non-empty = fan-out stage
@property
def is_parallel(self) -> bool:
"""Bước này có chạy nhiều agent song song hay không."""
return bool(self.parallel_agents)
@dataclass
class Flow:
"""Một luồng cũ: tên, mô tả, và danh sách bước chạy tuần tự."""
name: str
description: str = ""
steps: List[FlowStep] = field(default_factory=list)
# Live run-status of a flow's steps (rendered by the Workflow view in the Code
# tab's preview panel). Kept here, free of any Qt import, so it is unit-testable.
STEP_PENDING = "pending"
STEP_RUNNING = "running"
STEP_DONE = "done"
STEP_ERROR = "error"
@dataclass
class FlowRunStatus:
"""Tracks how far a running flow has progressed.
``done`` = number of finished steps; the step at index ``done`` is the one
currently running (until ``finished``). Advance once per completed turn.
``substeps`` holds the running stage's sub-plan (``[{title, status}]``) so the
Plan checklist nests under each Workflow stage."""
step_names: List[str]
done: int = 0
finished: bool = False
last_error: bool = False
substeps: List[dict] = field(default_factory=list)
def state_of(self, i: int) -> str:
"""Trạng thái hiển thị của bước thứ ``i``: xong, đang chạy, lỗi hay còn chờ.
Chỉ bước ngay TRƯỚC con trỏ mới được đánh dấu lỗi — các bước xong trước đó
vẫn là xong.
"""
if i < self.done:
if self.last_error and i == self.done - 1:
return STEP_ERROR
return STEP_DONE
if i == self.done and not self.finished:
return STEP_RUNNING
return STEP_PENDING
def advance(self, error: bool = False) -> bool:
"""Mark the current step complete; the next becomes running. Returns
True once the whole flow is finished. Never advances past the last step."""
if self.finished:
return True
self.last_error = error
self.done = min(self.done + 1, len(self.step_names))
self.finished = self.done >= len(self.step_names)
return self.finished
def _slug(name: str) -> str:
"""Định danh an toàn cho tên file, suy từ tên luồng."""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in name.strip().lower())
return "-".join(filter(None, s.split("-"))) or "flow"
def flows_dir() -> Path:
"""Thư mục chứa file luồng cũ."""
return FLOWS_DIR
def default_req_to_demo() -> Flow:
"""Built-in template: from requirement to demo."""
return Flow(
name="Req → Demo",
description="Sample pipeline: from requirement to a working demo.",
steps=[
FlowStep("Analyze requirements",
"Read and analyze the requirements; list the work items and acceptance criteria."),
FlowStep("Design the solution",
"Propose the design/architecture and the list of files to create or edit."),
FlowStep("Generate code",
"Implement the code per the design; create/edit files in the working folder."),
FlowStep("Write tests",
"Write meaningful unit tests for what was implemented."),
FlowStep("Run & demo",
"Run/launch to verify, fix any issues, then describe how to demo it."),
],
)
def to_dict(flow: Flow) -> dict:
"""Chuyển một luồng thành dict để ghi JSON."""
return {"name": flow.name, "description": flow.description,
"steps": [asdict(s) for s in flow.steps]}
def from_dict(data: dict) -> Flow:
"""Dựng :class:`Flow` từ dict đọc trên đĩa, lọc bỏ khoá lạ."""
steps = []
for raw in data.get("steps", []):
raw = dict(raw)
sub_raw = raw.pop("parallel_agents", None) or []
step = FlowStep(**{**{"name": ""}, **raw})
step.parallel_agents = [SubAgent(**{**{"name": ""}, **sa}) for sa in sub_raw]
steps.append(step)
return Flow(name=data.get("name", "Flow"), description=data.get("description", ""), steps=steps)
def list_flows(directory: Path = FLOWS_DIR) -> List[Flow]:
"""Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng."""
if not directory.exists():
return []
flows: List[Flow] = []
for path in sorted(directory.glob("*.json")):
try:
flows.append(from_dict(json.loads(path.read_text(encoding="utf-8"))))
except (OSError, json.JSONDecodeError, TypeError):
continue
return flows
def save_flow(flow: Flow, directory: Path = FLOWS_DIR, old_name: str = "") -> Path:
"""Ghi một luồng xuống đĩa.
Đổi tên thì XOÁ file cũ trước — tên file suy từ tên luồng, không xoá sẽ để
lại một bản sao dưới tên cũ.
"""
directory.mkdir(parents=True, exist_ok=True)
if old_name and old_name != flow.name:
delete_flow(old_name, directory)
path = directory / f"{_slug(flow.name)}.json"
path.write_text(json.dumps(to_dict(flow), ensure_ascii=False, indent=2), encoding="utf-8")
return path
def delete_flow(name: str, directory: Path = FLOWS_DIR) -> None:
"""Xoá file luồng theo tên; không có thì bỏ qua."""
path = directory / f"{_slug(name)}.json"
if path.exists():
try:
path.unlink()
except OSError:
pass
def build_step_prompt(step: FlowStep, index: int, total: int, skill_text: str = "") -> str:
"""Compose the message sent to the Code agent for one step."""
lines = [f"[Stage {index}/{total}: {step.name}]"]
if step.hint:
lines.append(f"Hint: {step.hint}")
if step.prompt:
lines.append(step.prompt)
if step.skill and skill_text:
lines.append(f"\n(Applied skill — {step.skill})\n{skill_text}")
return "\n".join(lines)
def generate_task_prompt(provider, stage_name: str = "", hint: str = "", cancel=None) -> str:
"""Best-effort: expand a stage name + short hint into a concrete task prompt
for the Code agent. Returns '' on any error (so the UI never breaks)."""
parts = []
if stage_name:
parts.append(f"Stage: {stage_name}")
if hint:
parts.append(f"Hint: {hint}")
if not parts:
return ""
messages = [
{"role": "system", "content":
"You write a task prompt for a coding agent. Given a stage name and a short hint, "
"expand them into ONE concise, actionable instruction (2–4 sentences) describing exactly "
"what to do. Reply with ONLY the task text — no preamble, no markdown heading."},
{"role": "user", "content": "\n".join(parts)},
]
try:
a = provider.chat(messages, tools=None, on_text=None, cancel=cancel)
except Exception: # noqa: BLE001 - generation must never break the dialog
return ""
return (a.get("content") or "").strip()
# --------------------------------------------------------------------------
# Per-step options that a flat prompt queue can't express: self-verify /
# review-completeness retry / compact-after-run need to inspect a step's
# OUTCOME before deciding what to run next, so a stateful driver (FlowRunner)
# replaces the old "enqueue every step's prompt up front" approach. Free of
# any Qt import so the decision logic is unit-testable on its own.
# --------------------------------------------------------------------------
_VERIFY_MARKER = re.compile(r"VERIFY_RESULT:\s*(PASS|FAIL)\b(.*)", re.IGNORECASE | re.DOTALL)
def build_verify_prompt(step: FlowStep) -> str:
"""A follow-up prompt asking the agent to self-check the stage it just ran,
ending with a strict machine-parseable marker line (see parse_verify_result)."""
goal = step.prompt or step.hint or step.name
return (
f"Review the work you just did for stage \"{step.name}\" against its goal:\n{goal}\n\n"
"Check completeness — did you actually finish everything asked, with no missing "
"pieces, TODOs, or placeholder code? Give a short assessment, then end your reply "
"with EXACTLY one line, nothing after it:\n"
"VERIFY_RESULT: PASS\n"
"or:\n"
"VERIFY_RESULT: FAIL - <one short reason>"
)
def parse_verify_result(text: str) -> Optional[bool]:
"""True = passed, False = failed, None = no marker found at all — treated
as a pass by the caller so a model that forgets the exact marker never
blocks the flow forever."""
m = _VERIFY_MARKER.search(text or "")
if not m:
return None
return m.group(1).upper() == "PASS"
@dataclass
class FlowAction:
"""What the UI layer should do next, returned by :class:`FlowRunner`."""
kind: str # "run" | "parallel" | "done"
prompt: str = "" # for kind == "run"
attachments: List[str] = field(default_factory=list)
compact: bool = False # trim history before running this action
step: Optional[FlowStep] = None # for kind == "parallel" (has .parallel_agents)
@dataclass
class FlowRunner:
"""Drives one Flow's steps sequentially, one turn at a time.
Usage: ``action = runner.start()``; run it; when the turn finishes, call
``action = runner.on_turn_finished(last_assistant_text)`` and run THAT
action; repeat until ``action.kind == "done"``. A parallel stage
(``kind == "parallel"``) has no single "last assistant text" — the caller
fans it out itself and calls ``on_parallel_finished()`` instead."""
flow: Flow
skill_map: dict = field(default_factory=dict) # step.skill name -> instructions text
_index: int = 0
_phase: str = "step" # "step" | "verify"
_retries_used: int = 0
@property
def step_index(self) -> int:
"""Chỉ số bước đang chạy."""
return self._index
def current_step(self) -> Optional[FlowStep]:
"""Bước đang chạy; ``None`` khi đã hết bước."""
if 0 <= self._index < len(self.flow.steps):
return self.flow.steps[self._index]
return None
def start(self) -> FlowAction:
"""Bắt đầu chạy luồng và trả về hành động đầu tiên cần thực hiện."""
if self.current_step() is None:
return FlowAction(kind="done")
return self._step_action()
def _step_action(self) -> FlowAction:
"""Hành động cho bước hiện tại: chạy một agent, hay chia ra nhiều agent song song."""
step = self.current_step()
self._phase = "step"
if step.is_parallel:
return FlowAction(kind="parallel", step=step)
skill_text = self.skill_map.get(step.skill, "")
prompt = build_step_prompt(step, self._index + 1, len(self.flow.steps), skill_text)
return FlowAction(kind="run", prompt=prompt, attachments=list(step.attachments))
def on_turn_finished(self, last_text: str) -> FlowAction:
"""Call after a normal ("run") stage's turn completes."""
step = self.current_step()
if step is None:
return FlowAction(kind="done")
if self._phase == "step":
if step.self_verify or step.review_retries > 0:
self._phase = "verify"
return FlowAction(kind="run", prompt=build_verify_prompt(step))
return self._advance(compact=step.compact_after_run)
# self._phase == "verify": last_text is the verify agent's reply.
passed = parse_verify_result(last_text)
if passed is False and self._retries_used < step.review_retries:
self._retries_used += 1
return self._step_action() # retry the SAME stage, no compact yet
self._retries_used = 0
return self._advance(compact=step.compact_after_run)
def skip_step(self) -> FlowAction:
"""Move past the current stage WITHOUT self-verify/retry — used when
the underlying turn itself failed/errored outright, so a later stage
still gets a chance to run instead of retrying a broken turn forever."""
step = self.current_step()
self._retries_used = 0
compact = step.compact_after_run if step else False
return self._advance(compact=compact)
def on_parallel_finished(self) -> FlowAction:
"""Call after a "parallel" stage's sub-agents have all finished and
their consolidated result has already been fed back as one more
normal turn by the caller (see code_tab.py) — this just advances."""
step = self.current_step()
compact = step.compact_after_run if step else False
return self._advance(compact=compact)
def _advance(self, compact: bool) -> FlowAction:
"""Sang bước kế tiếp; hết bước thì báo luồng đã xong."""
self._index += 1
if self.current_step() is None:
return FlowAction(kind="done", compact=compact)
action = self._step_action()
action.compact = compact
return action