graph: 补齐图模式剩余四件(验收机判/回退边/运行期改图/跨图子图),163 条测试全绿
- F1 accepts 机判:report done 前按 artifact:/evidence: 逐条校验,缺什么报什么,不许绕过 - F2 rework_to 回退边:判不过回到指定上游重做,attempts/reworks/事件齐备,人审驳回同路 - F3 运行期改图:add-node / remove-edge,目录锁+唯一临时名+原子替换+_rev 回读,并发不丢更新 - F4 跨图子图:subgraph.gid 引用同工作区另一张图,deps/when/rework_to 加前缀,父等收敛;缺图/自引用/跨图环/嵌套均硬报错 - 测试:122 → 163,新增 T20-T23;顺带修测试进程被本机批量删除守卫误杀的假红
This commit is contained in:
1 parent
ed861471da
commit
ce05207aa2
3 files changed
+1135
-31
No files matched your search
@@ -44,6 +44,9 @@
|
||||
ready --ws <ws> --gid <gid> 就绪集(含为什么没被选);顺带回收超时租约、展开 map
|
||||
start --ws <ws> --gid <gid> --node N1 [--by ...] [--lease-sec S]
|
||||
report --ws <ws> --gid <gid> --node N1 --state done|blocked [--evidence "..."] [--artifact P ...]
|
||||
rework --ws <ws> --gid <gid> --node N1 [--to 上游] [--reason ...] 回退到上游重做
|
||||
add-node --ws <ws> --gid <gid> --node N9 [--deps A,B] [--domain P] [--accepts C]
|
||||
remove-edge --ws <ws> --gid <gid> --from A --to B 删掉「B 依赖 A」这条边
|
||||
expand --ws <ws> --gid <gid> --node P [--force] 手动触发展开
|
||||
show --ws <ws> --gid <gid>
|
||||
list --ws <ws>
|
||||
@@ -66,6 +69,52 @@
|
||||
分池:`meta.pools: {"域前缀": 并发上限}` —— 同域并发受限,与 `max_parallel` 叠加。
|
||||
进度:`progress` 给出 by_status/percent/关键路径(未完成节点的最长依赖链)。
|
||||
|
||||
## 验收判据(accepts)怎么机判(2026-10-19 补)
|
||||
|
||||
`report --state done` 时**先按 `accepts` 校验产物与证据**,不满足直接拒收(rc≠0,节点
|
||||
不置 done,并在 stderr 逐条列出缺什么)。条目两种写法,**纯文本=描述性判据、不参与机判**:
|
||||
|
||||
"artifact:docs/*.md" 产物里至少一条匹配该通配(反斜杠与 `/` 等价)
|
||||
"artifact:>=2" 产物至少 2 条
|
||||
"evidence:测试通过" 证据里必须出现「测试通过」
|
||||
"evidence:全绿,零回归" 多个关键词用 `,` 分隔 ⇒ 全部都要出现(AND)
|
||||
"evidence:>=20" 证据至少 20 个字符
|
||||
{"kind": "artifact", "glob": "..."} / {"kind": "artifact", "min": 2}
|
||||
{"kind": "evidence", "contains": ["a", "b"]} / {"kind": "evidence", "min": 20}
|
||||
|
||||
校验读的是**本次 + 历史**的合并视图(`--artifact` 是追加语义)。
|
||||
|
||||
## 回退边(rework_to,2026-10-19 补)
|
||||
|
||||
节点写 `rework_to: "上游节点"`(或列表,取第一个)。判不过时用 `rework --node X`:
|
||||
|
||||
① 目标必须是 X 的**上游**(传递依赖),⛔ 不能是自己或下游;
|
||||
② 目标及其**全部传递下游**复位 `pending`,清租约/完成时刻/驳回原因;
|
||||
③ 复位时把 `evidence` / `artifacts` 挪到 `prev_evidence` / `prev_artifacts` 再清空
|
||||
⇒ 重做之后必须重新交产物(否则验收闸会被旧产物蒙过);
|
||||
④ 目标 `attempts +1`、全链 `reworks +1`,写一条 `rework` 事件。
|
||||
⑤ `approve --reject` 时若节点带 `rework_to`,自动走同一套回退(人审判不过=判不过)。
|
||||
⑥ 链上有已展开(`map`/子图)的节点 ⇒ **收回它的子节点并清 `expanded`**,
|
||||
下次 `ready`/`tick` 按当时的输入重新展开(⛔ 不留半截子节点)。
|
||||
|
||||
## 运行期改图(2026-10-19 补)
|
||||
|
||||
add-node --node N9 --deps A,B --domain docs/x/ --accepts "artifact:docs/x/a.md"
|
||||
remove-edge --from A --to B 删掉「B 依赖 A」
|
||||
两条都走 `_update`(目录锁 + 唯一临时名 + 原子替换 + `_rev` 回读)⇒ 与并发的
|
||||
`start`/`report` 互不覆盖;边不存在、节点重复、依赖悬空 ⇒ **报错**,⛔ 不静默空操作。
|
||||
|
||||
## 跨图子图(2026-10-19 补)
|
||||
|
||||
`subgraph: {"gid": "<同工作区另一张图的 gid>"}` —— 除内联 `{"nodes": [...]}` 外,
|
||||
可引用 `<ws>/.workbuddy/graph/<gid>/graph.json`,把它的**节点定义**展开成本图的
|
||||
`<父>/<子id>`,父节点照旧 `gating`、等子节点全部收敛。
|
||||
① 只搬定义(title/deps/domain/accepts/deps_rule/on_dep_fail/when/retry/
|
||||
lease_sec/approval/rework_to),⛔ 不搬运行状态(引用即重跑一遍);
|
||||
② 子图内部依赖与被引用图的 `when`/`rework_to` 引用都加父前缀;
|
||||
③ ⛔ 被引用的图里不允许再有 `map`/`subgraph`(不嵌套),`check` 报硬错;
|
||||
④ 自引用、A→B→A 成环 ⇒ `check` 与展开时都报错。
|
||||
|
||||
## 就绪的判据
|
||||
|
||||
① 依赖按 `deps_rule` 满足(默认 all_success)
|
||||
@@ -94,6 +143,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
@@ -283,6 +333,285 @@ def _out(obj) -> None:
|
||||
sys.stdout.buffer.write(data.encode("utf-8"))
|
||||
|
||||
|
||||
# ── 验收判据(accepts 机判)──────────────────────────────────────────────
|
||||
def _accepts_problem(vals):
|
||||
"""校验 accepts 语法;有问题返回一句人读的原因,没问题返回 None。"""
|
||||
for raw in (vals or []):
|
||||
if isinstance(raw, dict):
|
||||
kind = str(raw.get("kind") or "").strip()
|
||||
if kind not in ("artifact", "evidence"):
|
||||
return 'accepts 的对象条目要写 kind=artifact 或 evidence(实际 %r)' % (raw,)
|
||||
if kind == "artifact":
|
||||
if raw.get("glob") in (None, "") and raw.get("min") in (None, ""):
|
||||
return 'accepts 的 artifact 条目要写 glob 或 min:%r' % (raw,)
|
||||
if raw.get("min") not in (None, ""):
|
||||
try:
|
||||
if int(raw["min"]) <= 0:
|
||||
return "accepts 的 min 必须 ≥1:%r" % (raw,)
|
||||
except (TypeError, ValueError):
|
||||
return "accepts 的 min 不是整数:%r" % (raw,)
|
||||
else:
|
||||
cs = raw.get("contains")
|
||||
if cs is not None and not isinstance(cs, list):
|
||||
return 'accepts 的 evidence.contains 要写成列表:%r' % (raw,)
|
||||
if not cs and raw.get("min") in (None, ""):
|
||||
return 'accepts 的 evidence 条目要写 contains 或 min:%r' % (raw,)
|
||||
if raw.get("min") not in (None, ""):
|
||||
try:
|
||||
if int(raw["min"]) <= 0:
|
||||
return "accepts 的 min 必须 ≥1:%r" % (raw,)
|
||||
except (TypeError, ValueError):
|
||||
return "accepts 的 min 不是整数:%r" % (raw,)
|
||||
continue
|
||||
if not isinstance(raw, str):
|
||||
return "accepts 条目只认字符串或对象(实际 %r)" % (raw,)
|
||||
s = raw.strip()
|
||||
low = s.lower()
|
||||
if not (low.startswith("artifact:") or low.startswith("evidence:")):
|
||||
continue # 纯文本 = 描述性判据,不机判
|
||||
head, _, arg = s.partition(":")
|
||||
arg = arg.strip()
|
||||
if not arg:
|
||||
return "accepts 条目缺参数:%r(写成 %s:<通配|>=N|关键词>)" % (s, head)
|
||||
if arg.startswith(">="):
|
||||
try:
|
||||
if int(arg[2:]) <= 0:
|
||||
return "accepts 的 >=N 要 ≥1:%r" % s
|
||||
except ValueError:
|
||||
return "accepts 的 >=N 不是整数:%r" % s
|
||||
elif low.startswith("evidence:") and not any(
|
||||
x.strip() for x in arg.replace(",", ",").split(",")):
|
||||
return "accepts 的 evidence 关键词为空:%r" % s
|
||||
return None
|
||||
|
||||
|
||||
def _norm_accepts(vals) -> list:
|
||||
p = _accepts_problem(vals)
|
||||
if p:
|
||||
raise SystemExit(p)
|
||||
out = []
|
||||
for raw in (vals or []):
|
||||
if isinstance(raw, dict):
|
||||
kind = str(raw.get("kind")).strip()
|
||||
it = {"kind": kind}
|
||||
if kind == "artifact":
|
||||
if raw.get("glob") not in (None, ""):
|
||||
it["glob"] = str(raw["glob"]).strip()
|
||||
if raw.get("min") not in (None, ""):
|
||||
it["min"] = int(raw["min"])
|
||||
else:
|
||||
if raw.get("contains"):
|
||||
it["contains"] = [str(x) for x in raw["contains"]]
|
||||
if raw.get("min") not in (None, ""):
|
||||
it["min"] = int(raw["min"])
|
||||
out.append(it)
|
||||
else:
|
||||
out.append(str(raw).strip())
|
||||
return out
|
||||
|
||||
|
||||
def _norm_path(s) -> str:
|
||||
return str(s or "").replace("\\", "/").strip()
|
||||
|
||||
|
||||
def _accepts_missing(accepts, artifacts, evidence) -> list:
|
||||
"""逐条判 accepts,返回没达标的描述(空列表=全过)。"""
|
||||
arts = [_norm_path(x) for x in (artifacts or [])]
|
||||
ev = str(evidence or "")
|
||||
miss = []
|
||||
for raw in (accepts or []):
|
||||
if isinstance(raw, dict):
|
||||
kind = str(raw.get("kind") or "")
|
||||
glob = raw.get("glob")
|
||||
mn = raw.get("min")
|
||||
need = [str(x) for x in (raw.get("contains") or [])]
|
||||
else:
|
||||
s = str(raw).strip()
|
||||
head, _, arg = s.partition(":")
|
||||
low = head.strip().lower()
|
||||
arg = arg.strip()
|
||||
if low not in ("artifact", "evidence"):
|
||||
continue # 描述性判据,不机判
|
||||
kind = low
|
||||
glob = None
|
||||
mn = None
|
||||
need = []
|
||||
if arg.startswith(">="):
|
||||
mn = int(arg[2:])
|
||||
elif kind == "artifact":
|
||||
glob = arg
|
||||
else:
|
||||
need = [x.strip() for x in arg.replace(",", ",").split(",") if x.strip()]
|
||||
if kind == "artifact":
|
||||
if glob:
|
||||
if not any(fnmatch.fnmatch(a, _norm_path(glob)) for a in arts):
|
||||
miss.append("缺产物 artifact:%s(现有产物:%s)"
|
||||
% (glob, "、".join(arts) if arts else "无"))
|
||||
if mn is not None and len(arts) < mn:
|
||||
miss.append("产物数量不足 artifact:>=%d(现有 %d 条)" % (mn, len(arts)))
|
||||
elif kind == "evidence":
|
||||
if mn is not None and len(ev.strip()) < mn:
|
||||
miss.append("证据太短 evidence:>=%d(现有 %d 字)" % (mn, len(ev.strip())))
|
||||
if need:
|
||||
absent = [k for k in need if k not in ev]
|
||||
if absent:
|
||||
miss.append("证据缺关键词 evidence:%s(现缺:%s)"
|
||||
% (",".join(need), "、".join(absent)))
|
||||
return miss
|
||||
|
||||
|
||||
# ── 回退边(rework_to)──────────────────────────────────────────────────
|
||||
def _ancestors(nodes: dict, nid: str) -> set:
|
||||
seen, stack = set(), list(_deps(nodes.get(nid) or {}))
|
||||
while stack:
|
||||
x = stack.pop()
|
||||
if x in seen or x not in nodes:
|
||||
continue
|
||||
seen.add(x)
|
||||
stack.extend(_deps(nodes[x]))
|
||||
return seen
|
||||
|
||||
|
||||
def _descendants(nodes: dict, nid: str) -> set:
|
||||
"""所有传递下游(不含自己)。"""
|
||||
kids = {}
|
||||
for k, n in nodes.items():
|
||||
for d in _deps(n):
|
||||
kids.setdefault(d, []).append(k)
|
||||
seen, stack = set(), list(kids.get(nid) or [])
|
||||
while stack:
|
||||
x = stack.pop()
|
||||
if x in seen or x not in nodes:
|
||||
continue
|
||||
seen.add(x)
|
||||
stack.extend(kids.get(x) or [])
|
||||
return seen
|
||||
|
||||
|
||||
def _rework_target(n: dict):
|
||||
"""节点自己的 rework_to(字符串或列表)⇒ 第一个目标 id(可能为空)。"""
|
||||
rt = n.get("rework_to")
|
||||
if isinstance(rt, (list, tuple)):
|
||||
for x in rt:
|
||||
if str(x).strip():
|
||||
return str(x).strip()
|
||||
return ""
|
||||
return str(rt or "").strip()
|
||||
|
||||
|
||||
def _drop_subtree(nodes: dict, nid: str) -> list:
|
||||
"""收回一个已展开节点的全部子节点(展开产物,重新展开时按需重建)。"""
|
||||
out, stack = [], list((nodes.get(nid) or {}).get("children") or [])
|
||||
while stack:
|
||||
c = stack.pop()
|
||||
if c not in nodes:
|
||||
continue
|
||||
out.append(c)
|
||||
stack.extend((nodes[c] or {}).get("children") or [])
|
||||
for c in out:
|
||||
nodes.pop(c, None)
|
||||
return out
|
||||
|
||||
|
||||
def _rework_reset(nodes: dict, from_nid: str, tgt: str, reason: str = "") -> dict:
|
||||
"""把 tgt 及其全部传递下游复位成 pending(调用方保证已通过前置校验)。
|
||||
|
||||
链上有已展开(map/subgraph)的节点 ⇒ **收回它的子节点、清掉 expanded**,
|
||||
下次 ready/tick 会按当时的输入重新展开(⛔ 不留半截子节点)。
|
||||
"""
|
||||
down = _descendants(nodes, tgt) | {tgt}
|
||||
bad = [x for x in down if str((nodes.get(x) or {}).get("status")) == "running"]
|
||||
if bad:
|
||||
raise SystemExit("回退链上有正在跑的节点:%s(先 cancel 或等它结束)"
|
||||
% ", ".join(sorted(bad)))
|
||||
dropped = []
|
||||
for x in sorted(down):
|
||||
m = nodes.get(x)
|
||||
if m is not None and (m.get("expanded") or str(m.get("status")) == "gating"):
|
||||
dropped.extend(_drop_subtree(nodes, x))
|
||||
now = _now()
|
||||
reset = []
|
||||
for x in sorted(down):
|
||||
m = nodes.get(x)
|
||||
if m is None: # 已被当作子节点收回
|
||||
continue
|
||||
if m.get("artifacts"):
|
||||
m["prev_artifacts"] = list(m["artifacts"])
|
||||
m["artifacts"] = []
|
||||
if m.get("evidence"):
|
||||
m["prev_evidence"] = m["evidence"]
|
||||
m["evidence"] = ""
|
||||
m["status"] = "pending"
|
||||
for k in ("lease_at", "lease_at_ts", "lease_sec", "done_at", "awaiting_at",
|
||||
"approved_at", "approved_by", "not_before", "retry_at",
|
||||
"retry_exhausted", "rejected_at", "blocked_by", "blocked_reason",
|
||||
"skipped_at", "skipped_reason", "completed_at",
|
||||
"expanded", "expanded_at", "item_count", "children"):
|
||||
m.pop(k, None)
|
||||
m["reworked_at"] = now
|
||||
m["rework_of"] = tgt
|
||||
m["reworks"] = int(m.get("reworks") or 0) + 1
|
||||
reset.append(x)
|
||||
nodes[tgt]["attempts"] = int(nodes[tgt].get("attempts") or 0) + 1
|
||||
nodes[tgt]["rework_from"] = from_nid
|
||||
if reason:
|
||||
nodes[tgt]["rework_reason"] = reason
|
||||
res = {"target": tgt, "reset": reset,
|
||||
"attempts": nodes[tgt]["attempts"],
|
||||
"reworks": nodes[tgt]["reworks"]}
|
||||
if dropped:
|
||||
res["dropped_children"] = dropped
|
||||
return res
|
||||
|
||||
|
||||
# ── 跨图子图(subgraph.gid)─────────────────────────────────────────────
|
||||
def _sg_chain(g: dict, nid: str) -> list:
|
||||
"""从自己往上走 parent 链,收集沿途声明的 subgraph.gid(防跨图成环)。"""
|
||||
nodes = g.get("nodes") or {}
|
||||
out, seen, cur = [], set(), nid
|
||||
while cur and cur in nodes and cur not in seen:
|
||||
seen.add(cur)
|
||||
sg = (nodes.get(cur) or {}).get("subgraph")
|
||||
if isinstance(sg, dict) and str(sg.get("gid") or "").strip():
|
||||
out.append(str(sg["gid"]).strip())
|
||||
cur = (nodes.get(cur) or {}).get("parent")
|
||||
return out
|
||||
|
||||
|
||||
def _sg_specs_from_gid(ws: str, ref_gid: str, chain: list) -> list:
|
||||
"""读另一张图,把它的节点定义翻成内联子图规格。"""
|
||||
if not ws:
|
||||
raise SystemExit("内部错误:展开跨图子图时缺工作区路径(gid=%s)" % ref_gid)
|
||||
if ref_gid in chain:
|
||||
raise SystemExit("子图引用成环:%s" % " → ".join(list(chain) + [ref_gid]))
|
||||
p = _gdir(ws, ref_gid) / "graph.json"
|
||||
if not p.is_file():
|
||||
raise SystemExit("子图引用的图不存在:%s(gid=%s)" % (p, ref_gid))
|
||||
sg = json.loads(p.read_text(encoding="utf-8"))
|
||||
specs = []
|
||||
for sid, sn in (sg.get("nodes") or {}).items():
|
||||
if not isinstance(sn, dict):
|
||||
continue
|
||||
if sn.get("map") or sn.get("subgraph"):
|
||||
raise SystemExit("子图 %s 的节点 %s 自己又声明了 map/subgraph ⇒ 不支持嵌套"
|
||||
% (ref_gid, sid))
|
||||
specs.append({
|
||||
"id": sid,
|
||||
"title": sn.get("title") or sid,
|
||||
"deps": list(sn.get("deps") or []),
|
||||
"domain": _norm_dom(sn.get("domain")),
|
||||
"accepts": list(sn.get("accepts") or []),
|
||||
"deps_rule": sn.get("deps_rule"),
|
||||
"on_dep_fail": sn.get("on_dep_fail"),
|
||||
"when": sn.get("when"),
|
||||
"retry": sn.get("retry"),
|
||||
"lease_sec": sn.get("lease_sec"),
|
||||
"approval": sn.get("approval"),
|
||||
"rework_to": sn.get("rework_to"),
|
||||
})
|
||||
return specs
|
||||
|
||||
|
||||
# ── 域与依赖 ────────────────────────────────────────────────────────────
|
||||
def _norm_dom(d) -> list:
|
||||
if isinstance(d, str):
|
||||
@@ -467,6 +796,45 @@ def _find_cycle(nodes: dict) -> list:
|
||||
return []
|
||||
|
||||
|
||||
def _ref_graph(ws: str, gid: str):
|
||||
p = _gdir(ws, gid) / "graph.json"
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _check_sg_ref(ws: str, chain: list, nid: str, ref: str, errs: list,
|
||||
depth: int = 0) -> list:
|
||||
"""校验 `subgraph.gid`:存在性/自引用/跨图成环/不许嵌套;返回子图节点规格。"""
|
||||
if ref in chain:
|
||||
errs.append("%s 的 subgraph 引用成环:%s" % (nid, " → ".join(chain + [ref])))
|
||||
return []
|
||||
if depth > 6:
|
||||
errs.append("%s 的 subgraph 引用链超过 6 层(gid=%s)" % (nid, ref))
|
||||
return []
|
||||
rg = _ref_graph(ws, ref)
|
||||
if rg is None:
|
||||
errs.append("%s 的 subgraph 引用的图不存在:gid=%s(%s)"
|
||||
% (nid, ref, _gdir(ws, ref) / "graph.json"))
|
||||
return []
|
||||
rnodes = {k: v for k, v in (rg.get("nodes") or {}).items() if isinstance(v, dict)}
|
||||
out = []
|
||||
for sid, sn in rnodes.items():
|
||||
if sn.get("map") or sn.get("subgraph"):
|
||||
errs.append("%s 引用的图 %s 里节点 %s 又声明了 map/subgraph ⇒ 不支持嵌套"
|
||||
% (nid, ref, sid))
|
||||
out.append({"id": sid, "deps": [str(x) for x in (sn.get("deps") or [])]})
|
||||
for sid, sn in rnodes.items():
|
||||
sub2 = sn.get("subgraph")
|
||||
g2 = str((sub2 or {}).get("gid") or "").strip() if isinstance(sub2, dict) else ""
|
||||
if g2:
|
||||
_check_sg_ref(ws, chain + [ref], "%s/%s" % (nid, sid), g2, errs, depth + 1)
|
||||
return out
|
||||
|
||||
|
||||
def cmd_check(a) -> int:
|
||||
g = _load(a.ws, a.gid)
|
||||
nodes = g.get("nodes") or {}
|
||||
@@ -499,9 +867,20 @@ def cmd_check(a) -> int:
|
||||
sg = n.get("subgraph")
|
||||
if sg is not None:
|
||||
kids = sg.get("nodes") if isinstance(sg, dict) else None
|
||||
if not isinstance(kids, list):
|
||||
errs.append('%s 的 subgraph 要写 {"nodes": [...]}' % nid)
|
||||
ref = str((sg or {}).get("gid") or "").strip() if isinstance(sg, dict) else ""
|
||||
if isinstance(kids, list) and ref:
|
||||
errs.append("%s 的 subgraph 同时给了 nodes 与 gid(只能选一种)" % nid)
|
||||
if not isinstance(kids, list) and not ref:
|
||||
errs.append('%s 的 subgraph 要写 {"nodes": [...]} 或 {"gid": "..."}' % nid)
|
||||
kids = []
|
||||
if ref:
|
||||
kids = _check_sg_ref(a.ws, [a.gid], nid, ref, errs)
|
||||
extra = [s["id"] for s in kids]
|
||||
for s in kids:
|
||||
for d in s["deps"]:
|
||||
if d not in extra:
|
||||
errs.append("%s 引用的图 %s 里 %s 依赖 %s 不在那张图里"
|
||||
% (nid, ref, s["id"], d))
|
||||
seen = set()
|
||||
for s in kids:
|
||||
sid = str((s or {}).get("id") or "")
|
||||
@@ -537,6 +916,18 @@ def cmd_check(a) -> int:
|
||||
"保守做法是让它单独跑" % nid)
|
||||
if str(n.get("status")) not in STATUSES:
|
||||
errs.append("%s 的状态非法:%r" % (nid, n.get("status")))
|
||||
ap = _accepts_problem(n.get("accepts"))
|
||||
if ap:
|
||||
errs.append("%s:%s" % (nid, ap))
|
||||
rt = _rework_target(n)
|
||||
if rt:
|
||||
if rt not in nodes:
|
||||
errs.append("%s 的 rework_to 指向不存在的节点:%s" % (nid, rt))
|
||||
elif rt == nid:
|
||||
errs.append("%s 的 rework_to 指向自己" % nid)
|
||||
elif rt not in _ancestors(nodes, nid):
|
||||
errs.append("%s 的 rework_to %s 不是它的上游(回退只能回到上游)"
|
||||
% (nid, rt))
|
||||
if not (n.get("accepts") or []) and not n.get("map") and not n.get("parent"):
|
||||
warns.append("%s 没有验收判据 ⇒ 判不出完成" % nid)
|
||||
|
||||
@@ -820,7 +1211,7 @@ def _apply_conditions(g: dict, acts: list) -> int:
|
||||
|
||||
|
||||
# ── 子图(节点内部再拆子流程)───────────────────────────────────────────
|
||||
def _expand_subgraphs(g: dict, acts: list) -> int:
|
||||
def _expand_subgraphs(g: dict, acts: list, ws: str = "") -> int:
|
||||
nodes = g.get("nodes") or {}
|
||||
changed = 0
|
||||
for nid, n in list(nodes.items()):
|
||||
@@ -828,16 +1219,28 @@ def _expand_subgraphs(g: dict, acts: list) -> int:
|
||||
continue
|
||||
if not _deps_ok(nodes, n)[0]:
|
||||
continue
|
||||
acts.append(_expand_subgraph(g, nodes, nid))
|
||||
acts.append(_expand_subgraph(g, nodes, nid, ws))
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def _expand_subgraph(g: dict, nodes: dict, nid: str) -> dict:
|
||||
"""内联子图 ⇒ 子节点 `<父>/<子id>`,父节点转 gating,子节点全成功才 done。"""
|
||||
def _expand_subgraph(g: dict, nodes: dict, nid: str, ws: str = "") -> dict:
|
||||
"""子图 ⇒ 子节点 `<父>/<子id>`,父节点转 gating,子节点全成功才 done。
|
||||
|
||||
`subgraph.nodes` 内联;`subgraph.gid` 引用同工作区另一张图的节点定义。
|
||||
"""
|
||||
n = nodes[nid]
|
||||
sub = n.get("subgraph") or {}
|
||||
specs = [s for s in (sub.get("nodes") or []) if isinstance(s, dict)]
|
||||
ref_gid = str(sub.get("gid") or "").strip()
|
||||
if ref_gid:
|
||||
if ref_gid == str(g.get("gid") or ""):
|
||||
raise SystemExit("子图引用了当前图自己:%s" % ref_gid)
|
||||
par = n.get("parent")
|
||||
chain = list(reversed(_sg_chain(g, par))) if par else []
|
||||
specs = _sg_specs_from_gid(ws, ref_gid, chain)
|
||||
n["subgraph_ref"] = ref_gid
|
||||
else:
|
||||
specs = [s for s in (sub.get("nodes") or []) if isinstance(s, dict)]
|
||||
prefix = nid + "/"
|
||||
local, kids = {}, []
|
||||
for i, s in enumerate(specs, 1):
|
||||
@@ -868,19 +1271,30 @@ def _expand_subgraph(g: dict, nodes: dict, nid: str) -> dict:
|
||||
cd = sub.get("child_domain")
|
||||
dom = _norm_dom(str(cd).replace("{id}", cid[len(prefix):])) if cd else list(base)
|
||||
kid = {"title": str(s.get("title") or cid), "deps": deps, "domain": dom,
|
||||
"accepts": [str(x) for x in (s.get("accepts") or [])],
|
||||
"accepts": _norm_accepts(s.get("accepts") or []),
|
||||
"status": "pending", "evidence": "", "artifacts": [],
|
||||
"attempts": 0, "parent": nid, "sub": True}
|
||||
for k in ("deps_rule", "on_dep_fail", "approval", "lease_sec", "when"):
|
||||
for k in ("deps_rule", "on_dep_fail", "approval", "lease_sec"):
|
||||
if s.get(k) not in (None, "", []):
|
||||
kid[k] = s[k]
|
||||
# `when` / `rework_to` 里的节点引用要跟着加父前缀,否则指向不存在的 id
|
||||
if s.get("when"):
|
||||
kid["when"] = [{"node": local.get(str(c.get("node")), str(c.get("node"))),
|
||||
"status": str(c.get("status") or "done")}
|
||||
for c in s["when"] if isinstance(c, dict)]
|
||||
rt = _rework_target(s)
|
||||
if rt:
|
||||
kid["rework_to"] = local.get(rt, rt)
|
||||
if s.get("retry"):
|
||||
kid["retry"] = _retry_cfg({"meta": {}}, {"retry": s["retry"]})
|
||||
if ref_gid:
|
||||
kid["origin_graph"] = ref_gid
|
||||
nodes[cid] = kid
|
||||
n["status"] = "gating"
|
||||
n["children"] = [c for c, _ in kids]
|
||||
return {"node": nid, "event": "expand", "kind": "subgraph",
|
||||
"count": len(kids), "children": n["children"], "to": "gating"}
|
||||
"count": len(kids), "children": n["children"], "to": "gating",
|
||||
**({"from_graph": ref_gid} if ref_gid else {})}
|
||||
|
||||
|
||||
# ── 按域分池 ────────────────────────────────────────────────────────────
|
||||
@@ -919,7 +1333,7 @@ def _pool_full(g: dict, n: dict, occupied: list) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _advance(g: dict) -> list:
|
||||
def _advance(g: dict, ws: str = "") -> list:
|
||||
"""把「超时回收/子节点收敛/展开/条件判定」推到不动点;返回动了哪些(供写事件)。"""
|
||||
acts: list = []
|
||||
for _ in range(6):
|
||||
@@ -927,7 +1341,7 @@ def _advance(g: dict) -> list:
|
||||
moved += _reclaim_leases(g, acts)
|
||||
moved += _complete_parents(g, acts)
|
||||
moved += _expand_maps(g, acts)
|
||||
moved += _expand_subgraphs(g, acts)
|
||||
moved += _expand_subgraphs(g, acts, ws)
|
||||
moved += _apply_conditions(g, acts)
|
||||
if not moved:
|
||||
break
|
||||
@@ -972,7 +1386,7 @@ def _advance_graph(ws: str, gid: str, g: dict):
|
||||
return g, acts
|
||||
|
||||
def _mut(gg):
|
||||
acts.extend(_advance(gg))
|
||||
acts.extend(_advance(gg, ws))
|
||||
|
||||
g2 = _update(ws, gid, _mut)
|
||||
for x in acts:
|
||||
@@ -1120,6 +1534,14 @@ def cmd_report(a) -> int:
|
||||
n["evidence"] = a.evidence
|
||||
for art in (a.artifact or []):
|
||||
n.setdefault("artifacts", []).append(str(art))
|
||||
if a.state == "done": # F1:报 done 先过验收判据
|
||||
gaps = _accepts_missing(n.get("accepts"), n.get("artifacts"), n.get("evidence"))
|
||||
if gaps:
|
||||
raise SystemExit("节点 %s 验收不通过(不置 done):\n - %s\n"
|
||||
"补齐产物/证据后重新 report(accepts 不可绕过)"
|
||||
% (a.node, "\n - ".join(gaps)))
|
||||
n["accepts_checked_at"] = _now()
|
||||
n["accepts_gaps"] = []
|
||||
for k in ("lease_at", "lease_at_ts", "lease_sec"):
|
||||
n.pop(k, None)
|
||||
if a.state == "blocked":
|
||||
@@ -1209,15 +1631,34 @@ def cmd_expand(a) -> int:
|
||||
|
||||
# ── 人审 / 取消 / 派活 / 排期 / 进度 ─────────────────────────────────────
|
||||
def cmd_approve(a) -> int:
|
||||
"""人审节点:`awaiting` ⇒ done(默认)或 blocked(--reject)。"""
|
||||
"""人审节点:`awaiting` ⇒ done(默认)或 blocked(--reject)。
|
||||
|
||||
驳回时若节点带 `rework_to` ⇒ 直接走回退(人审判不过 = 判不过)。
|
||||
"""
|
||||
rw = {}
|
||||
|
||||
def _mut(g):
|
||||
n = (g.get("nodes") or {}).get(a.node)
|
||||
nodes = g.get("nodes") or {}
|
||||
n = nodes.get(a.node)
|
||||
if not n:
|
||||
raise SystemExit("没有这个节点:%s" % a.node)
|
||||
st = str(n.get("status"))
|
||||
if st != "awaiting":
|
||||
raise SystemExit("节点 %s 不在等人审(当前 %s)" % (a.node, st))
|
||||
if a.reject:
|
||||
tgt = _rework_target(n)
|
||||
if tgt:
|
||||
if tgt not in nodes:
|
||||
raise SystemExit("回退目标不存在:%s" % tgt)
|
||||
if tgt not in _ancestors(nodes, a.node):
|
||||
raise SystemExit("回退目标 %s 不是 %s 的上游" % (tgt, a.node))
|
||||
n["status"] = "blocked"
|
||||
n["blocked_by"] = "rejected"
|
||||
n["blocked_reason"] = a.reason or "人审驳回"
|
||||
n["rejected_at"] = _now()
|
||||
rw.update(_rework_reset(nodes, a.node, tgt, a.reason or "人审驳回"))
|
||||
rw["auto"] = True
|
||||
return
|
||||
n["status"] = "blocked"
|
||||
n["blocked_by"] = "rejected"
|
||||
n["blocked_reason"] = a.reason or "人审驳回"
|
||||
@@ -1231,11 +1672,12 @@ def cmd_approve(a) -> int:
|
||||
g = _update(a.ws, a.gid, _mut)
|
||||
st = _st(g.get("nodes") or {}, a.node)
|
||||
_event(a.ws, a.gid, {"node": a.node, "to": st, "by": a.by or "",
|
||||
"reject": bool(a.reject), "reason": a.reason or ""})
|
||||
"reject": bool(a.reject), "reason": a.reason or "",
|
||||
**({"rework": rw} if rw else {})})
|
||||
g, _acts = _advance_graph(a.ws, a.gid, g)
|
||||
r = _ready(g)
|
||||
_out({"ok": True, "node": a.node, "status": _st(g.get("nodes") or {}, a.node),
|
||||
"next_ready": r["ready"]})
|
||||
"next_ready": r["ready"], "rework": rw or None})
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1276,6 +1718,120 @@ def cmd_cancel(a) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_rework(a) -> int:
|
||||
"""回退到上游重做:目标及其全部传递下游复位 pending,记 attempts/reworks 与事件。"""
|
||||
res = {}
|
||||
|
||||
def _mut(g):
|
||||
nodes = g.get("nodes") or {}
|
||||
n = nodes.get(a.node)
|
||||
if not n:
|
||||
raise SystemExit("没有这个节点:%s" % a.node)
|
||||
tgt = str(a.to or "").strip() or _rework_target(n)
|
||||
if not tgt:
|
||||
raise SystemExit("节点 %s 没有 rework_to,也没给 --to" % a.node)
|
||||
if tgt not in nodes:
|
||||
raise SystemExit("回退目标不存在:%s" % tgt)
|
||||
if tgt == a.node:
|
||||
raise SystemExit("回退目标不能是自己(要重做自己直接 start 该节点)")
|
||||
if tgt not in _ancestors(nodes, a.node):
|
||||
raise SystemExit("回退目标 %s 不是 %s 的上游(回退只能回到上游)"
|
||||
% (tgt, a.node))
|
||||
res.update(_rework_reset(nodes, a.node, tgt, a.reason or ""))
|
||||
res["node"] = a.node
|
||||
|
||||
g = _update(a.ws, a.gid, _mut)
|
||||
_event(a.ws, a.gid, {"event": "rework", "node": a.node, "to": res.get("target"),
|
||||
"reset": res.get("reset") or [], "reason": a.reason or "",
|
||||
"by": a.by or "", "attempts": res.get("attempts"),
|
||||
"reworks": res.get("reworks")})
|
||||
g, acts = _advance_graph(a.ws, a.gid, g)
|
||||
r = _ready(g)
|
||||
_out({"ok": True, "node": a.node, "target": res.get("target"),
|
||||
"reset": res.get("reset") or [],
|
||||
"dropped_children": res.get("dropped_children") or [],
|
||||
"attempts": res.get("attempts"),
|
||||
"reworks": res.get("reworks"),
|
||||
"skipped_by_rework": [x["node"] for x in acts if x.get("event") == "skip"],
|
||||
"next_ready": r["ready"]})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_add_node(a) -> int:
|
||||
"""运行期加节点(与并发的 start/report 共用同一把目录锁,不会互相覆盖)。"""
|
||||
nid = str(a.node or "").strip()
|
||||
if not nid:
|
||||
raise SystemExit("--node 不能为空")
|
||||
deps = [x.strip() for x in str(a.deps or "").replace(",", ",").split(",") if x.strip()]
|
||||
rule = str(a.deps_rule or "all_success")
|
||||
if rule not in DEP_RULES:
|
||||
raise SystemExit("--deps-rule 只认 %s" % " / ".join(DEP_RULES))
|
||||
accepts = _norm_accepts(a.accepts or [])
|
||||
rework_to = str(a.rework_to or "").strip()
|
||||
|
||||
def _mut(g):
|
||||
nodes = g.get("nodes") or {}
|
||||
if nid in nodes:
|
||||
raise SystemExit("节点已存在:%s(换一个 id 或先 remove-edge)" % nid)
|
||||
miss = [d for d in deps if d not in nodes]
|
||||
if miss:
|
||||
raise SystemExit("依赖不存在:%s" % ", ".join(miss))
|
||||
if rework_to and rework_to not in nodes:
|
||||
raise SystemExit("rework_to 指向的节点不存在:%s" % rework_to)
|
||||
if rework_to == nid:
|
||||
raise SystemExit("rework_to 不能指向自己")
|
||||
nodes[nid] = {
|
||||
"title": str(a.title or nid), "deps": deps,
|
||||
"domain": _norm_dom(a.domain or []), "accepts": accepts,
|
||||
"status": "pending", "evidence": "", "artifacts": [], "attempts": 0,
|
||||
"deps_rule": rule, "added_at": _now(),
|
||||
}
|
||||
if rework_to:
|
||||
nodes[nid]["rework_to"] = rework_to
|
||||
if a.approval:
|
||||
nodes[nid]["approval"] = True
|
||||
g["nodes"] = nodes
|
||||
|
||||
g = _update(a.ws, a.gid, _mut)
|
||||
_event(a.ws, a.gid, {"event": "add_node", "node": nid, "deps": deps,
|
||||
"by": a.by or "", "rev": g.get("_rev")})
|
||||
g, _acts = _advance_graph(a.ws, a.gid, g)
|
||||
r = _ready(g)
|
||||
_out({"ok": True, "node": nid, "deps": deps,
|
||||
"status": _st(g.get("nodes") or {}, nid),
|
||||
"rev": g.get("_rev"), "next_ready": r["ready"]})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_remove_edge(a) -> int:
|
||||
"""删边:删掉「--to 依赖 --from」这一条依赖(边不存在即报错,⛔ 不静默空操作)。"""
|
||||
src, dst = str(a.src or "").strip(), str(a.to or "").strip()
|
||||
if not src or not dst:
|
||||
raise SystemExit("要给 --from <上游> 和 --to <下游>")
|
||||
|
||||
def _mut(g):
|
||||
nodes = g.get("nodes") or {}
|
||||
for x in (src, dst):
|
||||
if x not in nodes:
|
||||
raise SystemExit("没有这个节点:%s" % x)
|
||||
d = _deps(nodes[dst])
|
||||
if src not in d:
|
||||
raise SystemExit("边不存在:%s → %s(%s 的 deps=%s 里没有 %s)"
|
||||
% (src, dst, dst, d, src))
|
||||
nodes[dst]["deps"] = [x for x in d if x != src]
|
||||
nodes[dst]["edge_removed_at"] = _now()
|
||||
|
||||
g = _update(a.ws, a.gid, _mut)
|
||||
_event(a.ws, a.gid, {"event": "remove_edge", "from": src, "to": dst,
|
||||
"by": a.by or "", "rev": g.get("_rev")})
|
||||
g, _acts = _advance_graph(a.ws, a.gid, g)
|
||||
r = _ready(g)
|
||||
_out({"ok": True, "from": src, "to": dst, "deps": _deps(
|
||||
(g.get("nodes") or {}).get(dst) or {}), "rev": g.get("_rev"),
|
||||
"next_ready": r["ready"]})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_tick(a) -> int:
|
||||
"""自动派活:推进到不动点 → 算就绪集 → 直接把就绪节点开成 running。"""
|
||||
started, missed = [], []
|
||||
@@ -1467,7 +2023,7 @@ def cmd_init(a) -> int:
|
||||
"title": str(n.get("title") or nid),
|
||||
"deps": [str(x) for x in (n.get("deps") or [])],
|
||||
"domain": _norm_dom(n.get("domain")),
|
||||
"accepts": [str(x) for x in (n.get("accepts") or [])],
|
||||
"accepts": _norm_accepts(n.get("accepts") or []),
|
||||
"status": "pending", "evidence": "", "artifacts": [], "attempts": 0,
|
||||
"deps_rule": rule,
|
||||
}
|
||||
@@ -1485,14 +2041,33 @@ def cmd_init(a) -> int:
|
||||
nodes[nid]["map"] = _norm_map(n["map"])
|
||||
if n.get("subgraph"):
|
||||
sg = n["subgraph"]
|
||||
if not isinstance(sg, dict) or not isinstance(sg.get("nodes"), list):
|
||||
raise SystemExit('%s 的 subgraph 要写 {"nodes": [...]}' % nid)
|
||||
if not isinstance(sg, dict):
|
||||
raise SystemExit('%s 的 subgraph 要写 {"nodes": [...]} 或 {"gid": "另一张图"}'
|
||||
% nid)
|
||||
has_nodes = isinstance(sg.get("nodes"), list)
|
||||
has_gid = bool(str(sg.get("gid") or "").strip())
|
||||
if has_nodes and has_gid:
|
||||
raise SystemExit("%s 的 subgraph 只能选一种:内联 nodes 或引用 gid" % nid)
|
||||
if not has_nodes and not has_gid:
|
||||
raise SystemExit('%s 的 subgraph 要写 {"nodes": [...]} 或 {"gid": "..."}'
|
||||
% nid)
|
||||
if n.get("map"):
|
||||
raise SystemExit("%s 同时声明了 map 与 subgraph(只能选一个)" % nid)
|
||||
sub = {"nodes": [dict(s) for s in sg["nodes"] if isinstance(s, dict)]}
|
||||
sub = {}
|
||||
if has_nodes:
|
||||
sub["nodes"] = [dict(s) for s in sg["nodes"] if isinstance(s, dict)]
|
||||
else:
|
||||
sub["gid"] = str(sg["gid"]).strip()
|
||||
if sg.get("child_domain"):
|
||||
sub["child_domain"] = str(sg["child_domain"])
|
||||
nodes[nid]["subgraph"] = sub
|
||||
if n.get("rework_to"):
|
||||
rt = _rework_target({"rework_to": n["rework_to"]})
|
||||
if not rt:
|
||||
raise SystemExit("%s 的 rework_to 不能为空" % nid)
|
||||
if rt == nid:
|
||||
raise SystemExit("%s 的 rework_to 不能指向自己" % nid)
|
||||
nodes[nid]["rework_to"] = rt
|
||||
if n.get("when"):
|
||||
if not isinstance(n["when"], list):
|
||||
raise SystemExit("%s 的 when 必须是列表" % nid)
|
||||
@@ -1632,6 +2207,38 @@ def main(argv=None) -> int:
|
||||
p.add_argument("--by", default="")
|
||||
p.set_defaults(fn=cmd_report)
|
||||
|
||||
p = sub.add_parser("rework", help="回退到上游重做(目标及其下游复位 pending)")
|
||||
p.add_argument("--ws", required=True)
|
||||
p.add_argument("--gid", required=True)
|
||||
p.add_argument("--node", required=True)
|
||||
p.add_argument("--to", default="", help="回退目标(默认取节点自己的 rework_to)")
|
||||
p.add_argument("--reason", default="")
|
||||
p.add_argument("--by", default="")
|
||||
p.set_defaults(fn=cmd_rework)
|
||||
|
||||
p = sub.add_parser("add-node", help="运行期加节点")
|
||||
p.add_argument("--ws", required=True)
|
||||
p.add_argument("--gid", required=True)
|
||||
p.add_argument("--node", required=True)
|
||||
p.add_argument("--title", default="")
|
||||
p.add_argument("--deps", default="", help="依赖节点,逗号分隔")
|
||||
p.add_argument("--domain", action="append", default=[], help="域前缀,可重复")
|
||||
p.add_argument("--accepts", action="append", default=[],
|
||||
help="验收判据,可重复(artifact:… / evidence:… / 纯文本)")
|
||||
p.add_argument("--deps-rule", default="all_success")
|
||||
p.add_argument("--rework-to", default="")
|
||||
p.add_argument("--approval", action="store_true")
|
||||
p.add_argument("--by", default="")
|
||||
p.set_defaults(fn=cmd_add_node)
|
||||
|
||||
p = sub.add_parser("remove-edge", help="删边:删掉「--to 依赖 --from」")
|
||||
p.add_argument("--ws", required=True)
|
||||
p.add_argument("--gid", required=True)
|
||||
p.add_argument("--from", dest="src", required=True, help="上游节点")
|
||||
p.add_argument("--to", required=True, help="下游节点(它的 deps 里删掉上游)")
|
||||
p.add_argument("--by", default="")
|
||||
p.set_defaults(fn=cmd_remove_edge)
|
||||
|
||||
p = sub.add_parser("expand", help="手动触发展开")
|
||||
p.add_argument("--ws", required=True)
|
||||
p.add_argument("--gid", required=True)
|
||||
|
||||
Reference in new issue
Block a user