mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 02:50:48 +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
166
scripts/mcp_acceptance.py
Normal file
166
scripts/mcp_acceptance.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""通过真实 stdio MCP 留存本地验收证据;不安装插件、不导入 handler。
|
||||
|
||||
使用已有本机 MCP 配置中的凭据,只在子进程内存中传递,证据自动去敏。
|
||||
示例:python scripts/mcp_acceptance.py ops runNmsDiagnostics '{}'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GROUPS = {
|
||||
"ops": ("netx", "netx_mcp", "netx-mcp"),
|
||||
"topology": ("netx-topology", "netx_topology_mcp", "netx-topology-mcp"),
|
||||
"biz": ("netx-bizmonitor", "netx_bizmonitor_mcp", "netx-bizmonitor-mcp"),
|
||||
}
|
||||
SENSITIVE = re.compile(r"password|passwd|secret|token|authorization|cookie|private_key", re.I)
|
||||
|
||||
|
||||
def redact(value):
|
||||
if isinstance(value, dict):
|
||||
return {k: "[已去敏]" if SENSITIVE.search(k) else redact(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [redact(x) for x in value]
|
||||
if isinstance(value, str):
|
||||
# MCP 的 JSON 位于 content.text 内;递归处理,避免只净化外层。
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return re.sub(r"\bnxt_[\w-]+", "[已去敏]", value)
|
||||
if isinstance(decoded, (dict, list)):
|
||||
return json.dumps(redact(decoded), ensure_ascii=False)
|
||||
return value
|
||||
|
||||
|
||||
def payload(response):
|
||||
result = response.get("result", {})
|
||||
content = result.get("content", [])
|
||||
if content and content[0].get("type") == "text":
|
||||
try:
|
||||
return json.loads(content[0]["text"])
|
||||
except ValueError:
|
||||
pass
|
||||
return result or response.get("error", {})
|
||||
|
||||
|
||||
class McpSession:
|
||||
def __init__(self, group, config, evidence, source_root=ROOT):
|
||||
source_root = Path(source_root).resolve()
|
||||
server_id, module, package = GROUPS[group]
|
||||
cfg = json.loads(Path(config).read_text(encoding="utf-8-sig"))
|
||||
settings = cfg["mcpServers"][server_id].get("env", {})
|
||||
env = os.environ.copy()
|
||||
env.update({k: str(v) for k, v in settings.items()})
|
||||
# 只允许本机 netx;不能由配置悄悄指向生产服务。
|
||||
actual_url = (env.get("NETX_API_URL") or env.get("OCLAW_NETX_BASE_URL")
|
||||
or "http://127.0.0.1:8890").rstrip("/")
|
||||
if actual_url not in (
|
||||
"http://127.0.0.1:8890", "http://localhost:8890",
|
||||
"http://127.0.0.1:8891", "http://localhost:8891"
|
||||
):
|
||||
raise ValueError("验收仅允许本机 8890 API 或隔离样例 8891 API")
|
||||
env["NETX_API_URL"] = actual_url
|
||||
env.pop("OCLAW_NETX_BASE_URL", None)
|
||||
env["PYTHONPATH"] = str(source_root / "packages" / package / "src")
|
||||
env["PYTHONUTF8"] = "1"
|
||||
self.group, self.evidence, self.seq = group, Path(evidence), 0
|
||||
self.stderr = []
|
||||
self.proc = subprocess.Popen(
|
||||
[sys.executable, "-m", module], cwd=source_root, env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
encoding="utf-8", bufsize=1,
|
||||
)
|
||||
self.lines = queue.Queue()
|
||||
threading.Thread(target=self._read_stdout, daemon=True).start()
|
||||
threading.Thread(target=self._read_stderr, daemon=True).start()
|
||||
self.rpc("initialize", {"protocolVersion": "2024-11-05", "capabilities": {},
|
||||
"clientInfo": {"name": "netx-mcp-acceptance", "version": "1"}})
|
||||
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
|
||||
self.proc.stdin.flush()
|
||||
|
||||
def _read_stdout(self):
|
||||
for line in self.proc.stdout:
|
||||
self.lines.put(line)
|
||||
|
||||
def _read_stderr(self):
|
||||
for line in self.proc.stderr:
|
||||
self.stderr.append(line)
|
||||
|
||||
def rpc(self, method, params=None, timeout=180):
|
||||
self.seq += 1
|
||||
request = {"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params or {}}
|
||||
start = time.monotonic()
|
||||
self.proc.stdin.write(json.dumps(request, ensure_ascii=False) + "\n")
|
||||
self.proc.stdin.flush()
|
||||
deadline = start + timeout
|
||||
while True:
|
||||
try:
|
||||
line = self.lines.get(timeout=max(0.01, deadline - time.monotonic()))
|
||||
except queue.Empty as exc:
|
||||
raise TimeoutError(f"{self.group} {method} 超时;stderr={''.join(self.stderr)[-500:]}") from exc
|
||||
response = json.loads(line)
|
||||
if response.get("id") == self.seq:
|
||||
break
|
||||
if "id" in response:
|
||||
raise RuntimeError("服务器对通知回包或返回了错误的请求 id")
|
||||
record = {"at": dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(),
|
||||
"group": self.group, "request": request, "response": response,
|
||||
"elapsed_ms": round((time.monotonic() - start) * 1000)}
|
||||
self.evidence.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.evidence.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(redact(record), ensure_ascii=False) + "\n")
|
||||
return response
|
||||
|
||||
def call(self, name, args):
|
||||
if name == "execManagedNe":
|
||||
commands = list(args.get("commands") or [])
|
||||
for target in args.get("targets") or []:
|
||||
commands.extend(target.get("commands") or [])
|
||||
for cmd in commands:
|
||||
if not re.match(r"(?i)^(show|display)\s+", cmd) or re.search(r"[;\r\n`$&<>|]", cmd):
|
||||
raise ValueError("验收 CLI 只允许单行 show/display")
|
||||
return self.rpc("tools/call", {"name": name, "arguments": args})
|
||||
|
||||
def close(self):
|
||||
self.proc.stdin.close()
|
||||
try:
|
||||
self.proc.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.terminate()
|
||||
self.proc.wait(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("group", choices=GROUPS)
|
||||
parser.add_argument("tool", help="工具名,或 tools/list")
|
||||
parser.add_argument("arguments", nargs="?", default="{}")
|
||||
parser.add_argument("--config", default=str(ROOT / "mcp.all.json"))
|
||||
parser.add_argument("--source-root", default=str(ROOT), help="被验收包所在 worktree 根目录")
|
||||
parser.add_argument("--evidence", default=str(ROOT / "artifacts/mcp-evidence.jsonl"))
|
||||
opts = parser.parse_args()
|
||||
session = McpSession(opts.group, opts.config, opts.evidence, opts.source_root)
|
||||
try:
|
||||
if opts.tool == "tools/list":
|
||||
response = session.rpc("tools/list")
|
||||
names = [t["name"] for t in response.get("result", {}).get("tools", [])]
|
||||
print(json.dumps({"count": len(names), "tools": names}, ensure_ascii=False))
|
||||
else:
|
||||
response = session.call(opts.tool, json.loads(opts.arguments))
|
||||
print(json.dumps(redact(payload(response)), ensure_ascii=False))
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
264
scripts/seed_mcp_acceptance.py
Normal file
264
scripts/seed_mcp_acceptance.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""离线生成验收样例/非现场故障;评价结论必须来自真实 netx 业务逻辑。
|
||||
|
||||
用法(在 worktree 根目录,使用已有 netx Python 环境):
|
||||
python scripts/seed_mcp_acceptance.py --database <绝对路径>/artifacts/acceptance.sqlite \
|
||||
--metadata <绝对路径>/artifacts/acceptance.json
|
||||
|
||||
只接受本工人/组长 worktree artifacts 下带 acceptance 的新文件。
|
||||
不启动服务、不导入应用入口、不使用网络、不连接应用数据库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ALLOWED_WORKTREES = frozenset({"netx-mcp-bizmonitor-codex", "netx-mcp-lead"})
|
||||
DECLARATION = "验收样例/非现场故障:离线合成接口采集输入,真实执行 netx 业务评价;无真实设备或凭据。"
|
||||
PREFIX = "acceptance-"
|
||||
|
||||
|
||||
def artifact_path(raw: str, *, metadata: bool = False) -> Path:
|
||||
if ROOT.parent.name != "agent-wt" or ROOT.name not in ALLOWED_WORKTREES:
|
||||
raise ValueError("脚本必须位于允许的 agent-wt worktree 内")
|
||||
path = Path(raw)
|
||||
if not path.is_absolute() or "://" in raw:
|
||||
raise ValueError("必须指定 SQLite 文件绝对路径,不能使用数据库 URL")
|
||||
path = path.resolve()
|
||||
allowed = []
|
||||
for name in ALLOWED_WORKTREES:
|
||||
worktree = ROOT.parent / name
|
||||
base = worktree / "artifacts"
|
||||
# Reparse points/symlinks must not turn the allowlist into an external directory.
|
||||
if worktree.resolve() != worktree or base.resolve() != base:
|
||||
continue
|
||||
allowed.append(base)
|
||||
if not any(path.is_relative_to(base) for base in allowed):
|
||||
raise ValueError("仅允许本工人/组长 worktree 的 artifacts 目录")
|
||||
suffixes = {".json"} if metadata else {".sqlite", ".sqlite3", ".db"}
|
||||
if "acceptance" not in path.name.lower() or path.suffix.lower() not in suffixes:
|
||||
raise ValueError("文件名必须含 acceptance,扩展名须为 SQLite 文件或元数据 JSON")
|
||||
if path.exists():
|
||||
raise ValueError("目标已存在,拒绝覆盖;请使用新的验收文件名")
|
||||
return path
|
||||
|
||||
|
||||
def _offline_audit(event: str, _args: tuple[Any, ...]) -> None:
|
||||
if event in {"socket.connect", "socket.getaddrinfo", "socket.bind"}:
|
||||
raise RuntimeError("离线样例脚本禁止网络/监听端口")
|
||||
|
||||
|
||||
def seed(database: Path) -> dict[str, Any]:
|
||||
# Environment must precede imports; explicit sys.path wins over editable main repo.
|
||||
if any(name == "netx_api" or name.startswith("netx_api.") for name in sys.modules):
|
||||
raise RuntimeError("必须通过独立 Python 进程运行,不能复用已导入 netx_api 的进程")
|
||||
url = "sqlite:///" + database.as_posix()
|
||||
os.environ["NETX_DATABASE_URL"] = url
|
||||
os.environ["NETX_CREDENTIAL_SECRET_FILE"] = str(database.with_suffix(".unused-secret"))
|
||||
for setting in ("NE_COLLECT_SCHEDULER_ENABLED", "CONFIG_SYNC_SCHEDULER_ENABLED",
|
||||
"LLDP_COLLECT_SCHEDULER_ENABLED", "FABRIC_RECONCILE_SCHEDULER_ENABLED",
|
||||
"PORT_TRAFFIC_SCHEDULER_ENABLED", "BIZ_STATE_SCHEDULER_ENABLED",
|
||||
"UME_KEEPALIVE_ENABLED", "UME_ALARM_WS_ENABLED",
|
||||
"UME_SYNC_ALARMS_CURRENT_ENABLED", "UME_SYNC_INVENTORY_AUTO_ENABLED",
|
||||
"UME_SYNC_TOPOLOGY_AUTO_ENABLED"):
|
||||
os.environ["NETX_" + setting] = "false"
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.addaudithook(_offline_audit)
|
||||
|
||||
import netx_api
|
||||
from sqlalchemy.engine import make_url
|
||||
from netx_api.db import Base, SessionLocal, engine
|
||||
from netx_api.biz_migration import service as mig
|
||||
from netx_api.biz_state import service as biz
|
||||
from netx_api.models import (
|
||||
BizCompareTemplate, BizMigrationProject, BizMonitorTemplate,
|
||||
BizPortMapping, BizPortMappingRow, BizStateBatch, BizStateBatchCommand,
|
||||
BizStateMetricRow, BizStateTask, BizStateTaskItem,
|
||||
)
|
||||
|
||||
if Path(netx_api.__file__).resolve().parent != ROOT / "netx_api":
|
||||
raise RuntimeError("netx_api 未从当前 worktree 导入,拒绝运行")
|
||||
if engine.url != make_url(url):
|
||||
raise RuntimeError("数据库配置未指向隔离 SQLite 文件,拒绝运行")
|
||||
Base.metadata.create_all(engine)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
ids = {
|
||||
"project_id": PREFIX + "project",
|
||||
"old_portrait_task_id": PREFIX + "old-portrait",
|
||||
"new_portrait_task_id": PREFIX + "new-portrait",
|
||||
"old_current_task_id": PREFIX + "old-current",
|
||||
"new_current_task_id": PREFIX + "new-current",
|
||||
}
|
||||
batch_ids: dict[str, str] = {}
|
||||
command_ids: dict[str, str] = {}
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
sheet = {"sheet_id": "interface_brief", "title": "验收样例接口",
|
||||
"metric_id": "interface_brief", "key_fields": ["interface"],
|
||||
"iface_fields": ["interface"], "compare_fields": ["admin", "phy", "prot"],
|
||||
"row_filters": [], "field_rules": []}
|
||||
db.add(BizCompareTemplate(id=PREFIX + "compare", name="验收样例/非现场故障-接口对比",
|
||||
metric_id="interface_brief", key_fields=["interface"],
|
||||
iface_fields=["interface"], compare_fields=["admin", "phy", "prot"],
|
||||
metrics_json=[sheet], iface_normalize_json=[{"from": "GE", "to": "gei"}]))
|
||||
db.add(BizMonitorTemplate(id=PREFIX + "monitor", name="验收样例/非现场故障-接口监控",
|
||||
compare_template_id=PREFIX + "compare",
|
||||
collect_metric_ids_json=["interface_brief"],
|
||||
defaults_json={"out_of_expect": "strict"}, sheet_overrides_json=[{
|
||||
"metric_id": "interface_brief", "sheet_id": "interface_brief",
|
||||
"status_fields": ["admin", "phy", "prot"],
|
||||
"down_values": ["down"], "up_values": ["up"],
|
||||
"success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}],
|
||||
"anomaly": [],
|
||||
}]))
|
||||
db.add(BizPortMapping(id=PREFIX + "mapping", name="验收样例端口映射", note=DECLARATION))
|
||||
db.add(BizPortMappingRow(id=PREFIX + "mapping-row", mapping_id=PREFIX + "mapping",
|
||||
before_if="gei-old", after_if="xgei-new"))
|
||||
for side in ("old", "new"):
|
||||
for mode in ("portrait", "current"):
|
||||
tid = ids[f"{side}_{mode}_task_id"]
|
||||
task = BizStateTask(
|
||||
id=tid, source="import", ne_id=PREFIX + side + "-offline",
|
||||
ne_name=f"验收样例-{'旧' if side == 'old' else '新'}侧(非现场)", ne_ip="",
|
||||
vendor="zte", device_type="router", note=DECLARATION,
|
||||
purpose="portrait" if mode == "portrait" else "cutover_hf",
|
||||
status="draft", collect_manual_only=True, collect_running=False,
|
||||
interval_sec=3600, retention_days=30,
|
||||
)
|
||||
db.add(task)
|
||||
db.add(BizStateTaskItem(id=PREFIX + side + "-" + mode + "-item", task_id=tid,
|
||||
source_profile_id="zte.interface_brief", kind="catalog",
|
||||
enabled=False, title="验收样例离线输入;禁用采集"))
|
||||
bid = PREFIX + side + "-" + mode + "-collect"
|
||||
cid = PREFIX + side + "-" + mode + "-command"
|
||||
batch_ids[f"{side}_{mode}"] = bid
|
||||
command_ids[f"{side}_{mode}"] = cid
|
||||
at = now - timedelta(minutes=10) if mode == "portrait" else now - timedelta(seconds=2)
|
||||
interface = "GE-old" if side == "old" else "xgei-new"
|
||||
state = "up" if side == "old" else "down"
|
||||
raw = ("# 验收样例/非现场故障;离线合成输出\n"
|
||||
"show interface brief\nInterface Admin Phy Prot\n"
|
||||
f"{interface} {state} {state} {state}\n")
|
||||
db.add(BizStateBatch(id=bid, task_id=tid, source="import", ne_id=task.ne_id,
|
||||
ne_name=task.ne_name, vendor="zte", status="success",
|
||||
started_at=at, ended_at=at + timedelta(seconds=1),
|
||||
row_count=1, command_count=1, message=DECLARATION,
|
||||
is_baseline=mode == "portrait"))
|
||||
db.add(BizStateBatchCommand(id=cid, batch_id=bid,
|
||||
profile_id="zte.interface_brief", parser_id="zte.interface_brief",
|
||||
metric_id="interface_brief", raw_command="show interface brief",
|
||||
parse_status="ok", row_count=1, raw_line_count=len(raw.splitlines()),
|
||||
raw_text=raw, message=DECLARATION, created_at=at))
|
||||
db.add(BizStateMetricRow(id=PREFIX + side + "-" + mode + "-row", batch_id=bid,
|
||||
batch_command_id=cid, task_id=tid, ne_id=task.ne_id,
|
||||
metric_id="interface_brief", seq=0, collected_at=at,
|
||||
data_json={"interface": interface, "admin": state, "phy": state, "prot": state}))
|
||||
db.add(BizMigrationProject(
|
||||
id=ids["project_id"], name="验收样例/非现场故障-旧口仍UP新口DOWN", note=DECLARATION,
|
||||
old_task_id=ids["old_portrait_task_id"], new_task_id=ids["new_portrait_task_id"],
|
||||
old_hf_task_id=ids["old_current_task_id"], new_hf_task_id=ids["new_current_task_id"],
|
||||
old_baseline_batch_id=batch_ids["old_portrait"], new_baseline_batch_id=batch_ids["new_portrait"],
|
||||
mapping_id=PREFIX + "mapping", monitor_template_id=PREFIX + "monitor",
|
||||
collect_metric_ids_json=["interface_brief"], status="draft", hf_interval_sec=3600,
|
||||
))
|
||||
db.commit()
|
||||
cutover = mig.create_batch(db, ids["project_id"], {
|
||||
"batch_label": "验收样例/非现场故障-第一批", "status": "active",
|
||||
"expect_set": {"ports": ["gei-old"]}, "note": DECLARATION,
|
||||
})
|
||||
# This executes real run_evaluate and real red-ticket persistence.
|
||||
finished = mig.finish_batch(db, cutover["id"], mark_done=False)
|
||||
run_id = finished["run"]["id"]
|
||||
diffs = mig.list_run_diffs(db, run_id, color="red", limit=50)
|
||||
reds = mig.list_red_tickets(db, ids["project_id"])
|
||||
if diffs["total"] < 1 or reds["open_count"] < 1 or finished["accept_summary"]["passed"]:
|
||||
raise RuntimeError("真实评价未生成预期红项,样例验证失败")
|
||||
item = diffs["items"][0]
|
||||
if item["old_key"] != "GE-old" or item["new_key"] != "xgei-new":
|
||||
raise RuntimeError("缺少原始 A/B 键,样例验证失败")
|
||||
evidence = item.get("evidence") or {}
|
||||
raw_checks: dict[str, Any] = {}
|
||||
for side in ("old", "new"):
|
||||
ev = evidence.get(side) or {}
|
||||
command = ev.get("command") or {}
|
||||
collect = ev.get("collect") or {}
|
||||
if command.get("raw_command") != "show interface brief" or not collect.get("collected_at"):
|
||||
# Some evidence versions name the timestamp at batch granularity.
|
||||
if command.get("raw_command") != "show interface brief" or not any(collect.get(k) for k in ("started_at", "ended_at", "batch_started_at")):
|
||||
raise RuntimeError("双侧命令/采集时间证据缺失")
|
||||
bid = collect["batch_id"]
|
||||
cid = command.get("id") or command.get("command_id")
|
||||
if not cid:
|
||||
raise RuntimeError("证据没有可请求的命令 ID")
|
||||
raw = biz.get_batch_command(db, bid, cid)
|
||||
if "验收样例/非现场故障" not in raw["raw_text"]:
|
||||
raise RuntimeError("命令原文回读失败")
|
||||
raw_checks[side] = {"batch_id": bid, "command_id": cid, "raw_text": raw["raw_text"],
|
||||
"collected_at": raw["collected_at"]}
|
||||
# Read paths behind all eight MCP tools; HTTP/stdio is the lead's next step.
|
||||
assert mig.list_projects(db) and biz.list_tasks(db)
|
||||
assert mig.list_batches(db, ids["project_id"]) and biz.list_batches(db, ids["old_current_task_id"])
|
||||
assert mig.list_runs(db, cutover["id"])
|
||||
assert mig.get_monitor_context(db, project_id=ids["project_id"])
|
||||
assert mig.board(db, cutover["id"], run_id=run_id)
|
||||
assert biz.get_batch(db, batch_ids["new_current"])["commands"]
|
||||
tasks = db.query(BizStateTask).all()
|
||||
if not all(t.status == "draft" and t.collect_manual_only and not t.collect_running for t in tasks):
|
||||
raise RuntimeError("样例任务安全状态检查失败")
|
||||
db.commit()
|
||||
return {
|
||||
"declaration": DECLARATION, "database": str(database), "database_url": url,
|
||||
"source_root": str(ROOT), "created_at": now.isoformat() + "Z", **ids,
|
||||
"cutover_batch_id": cutover["id"], "run_id": run_id,
|
||||
"collect_batch_ids": batch_ids, "command_ids": command_ids,
|
||||
"expected": {"scenario": "旧侧接口仍UP,新侧映射接口DOWN,割接验收未完成,应产生红项",
|
||||
"old_key": "GE-old", "new_key": "xgei-new", "minimum_red_count": 1},
|
||||
"observed": {"red_diff_count": diffs["total"], "open_red_count": reds["open_count"],
|
||||
"verdict": item["verdict"], "color": item["color"],
|
||||
"accept_status": finished["batch"]["accept_status"],
|
||||
"accept_summary": finished["accept_summary"], "raw_evidence": raw_checks},
|
||||
"safety": {"network_blocked": True, "task_status": "draft", "manual_only": True,
|
||||
"task_items_enabled": False, "device_credentials": False, "real_device_ip": False},
|
||||
}
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--database", required=True, help="artifacts 内新 SQLite 文件的绝对路径,文件名含 acceptance")
|
||||
parser.add_argument("--metadata", help="artifacts 内新 JSON 文件的绝对路径,文件名含 acceptance")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
database = artifact_path(args.database)
|
||||
metadata = artifact_path(args.metadata, metadata=True) if args.metadata else None
|
||||
database.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Exclusive creation prevents overwrite, including races after validation.
|
||||
with database.open("xb"):
|
||||
pass
|
||||
result = seed(database)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
if metadata:
|
||||
metadata.parent.mkdir(parents=True, exist_ok=True)
|
||||
with metadata.open("x", encoding="utf-8") as output:
|
||||
output.write(text + "\n")
|
||||
print(text)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
# Known local validation messages are safe; other failures omit internals.
|
||||
message = str(exc) if isinstance(exc, (ValueError, RuntimeError)) else type(exc).__name__
|
||||
print(json.dumps({"ok": False, "error": message, "declaration": DECLARATION}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue