sync: WIN10-I9-64G 2026-09-19
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
"""kf_flow.py — 驱动 kicad-flow 的 LangGraph 图(进程内,无 langgraph dev)。
|
||||
|
||||
图本体在 ~/skills_env.md 的 KICAD_FLOW_ROOT(app/main_graph.py),本脚本只做三件事:
|
||||
1) 用 SqliteSaver 打开持久化 checkpoint,按 thread_id 续跑;
|
||||
2) 把当前挂起的 interrupt 以结构化 JSON 打印给 Claude 会话;
|
||||
3) 把 Claude 执行完真实动作后的结论 Command(resume=...) 回填、推进到下一个 interrupt。
|
||||
|
||||
**数据库落点**:与 altium-kicad 不同,kicad-flow 的 checkpoint 库落在
|
||||
<被调用的 KiCad 工程>/.kicad-flow/checkpoint.sqlite,不在图工程根下。所以每个板子
|
||||
一个库,`list` 也是按工程列线程。落点由图自己的 app/config.py 闸门强制校验。
|
||||
|
||||
为什么不直接用工程里的 run.py:run.py 的 resume 只接受一个位置参数 JSON 字符串,
|
||||
在 PowerShell 里转义引号极易出错。本脚本用 --set key=value 免转义回填。
|
||||
|
||||
退出码: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
|
||||
|
||||
# Windows 控制台默认 GBK,interrupt 里的中文 hint 会变乱码看不清。自己转 UTF-8,
|
||||
# 别指望调用方每次都记得带 PYTHONIOENCODING。
|
||||
for _s in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_s.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
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).strip()
|
||||
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
|
||||
|
||||
|
||||
DEFAULT_GRAPH_ROOT = _env_lookup("KICAD_FLOW_ROOT", r"E:\LangGraph_Design\kicad-flow")
|
||||
|
||||
#: 与 app/state.py 的 max_*_retry 一一对应
|
||||
RETRY_LIMITS = {
|
||||
"partition": 3, "material": 5, "layout": 5, "vivado": 3, "mcu": 3,
|
||||
"footprint": 5, "drawio-sync": 3, "recheck": 3, "requirement": 3,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- 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(prj_dir: str) -> str:
|
||||
"""E:\\Kicad_design\\MyBoard_V1p0 -> kf-myboard-v1p0"""
|
||||
name = Path(prj_dir.rstrip("\\/")).name or "run"
|
||||
name = re.sub(r"[^0-9A-Za-z]+", "-", name).strip("-").lower()
|
||||
return f"kf-{name or 'run'}"
|
||||
|
||||
|
||||
def coerce(raw: str):
|
||||
"""--set 的值做最小类型推断:true/false→bool,纯数字→int,[ 或 { 开头→JSON。"""
|
||||
s = raw.strip()
|
||||
low = s.lower()
|
||||
if low in ("true", "yes"):
|
||||
return True
|
||||
if low in ("false", "no"):
|
||||
return False
|
||||
if re.fullmatch(r"-?\d+", s):
|
||||
return int(s)
|
||||
if s[:1] in ("[", "{"):
|
||||
try:
|
||||
return json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
return raw
|
||||
return raw
|
||||
|
||||
|
||||
def pending_interrupt(snap):
|
||||
for task in snap.tasks:
|
||||
if task.interrupts:
|
||||
return task.interrupts[0].value
|
||||
return None
|
||||
|
||||
|
||||
def load_graph_root(graph_root: Path):
|
||||
"""把图工程根加进 sys.path,返回 (build_graph, config 模块)。"""
|
||||
if not (graph_root / "app" / "main_graph.py").is_file():
|
||||
_fatal(f"图工程根不对:{graph_root} 下没有 app\\main_graph.py。"
|
||||
f"用 --graph-root 指定,设环境变量 KICAD_FLOW_ROOT,"
|
||||
f"或改 ~/skills_env.md 的 KICAD_FLOW_ROOT。")
|
||||
sys.path.insert(0, str(graph_root))
|
||||
from app.main_graph import build_graph # noqa: E402
|
||||
from app import config # noqa: E402
|
||||
return build_graph, config
|
||||
|
||||
|
||||
def init_state(layout: dict, thread_id: str, limits: dict[str, int]) -> dict:
|
||||
state = {**layout, "thread_id": thread_id,
|
||||
"references": [], "sim_results": [], "history": []}
|
||||
for name, default in RETRY_LIMITS.items():
|
||||
key = name.replace("-", "_")
|
||||
state[f"{key}_retry"] = 0
|
||||
state[f"max_{key}_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("prj_dir"):
|
||||
_out(f"[PATHS] prj_dir = {values['prj_dir']}")
|
||||
_out(f"[PATHS] db = {values.get('flow_home', '')}\\checkpoint.sqlite")
|
||||
_out(f"[PATHS] evidence = {values.get('runs_dir', '')}")
|
||||
for key, label in (("drawio_path", "drawio"), ("schematic_path", "sch"),
|
||||
("pcb_path", "pcb")):
|
||||
if values.get(key):
|
||||
_out(f"[FILES] {label:8s} = {values[key]}")
|
||||
|
||||
retries = {k: v for k, v in values.items()
|
||||
if k.endswith("_retry") and not k.startswith("max_") and v}
|
||||
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 values.get("halted"):
|
||||
_out(f"[SAFETY-END] {values.get('halted_reason', '')}")
|
||||
_out(f"[REPORT] {values.get('report_path', '')}")
|
||||
_out("先解决这个卡点,再用 `start --restart` 重跑,或用 --max-*-retry 调高上限。")
|
||||
return 0
|
||||
|
||||
_out("[DONE] 图已跑完:原理图设计完成并已导入 PCB。")
|
||||
_out(f"[REPORT] {values.get('report_path', '(未生成)')}")
|
||||
_out(f"[PCB] {values.get('pcb_path', '(未回填)')}")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- commands
|
||||
def _open(args):
|
||||
"""解析工程路径 + 落点,返回 (build_graph, db_path, thread, layout)。"""
|
||||
build_graph, config = load_graph_root(Path(args.graph_root))
|
||||
prj_raw = getattr(args, "prj_dir", None)
|
||||
if not prj_raw:
|
||||
_fatal("缺少 --prj-dir(目标 KiCad 工程根目录)。kicad-flow 的数据库就建在它下面,"
|
||||
"没有默认值,猜错会把库写到别处。")
|
||||
try:
|
||||
prj = config.resolve_project_dir(prj_raw)
|
||||
thread_id = args.thread or slug_for(str(prj))
|
||||
layout = config.prepare_project_layout(prj, thread_id)
|
||||
except config.ProjectDirError as exc:
|
||||
_fatal(str(exc))
|
||||
return build_graph, Path(layout["db_path"]), \
|
||||
{"configurable": {"thread_id": thread_id}}, layout
|
||||
|
||||
|
||||
def cmd_start(args):
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
build_graph, db_path, thread, layout = _open(args)
|
||||
|
||||
if args.restart:
|
||||
n = wipe_thread(db_path, thread["configurable"]["thread_id"])
|
||||
_out(f"[RESTART] 清掉 {n} 条 checkpoint 记录")
|
||||
|
||||
limits = {name: getattr(args, f"max_{name.replace('-', '_')}_retry", None)
|
||||
for name in RETRY_LIMITS}
|
||||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||||
graph = build_graph(checkpointer=cp)
|
||||
snap = graph.get_state(thread)
|
||||
before = len((snap.values or {}).get("history", []))
|
||||
if snap.next or snap.values:
|
||||
_out("[RESUME] 该工程已有进行中的线程,直接续跑(不重置)")
|
||||
graph.invoke(None, thread)
|
||||
else:
|
||||
graph.invoke(init_state(layout, thread["configurable"]["thread_id"], limits),
|
||||
thread)
|
||||
return report(graph, thread, before)
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
build_graph, db_path, thread, _ = _open(args)
|
||||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||||
graph = build_graph(checkpointer=cp)
|
||||
snap = graph.get_state(thread)
|
||||
return report(graph, thread, len((snap.values or {}).get("history", [])))
|
||||
|
||||
|
||||
def cmd_resume(args):
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.types import Command
|
||||
build_graph, db_path, thread, _ = _open(args)
|
||||
|
||||
payload: dict = {}
|
||||
if args.json:
|
||||
try:
|
||||
parsed = json.loads(args.json)
|
||||
except json.JSONDecodeError as exc:
|
||||
_fatal(f"--json 不是合法 JSON:{exc}")
|
||||
if not isinstance(parsed, dict):
|
||||
_fatal("--json 必须是一个对象")
|
||||
payload.update(parsed)
|
||||
for item in args.set or []:
|
||||
if "=" not in item:
|
||||
_fatal(f"--set 要写成 KEY=VALUE,收到:{item}")
|
||||
k, v = item.split("=", 1)
|
||||
payload[k.strip()] = coerce(v)
|
||||
|
||||
if not payload:
|
||||
_fatal("回填内容不能为空 —— langgraph 把 falsy 的 resume 当作『没提供』,"
|
||||
"会在同一个节点再中断一次,看起来像卡住。至少给一个键。")
|
||||
|
||||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||||
graph = build_graph(checkpointer=cp)
|
||||
snap = graph.get_state(thread)
|
||||
if not snap.next:
|
||||
_fatal("当前没有挂起的 interrupt,无需回填。先跑 status 看状态。")
|
||||
before = len((snap.values or {}).get("history", []))
|
||||
_out(f"[RESUME] {json.dumps(payload, ensure_ascii=False)}")
|
||||
graph.invoke(Command(resume=payload), thread)
|
||||
return report(graph, thread, before)
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
"""列出某个工程库里的所有线程(DB 按工程隔离,所以要给 --prj-dir)。"""
|
||||
_, config = load_graph_root(Path(args.graph_root))
|
||||
try:
|
||||
prj = config.resolve_project_dir(args.prj_dir)
|
||||
except config.ProjectDirError as exc:
|
||||
_fatal(str(exc))
|
||||
db = config.db_path(prj)
|
||||
if not db.is_file():
|
||||
_out(f"[EMPTY] {prj} 下还没有 kicad-flow 库({db} 不存在)")
|
||||
return 0
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT thread_id, COUNT(*) FROM checkpoints GROUP BY thread_id"
|
||||
).fetchall()
|
||||
except sqlite3.DatabaseError as exc:
|
||||
_fatal(f"读 {db} 失败:{exc}")
|
||||
finally:
|
||||
con.close()
|
||||
if not rows:
|
||||
_out("[EMPTY] 库里没有线程")
|
||||
return 0
|
||||
_out(f"[DB] {db}")
|
||||
for tid, n in rows:
|
||||
_out(f" {tid:<28} {n} 条 checkpoint")
|
||||
return 0
|
||||
|
||||
|
||||
def wipe_thread(db_path: Path, thread_id: str) -> int:
|
||||
if not db_path.is_file():
|
||||
return 0
|
||||
con = sqlite3.connect(str(db_path))
|
||||
total = 0
|
||||
try:
|
||||
for table in ("checkpoints", "writes"):
|
||||
try:
|
||||
cur = con.execute(f"DELETE FROM {table} WHERE thread_id = ?", (thread_id,))
|
||||
total += cur.rowcount or 0
|
||||
except sqlite3.DatabaseError:
|
||||
pass
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
return total
|
||||
|
||||
|
||||
def cmd_reset(args):
|
||||
_, config = load_graph_root(Path(args.graph_root))
|
||||
try:
|
||||
prj = config.resolve_project_dir(args.prj_dir)
|
||||
except config.ProjectDirError as exc:
|
||||
_fatal(str(exc))
|
||||
thread_id = args.thread or slug_for(str(prj))
|
||||
n = wipe_thread(config.db_path(prj), thread_id)
|
||||
_out(f"[RESET] {thread_id}:清掉 {n} 条记录")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- main
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(prog="kf_flow", description="驱动 kicad-flow 的 LangGraph 图")
|
||||
ap.add_argument("--graph-root", default=os.environ.get("KICAD_FLOW_ROOT", DEFAULT_GRAPH_ROOT),
|
||||
help="图工程根(默认取 ~/skills_env.md 的 KICAD_FLOW_ROOT)")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("start", help="新建或续跑某个板子的原理图设计流程")
|
||||
p.add_argument("--prj-dir", help="目标 KiCad 工程根目录(数据库就建在它下面)")
|
||||
p.add_argument("--thread", help="线程 id(默认由 prj-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})")
|
||||
p.set_defaults(func=cmd_start)
|
||||
|
||||
p = sub.add_parser("status", help="看当前停在哪个 interrupt")
|
||||
p.add_argument("--prj-dir")
|
||||
p.add_argument("--thread")
|
||||
p.set_defaults(func=cmd_status)
|
||||
|
||||
p = sub.add_parser("resume", help="回填结论并推进到下一个 interrupt")
|
||||
p.add_argument("--prj-dir")
|
||||
p.add_argument("--thread")
|
||||
p.add_argument("--set", action="append", metavar="KEY=VALUE",
|
||||
help="免转义回填;true/false→bool,纯数字→int,[ 或 { 开头→JSON")
|
||||
p.add_argument("--json", help="直接给 JSON 对象(与 --set 可混用)")
|
||||
p.set_defaults(func=cmd_resume)
|
||||
|
||||
p = sub.add_parser("list", help="列出该工程库里的所有线程")
|
||||
p.add_argument("--prj-dir")
|
||||
p.set_defaults(func=cmd_list)
|
||||
|
||||
p = sub.add_parser("reset", help="删除某个线程的 checkpoint")
|
||||
p.add_argument("--prj-dir")
|
||||
p.add_argument("--thread")
|
||||
p.set_defaults(func=cmd_reset)
|
||||
|
||||
args = ap.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user