338 lines
13 KiB
Python
338 lines
13 KiB
Python
"""ak_flow.py — 驱动 Altium→KiCad 迁移的 LangGraph 图(进程内,无 langgraph dev)。
|
||
|
||
图本体在 ~/skills_env.md 的 ALTIUM_KICAD_GRAPH_ROOT(app/main_graph.py),本脚本只做三件事:
|
||
1) 用 SqliteSaver 打开持久化 checkpoint,按 thread_id 续跑;
|
||
2) 把当前挂起的 interrupt 以结构化 JSON 打印给 Claude 会话;
|
||
3) 把 Claude 执行完真实动作后的结论 Command(resume=...) 回填、推进到下一个 interrupt。
|
||
|
||
为什么不直接用工程里的 run.py:run.py 的 resume 只接受一个位置参数 JSON 字符串,
|
||
在 PowerShell 里转义引号极易出错;且 thread 固定为 AK_THREAD 单值,多工程并发会串。
|
||
本脚本用 --set key=value 免转义回填,thread 由 target_dir 推导,DB 沿用
|
||
runs/skill_checkpoint.sqlite(已跑完的 ak-xmod-v1p0 / ak-gq-exct7g-v1 仍可续)。
|
||
|
||
退出码:0 = 图已结束(done);2 = 停在 interrupt 等回填(waiting);1 = 出错。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
def _env_lookup(key: str, default: str | None = None) -> str:
|
||
"""环境变量优先,其次 ~/skills_env.md 的 `KEY = value` 行(机器相关参数的真源)。"""
|
||
v = os.environ.get(key)
|
||
if v:
|
||
return v
|
||
path = os.path.join(os.path.expanduser("~"), "skills_env.md")
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
for line in f:
|
||
m = re.match(rf"\s*{key}\s*=\s*(\S+)", line)
|
||
if m:
|
||
return m.group(1)
|
||
except OSError:
|
||
pass
|
||
if default is None:
|
||
sys.exit(f"missing {key}: add it to ~/skills_env.md or set it as an env var")
|
||
return default
|
||
|
||
|
||
# 图工程根是机器相关的:真源是 ~/skills_env.md 的 ALTIUM_KICAD_GRAPH_ROOT。
|
||
DEFAULT_PROJECT_ROOT = _env_lookup("ALTIUM_KICAD_GRAPH_ROOT", r"E:\LangGraph_Design\Altium-Kicad")
|
||
DB_RELATIVE = ("runs", "skill_checkpoint.sqlite")
|
||
|
||
|
||
# --------------------------------------------------------------------------- utils
|
||
def _out(*parts: str) -> None:
|
||
print(*parts, flush=True)
|
||
|
||
|
||
def _fatal(msg: str) -> "None":
|
||
_out(f"[ERROR] {msg}")
|
||
sys.exit(1)
|
||
|
||
|
||
def slug_for(target_dir: str) -> str:
|
||
"""E:\\Kicad_design\\XMOD_V1p0 -> ak-xmod-v1p0 (与既有线程命名一致)。"""
|
||
name = Path(target_dir.rstrip("\\/")).name or "run"
|
||
name = re.sub(r"[^0-9A-Za-z]+", "-", name).strip("-").lower()
|
||
return f"ak-{name or 'run'}"
|
||
|
||
|
||
def coerce(raw: str):
|
||
"""--set 的值做最小类型推断:true/false→bool,纯数字→int,其余原样字符串。"""
|
||
low = raw.strip().lower()
|
||
if low in ("true", "yes", "1"):
|
||
return True
|
||
if low in ("false", "no", "0"):
|
||
return False
|
||
if re.fullmatch(r"-?\d+", raw.strip()):
|
||
return int(raw.strip())
|
||
return raw
|
||
|
||
|
||
def pending_interrupt(snap):
|
||
for task in snap.tasks:
|
||
if task.interrupts:
|
||
return task.interrupts[0].value
|
||
return None
|
||
|
||
|
||
def load_graph(project_root: Path):
|
||
if not (project_root / "app" / "main_graph.py").is_file():
|
||
_fatal(f"工程根不对:{project_root} 下没有 app\\main_graph.py。"
|
||
f"用 --project-root 指定,设环境变量 AK_PROJECT_ROOT,或改 ~/skills_env.md 的 ALTIUM_KICAD_GRAPH_ROOT。")
|
||
sys.path.insert(0, str(project_root))
|
||
from app.main_graph import build_graph # noqa: E402
|
||
return build_graph
|
||
|
||
|
||
RETRY_LIMITS = {"bridge": 3, "import": 3, "netlist": 5, "gerber": 5}
|
||
|
||
|
||
def init_state(source_prj: str, target_dir: str, limits: dict[str, int]) -> dict:
|
||
state = {
|
||
"source_prj": source_prj,
|
||
"target_dir": target_dir,
|
||
"migration_dir": str(Path(target_dir) / "_migration"),
|
||
"history": [],
|
||
}
|
||
for name, default in RETRY_LIMITS.items():
|
||
state[f"{name}_retry"] = 0
|
||
state[f"max_{name}_retry"] = limits.get(name) or default
|
||
return state
|
||
|
||
|
||
# --------------------------------------------------------------------------- report
|
||
def report(graph, thread: dict, before_len: int) -> int:
|
||
"""打印当前状态;返回进程退出码。"""
|
||
snap = graph.get_state(thread)
|
||
values = snap.values or {}
|
||
history = values.get("history", [])
|
||
walked = [h.get("node") for h in history[before_len:]]
|
||
if walked:
|
||
_out("[NODES] " + " -> ".join(str(n) for n in walked))
|
||
|
||
_out(f"[THREAD] {thread['configurable']['thread_id']}")
|
||
if values.get("source_prj"):
|
||
_out(f"[PATHS] source_prj = {values['source_prj']}")
|
||
_out(f"[PATHS] target_dir = {values['target_dir']}")
|
||
_out(f"[PATHS] migration = {values.get('migration_dir', '')}")
|
||
|
||
retries = {k: values.get(k) for k in
|
||
("bridge_retry", "import_retry", "netlist_retry", "gerber_retry")
|
||
if values.get(k)}
|
||
if retries:
|
||
_out("[RETRY] " + json.dumps(retries))
|
||
|
||
if snap.next:
|
||
payload = pending_interrupt(snap)
|
||
_out(f"[WAITING] next node: {', '.join(snap.next)}")
|
||
if payload is not None:
|
||
_out("[INTERRUPT] " + json.dumps(payload, ensure_ascii=False, indent=2))
|
||
else:
|
||
_out("[INTERRUPT] (无 payload — 直接 resume 推进)")
|
||
return 2
|
||
|
||
if any(h.get("node") == "safety_exit" for h in history):
|
||
_out("[SAFETY-END] 某个判定回环超过重试上限,图已优雅退出。"
|
||
"先修好卡住的那一步,再用 restart 重跑,或调高对应 max_*_retry。")
|
||
return 0
|
||
|
||
_out("[DONE] 图已跑完。")
|
||
_out(f"[REPORT] {values.get('report_path', '(未回填 report_path)')}")
|
||
return 0
|
||
|
||
|
||
# --------------------------------------------------------------------------- commands
|
||
def cmd_start(args, graph_builder, db_path: Path):
|
||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||
|
||
source = args.source_prj
|
||
target = args.target_dir
|
||
if not source or not target:
|
||
_fatal("start 需要 --source-prj 与 --target-dir(均为绝对路径,无默认值)。")
|
||
if not Path(source).is_file():
|
||
_fatal(f"源工程不存在:{source}(应指向 .PrjPCB 文件本身)")
|
||
Path(target).mkdir(parents=True, exist_ok=True)
|
||
|
||
thread_id = args.thread or slug_for(target)
|
||
thread = {"configurable": {"thread_id": thread_id}}
|
||
|
||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||
graph = graph_builder(checkpointer=cp)
|
||
existing = graph.get_state(thread)
|
||
if (existing.next or existing.values) and not args.restart:
|
||
_out(f"[INFO] 线程 {thread_id} 已存在,续跑(不重置)。要从头开始请加 --restart。")
|
||
before = len(existing.values.get("history", []))
|
||
if existing.next and pending_interrupt(existing) is not None:
|
||
# 已经停在 interrupt 上,什么都不推进,直接把 payload 亮出来
|
||
return report(graph, thread, before)
|
||
graph.invoke(None, thread)
|
||
return report(graph, thread, before)
|
||
|
||
if args.restart:
|
||
_out(f"[INFO] --restart:清空线程 {thread_id} 的 checkpoint。")
|
||
wipe_thread(db_path, thread_id)
|
||
|
||
limits = {name: getattr(args, f"max_{name}_retry", None) for name in RETRY_LIMITS}
|
||
_out(f"[INFO] 新建线程 {thread_id}")
|
||
graph.invoke(init_state(source, target, limits), thread)
|
||
return report(graph, thread, 0)
|
||
|
||
|
||
def cmd_status(args, graph_builder, db_path: Path):
|
||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||
|
||
thread_id = resolve_thread(args)
|
||
thread = {"configurable": {"thread_id": thread_id}}
|
||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||
graph = graph_builder(checkpointer=cp)
|
||
snap = graph.get_state(thread)
|
||
if not snap.values and not snap.next:
|
||
_fatal(f"线程 {thread_id} 没有任何 checkpoint。先跑 start。")
|
||
return report(graph, thread, len(snap.values.get("history", [])))
|
||
|
||
|
||
def cmd_resume(args, graph_builder, db_path: Path):
|
||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||
from langgraph.types import Command
|
||
|
||
payload: dict = {}
|
||
if args.json:
|
||
try:
|
||
payload = json.loads(args.json)
|
||
except json.JSONDecodeError as exc:
|
||
_fatal(f"--json 不是合法 JSON:{exc}")
|
||
for item in args.set or []:
|
||
if "=" not in item:
|
||
_fatal(f"--set 要写成 key=value,收到:{item}")
|
||
key, raw = item.split("=", 1)
|
||
payload[key.strip()] = coerce(raw)
|
||
|
||
if not payload:
|
||
_fatal("resume 的回填不能为空——langgraph 会把 falsy 的 resume 当作『没提供』"
|
||
"从而在同一个节点再次中断。至少给一个键,例如 --set acknowledged=true。")
|
||
|
||
thread_id = resolve_thread(args)
|
||
thread = {"configurable": {"thread_id": thread_id}}
|
||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||
graph = graph_builder(checkpointer=cp)
|
||
snap = graph.get_state(thread)
|
||
if not snap.next:
|
||
_fatal(f"线程 {thread_id} 没有挂起的 interrupt(图已结束或未启动)。先跑 status。")
|
||
before = len(snap.values.get("history", []))
|
||
_out("[RESUME] " + json.dumps(payload, ensure_ascii=False))
|
||
graph.invoke(Command(resume=payload), thread)
|
||
return report(graph, thread, before)
|
||
|
||
|
||
def cmd_list(args, graph_builder, db_path: Path):
|
||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||
|
||
if not db_path.is_file():
|
||
_out("[INFO] 还没有任何 checkpoint 数据库。")
|
||
return 0
|
||
conn = sqlite3.connect(str(db_path))
|
||
try:
|
||
threads = sorted({r[0] for r in conn.execute("select distinct thread_id from checkpoints")})
|
||
finally:
|
||
conn.close()
|
||
if not threads:
|
||
_out("[INFO] 数据库里没有线程。")
|
||
return 0
|
||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||
graph = graph_builder(checkpointer=cp)
|
||
for tid in threads:
|
||
snap = graph.get_state({"configurable": {"thread_id": tid}})
|
||
v = snap.values or {}
|
||
state = f"waiting@{','.join(snap.next)}" if snap.next else "done"
|
||
_out(f"[THREAD] {tid:<24} {state:<28} target={v.get('target_dir', '?')}")
|
||
return 0
|
||
|
||
|
||
def cmd_reset(args, graph_builder, db_path: Path):
|
||
thread_id = resolve_thread(args)
|
||
n = wipe_thread(db_path, thread_id)
|
||
_out(f"[INFO] 已删除线程 {thread_id} 的 {n} 条 checkpoint 记录。")
|
||
return 0
|
||
|
||
|
||
def wipe_thread(db_path: Path, thread_id: str) -> int:
|
||
if not db_path.is_file():
|
||
return 0
|
||
conn = sqlite3.connect(str(db_path))
|
||
try:
|
||
n = conn.execute("delete from checkpoints where thread_id=?", (thread_id,)).rowcount
|
||
conn.execute("delete from writes where thread_id=?", (thread_id,))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
return n
|
||
|
||
|
||
def resolve_thread(args) -> str:
|
||
if getattr(args, "thread", None):
|
||
return args.thread
|
||
if getattr(args, "target_dir", None):
|
||
return slug_for(args.target_dir)
|
||
_fatal("需要 --thread <id> 或 --target-dir <目标 KiCad 目录> 来定位线程。用 list 看所有线程。")
|
||
|
||
|
||
# --------------------------------------------------------------------------- main
|
||
def main() -> int:
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
ap = argparse.ArgumentParser(description="Drive the Altium->KiCad migration LangGraph flow.")
|
||
ap.add_argument("--project-root", default=os.environ.get("AK_PROJECT_ROOT", DEFAULT_PROJECT_ROOT))
|
||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||
|
||
p = sub.add_parser("start", help="新建或续跑一次迁移")
|
||
p.add_argument("--source-prj", help="源 Altium .PrjPCB 绝对路径")
|
||
p.add_argument("--target-dir", help="目标 KiCad 工程目录绝对路径")
|
||
p.add_argument("--thread", help="线程 id(默认由 target-dir 推导)")
|
||
p.add_argument("--restart", action="store_true", help="清空该线程重新开始")
|
||
for name, default in RETRY_LIMITS.items():
|
||
p.add_argument(f"--max-{name}-retry", type=int,
|
||
help=f"{name} 回环的重试上限(默认 {default},超限走 safety_end)")
|
||
p.set_defaults(func=cmd_start)
|
||
|
||
p = sub.add_parser("status", help="看当前停在哪个 interrupt")
|
||
p.add_argument("--thread")
|
||
p.add_argument("--target-dir")
|
||
p.set_defaults(func=cmd_status)
|
||
|
||
p = sub.add_parser("resume", help="回填结论并推进到下一个 interrupt")
|
||
p.add_argument("--thread")
|
||
p.add_argument("--target-dir")
|
||
p.add_argument("--set", action="append", metavar="KEY=VALUE",
|
||
help="免转义回填,可重复;true/false→bool,纯数字→int")
|
||
p.add_argument("--json", help="直接给 JSON 对象(与 --set 可混用)")
|
||
p.set_defaults(func=cmd_resume)
|
||
|
||
p = sub.add_parser("list", help="列出所有迁移线程")
|
||
p.set_defaults(func=cmd_list)
|
||
|
||
p = sub.add_parser("reset", help="删除某个线程的 checkpoint")
|
||
p.add_argument("--thread")
|
||
p.add_argument("--target-dir")
|
||
p.set_defaults(func=cmd_reset)
|
||
|
||
args = ap.parse_args()
|
||
root = Path(args.project_root)
|
||
db_path = root.joinpath(*DB_RELATIVE)
|
||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||
graph_builder = load_graph(root)
|
||
return args.func(args, graph_builder, db_path)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|