mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 20:33:19 +08:00
MCP: add bizMonitor server, align ops/topology tools, harden CLI exec
- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools mirroring the netxops bizMonitor group. - Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP contracts; surface partial batch failures as tool errors. - CLI hard gate: single-line show/display only; exec job IDs, path containment, owner and ne:exec checks. - Cisco IOSv collection: stable prompt/echo handling over CR. - Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json; sync skills and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8da7acb7d
commit
9085cac742
63 changed files with 3251 additions and 1092 deletions
|
|
@ -8,7 +8,7 @@ MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → netx AP
|
|||
|
||||
详细说明 → **[docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md)**
|
||||
|
||||
配套 Skill → **[`.cursor/skills/netx-topology`](../../.cursor/skills/netx-topology/SKILL.md)**(画图流水线与硬规则)
|
||||
配套 Skill → **[`skills/topology/netx-topology`](../../skills/topology/netx-topology/SKILL.md)**(画图流水线与硬规则)
|
||||
|
||||
## 目录模型
|
||||
|
||||
|
|
@ -38,14 +38,35 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
|
||||
画图请在 env 里设带 `ne:write` 的 `NETX_API_TOKEN`(不要只靠仓库 `mcp_token`)。`getTopologyView` / `projectTopologyNeighbors` 默认 `detail=summary`(坐标抽样 + `links[]` 邻接);`queryTopologyEdges` 默认 adjacency(一对网元一条线),避免整图/端口边占满上下文。
|
||||
|
||||
## 工具(14)
|
||||
## 工具(16)
|
||||
|
||||
覆盖 netxops `topology` 清单全部 15 项,另提供 `sinkTopologyDualUnits`。netxops 工具名前缀为 `netx__`,独立 MCP 不带此前缀。
|
||||
|
||||
| 类别 | 工具 |
|
||||
|------|------|
|
||||
| 树/区域/画布 | `getTopologyTree`, `createTopologyFolder`(唯一建图,返回 view_id), `getTopologyView` |
|
||||
| 画图 | `addTopologyViewNodes` / `remove…` / `sinkTopologyDualUnits`(分批沉入+默认可先 layout_dual_unit) / `copyTopologyViewNodes`(一键克隆测沙箱) / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` |
|
||||
| Fabric 只读 | `queryTopologyFabricNodes`(summary\|list\|search), `queryTopologyNeighborhood`, `queryTopologyEdges` |
|
||||
| Fabric 分类 | `classifyTopologyFabricNodes`(match/tag/patch/unmatched/preview_rules/apply_rules/list_rules) |
|
||||
| 自动布图 | `layoutTopologyView`(小图 `compact`/`corridor`/`rings`;巨图:`sinkTopologyDualUnits`→`orbit_sweep`→`polish_crossings`→`clear_edge_hits`) |
|
||||
| 布图验收/规划 | `analyzeTopologyViewLayout`(score;`detail=structure` 定重心;`both`=structure+手拖 sight) |
|
||||
|
||||
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。
|
||||
|
||||
画布节点增删和位置布局只接受 `keyword/role/vendor/link_status` 筛选。按 `region_folder_id/level/level_major` 画图时,先用 `queryTopologyFabricNodes(mode=list)` 分页查询,再将返回的 `id` 作为 `fabric_node_ids` 传入画布工具。`projectTopologyNeighbors(dry_run=true)` 可预览邻居投影而不保存。
|
||||
|
||||
## 自动布局与异步任务
|
||||
|
||||
1. `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"preview"})` 预览;`mode:"apply"` 才保存坐标。
|
||||
2. 需要后台计算时,调用 `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"apply", params:{background:true}})`,记录返回的 `job_id`。布局/局部优化计算支持显式后台;`move_nodes` 和 `align_reference` 按各自流程同步执行。
|
||||
3. 调用 `layoutTopologyView({action:"job_status", params:{job_id}})`,直到 `status` 为 `done/error/cancelled`。`running/cancelling` 仍需轮询;`stale=true` 仅表示心跳过期,不能视为完成。成功必须检查 `ok=true`、`status=done`、`result.applied=true`。
|
||||
4. `getTopologyView({view_id, detail:"full"})` 回读 HTTP API 持久化后的坐标。`job_status` 的结果会裁剪大字段,不替代完整画布回读。
|
||||
5. 需要取消时调用 `layoutTopologyView({action:"job_cancel", params:{job_id}})`;取消为协作式,仍需轮询终态。已经写入的坐标不会因取消自动回滚。
|
||||
|
||||
部分大图优化会自动转后台:`polish_crossings/orbit_sweep/clear_edge_hits/compact_bbox/pull_far_chains/layout_dual_unit` 在节点数达到 600 时;`orbit_sweep(until_limit=true)` 的 apply 总是后台。`params.sync=true` 可强制同步。
|
||||
|
||||
任务 JSON 默认位于仓库 `data/runtime/layout_jobs`;用绝对路径 `NETX_LAYOUT_JOB_DIR` 可指定共享目录。后台子进程和任务文件能跨 MCP 主进程重启,轮询方必须访问同一目录;这不是 HTTP 后端任务接口,也不支持跨机器自动共享。历史回收仅清理终态,保留运行中的任务。
|
||||
|
||||
HTTP 200 内的 `ok=false` 会作为 MCP `isError=true` 返回。分批写入遇错即停;复制/移动/下沉若保存目标坐标失败,会保留源成员并报告已增加的目标成员。最终回读失败返回 `view_readback_failed` 和 `write_completed=true`,此时应先检查画布再重试。多次 HTTP 写入不构成事务,已完成的部分不会自动撤销。
|
||||
|
||||
只使用工具 schema 中公开的动作。旧动作别名 `resolve_overlaps/sink_nodes/align_to_reference`、查询模式 `stats/count/find`、分类动作 `find/bulk_tag/assign/preview/apply/rules`、详情别名 `raw/graph/full`(边查询的 full)已移除;规范动作分别见 `layoutTopologyView(catalog=true)` 与各工具 schema。
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ def _data(out: dict[str, Any]) -> dict[str, Any]:
|
|||
data = out.get("data")
|
||||
if isinstance(data, dict):
|
||||
merged = dict(data)
|
||||
merged["ok"] = True
|
||||
# HTTP 200 can carry a failed mutation (for example membership_max_nodes).
|
||||
merged.setdefault("ok", True)
|
||||
if merged.get("ok") is False and not merged.get("error"):
|
||||
merged["error"] = "netx_operation_failed"
|
||||
return merged
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
|
@ -233,10 +236,12 @@ def _get_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if not out.get("ok"):
|
||||
return out
|
||||
detail = str(args.get("detail") or "summary").strip().lower()
|
||||
if detail in {"full", "raw", "graph"}:
|
||||
if detail not in {"summary", "full"}:
|
||||
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
|
||||
if detail == "full":
|
||||
out["detail"] = "full"
|
||||
return out
|
||||
sample = int(args.get("sample") or 20)
|
||||
sample = int(args["sample"]) if args.get("sample") is not None else 20
|
||||
return _summarize_view_graph(out, sample=sample)
|
||||
|
||||
|
||||
|
|
@ -359,7 +364,7 @@ def _patch_positions_chunked(view_id: str, positions: list[dict[str, Any]]) -> d
|
|||
"updated": updated,
|
||||
"detail": out,
|
||||
}
|
||||
updated += int(out.get("updated") or len(chunk))
|
||||
updated += int(out.get("updated", len(chunk)))
|
||||
chunks += 1
|
||||
return {"ok": True, "updated": updated, "chunks": chunks}
|
||||
|
||||
|
|
@ -469,7 +474,6 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
_public = {
|
||||
"layout",
|
||||
"fix_overlaps",
|
||||
"resolve_overlaps",
|
||||
"untangle",
|
||||
"straighten_channels",
|
||||
"layout_dual_unit",
|
||||
|
|
@ -478,11 +482,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"compact_bbox",
|
||||
"pull_far_chains",
|
||||
"align_reference",
|
||||
"align_to_reference",
|
||||
"orbit_sweep",
|
||||
"level_bands",
|
||||
"move_nodes",
|
||||
"sink_nodes",
|
||||
"job_status",
|
||||
"job_cancel",
|
||||
}
|
||||
|
|
@ -503,16 +505,12 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
),
|
||||
**list_layout_catalog(),
|
||||
}
|
||||
if action == "resolve_overlaps":
|
||||
action = "fix_overlaps"
|
||||
args = {**args, "action": "fix_overlaps"}
|
||||
|
||||
# Bidirectional membership move: view_id=TO, source_view_id=FROM.
|
||||
if action in {"move_nodes", "sink_nodes"}:
|
||||
if action == "move_nodes":
|
||||
return _move_topology_view_nodes(args)
|
||||
|
||||
# Reference alignment: view_id=TO, source_view_id|params.reference_view_id=FROM.
|
||||
if action in {"align_reference", "align_to_reference"}:
|
||||
if action == "align_reference":
|
||||
return _align_topology_reference(args)
|
||||
|
||||
# Background job poll / cancel (no view_id required).
|
||||
|
|
@ -544,8 +542,8 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"error": "job_not_found",
|
||||
"job_id": job_id,
|
||||
"hint": (
|
||||
"Unknown job_id. Jobs are in-process on this MCP worker; "
|
||||
"restart or another instance cannot see them."
|
||||
"Unknown job_id. Poll from a worker sharing NETX_LAYOUT_JOB_DIR; "
|
||||
"records survive MCP restarts but may have been pruned."
|
||||
),
|
||||
}
|
||||
status = str(job.get("status") or "unknown")
|
||||
|
|
@ -870,9 +868,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
force_sync = _truthy((overrides or {}).get("sync")) or _truthy(
|
||||
(overrides or {}).get("_force_sync")
|
||||
)
|
||||
until_limit = _truthy((overrides or {}).get("until_limit")) or _truthy(
|
||||
(overrides or {}).get("until_stall")
|
||||
)
|
||||
until_limit = _truthy((overrides or {}).get("until_limit"))
|
||||
heavy_bg = action in {
|
||||
"polish_crossings",
|
||||
"orbit_sweep",
|
||||
|
|
@ -885,10 +881,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
bg_n_thr = 80 if (action == "orbit_sweep" and until_limit) else 600
|
||||
if (
|
||||
mode == "apply"
|
||||
and heavy_bg
|
||||
and not force_sync
|
||||
and (
|
||||
len(nodes) >= bg_n_thr
|
||||
(heavy_bg and len(nodes) >= bg_n_thr)
|
||||
or _truthy((overrides or {}).get("background"))
|
||||
or (until_limit and action == "orbit_sweep")
|
||||
)
|
||||
|
|
@ -912,6 +907,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
tool_args=sync_args,
|
||||
meta={"node_count": len(nodes), "until_limit": until_limit},
|
||||
)
|
||||
started = job_public(job_id)
|
||||
if started and started.get("status") in {"done", "error", "cancelled"}:
|
||||
return _layout_topology_view({"action": "job_status", "params": {"job_id": job_id}})
|
||||
print(
|
||||
f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}"
|
||||
+ (" until_limit" if until_limit else ""),
|
||||
|
|
@ -1870,10 +1868,6 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"error": "fabric_nodes_only",
|
||||
"detail": "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.",
|
||||
}
|
||||
if args.get("max_nodes") is not None:
|
||||
bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000))
|
||||
if isinstance(bump, dict) and bump.get("ok") is False:
|
||||
return bump
|
||||
filters = _filter_fields(args)
|
||||
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
|
||||
if not filters and not fabric_ids:
|
||||
|
|
@ -1883,6 +1877,11 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"detail": "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.",
|
||||
}
|
||||
|
||||
if args.get("max_nodes") is not None:
|
||||
bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000))
|
||||
if bump.get("ok") is False:
|
||||
return bump
|
||||
|
||||
layout = str(args.get("layout") or "grid").strip() or "grid"
|
||||
if filters:
|
||||
body: dict[str, Any] = {
|
||||
|
|
@ -1909,6 +1908,8 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"return_graph": False,
|
||||
}
|
||||
parts.append(_data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body)))
|
||||
if not parts[-1].get("ok"):
|
||||
break
|
||||
return _merge_mutation_summaries(parts)
|
||||
|
||||
|
||||
|
|
@ -1935,6 +1936,8 @@ def _remove_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
)
|
||||
)
|
||||
if not parts[-1].get("ok"):
|
||||
break
|
||||
return _merge_mutation_summaries(parts)
|
||||
|
||||
|
||||
|
|
@ -2002,6 +2005,8 @@ def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
)
|
||||
)
|
||||
if not parts[-1].get("ok"):
|
||||
break
|
||||
return _merge_mutation_summaries(parts)
|
||||
|
||||
|
||||
|
|
@ -2009,8 +2014,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
|||
view_id = str(args.get("view_id") or "").strip()
|
||||
if not view_id:
|
||||
return {"ok": False, "error": "view_id_required"}
|
||||
detail = str(args.get("detail") or "summary").strip().lower()
|
||||
if detail not in {"summary", "full"}:
|
||||
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
|
||||
body: dict[str, Any] = {}
|
||||
seeds = args.get("seed_fabric_node_ids") or args.get("fabric_node_ids") or []
|
||||
seeds = args.get("seed_fabric_node_ids") or []
|
||||
if isinstance(seeds, list) and seeds:
|
||||
body["seed_fabric_node_ids"] = [str(x).strip() for x in seeds if str(x).strip()]
|
||||
mids = args.get("managed_ne_ids") or []
|
||||
|
|
@ -2019,6 +2027,8 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
|||
region_folder_id = str(args.get("region_folder_id") or "").strip()
|
||||
if region_folder_id:
|
||||
body["region_folder_id"] = region_folder_id
|
||||
if "dry_run" in args:
|
||||
body["dry_run"] = _truthy(args["dry_run"])
|
||||
# API still returns the full graph; large canvases easily exceed the 60s default.
|
||||
out = _data(
|
||||
http_json(
|
||||
|
|
@ -2030,12 +2040,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
detail = str(args.get("detail") or "summary").strip().lower()
|
||||
skipped = int(out.get("out_of_region_skipped") or 0)
|
||||
skipped_sample = out.get("out_of_region_sample") or []
|
||||
if not isinstance(skipped_sample, list):
|
||||
skipped_sample = []
|
||||
if detail in {"full", "raw", "graph"}:
|
||||
if detail == "full":
|
||||
out["detail"] = "full"
|
||||
out["region_folder_id"] = region_folder_id or None
|
||||
out["out_of_region_skipped"] = skipped
|
||||
|
|
@ -2046,9 +2055,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
|||
f"skipped {skipped} out-of-region neighbors (not added)."
|
||||
)
|
||||
return out
|
||||
summary = _summarize_view_graph(out, sample=int(args.get("sample") or 20))
|
||||
sample = int(args["sample"]) if args.get("sample") is not None else 20
|
||||
summary = _summarize_view_graph(out, sample=sample)
|
||||
summary["view_id"] = view_id
|
||||
summary["projected"] = True
|
||||
summary["dry_run"] = bool(body.get("dry_run"))
|
||||
summary["region_folder_id"] = region_folder_id or None
|
||||
summary["out_of_region_skipped"] = skipped
|
||||
summary["out_of_region_sample"] = [
|
||||
|
|
@ -2076,13 +2087,15 @@ def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
mode = "search"
|
||||
else:
|
||||
mode = "list"
|
||||
if mode in {"summary", "stats", "count"}:
|
||||
if mode not in {"summary", "search", "list"}:
|
||||
return {"ok": False, "error": "mode_invalid", "hint": "mode=summary|list|search"}
|
||||
if mode == "summary":
|
||||
out = _data(http_json("GET", "/v1/topology/fabric/summary"))
|
||||
if isinstance(out, dict) and out.get("ok") is not False:
|
||||
out = dict(out)
|
||||
out["mode"] = "summary"
|
||||
return out
|
||||
if mode in {"search", "find"}:
|
||||
if mode == "search":
|
||||
needle = q or keyword
|
||||
if not needle:
|
||||
return {
|
||||
|
|
@ -2141,11 +2154,11 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"hint": (
|
||||
"action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules. "
|
||||
"Typical: match → tag(level|role, dry_run) → tag; "
|
||||
"then addTopologyViewNodes by role/level_major/region."
|
||||
"then queryTopologyFabricNodes by level/region and add returned fabric_node_ids."
|
||||
),
|
||||
}
|
||||
|
||||
if action in {"match", "find"}:
|
||||
if action == "match":
|
||||
pattern = str(args.get("pattern") or args.get("q") or "").strip()
|
||||
if not pattern:
|
||||
return {"ok": False, "error": "pattern_required"}
|
||||
|
|
@ -2180,7 +2193,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
return out
|
||||
|
||||
if action in {"tag", "bulk_tag", "assign"}:
|
||||
if action == "tag":
|
||||
level = args.get("level") if "level" in args else Ellipsis
|
||||
role = args.get("role") if "role" in args else Ellipsis
|
||||
region = args.get("region_folder_id") if "region_folder_id" in args else Ellipsis
|
||||
|
|
@ -2232,7 +2245,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
else:
|
||||
out["hint"] = (
|
||||
"Tagged. Use queryTopologyFabricNodes(role|level_major|region_folder_id) or "
|
||||
"addTopologyViewNodes filters to place on region canvas."
|
||||
"pass returned fabric_node_ids to addTopologyViewNodes to place on region canvas."
|
||||
)
|
||||
return out
|
||||
|
||||
|
|
@ -2286,7 +2299,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
page["action"] = "unmatched"
|
||||
return page
|
||||
|
||||
if action in {"preview_rules", "preview"}:
|
||||
if action == "preview_rules":
|
||||
out = _data(http_json("POST", "/v1/topology/classify/preview"))
|
||||
if isinstance(out, dict) and out.get("ok") is not False:
|
||||
out = dict(out)
|
||||
|
|
@ -2294,7 +2307,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
out["hint"] = "Rule engine dry-run. Re-call action=apply_rules to write."
|
||||
return out
|
||||
|
||||
if action in {"apply_rules", "apply"}:
|
||||
if action == "apply_rules":
|
||||
skip_manual = True
|
||||
if "skip_manual" in args:
|
||||
skip_manual = _truthy(args.get("skip_manual"))
|
||||
|
|
@ -2310,7 +2323,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
out["action"] = "apply_rules"
|
||||
return out
|
||||
|
||||
if action in {"list_rules", "rules"}:
|
||||
if action == "list_rules":
|
||||
out = _data(http_json("GET", "/v1/topology/classify/rules"))
|
||||
if isinstance(out, dict) and out.get("ok") is not False:
|
||||
out = dict(out)
|
||||
|
|
@ -2362,7 +2375,9 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
|
|||
page_size = min(500, max(1, int(args.get("page_size") or 100)))
|
||||
node_id = str(args.get("node_id") or "").strip()
|
||||
detail = str(args.get("detail") or "adjacency").strip().lower()
|
||||
want_ports = detail in {"ports", "raw", "full"}
|
||||
if detail not in {"adjacency", "ports"}:
|
||||
return {"ok": False, "error": "detail_invalid", "hint": "detail=adjacency|ports"}
|
||||
want_ports = detail == "ports"
|
||||
params: dict[str, Any] = {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
|
|
@ -2374,16 +2389,13 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
|
|||
params["status"] = str(args.get("status")).strip()
|
||||
if str(args.get("source") or "").strip():
|
||||
src = str(args.get("source")).strip().lower()
|
||||
if src == "stale":
|
||||
src = "lldp"
|
||||
params["source"] = src
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
|
||||
out = http_json("GET", "/v1/topology/fabric/edges", params=params)
|
||||
if not isinstance(out, dict) or not out.get("ok"):
|
||||
return out if isinstance(out, dict) else {"ok": False, "error": "invalid_response"}
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else {}
|
||||
data = _data(http_json("GET", "/v1/topology/fabric/edges", params=params))
|
||||
if not data.get("ok"):
|
||||
return data
|
||||
items = [e for e in (data.get("items") or []) if isinstance(e, dict)]
|
||||
edge_total = int(data.get("total") or len(items))
|
||||
links = _collapse_edges_to_links(items, include_names=True)
|
||||
|
|
@ -2528,12 +2540,8 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
|
|||
units_as_batch_rows,
|
||||
)
|
||||
|
||||
source_view_id = str(
|
||||
args.get("source_view_id") or args.get("root_view_id") or ""
|
||||
).strip()
|
||||
sink_view_id = str(
|
||||
args.get("sink_view_id") or args.get("target_view_id") or ""
|
||||
).strip()
|
||||
source_view_id = str(args.get("source_view_id") or "").strip()
|
||||
sink_view_id = str(args.get("sink_view_id") or "").strip()
|
||||
if not source_view_id or not sink_view_id:
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -2883,6 +2891,17 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
|
|||
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
|
||||
if pos_patch:
|
||||
patch_out = _patch_positions_chunked(sink_view_id, pos_patch)
|
||||
if not patch_out.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": patch_out.get("error") or "positions_patch_failed",
|
||||
"detail": patch_out,
|
||||
"source_preserved": True,
|
||||
"added": int(add_out.get("added", 0)),
|
||||
"batches": batches,
|
||||
"batch": batch_row,
|
||||
"hint": "Sink membership may already be added; source nodes were kept. Inspect both views before retrying.",
|
||||
}
|
||||
|
||||
rm_out = _remove_topology_view_nodes(
|
||||
{"view_id": source_view_id, "fabric_node_ids": move_ids}
|
||||
|
|
@ -2900,6 +2919,16 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
|
|||
# Refresh counts
|
||||
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
|
||||
snk2 = _data(http_json("GET", f"/v1/topology/views/{sink_view_id}", timeout=120.0))
|
||||
if not src2.get("ok") or not snk2.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "view_readback_failed",
|
||||
"write_completed": True,
|
||||
"detail": {"source": src2, "sink": snk2},
|
||||
"batches": batches,
|
||||
"batch": batch_row,
|
||||
"hint": "Writes completed but verification failed. Read both views before retrying this batch.",
|
||||
}
|
||||
src_ids2, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
|
||||
snk_ids2, _ = _view_node_id_pos(snk2 if snk2.get("ok") else {})
|
||||
source_remaining = len(src_ids2) if src2.get("ok") else max(0, source_remaining - len(move_ids))
|
||||
|
|
@ -2908,8 +2937,8 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
|
|||
batch_row.update(
|
||||
{
|
||||
"added": int(add_out.get("added") or len(add_ids)),
|
||||
"positions_updated": int(patch_out.get("updated") or len(pos_patch)),
|
||||
"removed_from_source": int(rm_out.get("removed") or len(move_ids)),
|
||||
"positions_updated": int(patch_out.get("updated", 0)),
|
||||
"removed_from_source": int(rm_out.get("removed", 0)),
|
||||
"source_after": source_remaining,
|
||||
"sink_after": sink_count,
|
||||
"max_nodes_bump": bump,
|
||||
|
|
@ -2963,19 +2992,8 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
Bidirectional: swap view_id / source_view_id to send nodes back.
|
||||
Caller supplies fabric_node_ids — no auto CN/dual-unit selection.
|
||||
"""
|
||||
dest_view_id = str(
|
||||
args.get("view_id")
|
||||
or args.get("sink_view_id")
|
||||
or args.get("target_view_id")
|
||||
or args.get("to_view_id")
|
||||
or ""
|
||||
).strip()
|
||||
source_view_id = str(
|
||||
args.get("source_view_id")
|
||||
or args.get("from_view_id")
|
||||
or args.get("root_view_id")
|
||||
or ""
|
||||
).strip()
|
||||
dest_view_id = str(args.get("view_id") or "").strip()
|
||||
source_view_id = str(args.get("source_view_id") or "").strip()
|
||||
if not dest_view_id or not source_view_id:
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -3010,6 +3028,8 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
return {"ok": False, "error": "fabric_node_ids_empty"}
|
||||
|
||||
mode = str(args.get("mode") or "preview").strip().lower() or "preview"
|
||||
if mode not in {"preview", "apply"}:
|
||||
return {"ok": False, "error": "mode_invalid", "hint": "mode=preview|apply"}
|
||||
dry_run = bool(overrides.get("dry_run") if "dry_run" in overrides else args.get("dry_run"))
|
||||
if mode != "apply":
|
||||
dry_run = True
|
||||
|
|
@ -3109,7 +3129,7 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
bump = _ensure_view_max_nodes(dest_view_id, min(2000, max(need_cap + 20, 200)))
|
||||
if isinstance(bump, dict) and bump.get("ok") is False:
|
||||
return {**bump, **base}
|
||||
return {**base, **bump}
|
||||
|
||||
add_out: dict[str, Any] = {"ok": True, "added": 0, "skipped_existing": len(already)}
|
||||
if add_ids:
|
||||
|
|
@ -3158,6 +3178,16 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
|
||||
if pos_patch:
|
||||
patch_out = _patch_positions_chunked(dest_view_id, pos_patch)
|
||||
if not patch_out.get("ok"):
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"error": patch_out.get("error") or "positions_patch_failed",
|
||||
"detail": patch_out,
|
||||
"added": int(add_out.get("added", 0)),
|
||||
"source_preserved": True,
|
||||
"hint": "Destination membership may already be added; source nodes were kept. Inspect both views before retrying.",
|
||||
}
|
||||
|
||||
rm_out: dict[str, Any] = {"ok": True, "removed": 0}
|
||||
if remove_from_source:
|
||||
|
|
@ -3176,14 +3206,23 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
|
||||
dst2 = _data(http_json("GET", f"/v1/topology/views/{dest_view_id}", timeout=120.0))
|
||||
if not src2.get("ok") or not dst2.get("ok"):
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"error": "view_readback_failed",
|
||||
"write_completed": True,
|
||||
"detail": {"source": src2, "destination": dst2},
|
||||
"hint": "Writes completed but verification failed. Read both views before retrying the move.",
|
||||
}
|
||||
src_after, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
|
||||
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
|
||||
return {
|
||||
**base,
|
||||
"dry_run": False,
|
||||
"added": int(add_out.get("added") or len(add_ids)),
|
||||
"positions_updated": int(patch_out.get("updated") or len(pos_patch)),
|
||||
"removed_from_source": int(rm_out.get("removed") or (len(move_ids) if remove_from_source else 0)),
|
||||
"positions_updated": int(patch_out.get("updated", 0)),
|
||||
"removed_from_source": int(rm_out.get("removed", 0)),
|
||||
"source_after": len(src_after) if src2.get("ok") else None,
|
||||
"dest_after": len(dst_after) if dst2.get("ok") else None,
|
||||
"max_nodes_bump": bump,
|
||||
|
|
@ -3201,12 +3240,8 @@ def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
Source membership is unchanged. For test sandboxes: clone a known-good
|
||||
canvas onto a fresh child region without re-adding ids by hand.
|
||||
"""
|
||||
source_view_id = str(
|
||||
args.get("source_view_id") or args.get("from_view_id") or ""
|
||||
).strip()
|
||||
target_view_id = str(
|
||||
args.get("target_view_id") or args.get("to_view_id") or ""
|
||||
).strip()
|
||||
source_view_id = str(args.get("source_view_id") or "").strip()
|
||||
target_view_id = str(args.get("target_view_id") or "").strip()
|
||||
if not source_view_id or not target_view_id:
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -3322,8 +3357,29 @@ def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
]
|
||||
if positions:
|
||||
patch_out = _patch_positions_chunked(target_view_id, positions)
|
||||
if not patch_out.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": patch_out.get("error") or "positions_patch_failed",
|
||||
"detail": patch_out,
|
||||
"source_view_id": source_view_id,
|
||||
"target_view_id": target_view_id,
|
||||
"cleared": cleared,
|
||||
"added": int(add_out.get("added", 0)),
|
||||
"hint": "Target membership may already be changed; source is unchanged. Inspect target before retrying.",
|
||||
}
|
||||
|
||||
dst2 = _data(http_json("GET", f"/v1/topology/views/{target_view_id}", timeout=120.0))
|
||||
if not dst2.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "view_readback_failed",
|
||||
"write_completed": True,
|
||||
"source_view_id": source_view_id,
|
||||
"target_view_id": target_view_id,
|
||||
"detail": dst2,
|
||||
"hint": "Writes completed but verification failed. Read target before retrying the copy.",
|
||||
}
|
||||
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
|
||||
return {
|
||||
"ok": True,
|
||||
|
|
@ -3779,6 +3835,11 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"skipped peers are counted in out_of_region_skipped."
|
||||
),
|
||||
},
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Preview projected graph without saving placements.",
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"enum": ["summary", "full"],
|
||||
|
|
@ -3797,8 +3858,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"Default: q→search, else list. "
|
||||
"summary=node/edge counts; list=paged filters "
|
||||
"(keyword/role/link_status/region_folder_id); "
|
||||
"search=quick name/IP/id (needs q). Replaces getTopologyFabricSummary / "
|
||||
"listTopologyFabricNodes / searchTopologyFabricNodes."
|
||||
"search=quick name/IP/id (needs q)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
|
|
@ -3853,7 +3913,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"level: 0=external, 1=core, 2=agg, 3=access; use 1.1 / 2.1 for sub-tiers. "
|
||||
"action=match → tag(level|role preset, dry_run) → tag. "
|
||||
"role preset still accepted (maps to *.0). "
|
||||
"After tagging, addTopologyViewNodes by role/region. No slice maps. "
|
||||
"After tagging, queryTopologyFabricNodes by region/level, then place returned "
|
||||
"fabric_node_ids with addTopologyViewNodes (canvas filters do not support region/level). "
|
||||
"Requires ne:write."
|
||||
),
|
||||
"inputSchema": {
|
||||
|
|
@ -4030,8 +4091,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
"use_source_dual_portals": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Exclude portals from source dual_units (default true).",
|
||||
"default": False,
|
||||
"description": "Exclude portals from source dual_units (default false).",
|
||||
},
|
||||
"use_sink_dual_portals": {
|
||||
"type": "boolean",
|
||||
|
|
@ -4156,7 +4217,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"action=layout: recipe=rings|corridor|compact|unstick (small graphs). "
|
||||
"action=layout_dual_unit: CN/core dual-portal eye (max membership; "
|
||||
"residual crossings OK unless params.require_zero_cross=true). "
|
||||
"action=move_nodes (alias sink_nodes): move fabric_node_ids from "
|
||||
"action=move_nodes: move fabric_node_ids from "
|
||||
"source_view_id→view_id; park=true for orbit attach; swap views to reverse. "
|
||||
"Prefer sinkTopologyDualUnits for dual_units batches. "
|
||||
"action=orbit_sweep: crossing orbit; preview+node_id / apply+pick; "
|
||||
|
|
@ -4170,9 +4231,12 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"action=pull_far_chains: scale far corridors/isolates toward portal mid-point. "
|
||||
"action=align_reference: SAME-network A/B debug only "
|
||||
"(shared fabric_node_ids); not a cross-network template. "
|
||||
"action=fix_overlaps|resolve_overlaps: pull apart overlaps (not on eye sinks). "
|
||||
"action=fix_overlaps: pull apart overlaps (not on eye sinks). "
|
||||
"action=untangle / straighten_channels: surgical polish (not on eye sinks). "
|
||||
"action=job_status|job_cancel: poll/cancel background jobs. "
|
||||
"params.background=true starts an apply action in a durable subprocess; "
|
||||
"poll params.job_id until done|error|cancelled, then inspect result. "
|
||||
"Successful apply persists positions; getTopologyView reads them back. "
|
||||
"preset: loose|balanced|dense. mode: preview|apply. "
|
||||
"Workflow: analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
|
||||
"suggestSinkHubs/move_nodes(park) → until_limit(crossing→total) → "
|
||||
|
|
@ -4192,7 +4256,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"enum": [
|
||||
"layout",
|
||||
"fix_overlaps",
|
||||
"resolve_overlaps",
|
||||
"layout_dual_unit",
|
||||
"straighten_channels",
|
||||
"untangle",
|
||||
|
|
@ -4204,7 +4267,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"orbit_sweep",
|
||||
"level_bands",
|
||||
"move_nodes",
|
||||
"sink_nodes",
|
||||
"job_status",
|
||||
"job_cancel",
|
||||
],
|
||||
|
|
@ -4212,13 +4274,13 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"description": (
|
||||
"layout=full recipe; layout_dual_unit=dual-portal eye; "
|
||||
"level_bands=horizontal layer bands; "
|
||||
"move_nodes|sink_nodes=migrate fabric_node_ids; "
|
||||
"move_nodes=migrate fabric_node_ids; "
|
||||
"orbit_sweep=polar sweep; polish_crossings=one-shot cut crossings; "
|
||||
"clear_edge_hits=eject edge hits (no crossing rise); "
|
||||
"pull_far_chains=scale far corridors toward portal mid; "
|
||||
"compact_bbox=gated farthest-K shrink; "
|
||||
"align_reference=same-network A/B debug only; "
|
||||
"fix_overlaps|resolve_overlaps; "
|
||||
"fix_overlaps; "
|
||||
"untangle/straighten_channels; job_status|job_cancel."
|
||||
),
|
||||
},
|
||||
|
|
@ -4281,10 +4343,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"max_jump/angle_step/nn_floor; until_limit defaults "
|
||||
"protect_rigid=portals + objective=crossing; "
|
||||
"single-node/round default protect_rigid=off. "
|
||||
"move_nodes|sink_nodes: fabric_node_ids[] (required), "
|
||||
"move_nodes: fabric_node_ids[] (required), "
|
||||
"copy_positions (default true), park, remove_from_source "
|
||||
"(default true), pad/offset_x/offset_y."
|
||||
),
|
||||
"properties": {
|
||||
"job_id": {"type": "string", "description": "Required for job_status/job_cancel; returned by background start."},
|
||||
"background": {"type": "boolean", "description": "Run apply computation in a durable subprocess and return job_id immediately."},
|
||||
"sync": {"type": "boolean", "description": "Force inline computation, disabling automatic background execution."},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"catalog": {
|
||||
|
|
|
|||
|
|
@ -89,7 +89,12 @@ def _prune_disk() -> None:
|
|||
files = [p for p in files if not p.name.endswith(".args.json")]
|
||||
if len(files) <= _MAX_JOBS:
|
||||
return
|
||||
for path in files[: max(0, len(files) - _MAX_JOBS)]:
|
||||
# Active jobs must remain pollable and cancellable even when history fills up.
|
||||
removable = [
|
||||
path for path in files
|
||||
if (_read_job_disk(path.stem) or {}).get("status") in {"done", "error", "cancelled"}
|
||||
]
|
||||
for path in removable[: max(0, len(files) - _MAX_JOBS)]:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
path.with_name(path.stem + ".args.json").unlink(missing_ok=True)
|
||||
|
|
@ -400,25 +405,33 @@ def start_job(
|
|||
subprocess, "DETACHED_PROCESS", 0x00000008
|
||||
)
|
||||
|
||||
proc = subprocess.Popen( # noqa: S603 — controlled argv
|
||||
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
close_fds=True,
|
||||
)
|
||||
try:
|
||||
proc = subprocess.Popen( # noqa: S603 — controlled argv
|
||||
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
close_fds=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
finish_job(job_id, error=f"worker_start_failed:{exc}")
|
||||
_args_path(job_id).unlink(missing_ok=True)
|
||||
# The caller receives a pollable terminal job instead of an orphaned running record.
|
||||
return job_id
|
||||
with _LOCK:
|
||||
job = _read_job_disk(job_id) or job
|
||||
job["pid"] = proc.pid
|
||||
job["progress"] = {
|
||||
**(job.get("progress") or {}),
|
||||
"phase": "running",
|
||||
"message": f"worker pid={proc.pid}",
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
job["heartbeat_at"] = time.time()
|
||||
# A short computation can finish before Popen returns to the parent.
|
||||
# Keep its terminal progress/result intact.
|
||||
if job.get("status") in {"running", "cancelling"}:
|
||||
job["progress"] = {
|
||||
**(job.get("progress") or {}),
|
||||
"message": f"worker pid={proc.pid}",
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
job["heartbeat_at"] = time.time()
|
||||
_JOBS[job_id] = job
|
||||
_write_job_disk(job)
|
||||
return job_id
|
||||
|
|
|
|||
|
|
@ -144,7 +144,6 @@ _PARAM_KEYS = (
|
|||
ACTIONS = (
|
||||
"layout",
|
||||
"fix_overlaps",
|
||||
"resolve_overlaps", # alias of fix_overlaps
|
||||
"untangle",
|
||||
"straighten_channels",
|
||||
"layout_dual_unit",
|
||||
|
|
@ -328,8 +327,6 @@ def run_layout_on_graph(
|
|||
f"unknown_action:{action_key}; "
|
||||
f"allowed={','.join(ACTIONS)}"
|
||||
)
|
||||
if action_key == "resolve_overlaps":
|
||||
action_key = "fix_overlaps"
|
||||
base_params = build_params(preset=preset, overrides=params)
|
||||
st0 = build_state_from_nodes_edges(nodes, edges)
|
||||
park_phantom_nodes(st0)
|
||||
|
|
@ -899,14 +896,13 @@ def list_layout_catalog() -> dict[str, Any]:
|
|||
"actions": {
|
||||
"layout": "全图配方(骨架);块内 pack,结束时局部解叠",
|
||||
"fix_overlaps": "只拉开当前重叠点(+1 跳邻居),不重排全图",
|
||||
"resolve_overlaps": "fix_overlaps 别名",
|
||||
"straighten_channels": (
|
||||
"拉直 deg≤2 通道(弦/横/纵);仅全局交叉下降才接受该通道"
|
||||
"(params: step/min_len)"
|
||||
),
|
||||
"layout_dual_unit": (
|
||||
"双门户单元美化(多走廊→平行 H/V 道;链→拉直);"
|
||||
"单元内交叉必须为 0 才接受(params: unit_id)"
|
||||
"默认允许残留交叉;params.require_zero_cross=true 时要求为 0(params: unit_id)"
|
||||
),
|
||||
"untangle": (
|
||||
"贪心挪低度数点降交叉;默认 protect_rigid=portals "
|
||||
|
|
@ -963,7 +959,6 @@ def list_layout_catalog() -> dict[str, Any]:
|
|||
"默认 remove_from_source;对调两 view 即回迁;"
|
||||
"copy_positions|park;mode=preview|apply"
|
||||
),
|
||||
"sink_nodes": "move_nodes 别名",
|
||||
},
|
||||
"recipes": {
|
||||
"rings": "环+链花瓣;core_bar 时梁优先(多 CN 不塞进 AN 列)",
|
||||
|
|
|
|||
8
packages/netx-topology-mcp/tests/conftest.py
Normal file
8
packages/netx-topology-mcp/tests/conftest.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Keep layout-job persistence inside pytest temporary directories."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_layout_job_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("NETX_LAYOUT_JOB_DIR", str(tmp_path / "layout-jobs"))
|
||||
|
|
@ -16,6 +16,60 @@ from netx_topology_mcp.layout_jobs import (
|
|||
)
|
||||
|
||||
|
||||
def test_terminal_job_survives_cache_reset_and_reports_failure(tmp_path) -> None:
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
lj._write_job_disk({"job_id": "persisted", "status": "running", "started_at": time.time()})
|
||||
lj.finish_job("persisted", result={"ok": False, "error": "backend_write_failed", "applied": False})
|
||||
lj._JOBS.clear()
|
||||
out = _layout_topology_view({"action": "job_status", "params": {"job_id": "persisted"}})
|
||||
assert out["ok"] is False
|
||||
assert out["status"] == "error"
|
||||
assert out["error"] == "backend_write_failed"
|
||||
assert out["result"]["applied"] is False
|
||||
|
||||
|
||||
def test_spawn_failure_is_persisted_as_terminal_error() -> None:
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
with patch.object(lj.subprocess, "Popen", side_effect=OSError("spawn denied")):
|
||||
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
|
||||
lj._JOBS.clear()
|
||||
out = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
|
||||
assert out["ok"] is False
|
||||
assert out["status"] == "error"
|
||||
assert out["error"].startswith("worker_start_failed:")
|
||||
assert not lj._args_path(jid).exists()
|
||||
|
||||
|
||||
def test_fast_worker_terminal_progress_is_not_overwritten() -> None:
|
||||
from types import SimpleNamespace
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
def spawn(argv, **kwargs):
|
||||
jid = argv[-1]
|
||||
lj.finish_job(jid, result={"ok": True, "applied": True})
|
||||
return SimpleNamespace(pid=123)
|
||||
|
||||
with patch.object(lj.subprocess, "Popen", side_effect=spawn):
|
||||
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
|
||||
out = lj.job_public(jid)
|
||||
assert out["status"] == "done"
|
||||
assert out["progress"]["phase"] == "done"
|
||||
assert out["progress"]["pct"] == 100.0
|
||||
|
||||
|
||||
def test_pruning_preserves_active_jobs() -> None:
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
lj._write_job_disk({"job_id": "active", "status": "running"})
|
||||
lj._write_job_disk({"job_id": "completed", "status": "done"})
|
||||
with patch.object(lj, "_MAX_JOBS", 1):
|
||||
lj._prune_disk()
|
||||
assert lj._read_job_disk("active") is not None
|
||||
assert lj._read_job_disk("completed") is None
|
||||
|
||||
|
||||
def test_start_job_and_poll() -> None:
|
||||
def runner() -> dict:
|
||||
report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2)
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ def test_move_nodes_in_layout_schema_and_catalog() -> None:
|
|||
tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")
|
||||
enum = tool["inputSchema"]["properties"]["action"]["enum"]
|
||||
assert "move_nodes" in enum
|
||||
assert "sink_nodes" in enum
|
||||
assert "sink_nodes" not in enum
|
||||
cat = list_layout_catalog()
|
||||
assert "move_nodes" in cat["actions"]
|
||||
assert "sink_nodes" in cat["actions"]
|
||||
assert "sink_nodes" not in cat["actions"]
|
||||
|
||||
|
||||
def test_move_nodes_requires_ids_and_views() -> None:
|
||||
|
|
|
|||
263
packages/netx-topology-mcp/tests/test_topology_contract.py
Normal file
263
packages/netx-topology-mcp/tests/test_topology_contract.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"""HTTP contract and partial-failure regression tests; no backend required."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from netx_topology_mcp import http_tools as tools
|
||||
|
||||
|
||||
def payload(name, args):
|
||||
result = tools.call_http_tool(name, args)
|
||||
return result, json.loads(result["content"][0]["text"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("getTopologyTree", {}),
|
||||
("getTopologyView", {"view_id": "v"}),
|
||||
("createTopologyFolder", {"name": "测试"}),
|
||||
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
|
||||
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
|
||||
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
|
||||
("updateTopologyViewPositions", {"view_id": "v", "layout": "grid"}),
|
||||
("projectTopologyNeighbors", {"view_id": "v"}),
|
||||
("queryTopologyFabricNodes", {"mode": "list"}),
|
||||
("classifyTopologyFabricNodes", {"action": "apply_rules"}),
|
||||
("queryTopologyNeighborhood", {"node_id": "n"}),
|
||||
("queryTopologyEdges", {}),
|
||||
("layoutTopologyView", {"view_id": "v"}),
|
||||
("suggestSinkHubs", {"source_view_id": "s"}),
|
||||
("analyzeTopologyViewLayout", {"view_id": "v"}),
|
||||
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t"}),
|
||||
])
|
||||
def test_business_failure_is_mcp_error(name, args):
|
||||
with patch.object(tools, "http_json", return_value={
|
||||
"ok": True, "data": {"ok": False, "error": "backend_rejected"},
|
||||
}):
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is True, (name, data)
|
||||
assert data["ok"] is False
|
||||
assert data["error"] == "backend_rejected"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args,method", [
|
||||
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
|
||||
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
|
||||
("updateTopologyViewPositions", {"view_id": "v", "positions": [
|
||||
{"fabric_node_id": f"n{i}", "x": i, "y": 0} for i in range(501)
|
||||
]}, "PATCH"),
|
||||
])
|
||||
def test_chunk_failure_stops_later_mutations(name, args, method):
|
||||
with patch.object(tools, "http_json", side_effect=[
|
||||
{"ok": True, "data": {"ok": True, "added": 200, "updated": 200, "removed": 200}},
|
||||
{"ok": True, "data": {"ok": False, "error": "write_failed"}},
|
||||
]) as call:
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is True
|
||||
assert call.call_count == 2
|
||||
assert call.call_args.args[0] == method
|
||||
assert data["error"] == "write_failed"
|
||||
|
||||
|
||||
def graph(node="n"):
|
||||
return {"ok": True, "nodes": [{"fabric_node_id": node, "x": 1, "y": 2}], "edges": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
|
||||
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
|
||||
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
|
||||
])
|
||||
def test_position_failure_preserves_source(name, args):
|
||||
def http(method, path, **kwargs):
|
||||
if method == "GET":
|
||||
return {"ok": True, "data": graph() if path.endswith("/s") else {"nodes": [], "edges": []}}
|
||||
raise AssertionError(f"Unexpected mutation {method} {path}")
|
||||
|
||||
with patch.object(tools, "http_json", side_effect=http), patch.object(
|
||||
tools, "_ensure_view_max_nodes", return_value={"ok": True}
|
||||
), patch.object(tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}), patch.object(
|
||||
tools, "_patch_positions_chunked", return_value={"ok": False, "error": "patch_denied", "updated": 0}
|
||||
), patch.object(tools, "_fabric_bridges_into", return_value=[]), patch.object(
|
||||
tools, "_remove_topology_view_nodes"
|
||||
) as remove:
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is True, data
|
||||
assert data["error"] == "patch_denied"
|
||||
assert data["added"] == 1
|
||||
remove.assert_not_called()
|
||||
|
||||
|
||||
def test_move_capacity_error_stays_error():
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}), patch.object(
|
||||
tools, "_ensure_view_max_nodes", return_value={"ok": False, "error": "cap_denied"}
|
||||
), patch.object(tools, "_add_topology_view_nodes") as add:
|
||||
result, data = payload("layoutTopologyView", {
|
||||
"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply",
|
||||
"params": {"fabric_node_ids": ["n"]},
|
||||
})
|
||||
assert result.get("isError") is True
|
||||
assert data["error"] == "cap_denied"
|
||||
add.assert_not_called()
|
||||
|
||||
|
||||
def test_invalid_add_does_not_change_capacity():
|
||||
with patch.object(tools, "http_json") as http:
|
||||
result, _ = payload("addTopologyViewNodes", {"view_id": "v", "max_nodes": 2000})
|
||||
assert result.get("isError") is True
|
||||
http.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("getTopologyView", {"view_id": "v", "sample": 0}),
|
||||
("projectTopologyNeighbors", {"view_id": "v", "sample": 0, "dry_run": True}),
|
||||
])
|
||||
def test_zero_sample_and_projection_preview(name, args):
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}) as http:
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is not True
|
||||
assert data["sample_nodes"] == []
|
||||
if name == "projectTopologyNeighbors":
|
||||
assert http.call_args.kwargs["body"]["dry_run"] is True
|
||||
assert data["dry_run"] is True
|
||||
|
||||
|
||||
def test_invalid_projection_detail_does_not_write():
|
||||
with patch.object(tools, "http_json") as http:
|
||||
result, data = payload("projectTopologyNeighbors", {"view_id": "v", "detail": "raw"})
|
||||
assert result.get("isError") is True
|
||||
assert data["error"] == "detail_invalid"
|
||||
http.assert_not_called()
|
||||
|
||||
|
||||
def test_zero_updated_is_not_reported_as_successful_count():
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"ok": True, "updated": 0, "skipped_locked": 1}}):
|
||||
assert tools._patch_positions_chunked("v", [{"fabric_node_id": "n", "x": 1, "y": 2}])["updated"] == 0
|
||||
|
||||
|
||||
def test_apply_rules_uses_query_flags():
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"updated": 1}}) as http:
|
||||
payload("classifyTopologyFabricNodes", {"action": "apply_rules", "skip_manual": False, "fill_empty_only": True})
|
||||
assert http.call_args.args == ("POST", "/v1/topology/classify/apply")
|
||||
assert http.call_args.kwargs["params"] == {"skip_manual": False, "fill_empty_only": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["resolve_overlaps", "sink_nodes", "align_to_reference", "compose_views", "compose_orbit"])
|
||||
def test_removed_layout_actions_are_not_advertised_or_called(action):
|
||||
with patch.object(tools, "http_json") as http:
|
||||
result, data = payload("layoutTopologyView", {"view_id": "v", "action": action})
|
||||
assert result.get("isError") is True
|
||||
assert data["error"] == f"unknown_action:{action}"
|
||||
assert action not in data["actions"]
|
||||
schema = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")["inputSchema"]
|
||||
assert action not in schema["properties"]["action"]["enum"]
|
||||
http.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("queryTopologyFabricNodes", {"mode": "find"}),
|
||||
("classifyTopologyFabricNodes", {"action": "apply"}),
|
||||
("queryTopologyEdges", {"detail": "raw"}),
|
||||
])
|
||||
def test_removed_query_aliases_do_not_call_backend(name, args):
|
||||
with patch.object(tools, "http_json") as http:
|
||||
result, _ = payload(name, args)
|
||||
assert result.get("isError") is True
|
||||
http.assert_not_called()
|
||||
|
||||
|
||||
def test_explicit_background_layout_starts_worker():
|
||||
canvas = graph()
|
||||
canvas["nodes"].append({"fabric_node_id": "n2", "x": 100, "y": 200})
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": canvas}), patch.object(
|
||||
tools, "start_job", return_value="test-job"
|
||||
) as start, patch.object(tools, "job_public", return_value=None), patch.object(tools, "run_layout_on_graph") as compute:
|
||||
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
|
||||
assert result.get("isError") is not True
|
||||
assert data["job_id"] == "test-job"
|
||||
assert data["applied"] is False
|
||||
assert start.call_args.kwargs["tool_args"]["params"]["_force_sync"] is True
|
||||
compute.assert_not_called()
|
||||
|
||||
|
||||
def test_background_spawn_error_is_immediately_reported():
|
||||
from netx_topology_mcp import layout_jobs as jobs
|
||||
canvas = graph()
|
||||
canvas["nodes"].append({"fabric_node_id": "n2", "x": 100, "y": 200})
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": canvas}), patch.object(
|
||||
jobs.subprocess, "Popen", side_effect=OSError("worker unavailable")
|
||||
):
|
||||
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
|
||||
assert result.get("isError") is True
|
||||
assert data["status"] == "error"
|
||||
assert data["error"].startswith("worker_start_failed:")
|
||||
assert data["job_id"]
|
||||
|
||||
|
||||
def test_background_apply_poll_and_persistent_view_readback():
|
||||
"""Execute worker orchestration inline against an in-memory HTTP backend."""
|
||||
from types import SimpleNamespace
|
||||
from netx_topology_mcp import layout_jobs as jobs
|
||||
from netx_topology_mcp.layout_job_worker import main as worker_main
|
||||
|
||||
canvas = {"nodes": [
|
||||
{"fabric_node_id": "n", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "n2", "x": 100, "y": 200},
|
||||
], "edges": []}
|
||||
positions = [
|
||||
{"fabric_node_id": "n", "x": 20, "y": 30},
|
||||
{"fabric_node_id": "n2", "x": 220, "y": 330},
|
||||
]
|
||||
|
||||
def http(method, path, **kwargs):
|
||||
if method == "GET":
|
||||
return {"ok": True, "data": canvas}
|
||||
assert method == "PATCH" and path == "/v1/topology/views/v/positions"
|
||||
canvas["nodes"] = kwargs["body"]["positions"]
|
||||
return {"ok": True, "data": {"ok": True, "updated": 2}}
|
||||
|
||||
def spawn(argv, **kwargs):
|
||||
assert worker_main(["--job-id", argv[-1]]) == 0
|
||||
return SimpleNamespace(pid=123)
|
||||
|
||||
with patch.object(tools, "http_json", side_effect=http), patch.object(
|
||||
jobs.subprocess, "Popen", side_effect=spawn
|
||||
), patch.object(tools, "run_layout_on_graph", return_value={"ok": True, "positions": positions, "overlap": {}}):
|
||||
_, started = payload("layoutTopologyView", {
|
||||
"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True},
|
||||
})
|
||||
jobs._JOBS.clear()
|
||||
_, polled = payload("layoutTopologyView", {"action": "job_status", "params": {"job_id": started["job_id"]}})
|
||||
_, view = payload("getTopologyView", {"view_id": "v", "detail": "full"})
|
||||
assert polled["status"] == "done"
|
||||
assert polled["result"]["applied"] is True
|
||||
assert polled["result"]["updated"] == 2
|
||||
assert view["nodes"] == positions
|
||||
|
||||
|
||||
def test_hub_schema_matches_default_exclusion_behavior():
|
||||
tool = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "suggestSinkHubs")
|
||||
assert tool["inputSchema"]["properties"]["use_source_dual_portals"]["default"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
|
||||
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
|
||||
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
|
||||
])
|
||||
def test_failed_final_readback_reports_completed_writes(name, args):
|
||||
with patch.object(tools, "http_json", side_effect=[
|
||||
{"ok": True, "data": graph()}, {"ok": True, "data": {"nodes": [], "edges": []}},
|
||||
{"ok": False, "error": "read_timeout"}, {"ok": False, "error": "read_timeout"},
|
||||
]), patch.object(tools, "_ensure_view_max_nodes", return_value={"ok": True}), patch.object(
|
||||
tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}
|
||||
), patch.object(tools, "_patch_positions_chunked", return_value={"ok": True, "updated": 1}), patch.object(
|
||||
tools, "_remove_topology_view_nodes", return_value={"ok": True, "removed": 1}
|
||||
), patch.object(tools, "_fabric_bridges_into", return_value=[]):
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is True
|
||||
assert data["error"] == "view_readback_failed"
|
||||
assert data["write_completed"] is True
|
||||
Loading…
Add table
Add a link
Reference in a new issue