sync: HP_BOWEI 2026-09-09

This commit is contained in:
Myfro9
2026-09-09 09:51:06 +08:00
parent 01b7a00b12
commit 170f4eb53f
10 changed files with 1926 additions and 5 deletions
+337
View File
@@ -0,0 +1,337 @@
"""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())