✨ feat: 完成aide flow程序实现
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import sys
|
||||
|
||||
from aide.main import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.exit(main())
|
||||
|
||||
2
aide-program/aide/flow/__init__.py
Normal file
2
aide-program/aide/flow/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""aide flow:进度追踪与 Git 集成。"""
|
||||
|
||||
8
aide-program/aide/flow/errors.py
Normal file
8
aide-program/aide/flow/errors.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""错误类型:用于将内部失败统一映射为 CLI 输出。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class FlowError(RuntimeError):
|
||||
pass
|
||||
|
||||
78
aide-program/aide/flow/git.py
Normal file
78
aide-program/aide/flow/git.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Git 操作封装:add、commit、查询提交变更文件。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aide.flow.errors import FlowError
|
||||
|
||||
|
||||
class GitIntegration:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
|
||||
def ensure_available(self) -> None:
|
||||
if shutil.which("git") is None:
|
||||
raise FlowError("未找到 git 命令,请先安装 git")
|
||||
|
||||
def ensure_repo(self) -> None:
|
||||
self.ensure_available()
|
||||
result = self._run(["rev-parse", "--is-inside-work-tree"], check=False)
|
||||
if result.returncode != 0 or "true" not in (result.stdout or ""):
|
||||
raise FlowError("当前目录不是 git 仓库,请先执行 git init 或切换到正确目录")
|
||||
|
||||
def add_all(self) -> None:
|
||||
self.ensure_repo()
|
||||
result = self._run(["add", "."], check=False)
|
||||
if result.returncode != 0:
|
||||
raise FlowError(_format_git_error("git add 失败", result))
|
||||
|
||||
def commit(self, message: str) -> str | None:
|
||||
self.ensure_repo()
|
||||
diff = self._run(["diff", "--cached", "--quiet"], check=False)
|
||||
if diff.returncode == 0:
|
||||
return None
|
||||
if diff.returncode != 1:
|
||||
raise FlowError(_format_git_error("git diff 失败", diff))
|
||||
|
||||
result = self._run(["commit", "-m", message], check=False)
|
||||
if result.returncode != 0:
|
||||
raise FlowError(_format_git_error("git commit 失败", result))
|
||||
return self.rev_parse_head()
|
||||
|
||||
def rev_parse_head(self) -> str:
|
||||
result = self._run(["rev-parse", "HEAD"], check=False)
|
||||
if result.returncode != 0:
|
||||
raise FlowError(_format_git_error("获取 commit hash 失败", result))
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
def status_porcelain(self, path: str) -> str:
|
||||
result = self._run(["status", "--porcelain", "--", path], check=False)
|
||||
if result.returncode != 0:
|
||||
raise FlowError(_format_git_error("git status 失败", result))
|
||||
return result.stdout or ""
|
||||
|
||||
def commit_touches_path(self, commit_hash: str, path: str) -> bool:
|
||||
result = self._run(["show", "--name-only", "--pretty=format:", commit_hash], check=False)
|
||||
if result.returncode != 0:
|
||||
raise FlowError(_format_git_error(f"读取提交内容失败: {commit_hash}", result))
|
||||
files = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
|
||||
return path in files
|
||||
|
||||
def _run(self, args: list[str], check: bool) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def _format_git_error(prefix: str, result: subprocess.CompletedProcess[str]) -> str:
|
||||
detail = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
if not detail:
|
||||
return prefix
|
||||
return f"{prefix}: {detail}"
|
||||
85
aide-program/aide/flow/hooks.py
Normal file
85
aide-program/aide/flow/hooks.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""环节钩子:PlantUML 与 CHANGELOG 校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aide.core import output
|
||||
from aide.flow.errors import FlowError
|
||||
from aide.flow.git import GitIntegration
|
||||
from aide.flow.types import FlowStatus
|
||||
|
||||
|
||||
def run_pre_commit_hooks(
|
||||
*,
|
||||
root: Path,
|
||||
git: GitIntegration,
|
||||
status: FlowStatus | None,
|
||||
from_phase: str | None,
|
||||
to_phase: str,
|
||||
action: str,
|
||||
) -> None:
|
||||
if from_phase == "flow-design" and action in {"next-part", "back-part"}:
|
||||
_hook_plantuml(root=root)
|
||||
if from_phase == "docs" and action in {"next-part", "back-part"}:
|
||||
_hook_changelog_on_leave_docs(root=root, git=git, status=status)
|
||||
|
||||
|
||||
def run_post_commit_hooks(*, to_phase: str, action: str) -> None:
|
||||
if to_phase == "docs" and action in {"start", "next-part", "back-part"}:
|
||||
output.info("请更新 CHANGELOG.md")
|
||||
|
||||
|
||||
def _hook_plantuml(*, root: Path) -> None:
|
||||
docs_dir = root / "docs"
|
||||
discuss_dir = root / "discuss"
|
||||
candidates: list[Path] = []
|
||||
for base in (docs_dir, discuss_dir):
|
||||
if not base.exists():
|
||||
continue
|
||||
candidates.extend([p for p in base.rglob("*.puml") if p.is_file()])
|
||||
candidates.extend([p for p in base.rglob("*.plantuml") if p.is_file()])
|
||||
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
if shutil.which("plantuml") is None:
|
||||
output.warn("未找到 plantuml,已跳过 PlantUML 校验/PNG 生成")
|
||||
return
|
||||
|
||||
for file_path in candidates:
|
||||
result = subprocess.run(
|
||||
["plantuml", "-tpng", str(file_path)],
|
||||
cwd=root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
raise FlowError(f"PlantUML 处理失败: {file_path} {detail}".strip())
|
||||
|
||||
|
||||
def _hook_changelog_on_leave_docs(*, root: Path, git: GitIntegration, status: FlowStatus | None) -> None:
|
||||
changelog = root / "CHANGELOG.md"
|
||||
if not changelog.exists():
|
||||
raise FlowError("离开 docs 前需要更新 CHANGELOG.md(当前文件不存在)")
|
||||
|
||||
git.ensure_repo()
|
||||
if git.status_porcelain("CHANGELOG.md").strip():
|
||||
return
|
||||
|
||||
if status is None:
|
||||
raise FlowError("离开 docs 前需要更新 CHANGELOG.md(未找到流程状态)")
|
||||
|
||||
for entry in status.history:
|
||||
if entry.phase != "docs":
|
||||
continue
|
||||
if not entry.git_commit:
|
||||
continue
|
||||
if git.commit_touches_path(entry.git_commit, "CHANGELOG.md"):
|
||||
return
|
||||
|
||||
raise FlowError("离开 docs 前需要更新 CHANGELOG.md(未检测到 docs 阶段的更新记录)")
|
||||
|
||||
91
aide-program/aide/flow/storage.py
Normal file
91
aide-program/aide/flow/storage.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""状态文件读写:锁、原子写入、归档。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from aide.flow.errors import FlowError
|
||||
from aide.flow.types import FlowStatus
|
||||
from aide.flow.utils import now_task_id
|
||||
|
||||
|
||||
class FlowStorage:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
self.aide_dir = self.root / ".aide"
|
||||
self.status_path = self.aide_dir / "flow-status.json"
|
||||
self.lock_path = self.aide_dir / "flow-status.lock"
|
||||
self.tmp_path = self.aide_dir / "flow-status.json.tmp"
|
||||
self.logs_dir = self.aide_dir / "logs"
|
||||
|
||||
def ensure_ready(self) -> None:
|
||||
if not self.aide_dir.exists():
|
||||
raise FlowError("未找到 .aide 目录,请先运行:aide init")
|
||||
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@contextmanager
|
||||
def lock(self, timeout_seconds: float = 3.0, poll_seconds: float = 0.2):
|
||||
self.ensure_ready()
|
||||
start = time.time()
|
||||
fd: int | None = None
|
||||
while True:
|
||||
try:
|
||||
fd = os.open(str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, str(os.getpid()).encode("utf-8"))
|
||||
break
|
||||
except FileExistsError:
|
||||
if time.time() - start >= timeout_seconds:
|
||||
raise FlowError("状态文件被占用,请稍后重试或删除 .aide/flow-status.lock")
|
||||
time.sleep(poll_seconds)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if fd is not None:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self.lock_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def load_status(self) -> FlowStatus | None:
|
||||
if not self.status_path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = self.status_path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("状态文件顶层必须为对象")
|
||||
return FlowStatus.from_dict(data)
|
||||
except Exception as exc:
|
||||
raise FlowError(f"状态文件解析失败: {exc}")
|
||||
|
||||
def save_status(self, status: FlowStatus) -> None:
|
||||
payload = json.dumps(status.to_dict(), ensure_ascii=False, indent=2) + "\n"
|
||||
try:
|
||||
self.tmp_path.write_text(payload, encoding="utf-8")
|
||||
os.replace(self.tmp_path, self.status_path)
|
||||
except Exception as exc:
|
||||
raise FlowError(f"写入状态文件失败: {exc}")
|
||||
|
||||
def archive_existing_status(self) -> None:
|
||||
if not self.status_path.exists():
|
||||
return
|
||||
suffix = now_task_id()
|
||||
try:
|
||||
current = self.load_status()
|
||||
suffix = current.task_id
|
||||
except FlowError:
|
||||
pass
|
||||
target = self.logs_dir / f"flow-status.{suffix}.json"
|
||||
try:
|
||||
os.replace(self.status_path, target)
|
||||
except Exception as exc:
|
||||
raise FlowError(f"归档旧状态失败: {exc}")
|
||||
|
||||
198
aide-program/aide/flow/tracker.py
Normal file
198
aide-program/aide/flow/tracker.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""FlowTracker:编排一次 flow 动作(校验 → hooks → git → 落盘 → 输出)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aide.core import output
|
||||
from aide.core.config import ConfigManager
|
||||
from aide.flow.errors import FlowError
|
||||
from aide.flow.git import GitIntegration
|
||||
from aide.flow.hooks import run_post_commit_hooks, run_pre_commit_hooks
|
||||
from aide.flow.storage import FlowStorage
|
||||
from aide.flow.types import FlowStatus, HistoryEntry
|
||||
from aide.flow.utils import now_iso, now_task_id, normalize_text
|
||||
from aide.flow.validator import FlowValidator
|
||||
|
||||
DEFAULT_PHASES = ["task-optimize", "flow-design", "impl", "verify", "docs", "finish"]
|
||||
|
||||
|
||||
class FlowTracker:
|
||||
def __init__(self, root: Path, cfg: ConfigManager):
|
||||
self.root = root
|
||||
self.cfg = cfg
|
||||
self.storage = FlowStorage(root)
|
||||
self.git = GitIntegration(root)
|
||||
|
||||
def start(self, phase: str, summary: str) -> bool:
|
||||
return self._run(action="start", to_phase=phase, text=summary)
|
||||
|
||||
def next_step(self, summary: str) -> bool:
|
||||
return self._run(action="next-step", to_phase=None, text=summary)
|
||||
|
||||
def back_step(self, reason: str) -> bool:
|
||||
return self._run(action="back-step", to_phase=None, text=reason)
|
||||
|
||||
def next_part(self, phase: str, summary: str) -> bool:
|
||||
return self._run(action="next-part", to_phase=phase, text=summary)
|
||||
|
||||
def back_part(self, phase: str, reason: str) -> bool:
|
||||
return self._run(action="back-part", to_phase=phase, text=reason)
|
||||
|
||||
def issue(self, description: str) -> bool:
|
||||
return self._run(action="issue", to_phase=None, text=description)
|
||||
|
||||
def error(self, description: str) -> bool:
|
||||
return self._run(action="error", to_phase=None, text=description)
|
||||
|
||||
def _run(self, *, action: str, to_phase: str | None, text: str) -> bool:
|
||||
try:
|
||||
self.storage.ensure_ready()
|
||||
config = self.cfg.load_config()
|
||||
phases = _get_phases(config)
|
||||
validator = FlowValidator(phases)
|
||||
|
||||
normalized_text = normalize_text(text)
|
||||
if not normalized_text:
|
||||
raise FlowError("文本参数不能为空")
|
||||
|
||||
with self.storage.lock():
|
||||
if action == "start":
|
||||
assert to_phase is not None
|
||||
validator.validate_start(to_phase)
|
||||
self.storage.archive_existing_status()
|
||||
status = FlowStatus(
|
||||
task_id=now_task_id(),
|
||||
current_phase=to_phase,
|
||||
current_step=0,
|
||||
started_at=now_iso(),
|
||||
history=[],
|
||||
)
|
||||
updated = self._apply_action(
|
||||
status=status,
|
||||
action=action,
|
||||
from_phase=None,
|
||||
to_phase=to_phase,
|
||||
text=normalized_text,
|
||||
validator=validator,
|
||||
)
|
||||
self.storage.save_status(updated)
|
||||
output.ok(f"任务开始: {to_phase}")
|
||||
run_post_commit_hooks(to_phase=to_phase, action=action)
|
||||
return True
|
||||
|
||||
status = self.storage.load_status()
|
||||
if status is None:
|
||||
raise FlowError("未找到流程状态,请先运行:aide flow start <环节名> \"<总结>\"")
|
||||
|
||||
current_phase = status.current_phase
|
||||
validator.validate_phase_exists(current_phase)
|
||||
|
||||
if action == "next-part":
|
||||
assert to_phase is not None
|
||||
validator.validate_next_part(current_phase, to_phase)
|
||||
elif action == "back-part":
|
||||
assert to_phase is not None
|
||||
validator.validate_back_part(current_phase, to_phase)
|
||||
else:
|
||||
to_phase = current_phase
|
||||
|
||||
updated = self._apply_action(
|
||||
status=status,
|
||||
action=action,
|
||||
from_phase=current_phase,
|
||||
to_phase=to_phase,
|
||||
text=normalized_text,
|
||||
validator=validator,
|
||||
)
|
||||
self.storage.save_status(updated)
|
||||
|
||||
if action == "next-part":
|
||||
output.ok(f"进入环节: {to_phase}")
|
||||
elif action == "back-part":
|
||||
output.warn(f"回退到环节: {to_phase}")
|
||||
elif action == "error":
|
||||
output.err(f"错误已记录: {normalized_text}")
|
||||
|
||||
run_post_commit_hooks(to_phase=to_phase, action=action)
|
||||
return True
|
||||
except FlowError as exc:
|
||||
output.err(str(exc))
|
||||
return False
|
||||
|
||||
def _apply_action(
|
||||
self,
|
||||
*,
|
||||
status: FlowStatus,
|
||||
action: str,
|
||||
from_phase: str | None,
|
||||
to_phase: str,
|
||||
text: str,
|
||||
validator: FlowValidator,
|
||||
) -> FlowStatus:
|
||||
if action in {"next-part", "back-part"} and from_phase is None:
|
||||
raise FlowError("内部错误:缺少 from_phase")
|
||||
|
||||
if action == "next-part":
|
||||
assert from_phase is not None
|
||||
validator.validate_next_part(from_phase, to_phase)
|
||||
elif action == "back-part":
|
||||
assert from_phase is not None
|
||||
validator.validate_back_part(from_phase, to_phase)
|
||||
elif action == "start":
|
||||
validator.validate_start(to_phase)
|
||||
else:
|
||||
validator.validate_phase_exists(to_phase)
|
||||
|
||||
run_pre_commit_hooks(
|
||||
root=self.root,
|
||||
git=self.git,
|
||||
status=status,
|
||||
from_phase=from_phase,
|
||||
to_phase=to_phase,
|
||||
action=action,
|
||||
)
|
||||
|
||||
message = _build_commit_message(action=action, phase=to_phase, text=text)
|
||||
self.git.add_all()
|
||||
commit_hash = self.git.commit(message)
|
||||
|
||||
next_step = status.current_step + 1
|
||||
entry = HistoryEntry(
|
||||
timestamp=now_iso(),
|
||||
action=action,
|
||||
phase=to_phase,
|
||||
step=next_step,
|
||||
summary=text,
|
||||
git_commit=commit_hash,
|
||||
)
|
||||
|
||||
history = [*status.history, entry]
|
||||
return FlowStatus(
|
||||
task_id=status.task_id,
|
||||
current_phase=to_phase,
|
||||
current_step=next_step,
|
||||
started_at=status.started_at,
|
||||
history=history,
|
||||
)
|
||||
|
||||
|
||||
def _get_phases(config: dict) -> list[str]:
|
||||
flow_cfg = config.get("flow", {})
|
||||
phases = flow_cfg.get("phases", DEFAULT_PHASES)
|
||||
if not isinstance(phases, list) or not phases:
|
||||
return DEFAULT_PHASES
|
||||
return phases
|
||||
|
||||
|
||||
def _build_commit_message(*, action: str, phase: str, text: str) -> str:
|
||||
if action == "issue":
|
||||
return f"[aide] {phase} issue: {text}"
|
||||
if action == "error":
|
||||
return f"[aide] {phase} error: {text}"
|
||||
if action == "back-step":
|
||||
return f"[aide] {phase} back-step: {text}"
|
||||
if action == "back-part":
|
||||
return f"[aide] {phase} back-part: {text}"
|
||||
return f"[aide] {phase}: {text}"
|
||||
|
||||
102
aide-program/aide/flow/types.py
Normal file
102
aide-program/aide/flow/types.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""数据结构:流程状态与历史条目。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HistoryEntry:
|
||||
timestamp: str
|
||||
action: str
|
||||
phase: str
|
||||
step: int
|
||||
summary: str
|
||||
git_commit: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"timestamp": self.timestamp,
|
||||
"action": self.action,
|
||||
"phase": self.phase,
|
||||
"step": self.step,
|
||||
"summary": self.summary,
|
||||
}
|
||||
if self.git_commit is not None:
|
||||
data["git_commit"] = self.git_commit
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, Any]) -> "HistoryEntry":
|
||||
timestamp = _require_str(data, "timestamp")
|
||||
action = _require_str(data, "action")
|
||||
phase = _require_str(data, "phase")
|
||||
step = _require_int(data, "step")
|
||||
summary = _require_str(data, "summary")
|
||||
git_commit = data.get("git_commit")
|
||||
if git_commit is not None and not isinstance(git_commit, str):
|
||||
raise ValueError("git_commit 必须为字符串或缺失")
|
||||
return HistoryEntry(
|
||||
timestamp=timestamp,
|
||||
action=action,
|
||||
phase=phase,
|
||||
step=step,
|
||||
summary=summary,
|
||||
git_commit=git_commit,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlowStatus:
|
||||
task_id: str
|
||||
current_phase: str
|
||||
current_step: int
|
||||
started_at: str
|
||||
history: list[HistoryEntry]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"current_phase": self.current_phase,
|
||||
"current_step": self.current_step,
|
||||
"started_at": self.started_at,
|
||||
"history": [h.to_dict() for h in self.history],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, Any]) -> "FlowStatus":
|
||||
task_id = _require_str(data, "task_id")
|
||||
current_phase = _require_str(data, "current_phase")
|
||||
current_step = _require_int(data, "current_step")
|
||||
started_at = _require_str(data, "started_at")
|
||||
raw_history = data.get("history")
|
||||
if not isinstance(raw_history, list):
|
||||
raise ValueError("history 必须为列表")
|
||||
history: list[HistoryEntry] = []
|
||||
for item in raw_history:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("history 条目必须为对象")
|
||||
history.append(HistoryEntry.from_dict(item))
|
||||
return FlowStatus(
|
||||
task_id=task_id,
|
||||
current_phase=current_phase,
|
||||
current_step=current_step,
|
||||
started_at=started_at,
|
||||
history=history,
|
||||
)
|
||||
|
||||
|
||||
def _require_str(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{key} 必须为非空字符串")
|
||||
return value
|
||||
|
||||
|
||||
def _require_int(data: dict[str, Any], key: str) -> int:
|
||||
value = data.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{key} 必须为整数")
|
||||
return value
|
||||
|
||||
18
aide-program/aide/flow/utils.py
Normal file
18
aide-program/aide/flow/utils.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""工具函数:时间戳与文本处理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def now_task_id() -> str:
|
||||
return datetime.now().astimezone().strftime("%Y-%m-%dT%H-%M-%S")
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
54
aide-program/aide/flow/validator.py
Normal file
54
aide-program/aide/flow/validator.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""流程校验:环节合法性与跳转规则。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aide.flow.errors import FlowError
|
||||
|
||||
|
||||
class FlowValidator:
|
||||
def __init__(self, phases: list[str]):
|
||||
self.phases = _normalize_phases(phases)
|
||||
|
||||
def validate_phase_exists(self, phase: str) -> None:
|
||||
if phase not in self.phases:
|
||||
raise FlowError(f"未知环节: {phase}(请检查 flow.phases 配置)")
|
||||
|
||||
def validate_start(self, phase: str) -> None:
|
||||
self.validate_phase_exists(phase)
|
||||
|
||||
def validate_next_part(self, from_phase: str, to_phase: str) -> None:
|
||||
self.validate_phase_exists(from_phase)
|
||||
self.validate_phase_exists(to_phase)
|
||||
from_index = self.phases.index(from_phase)
|
||||
to_index = self.phases.index(to_phase)
|
||||
if to_index != from_index + 1:
|
||||
raise FlowError(
|
||||
f"非法跳转: {from_phase} -> {to_phase}(next-part 只能前进到相邻环节)"
|
||||
)
|
||||
|
||||
def validate_back_part(self, from_phase: str, to_phase: str) -> None:
|
||||
self.validate_phase_exists(from_phase)
|
||||
self.validate_phase_exists(to_phase)
|
||||
from_index = self.phases.index(from_phase)
|
||||
to_index = self.phases.index(to_phase)
|
||||
if to_index >= from_index:
|
||||
raise FlowError(
|
||||
f"非法回退: {from_phase} -> {to_phase}(back-part 只能回退到之前环节)"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_phases(phases: list[str]) -> list[str]:
|
||||
if not isinstance(phases, list) or not phases:
|
||||
raise FlowError("flow.phases 配置无效:必须为非空列表")
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in phases:
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
raise FlowError("flow.phases 配置无效:环节名必须为非空字符串")
|
||||
name = item.strip()
|
||||
if name in seen:
|
||||
raise FlowError(f"flow.phases 配置无效:环节名重复 {name!r}")
|
||||
seen.add(name)
|
||||
normalized.append(name)
|
||||
return normalized
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
from aide.core import output
|
||||
from aide.core.config import ConfigManager
|
||||
from aide.env.manager import EnvManager
|
||||
from aide.flow.tracker import FlowTracker
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -91,6 +92,43 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
set_parser.add_argument("value", help="要写入的值,支持 bool/int/float/字符串")
|
||||
set_parser.set_defaults(func=handle_config_set)
|
||||
|
||||
# aide flow
|
||||
flow_parser = subparsers.add_parser("flow", help="进度追踪与 git 集成")
|
||||
flow_sub = flow_parser.add_subparsers(dest="flow_command")
|
||||
|
||||
flow_start = flow_sub.add_parser("start", help="开始新任务")
|
||||
flow_start.add_argument("phase", help="环节名(来自 flow.phases)")
|
||||
flow_start.add_argument("summary", help="本次操作的简要说明")
|
||||
flow_start.set_defaults(func=handle_flow_start)
|
||||
|
||||
flow_next_step = flow_sub.add_parser("next-step", help="记录步骤前进")
|
||||
flow_next_step.add_argument("summary", help="本次操作的简要说明")
|
||||
flow_next_step.set_defaults(func=handle_flow_next_step)
|
||||
|
||||
flow_back_step = flow_sub.add_parser("back-step", help="记录步骤回退")
|
||||
flow_back_step.add_argument("reason", help="回退原因")
|
||||
flow_back_step.set_defaults(func=handle_flow_back_step)
|
||||
|
||||
flow_next_part = flow_sub.add_parser("next-part", help="进入下一环节")
|
||||
flow_next_part.add_argument("phase", help="目标环节名(相邻下一环节)")
|
||||
flow_next_part.add_argument("summary", help="本次操作的简要说明")
|
||||
flow_next_part.set_defaults(func=handle_flow_next_part)
|
||||
|
||||
flow_back_part = flow_sub.add_parser("back-part", help="回退到之前环节")
|
||||
flow_back_part.add_argument("phase", help="目标环节名(任意之前环节)")
|
||||
flow_back_part.add_argument("reason", help="回退原因")
|
||||
flow_back_part.set_defaults(func=handle_flow_back_part)
|
||||
|
||||
flow_issue = flow_sub.add_parser("issue", help="记录一般问题(不阻塞继续)")
|
||||
flow_issue.add_argument("description", help="问题描述")
|
||||
flow_issue.set_defaults(func=handle_flow_issue)
|
||||
|
||||
flow_error = flow_sub.add_parser("error", help="记录严重错误(需要用户关注)")
|
||||
flow_error.add_argument("description", help="错误描述")
|
||||
flow_error.set_defaults(func=handle_flow_error)
|
||||
|
||||
flow_parser.set_defaults(func=handle_flow_help)
|
||||
|
||||
parser.add_argument("--version", action="version", version="aide dev")
|
||||
return parser
|
||||
|
||||
@@ -190,6 +228,60 @@ def handle_config_set(args: argparse.Namespace) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def handle_flow_help(args: argparse.Namespace) -> bool:
|
||||
output.info("用法: aide flow <start|next-step|back-step|next-part|back-part|issue|error> ...")
|
||||
return True
|
||||
|
||||
|
||||
def handle_flow_start(args: argparse.Namespace) -> bool:
|
||||
root = Path.cwd()
|
||||
cfg = ConfigManager(root)
|
||||
tracker = FlowTracker(root, cfg)
|
||||
return tracker.start(args.phase, args.summary)
|
||||
|
||||
|
||||
def handle_flow_next_step(args: argparse.Namespace) -> bool:
|
||||
root = Path.cwd()
|
||||
cfg = ConfigManager(root)
|
||||
tracker = FlowTracker(root, cfg)
|
||||
return tracker.next_step(args.summary)
|
||||
|
||||
|
||||
def handle_flow_back_step(args: argparse.Namespace) -> bool:
|
||||
root = Path.cwd()
|
||||
cfg = ConfigManager(root)
|
||||
tracker = FlowTracker(root, cfg)
|
||||
return tracker.back_step(args.reason)
|
||||
|
||||
|
||||
def handle_flow_next_part(args: argparse.Namespace) -> bool:
|
||||
root = Path.cwd()
|
||||
cfg = ConfigManager(root)
|
||||
tracker = FlowTracker(root, cfg)
|
||||
return tracker.next_part(args.phase, args.summary)
|
||||
|
||||
|
||||
def handle_flow_back_part(args: argparse.Namespace) -> bool:
|
||||
root = Path.cwd()
|
||||
cfg = ConfigManager(root)
|
||||
tracker = FlowTracker(root, cfg)
|
||||
return tracker.back_part(args.phase, args.reason)
|
||||
|
||||
|
||||
def handle_flow_issue(args: argparse.Namespace) -> bool:
|
||||
root = Path.cwd()
|
||||
cfg = ConfigManager(root)
|
||||
tracker = FlowTracker(root, cfg)
|
||||
return tracker.issue(args.description)
|
||||
|
||||
|
||||
def handle_flow_error(args: argparse.Namespace) -> bool:
|
||||
root = Path.cwd()
|
||||
cfg = ConfigManager(root)
|
||||
tracker = FlowTracker(root, cfg)
|
||||
return tracker.error(args.description)
|
||||
|
||||
|
||||
def _parse_value(raw: str) -> Any:
|
||||
lowered = raw.lower()
|
||||
if lowered in {"true", "false"}:
|
||||
|
||||
Reference in New Issue
Block a user