mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 04:00:46 +08:00
3002 lines
110 KiB
JavaScript
3002 lines
110 KiB
JavaScript
// src/netx/capability-groups.ts
|
||
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
|
||
ops: Object.freeze({ inPreset: true, public: false }),
|
||
topology: Object.freeze({ inPreset: false, public: false }),
|
||
bizMonitor: Object.freeze({ inPreset: true, public: false })
|
||
});
|
||
var TOOLS_BY_GROUP = Object.freeze({
|
||
ops: Object.freeze([
|
||
"netx__queryNmsAlarms",
|
||
"netx__aggregateNmsAlarms",
|
||
"netx__runNmsDiagnostics",
|
||
"netx__queryNmsNeInventory",
|
||
"netx__getNmsNe",
|
||
"netx__queryNmsAlarmsRaw",
|
||
"netx__aggregateNmsAlarmsRaw",
|
||
"netx__listNmsAlarmFields",
|
||
"netx__sqlQueryNms",
|
||
"netx__listManagedNe",
|
||
"netx__getManagedNe",
|
||
"netx__execManagedNe",
|
||
"netx__listCliTargets",
|
||
"netx__findTopologyPaths"
|
||
]),
|
||
topology: Object.freeze([
|
||
"netx__getTopologyTree",
|
||
"netx__getTopologyView",
|
||
"netx__createTopologyFolder",
|
||
"netx__addTopologyViewNodes",
|
||
"netx__removeTopologyViewNodes",
|
||
"netx__copyTopologyViewNodes",
|
||
"netx__updateTopologyViewPositions",
|
||
"netx__projectTopologyNeighbors",
|
||
"netx__queryTopologyFabricNodes",
|
||
"netx__classifyTopologyFabricNodes",
|
||
"netx__queryTopologyNeighborhood",
|
||
"netx__queryTopologyEdges",
|
||
"netx__layoutTopologyView",
|
||
"netx__suggestSinkHubs",
|
||
"netx__analyzeTopologyViewLayout",
|
||
"netx__sinkTopologyDualUnits"
|
||
]),
|
||
bizMonitor: Object.freeze([
|
||
"netx__listBizMonitors",
|
||
"netx__listBizMonitorBatches",
|
||
"netx__getBizMonitorContext",
|
||
"netx__getBizMonitorBoard",
|
||
"netx__listBizMonitorReds",
|
||
"netx__getBizMonitorDiffs",
|
||
"netx__getBizCollectBatch",
|
||
"netx__getBizCollectCommandRaw"
|
||
])
|
||
});
|
||
var SKILL_DIR_BY_GROUP = Object.freeze({
|
||
ops: "ops",
|
||
topology: "topology",
|
||
bizMonitor: "biz-monitor"
|
||
});
|
||
var CAPABILITY_GROUP_IDS = Object.freeze([
|
||
"ops",
|
||
"topology",
|
||
"bizMonitor"
|
||
]);
|
||
function groupsForPlane(groups, plane, only) {
|
||
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
|
||
const enabled = CAPABILITY_GROUP_IDS.filter((id) => plane === "preset" ? policy[id].inPreset : policy[id].public);
|
||
if (!only || only.length === 0)
|
||
return enabled;
|
||
const allow = new Set(only);
|
||
return enabled.filter((id) => allow.has(id));
|
||
}
|
||
function groupsForced(only) {
|
||
const normalized = only.map((id) => id === "nms" || id === "common" ? "ops" : id);
|
||
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
|
||
}
|
||
function toolNamesForGroups(groupIds) {
|
||
const out = new Set;
|
||
for (const id of groupIds) {
|
||
for (const name of TOOLS_BY_GROUP[id] ?? [])
|
||
out.add(name);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// src/netx/group-skills.ts
|
||
import { existsSync } from "node:fs";
|
||
import { readdir, readFile, stat } from "node:fs/promises";
|
||
import { dirname, join } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
function opsSkillsRoot() {
|
||
const envRoot = process.env.NETX_SKILLS_ROOT?.trim();
|
||
if (envRoot && existsSync(envRoot))
|
||
return envRoot;
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const siblingCandidates = [
|
||
join(here, "..", "..", "..", "netx", "skills"),
|
||
join(here, "..", "..", "netx", "skills")
|
||
];
|
||
for (const candidate of siblingCandidates) {
|
||
if (existsSync(candidate))
|
||
return candidate;
|
||
}
|
||
const packaged = [
|
||
join(here, "..", "presets", "netxops", "skills"),
|
||
join(here, "..", "..", "presets", "netxops", "skills")
|
||
];
|
||
for (const candidate of packaged) {
|
||
if (existsSync(candidate))
|
||
return candidate;
|
||
}
|
||
return packaged[0];
|
||
}
|
||
function parseFrontmatter(raw) {
|
||
if (!raw.startsWith("---"))
|
||
return;
|
||
const end = raw.indexOf(`
|
||
---`, 3);
|
||
if (end < 0)
|
||
return;
|
||
const yaml = raw.slice(3, end).replace(/^\r?\n/, "");
|
||
const body = raw.slice(end + 4).replace(/^\r?\n/, "");
|
||
const data = {};
|
||
const lines = yaml.split(/\r?\n/);
|
||
for (let i = 0;i < lines.length; i += 1) {
|
||
const line = lines[i];
|
||
const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line);
|
||
if (nameMatch) {
|
||
data.name = stripQuotes(nameMatch[1].trim());
|
||
continue;
|
||
}
|
||
const descMatch = /^description\s*:\s*(.*)$/.exec(line);
|
||
if (!descMatch)
|
||
continue;
|
||
const head = descMatch[1].trim();
|
||
if (head === ">" || head === ">-" || head === "|" || head === "|-") {
|
||
const parts = [];
|
||
while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) {
|
||
i += 1;
|
||
parts.push(lines[i].trim());
|
||
}
|
||
data.description = parts.filter(Boolean).join(" ");
|
||
continue;
|
||
}
|
||
data.description = stripQuotes(head);
|
||
}
|
||
return { data, body };
|
||
}
|
||
function stripQuotes(value) {
|
||
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
||
return value.slice(1, -1);
|
||
}
|
||
return value;
|
||
}
|
||
async function loadSkillBundle(dir) {
|
||
const skillPath = join(dir, "SKILL.md");
|
||
let raw;
|
||
try {
|
||
raw = await readFile(skillPath, "utf8");
|
||
} catch {
|
||
return null;
|
||
}
|
||
const parsed = parseFrontmatter(raw);
|
||
if (!parsed)
|
||
return null;
|
||
const name = typeof parsed.data.name === "string" ? parsed.data.name.trim() : "";
|
||
const description = typeof parsed.data.description === "string" ? parsed.data.description.trim() : "";
|
||
if (!name || !description)
|
||
return null;
|
||
return {
|
||
name,
|
||
description,
|
||
content: parsed.body.trimStart(),
|
||
path: skillPath,
|
||
directory: dir
|
||
};
|
||
}
|
||
async function loadGroupSkills(skillsRoot, groupId) {
|
||
const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId]);
|
||
let entries;
|
||
try {
|
||
entries = await readdir(groupDir);
|
||
} catch {
|
||
return [];
|
||
}
|
||
const skills = [];
|
||
for (const entry of entries) {
|
||
const full = join(groupDir, entry);
|
||
let isDir = false;
|
||
try {
|
||
isDir = (await stat(full)).isDirectory();
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (!isDir)
|
||
continue;
|
||
const skill = await loadSkillBundle(full);
|
||
if (skill)
|
||
skills.push(skill);
|
||
}
|
||
return skills;
|
||
}
|
||
async function registerGroupSkills(ctx, groupIds, providerLabel) {
|
||
const skillsApi = ctx.skills;
|
||
if (!skillsApi || typeof skillsApi.register !== "function") {
|
||
return () => {};
|
||
}
|
||
const root = opsSkillsRoot();
|
||
const disposers = [];
|
||
const enabled = new Set(groupIds);
|
||
for (const groupId of CAPABILITY_GROUP_IDS) {
|
||
if (!enabled.has(groupId))
|
||
continue;
|
||
const skills = await loadGroupSkills(root, groupId);
|
||
for (const skill of skills) {
|
||
disposers.push(skillsApi.register({
|
||
name: skill.name,
|
||
description: skill.description,
|
||
content: skill.content,
|
||
path: skill.path,
|
||
resourceBase: { kind: "directory", path: skill.directory },
|
||
provider: providerLabel,
|
||
source: "custom"
|
||
}));
|
||
}
|
||
}
|
||
return () => {
|
||
for (const dispose of disposers)
|
||
dispose();
|
||
};
|
||
}
|
||
|
||
// src/netx/kb-local-path.ts
|
||
import { existsSync as existsSync2, realpathSync, statSync } from "node:fs";
|
||
import { join as join2, relative, resolve, sep } from "node:path";
|
||
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
|
||
"memories",
|
||
"drafts",
|
||
"suggestions",
|
||
"refs"
|
||
]);
|
||
var REF_AREAS = Object.freeze([
|
||
"inventory",
|
||
"devices",
|
||
"topology",
|
||
"business",
|
||
"commands",
|
||
"handbooks"
|
||
]);
|
||
var MEMORY_BUCKET_DIR = Object.freeze({
|
||
note: "日常笔记",
|
||
rca_review: "排障复盘",
|
||
ai_trace: "AI思维链"
|
||
});
|
||
function resolveKbLocalRoot(snapshot) {
|
||
if (snapshot.status !== "configured")
|
||
return null;
|
||
const rel = snapshot.paths.local?.trim();
|
||
if (!rel)
|
||
return null;
|
||
return resolve(snapshot.realRoot, rel);
|
||
}
|
||
function kbLocalToolsEnabled(snapshot) {
|
||
return resolveKbLocalRoot(snapshot) !== null;
|
||
}
|
||
function sanitizeSlug(raw, fallback = "entry") {
|
||
const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||
return cleaned || fallback;
|
||
}
|
||
function todayIsoDate(now = new Date) {
|
||
const y = now.getFullYear();
|
||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||
const d = String(now.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
function toCompactDate(isoOrEmpty, now = new Date) {
|
||
const iso = (isoOrEmpty?.trim() || todayIsoDate(now)).replace(/-/g, "");
|
||
if (!/^\d{8}$/.test(iso)) {
|
||
throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${isoOrEmpty}`);
|
||
}
|
||
return iso;
|
||
}
|
||
function normalizeIsoDate(raw, now = new Date) {
|
||
const t = raw?.trim();
|
||
if (!t)
|
||
return todayIsoDate(now);
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(t))
|
||
return t;
|
||
if (/^\d{8}$/.test(t)) {
|
||
return `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}`;
|
||
}
|
||
throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${raw}`);
|
||
}
|
||
function memoryFileName(opts) {
|
||
const date = normalizeIsoDate(opts.date, opts.now);
|
||
return `${date}-${sanitizeSlug(opts.slug)}.md`;
|
||
}
|
||
function draftFileName(opts) {
|
||
const compact = toCompactDate(opts.date, opts.now);
|
||
const slug = sanitizeSlug(opts.slug);
|
||
const base = slug.toUpperCase().startsWith("DRAFT-") ? slug : `DRAFT-${compact}-${slug}`;
|
||
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
|
||
}
|
||
function suggestionFileName(opts) {
|
||
const date = normalizeIsoDate(opts.date, opts.now);
|
||
return `${date}-${sanitizeSlug(opts.slug)}.md`;
|
||
}
|
||
function refFileName(slug) {
|
||
const base = sanitizeSlug(slug, "note");
|
||
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
|
||
}
|
||
function resolveWritableLocalPath(localRoot, ...segments) {
|
||
if (segments.some((s) => s.includes("\x00"))) {
|
||
throw new Error("path segment contains NUL");
|
||
}
|
||
const absolutePath = resolve(localRoot, ...segments);
|
||
const rel = relative(localRoot, absolutePath);
|
||
if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) {
|
||
throw new Error(`path escapes local root: ${absolutePath}`);
|
||
}
|
||
const top = rel.split(/[/\\]/)[0];
|
||
if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) {
|
||
throw new Error(`writes only allowed under ${KB_LOCAL_WRITABLE_ROOTS.join("|")}/ (got ${top}/)`);
|
||
}
|
||
return {
|
||
absolutePath,
|
||
relativePath: rel.split(sep).join("/"),
|
||
writableRoot: top
|
||
};
|
||
}
|
||
function resolveExistingWritableFile(localRoot, pathArg) {
|
||
const trimmed = pathArg.trim();
|
||
if (!trimmed)
|
||
throw new Error("path is empty");
|
||
const candidate = resolve(trimmed);
|
||
let absolutePath;
|
||
const localResolved = resolve(localRoot);
|
||
const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep);
|
||
if (underLocal) {
|
||
absolutePath = candidate;
|
||
} else {
|
||
absolutePath = resolve(localRoot, trimmed);
|
||
}
|
||
const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean));
|
||
try {
|
||
if (existsSync2(checked.absolutePath)) {
|
||
const realFile = realpathSync(checked.absolutePath);
|
||
const realLocal = realpathSync(localRoot);
|
||
const realRel = relative(realLocal, realFile);
|
||
if (!realRel || realRel.startsWith("..") || realRel.split(/[/\\]/).includes("..")) {
|
||
throw new Error(`path escapes local root after realpath: ${realFile}`);
|
||
}
|
||
const top = realRel.split(/[/\\]/)[0];
|
||
if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) {
|
||
throw new Error(`path not under writable roots: ${realRel}`);
|
||
}
|
||
if (!statSync(realFile).isFile()) {
|
||
throw new Error(`not a file: ${realFile}`);
|
||
}
|
||
return {
|
||
absolutePath: realFile,
|
||
relativePath: realRel.split(sep).join("/"),
|
||
writableRoot: top
|
||
};
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof Error && /escapes|writable|not a file/.test(error.message)) {
|
||
throw error;
|
||
}
|
||
}
|
||
return checked;
|
||
}
|
||
function memoryDir(bucket) {
|
||
return join2("memories", MEMORY_BUCKET_DIR[bucket]);
|
||
}
|
||
function suggestionDir(kind) {
|
||
return join2("suggestions", kind);
|
||
}
|
||
function draftDir(domain) {
|
||
const d = domain?.trim();
|
||
if (!d)
|
||
return "drafts";
|
||
return join2("drafts", sanitizeSlug(d, "misc"));
|
||
}
|
||
function refDir(area, device) {
|
||
if (!REF_AREAS.includes(area)) {
|
||
throw new Error(`invalid refs area: ${area}`);
|
||
}
|
||
if (area === "devices") {
|
||
const host = sanitizeSlug(device?.trim() || "", "");
|
||
if (!host) {
|
||
throw new Error("refs/devices requires device (host_name)");
|
||
}
|
||
return join2("refs", "devices", host);
|
||
}
|
||
return join2("refs", area);
|
||
}
|
||
|
||
// src/netx/kb-context-skill.ts
|
||
var SKILL_NAME = "kb-context";
|
||
function skillBody(snapshot) {
|
||
if (snapshot.status === "configured") {
|
||
const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)";
|
||
const localRoot = resolveKbLocalRoot(snapshot);
|
||
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
|
||
const localGuide = localRoot ? [
|
||
"",
|
||
"### Dual plane (mandatory)",
|
||
"",
|
||
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
|
||
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
|
||
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
|
||
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
|
||
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
|
||
"",
|
||
"### refs — site product knowledge (evolve every task)",
|
||
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
|
||
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
|
||
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
|
||
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
|
||
"",
|
||
"### memories — write immediately when valuable",
|
||
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
|
||
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
|
||
] : [
|
||
"",
|
||
"No local write tools until `paths.local` is present in MANIFEST."
|
||
];
|
||
return {
|
||
description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).",
|
||
content: [
|
||
"## Knowledge base (configured)",
|
||
"",
|
||
"When troubleshooting with operator playbooks or docs, **compose paths from this root**.",
|
||
"Do not invent another operator or country.",
|
||
"",
|
||
`| Field | Value |`,
|
||
`| --- | --- |`,
|
||
`| kbStatus | configured |`,
|
||
`| kbRoot | \`${snapshot.realRoot}\` |`,
|
||
localRow,
|
||
`| kbOperator | ${snapshot.operatorName} |`,
|
||
`| kbCountry | ${snapshot.country} |`,
|
||
`| kbVersion | ${snapshot.version} |`,
|
||
`| kbContent (on) | ${flags} |`,
|
||
...localGuide,
|
||
"",
|
||
"Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.",
|
||
"",
|
||
"Business playbooks (kb-troubleshoot, kb-retrieve, …) register from",
|
||
`${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`,
|
||
"plus paths.localSkills when present — use those skills for KB triage;",
|
||
"keep netx-ops for live netx evidence only."
|
||
].join(`
|
||
`)
|
||
};
|
||
}
|
||
const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty";
|
||
return {
|
||
description: "Knowledge-base context is unavailable — stay on pure netx evidence.",
|
||
content: [
|
||
"## Knowledge base (unavailable)",
|
||
"",
|
||
`kbStatus=${snapshot.status}. ${reason}`,
|
||
"",
|
||
"**Do not invent an operator, country, or KB paths.**",
|
||
"Use only netx tools / live evidence (alarms, inventory, CLI, topology).",
|
||
"Operator-specific playbooks are out of scope until a valid MANIFEST is configured."
|
||
].join(`
|
||
`)
|
||
};
|
||
}
|
||
function registerKbContextSkill(ctx, snapshot) {
|
||
const skillsApi = ctx.skills;
|
||
if (!skillsApi || typeof skillsApi.register !== "function") {
|
||
return () => {};
|
||
}
|
||
const body = skillBody(snapshot);
|
||
return skillsApi.register({
|
||
name: SKILL_NAME,
|
||
description: body.description,
|
||
content: body.content,
|
||
provider: "netxops-kb",
|
||
source: "custom"
|
||
});
|
||
}
|
||
|
||
// src/netx/kb-local-tools.ts
|
||
import { defineTool } from "@deepseek-ai/dsh-tools";
|
||
|
||
// src/netx/kb-local-ops.ts
|
||
import {
|
||
existsSync as existsSync3,
|
||
mkdirSync,
|
||
readdirSync,
|
||
readFileSync,
|
||
rmSync,
|
||
statSync as statSync2,
|
||
writeFileSync
|
||
} from "node:fs";
|
||
import { dirname as dirname2, join as join3 } from "node:path";
|
||
function requireLocalRoot(snapshot) {
|
||
const root = resolveKbLocalRoot(snapshot);
|
||
if (!root) {
|
||
throw new Error("KB local root unavailable (configure kbRoot + MANIFEST paths.local)");
|
||
}
|
||
return root;
|
||
}
|
||
function ensureDraftBody(body) {
|
||
const trimmed = body.replace(/^\uFEFF/, "");
|
||
if (/^---\r?\n[\s\S]*?\r?\nstatus:\s*draft\b/m.test(trimmed) || /^---\r?\n[\s\S]*?\nstatus:\s*["']?draft["']?\s*$/m.test(trimmed)) {
|
||
return trimmed.endsWith(`
|
||
`) ? trimmed : `${trimmed}
|
||
`;
|
||
}
|
||
if (trimmed.startsWith("---")) {
|
||
const end = trimmed.indexOf(`
|
||
---`, 3);
|
||
if (end !== -1) {
|
||
const fm = trimmed.slice(0, end + 4);
|
||
const rest = trimmed.slice(end + 4);
|
||
if (/\nstatus:\s*/.test(fm))
|
||
return trimmed.endsWith(`
|
||
`) ? trimmed : `${trimmed}
|
||
`;
|
||
const injected = fm.replace(/^---\r?\n/, `---
|
||
status: draft
|
||
`);
|
||
const out = `${injected}${rest}`;
|
||
return out.endsWith(`
|
||
`) ? out : `${out}
|
||
`;
|
||
}
|
||
}
|
||
return `---
|
||
status: draft
|
||
---
|
||
|
||
${trimmed.endsWith(`
|
||
`) ? trimmed : `${trimmed}
|
||
`}`;
|
||
}
|
||
function createMemory(snapshot, args) {
|
||
const localRoot = requireLocalRoot(snapshot);
|
||
const name = memoryFileName({ date: args.date, slug: args.slug });
|
||
const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name);
|
||
if (existsSync3(target.absolutePath) && !args.overwrite) {
|
||
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
|
||
}
|
||
mkdirSync(dirname2(target.absolutePath), { recursive: true });
|
||
const existed = existsSync3(target.absolutePath);
|
||
writeFileSync(target.absolutePath, args.body.endsWith(`
|
||
`) ? args.body : `${args.body}
|
||
`, "utf8");
|
||
return {
|
||
ok: true,
|
||
action: existed ? "updated" : "created",
|
||
absolutePath: target.absolutePath,
|
||
relativePath: target.relativePath
|
||
};
|
||
}
|
||
function createDraft(snapshot, args) {
|
||
const localRoot = requireLocalRoot(snapshot);
|
||
const name = draftFileName({ date: args.date, slug: args.slug });
|
||
const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name);
|
||
if (existsSync3(target.absolutePath) && !args.overwrite) {
|
||
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
|
||
}
|
||
mkdirSync(dirname2(target.absolutePath), { recursive: true });
|
||
const existed = existsSync3(target.absolutePath);
|
||
writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8");
|
||
return {
|
||
ok: true,
|
||
action: existed ? "updated" : "created",
|
||
absolutePath: target.absolutePath,
|
||
relativePath: target.relativePath
|
||
};
|
||
}
|
||
function createSuggestion(snapshot, args) {
|
||
const localRoot = requireLocalRoot(snapshot);
|
||
const name = suggestionFileName({ date: args.date, slug: args.slug });
|
||
const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name);
|
||
if (existsSync3(target.absolutePath) && !args.overwrite) {
|
||
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
|
||
}
|
||
mkdirSync(dirname2(target.absolutePath), { recursive: true });
|
||
const existed = existsSync3(target.absolutePath);
|
||
writeFileSync(target.absolutePath, args.body.endsWith(`
|
||
`) ? args.body : `${args.body}
|
||
`, "utf8");
|
||
return {
|
||
ok: true,
|
||
action: existed ? "updated" : "created",
|
||
absolutePath: target.absolutePath,
|
||
relativePath: target.relativePath
|
||
};
|
||
}
|
||
function createRef(snapshot, args) {
|
||
const localRoot = requireLocalRoot(snapshot);
|
||
const name = refFileName(args.slug);
|
||
const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name);
|
||
if (existsSync3(target.absolutePath) && !args.overwrite) {
|
||
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
|
||
}
|
||
mkdirSync(dirname2(target.absolutePath), { recursive: true });
|
||
const existed = existsSync3(target.absolutePath);
|
||
writeFileSync(target.absolutePath, args.body.endsWith(`
|
||
`) ? args.body : `${args.body}
|
||
`, "utf8");
|
||
return {
|
||
ok: true,
|
||
action: existed ? "updated" : "created",
|
||
absolutePath: target.absolutePath,
|
||
relativePath: target.relativePath
|
||
};
|
||
}
|
||
function updateLocalFile(snapshot, args) {
|
||
const localRoot = requireLocalRoot(snapshot);
|
||
const target = resolveExistingWritableFile(localRoot, args.path);
|
||
if (!existsSync3(target.absolutePath)) {
|
||
throw new Error(`file not found: ${target.relativePath}`);
|
||
}
|
||
const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(`
|
||
`) ? args.body : `${args.body}
|
||
`;
|
||
writeFileSync(target.absolutePath, body, "utf8");
|
||
return {
|
||
ok: true,
|
||
action: "updated",
|
||
absolutePath: target.absolutePath,
|
||
relativePath: target.relativePath
|
||
};
|
||
}
|
||
function deleteLocalFile(snapshot, args) {
|
||
const localRoot = requireLocalRoot(snapshot);
|
||
const target = resolveExistingWritableFile(localRoot, args.path);
|
||
if (!existsSync3(target.absolutePath)) {
|
||
throw new Error(`file not found: ${target.relativePath}`);
|
||
}
|
||
rmSync(target.absolutePath, { force: false });
|
||
return {
|
||
ok: true,
|
||
action: "deleted",
|
||
absolutePath: target.absolutePath,
|
||
relativePath: target.relativePath
|
||
};
|
||
}
|
||
function walkFiles(dir, baseLocal, out) {
|
||
let entries;
|
||
try {
|
||
entries = readdirSync(dir);
|
||
} catch {
|
||
return;
|
||
}
|
||
for (const name of entries) {
|
||
if (name === "." || name === "..")
|
||
continue;
|
||
const full = join3(dir, name);
|
||
let st;
|
||
try {
|
||
st = statSync2(full);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (st.isDirectory()) {
|
||
walkFiles(full, baseLocal, out);
|
||
} else if (st.isFile() && name.toLowerCase().endsWith(".md")) {
|
||
const rel = full.slice(baseLocal.length).replace(/^[/\\]/, "").split(/[/\\]/).join("/");
|
||
out.push({
|
||
absolutePath: full,
|
||
relativePath: rel,
|
||
mtimeMs: st.mtimeMs,
|
||
size: st.size
|
||
});
|
||
}
|
||
}
|
||
}
|
||
function listLocalFiles(snapshot, args = {}) {
|
||
const localRoot = requireLocalRoot(snapshot);
|
||
const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS];
|
||
const entries = [];
|
||
for (const r of roots) {
|
||
walkFiles(join3(localRoot, r), localRoot, entries);
|
||
}
|
||
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||
const limit = Math.min(Math.max(args.limit ?? 50, 1), 200);
|
||
return {
|
||
ok: true,
|
||
localRoot,
|
||
entries: entries.slice(0, limit)
|
||
};
|
||
}
|
||
|
||
// src/netx/kb-manifest.ts
|
||
var EMPTY_CONTENT = {
|
||
hasRegions: false,
|
||
hasTheory: false,
|
||
hasPacket: false,
|
||
hasCommon: false,
|
||
hasSkills: false
|
||
};
|
||
function unconfiguredKbSnapshot() {
|
||
return {
|
||
status: "unconfigured",
|
||
realRoot: "",
|
||
operatorName: "",
|
||
country: "",
|
||
version: "",
|
||
content: { ...EMPTY_CONTENT },
|
||
paths: {},
|
||
errorMessage: ""
|
||
};
|
||
}
|
||
|
||
// src/netx/kb-runtime.ts
|
||
var STORE_KEY = Symbol.for("dsh-netxops.kb-store");
|
||
function store() {
|
||
const root = globalThis;
|
||
let current = root[STORE_KEY];
|
||
if (current === undefined) {
|
||
current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set };
|
||
root[STORE_KEY] = current;
|
||
}
|
||
return current;
|
||
}
|
||
function getKbContext() {
|
||
const snap = store().snapshot;
|
||
return {
|
||
...snap,
|
||
content: { ...snap.content },
|
||
paths: { ...snap.paths }
|
||
};
|
||
}
|
||
function watchKbContext(listener) {
|
||
const state = store();
|
||
state.listeners.add(listener);
|
||
return () => {
|
||
state.listeners.delete(listener);
|
||
};
|
||
}
|
||
|
||
// src/netx/kb-local-tools.ts
|
||
var str = (description) => ({ type: "string", ...description ? { description } : {} });
|
||
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
|
||
var num = (description) => ({ type: "number", ...description ? { description } : {} });
|
||
var reqStr = (description) => ({
|
||
type: "string",
|
||
required: true,
|
||
...description ? { description } : {}
|
||
});
|
||
function renderJson(_args, value) {
|
||
return [{ type: "text", text: JSON.stringify(value, null, 0) }];
|
||
}
|
||
var jsonOut = {
|
||
schema: { type: "json" },
|
||
render: renderJson
|
||
};
|
||
function liveSnapshot() {
|
||
return getKbContext();
|
||
}
|
||
function tool(name, description, parameters, execute) {
|
||
return defineTool({
|
||
name,
|
||
description,
|
||
parameters,
|
||
output: jsonOut,
|
||
timeoutMs: 30000,
|
||
isConcurrencySafe: () => false,
|
||
async execute(args) {
|
||
try {
|
||
return await execute(args);
|
||
} catch (error) {
|
||
throw new Error(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
||
if (!kbLocalToolsEnabled(snapshot))
|
||
return () => {};
|
||
const localRoot = resolveKbLocalRoot(snapshot);
|
||
const toolsApi = ctx.tools;
|
||
if (!toolsApi || typeof toolsApi.register !== "function")
|
||
return () => {};
|
||
const entries = [
|
||
tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", {
|
||
bucket: {
|
||
type: "string",
|
||
required: true,
|
||
enum: ["note", "rca_review", "ai_trace"],
|
||
description: "Memory subdirectory"
|
||
},
|
||
slug: reqStr("Short filename stem (no path separators)"),
|
||
body: reqStr("Full markdown body"),
|
||
date: str("Optional YYYY-MM-DD (default today)"),
|
||
overwrite: bool("Replace if the target file already exists")
|
||
}, (args) => createMemory(liveSnapshot(), {
|
||
bucket: args.bucket,
|
||
slug: String(args.slug ?? ""),
|
||
body: String(args.body ?? ""),
|
||
date: args.date != null ? String(args.date) : undefined,
|
||
overwrite: args.overwrite === true
|
||
})),
|
||
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", {
|
||
slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
|
||
body: reqStr("Markdown body (RCA-ish draft)"),
|
||
domain: str("Optional fault-domain subfolder under drafts/"),
|
||
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
|
||
overwrite: bool("Replace if the target file already exists")
|
||
}, (args) => createDraft(liveSnapshot(), {
|
||
slug: String(args.slug ?? ""),
|
||
body: String(args.body ?? ""),
|
||
domain: args.domain != null ? String(args.domain) : undefined,
|
||
date: args.date != null ? String(args.date) : undefined,
|
||
overwrite: args.overwrite === true
|
||
})),
|
||
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", {
|
||
kind: {
|
||
type: "string",
|
||
required: true,
|
||
enum: ["theory", "improvement"],
|
||
description: "theory = knowledge fix; improvement = tools/skills/process"
|
||
},
|
||
slug: reqStr("Short filename stem"),
|
||
body: reqStr("Full markdown body"),
|
||
date: str("Optional YYYY-MM-DD (default today)"),
|
||
overwrite: bool("Replace if the target file already exists")
|
||
}, (args) => createSuggestion(liveSnapshot(), {
|
||
kind: args.kind,
|
||
slug: String(args.slug ?? ""),
|
||
body: String(args.body ?? ""),
|
||
date: args.date != null ? String(args.date) : undefined,
|
||
overwrite: args.overwrite === true
|
||
})),
|
||
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
|
||
area: {
|
||
type: "string",
|
||
required: true,
|
||
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
|
||
description: "refs/ subtree"
|
||
},
|
||
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
|
||
body: reqStr("Full markdown body"),
|
||
device: str("Required when area=devices — host_name (never UUID)"),
|
||
overwrite: bool("Replace if the target file already exists")
|
||
}, (args) => createRef(liveSnapshot(), {
|
||
area: args.area,
|
||
slug: String(args.slug ?? ""),
|
||
body: String(args.body ?? ""),
|
||
device: args.device != null ? String(args.device) : undefined,
|
||
overwrite: args.overwrite === true
|
||
})),
|
||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
|
||
path: reqStr("Absolute path or path relative to KB local root"),
|
||
body: reqStr("New full markdown body")
|
||
}, (args) => updateLocalFile(liveSnapshot(), {
|
||
path: String(args.path ?? ""),
|
||
body: String(args.body ?? "")
|
||
})),
|
||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
|
||
path: reqStr("Absolute path or path relative to KB local root")
|
||
}, (args) => deleteLocalFile(liveSnapshot(), {
|
||
path: String(args.path ?? "")
|
||
})),
|
||
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", {
|
||
root: {
|
||
type: "string",
|
||
enum: ["memories", "drafts", "suggestions", "refs", "all"],
|
||
description: "Subtree to list (default all writable)"
|
||
},
|
||
limit: num("Max entries 1–200 (default 50)")
|
||
}, (args) => listLocalFiles(liveSnapshot(), {
|
||
root: args.root ?? "all",
|
||
limit: typeof args.limit === "number" ? args.limit : undefined
|
||
}))
|
||
];
|
||
const disposers = entries.map((entry) => toolsApi.register(entry));
|
||
ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot);
|
||
return () => {
|
||
for (const dispose of disposers)
|
||
dispose();
|
||
};
|
||
}
|
||
|
||
// src/netx/kb-pack-skills.ts
|
||
import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
|
||
import { join as join4 } from "node:path";
|
||
function kbPackSkillsEnabled(snapshot) {
|
||
return snapshot.status === "configured" && snapshot.content.hasSkills === true;
|
||
}
|
||
function resolveKbSkillsRoot(snapshot) {
|
||
const rel = snapshot.paths.skills?.trim() || "_skills";
|
||
return join4(snapshot.realRoot, rel);
|
||
}
|
||
function resolveKbLocalSkillsRoot(snapshot) {
|
||
const rel = snapshot.paths.localSkills?.trim();
|
||
if (!rel)
|
||
return null;
|
||
return join4(snapshot.realRoot, rel);
|
||
}
|
||
async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) {
|
||
let entries;
|
||
try {
|
||
entries = await readdir2(root);
|
||
} catch {
|
||
if (options.logMissing) {
|
||
ctx.logger.info("netxops: kb %s dir missing at %s — skip", options.label, root);
|
||
}
|
||
return [];
|
||
}
|
||
if (entries.length === 0)
|
||
return [];
|
||
const disposers = [];
|
||
for (const entry of entries) {
|
||
const full = join4(root, entry);
|
||
let isDir = false;
|
||
try {
|
||
isDir = (await stat2(full)).isDirectory();
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (!isDir)
|
||
continue;
|
||
let skill;
|
||
try {
|
||
skill = await loadSkillBundle(full);
|
||
} catch (error) {
|
||
ctx.logger.warn("netxops: kb %s skill load failed at %s: %s", options.label, full, error instanceof Error ? error.message : String(error));
|
||
continue;
|
||
}
|
||
if (!skill) {
|
||
ctx.logger.warn("netxops: kb %s skill skipped (invalid SKILL.md): %s", options.label, full);
|
||
continue;
|
||
}
|
||
disposers.push(skillsApi.register({
|
||
name: skill.name,
|
||
description: skill.description,
|
||
content: skill.content,
|
||
path: skill.path,
|
||
resourceBase: { kind: "directory", path: skill.directory },
|
||
provider,
|
||
source: "custom"
|
||
}));
|
||
}
|
||
return disposers;
|
||
}
|
||
async function registerKbPackSkills(ctx, snapshot, options) {
|
||
if (!options.enabled || !kbPackSkillsEnabled(snapshot)) {
|
||
return () => {};
|
||
}
|
||
const skillsApi = ctx.skills;
|
||
if (!skillsApi || typeof skillsApi.register !== "function") {
|
||
return () => {};
|
||
}
|
||
const provider = options.providerLabel ?? "netxops-kb-pack";
|
||
const disposers = [];
|
||
const skillsRoot = resolveKbSkillsRoot(snapshot);
|
||
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
|
||
logMissing: true,
|
||
label: "pack"
|
||
}));
|
||
const localRoot = resolveKbLocalSkillsRoot(snapshot);
|
||
if (localRoot) {
|
||
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, {
|
||
logMissing: false,
|
||
label: "local"
|
||
}));
|
||
}
|
||
if (disposers.length > 0) {
|
||
ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider);
|
||
}
|
||
return () => {
|
||
for (const dispose of disposers)
|
||
dispose();
|
||
};
|
||
}
|
||
|
||
// src/netx/runtime.ts
|
||
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||
function store2() {
|
||
const root = globalThis;
|
||
let current = root[STORE_KEY2];
|
||
if (current === undefined) {
|
||
current = { connection: undefined, listeners: new Set };
|
||
root[STORE_KEY2] = current;
|
||
}
|
||
return current;
|
||
}
|
||
function getNetxConnection() {
|
||
return store2().connection;
|
||
}
|
||
function watchNetxConnection(listener) {
|
||
const state = store2();
|
||
state.listeners.add(listener);
|
||
return () => {
|
||
state.listeners.delete(listener);
|
||
};
|
||
}
|
||
|
||
// src/netx/tools.ts
|
||
import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
|
||
|
||
// src/netx/http.ts
|
||
var PROTOCOL_KEY_ZH_TO_EN = {
|
||
其他: "Other",
|
||
时钟: "Clock",
|
||
"OTN/光": "OTN/Optical",
|
||
电源: "Power"
|
||
};
|
||
function localizePayload(lang, data) {
|
||
if (!lang.trim().toLowerCase().startsWith("en"))
|
||
return data;
|
||
const proto = data.protocol_summary;
|
||
if (!Array.isArray(proto))
|
||
return data;
|
||
for (const row of proto) {
|
||
if (typeof row !== "object" || row === null || Array.isArray(row))
|
||
continue;
|
||
const rec = row;
|
||
const key = typeof rec.key === "string" ? rec.key : "";
|
||
const mapped = PROTOCOL_KEY_ZH_TO_EN[key];
|
||
if (mapped !== undefined)
|
||
rec.key = mapped;
|
||
}
|
||
return data;
|
||
}
|
||
function encodeQuery(params) {
|
||
const sp = new URLSearchParams;
|
||
for (const [key, value] of Object.entries(params)) {
|
||
sp.set(key, String(value));
|
||
}
|
||
const q = sp.toString();
|
||
return q.length > 0 ? `?${q}` : "";
|
||
}
|
||
function createNetxClient(connection) {
|
||
const base = connection.apiUrl.replace(/\/$/, "");
|
||
const langParams = () => {
|
||
const lang = connection.lang.trim().toLowerCase();
|
||
if (lang.startsWith("en"))
|
||
return { lang: "en" };
|
||
return {};
|
||
};
|
||
async function request(method, path, options = {}) {
|
||
const token = connection.getToken().trim();
|
||
if (token.length === 0) {
|
||
return {
|
||
ok: false,
|
||
error: "netx_token_missing",
|
||
detail: "Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token)."
|
||
};
|
||
}
|
||
const merged = { ...langParams(), ...options.params };
|
||
const url = `${base}${path}${encodeQuery(merged)}`;
|
||
const timeoutMs = options.timeoutMs ?? connection.timeoutMs;
|
||
const controller = new AbortController;
|
||
const timer = setTimeout(() => {
|
||
controller.abort();
|
||
}, timeoutMs);
|
||
const onOuterAbort = () => {
|
||
controller.abort();
|
||
};
|
||
options.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
||
try {
|
||
const headers = {
|
||
accept: "application/json",
|
||
authorization: `Bearer ${token}`
|
||
};
|
||
if (options.body !== undefined)
|
||
headers["content-type"] = "application/json";
|
||
const init = {
|
||
method,
|
||
headers,
|
||
signal: controller.signal
|
||
};
|
||
if (options.body !== undefined)
|
||
init.body = JSON.stringify(options.body);
|
||
const resp = await fetch(url, init);
|
||
const text = await resp.text();
|
||
if (!resp.ok) {
|
||
return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) };
|
||
}
|
||
const data = text.length > 0 ? JSON.parse(text) : {};
|
||
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
|
||
return { ok: true, data: localizePayload(connection.lang, data) };
|
||
}
|
||
return { ok: true, data: { raw: data } };
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) };
|
||
} finally {
|
||
clearTimeout(timer);
|
||
options.signal?.removeEventListener("abort", onOuterAbort);
|
||
}
|
||
}
|
||
return {
|
||
get(path, params, signal, timeoutMs) {
|
||
return request("GET", path, { params, signal, timeoutMs });
|
||
},
|
||
post(path, body, signal, timeoutMs) {
|
||
return request("POST", path, { body, signal, timeoutMs });
|
||
},
|
||
patch(path, body, signal, timeoutMs) {
|
||
return request("PATCH", path, { body, signal, timeoutMs });
|
||
}
|
||
};
|
||
}
|
||
function quoteNeId(neId) {
|
||
return encodeURIComponent(neId.trim());
|
||
}
|
||
|
||
// src/netx/handlers.ts
|
||
var EXEC_MAX_COMMANDS = 5;
|
||
var UME_RAW_FIELD_PRESETS = {
|
||
brief: [
|
||
"alarm_alarm_key",
|
||
"alarm_host_name",
|
||
"alarm_perceived_severity",
|
||
"alarm_event_type",
|
||
"alarm_last_seen_at",
|
||
"ne_host_name",
|
||
"ne_user_label",
|
||
"ne_ne_name",
|
||
"ne_ip_address",
|
||
"ne_exists"
|
||
],
|
||
evidence: [
|
||
"alarm_alarm_key",
|
||
"alarm_host_name",
|
||
"alarm_object_name",
|
||
"alarm_event_type",
|
||
"alarm_native_probable_cause",
|
||
"alarm_perceived_severity",
|
||
"alarm_is_cleared",
|
||
"alarm_time_created",
|
||
"alarm_last_seen_at",
|
||
"ne_host_name",
|
||
"ne_user_label",
|
||
"ne_ne_name",
|
||
"ne_ip_address",
|
||
"ne_connection_status",
|
||
"ne_exists"
|
||
],
|
||
ne_debug: [
|
||
"alarm_alarm_key",
|
||
"alarm_ne_id",
|
||
"alarm_perceived_severity",
|
||
"alarm_last_seen_at",
|
||
"ne_user_label",
|
||
"ne_ne_name",
|
||
"ne_ip_address",
|
||
"ne_ipv6_address",
|
||
"ne_device_level",
|
||
"ne_host_name",
|
||
"ne_connection_status",
|
||
"ne_admin_status",
|
||
"ne_address_type",
|
||
"ne_maintain_status",
|
||
"ne_exists"
|
||
]
|
||
};
|
||
function asRecord(value) {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
||
}
|
||
function str2(args, key, fallback = "") {
|
||
const v = args[key];
|
||
if (typeof v === "string")
|
||
return v;
|
||
if (typeof v === "number" || typeof v === "boolean")
|
||
return String(v);
|
||
return fallback;
|
||
}
|
||
function nmsOrUme(args, nmsKey, umeKey) {
|
||
return str2(args, nmsKey).trim() || str2(args, umeKey).trim();
|
||
}
|
||
function nmsOrUmeList(args, nmsKey, umeKey) {
|
||
const primary = strList(args, nmsKey);
|
||
return primary.length > 0 ? primary : strList(args, umeKey);
|
||
}
|
||
function num2(args, key) {
|
||
const v = args[key];
|
||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||
}
|
||
function bool2(args, key) {
|
||
const v = args[key];
|
||
return typeof v === "boolean" ? v : undefined;
|
||
}
|
||
function strList(args, key) {
|
||
const v = args[key];
|
||
if (!Array.isArray(v))
|
||
return [];
|
||
return v.map((x) => String(x).trim()).filter((x) => x.length > 0);
|
||
}
|
||
function clampInt(value, fallback, min, max) {
|
||
const n = value === undefined ? fallback : Math.trunc(value);
|
||
return Math.max(min, Math.min(max, n));
|
||
}
|
||
function putStr(params, args, keys) {
|
||
for (const key of keys) {
|
||
const v = str2(args, key).trim();
|
||
if (v)
|
||
params[key] = v;
|
||
}
|
||
}
|
||
async function queryUmeAlarms(client, args, signal) {
|
||
let page = clampInt(num2(args, "page"), 1, 1, 2);
|
||
const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500);
|
||
const params = { page, page_size: pageSize };
|
||
putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]);
|
||
const keyword = str2(args, "keyword").trim();
|
||
const neName = str2(args, "ne_name").trim();
|
||
if (keyword)
|
||
params.keyword = keyword;
|
||
else if (neName)
|
||
params.keyword = neName;
|
||
return client.get("/v1/ume/alarms", params, signal);
|
||
}
|
||
async function aggregateUmeAlarmsRaw(client, args, signal) {
|
||
const params = {};
|
||
putStr(params, args, [
|
||
"group_by",
|
||
"group_by2",
|
||
"severity",
|
||
"is_cleared",
|
||
"ne_id",
|
||
"event_type",
|
||
"keyword",
|
||
"time_from",
|
||
"time_to",
|
||
"limit"
|
||
]);
|
||
if ("exclude_missing_host" in args) {
|
||
const flag = bool2(args, "exclude_missing_host");
|
||
if (flag !== undefined)
|
||
params.exclude_missing_host = flag;
|
||
}
|
||
return client.get("/v1/ume/alarms/aggregate/raw", params, signal);
|
||
}
|
||
async function aggregateUmeAlarms(client, args, signal) {
|
||
if (str2(args, "group_by").trim())
|
||
return aggregateUmeAlarmsRaw(client, args, signal);
|
||
const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500);
|
||
const params = { top_ne: topNe };
|
||
if ("exclude_missing_host" in args) {
|
||
const flag = bool2(args, "exclude_missing_host");
|
||
if (flag !== undefined)
|
||
params.exclude_missing_host = flag;
|
||
}
|
||
putStr(params, args, ["severity", "time_from", "time_to"]);
|
||
return client.get("/v1/ume/alarms/aggregate", params, signal);
|
||
}
|
||
async function runUmeDiagnostics(client, _args, signal) {
|
||
return client.get("/v1/ume/diagnostics", undefined, signal);
|
||
}
|
||
async function queryUmeNeInventory(client, args, signal) {
|
||
const params = {
|
||
page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||
page_size: clampInt(num2(args, "page_size"), 50, 1, 500)
|
||
};
|
||
putStr(params, args, ["keyword"]);
|
||
return client.get("/v1/ume/inventory/ne", params, signal);
|
||
}
|
||
async function getUmeNe(client, args, signal) {
|
||
const neId = str2(args, "ne_id").trim();
|
||
if (!neId)
|
||
return { ok: false, error: "ne_id_required", error_code: "ne_id_required" };
|
||
return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal);
|
||
}
|
||
async function queryUmeAlarmsRaw(client, args, signal) {
|
||
const params = {
|
||
page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||
page_size: clampInt(num2(args, "page_size"), 50, 1, 500)
|
||
};
|
||
putStr(params, args, [
|
||
"severity",
|
||
"is_cleared",
|
||
"ne_id",
|
||
"event_type",
|
||
"keyword",
|
||
"time_from",
|
||
"time_to",
|
||
"order_by",
|
||
"order"
|
||
]);
|
||
let fields = strList(args, "select_fields");
|
||
if (fields.length === 0) {
|
||
const preset = str2(args, "field_preset").trim().toLowerCase();
|
||
fields = UME_RAW_FIELD_PRESETS[preset] ?? [];
|
||
}
|
||
if (fields.length > 0)
|
||
params.select_fields = fields.join(",");
|
||
return client.get("/v1/ume/alarms/raw", params, signal);
|
||
}
|
||
async function listUmeAlarmFields(client, _args, signal) {
|
||
return client.get("/v1/ume/alarms/fields", undefined, signal);
|
||
}
|
||
async function sqlQueryUme(client, args, signal) {
|
||
const sql = str2(args, "sql").trim();
|
||
if (!sql)
|
||
return { ok: false, error: "sql_required" };
|
||
const limit = clampInt(num2(args, "limit"), 200, 1, 2000);
|
||
const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000);
|
||
return client.post("/v1/sql/ume_query", {
|
||
sql,
|
||
limit,
|
||
statement_timeout_ms: statementTimeoutMs
|
||
}, signal, 60000);
|
||
}
|
||
async function listManagedNe(client, args, signal) {
|
||
const keyword = str2(args, "keyword").trim();
|
||
const vendor = str2(args, "vendor").trim();
|
||
const connectStatus = str2(args, "connect_status").trim();
|
||
if (!(keyword || vendor || connectStatus)) {
|
||
return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" };
|
||
}
|
||
if (keyword && keyword.length < 2) {
|
||
return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" };
|
||
}
|
||
const params = {
|
||
page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||
page_size: clampInt(num2(args, "page_size"), 20, 1, 100)
|
||
};
|
||
if (keyword)
|
||
params.keyword = keyword;
|
||
if (vendor)
|
||
params.vendor = vendor;
|
||
if (connectStatus)
|
||
params.connect_status = connectStatus;
|
||
return client.get("/v1/managed-ne", params, signal);
|
||
}
|
||
async function getManagedNe(client, args, signal) {
|
||
const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(args, "id")).trim();
|
||
if (!neId) {
|
||
return {
|
||
ok: false,
|
||
error: "ne_id_required",
|
||
error_code: "ne_id_required",
|
||
hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.",
|
||
example: { ne_id: "<managed-ne-uuid-from-listManagedNe>" }
|
||
};
|
||
}
|
||
const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal);
|
||
if (out.ok === false) {
|
||
const detail = `${str2(out, "detail")}${str2(out, "error")}`.toLowerCase();
|
||
if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") {
|
||
return {
|
||
...out,
|
||
hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe."
|
||
};
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
async function execManagedNe(client, args, signal) {
|
||
const targetsRaw = args.targets;
|
||
const neIds = strList(args, "ne_ids");
|
||
const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids");
|
||
const sharedCommands = strList(args, "commands");
|
||
const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0;
|
||
if (multi) {
|
||
const body2 = {};
|
||
if (Array.isArray(targetsRaw) && targetsRaw.length > 0) {
|
||
const cleaned = [];
|
||
for (const t of targetsRaw) {
|
||
if (typeof t !== "object" || t === null || Array.isArray(t))
|
||
continue;
|
||
const row = t;
|
||
const item = {};
|
||
const neId2 = str2(row, "ne_id").trim();
|
||
const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id");
|
||
if (neId2)
|
||
item.ne_id = neId2;
|
||
if (umeNeId2)
|
||
item.ume_ne_id = umeNeId2;
|
||
const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : [];
|
||
if (cmds.length > 0)
|
||
item.commands = cmds;
|
||
if (Object.keys(item).length > 0)
|
||
cleaned.push(item);
|
||
}
|
||
body2.targets = cleaned;
|
||
}
|
||
if (neIds.length > 0)
|
||
body2.ne_ids = neIds;
|
||
if (umeNeIds.length > 0)
|
||
body2.ume_ne_ids = umeNeIds;
|
||
if (sharedCommands.length > 0) {
|
||
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
|
||
return { ok: false, error: "too_many_commands", error_code: "too_many_commands" };
|
||
}
|
||
body2.commands = sharedCommands;
|
||
}
|
||
body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120);
|
||
const concurrency = num2(args, "concurrency");
|
||
if (concurrency !== undefined)
|
||
body2.concurrency = clampInt(concurrency, 4, 1, 8);
|
||
const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000);
|
||
if (out2.ok !== true)
|
||
return out2;
|
||
const data2 = asRecord(out2.data);
|
||
if (data2.ok === false) {
|
||
return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") };
|
||
}
|
||
return { ok: true, data: data2 };
|
||
}
|
||
const neId = str2(args, "ne_id").trim();
|
||
const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id");
|
||
if (Boolean(neId) === Boolean(umeNeId)) {
|
||
return {
|
||
ok: false,
|
||
error: "exactly_one_of_ne_id_or_nms_ne_id_required",
|
||
error_code: "exactly_one_of_ne_id_or_nms_ne_id_required",
|
||
hint: "For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server."
|
||
};
|
||
}
|
||
if (sharedCommands.length === 0) {
|
||
return { ok: false, error: "commands_required", error_code: "commands_required" };
|
||
}
|
||
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
|
||
return { ok: false, error: "too_many_commands", error_code: "too_many_commands" };
|
||
}
|
||
const body = {
|
||
commands: sharedCommands,
|
||
read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120)
|
||
};
|
||
if (neId)
|
||
body.ne_id = neId;
|
||
if (umeNeId)
|
||
body.ume_ne_id = umeNeId;
|
||
const out = await client.post("/v1/managed-ne/exec", body, signal, 300000);
|
||
if (out.ok !== true)
|
||
return out;
|
||
const data = asRecord(out.data);
|
||
if (data.ok === false) {
|
||
return { ok: false, data, error: str2(data, "error", "exec_failed") };
|
||
}
|
||
return { ok: true, data };
|
||
}
|
||
async function listCliTargets(client, args, signal) {
|
||
const params = {
|
||
page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||
page_size: clampInt(num2(args, "page_size"), 50, 1, 500)
|
||
};
|
||
putStr(params, args, ["source", "keyword"]);
|
||
return client.get("/v1/cli/targets", params, signal);
|
||
}
|
||
async function findTopologyPaths(client, args, signal) {
|
||
const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id");
|
||
const fromMid = str2(args, "from_managed_ne_id").trim();
|
||
const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id");
|
||
const toMid = str2(args, "to_managed_ne_id").trim();
|
||
if (Boolean(fromUid) === Boolean(fromMid)) {
|
||
return { ok: false, error: "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required" };
|
||
}
|
||
if (Boolean(toUid) === Boolean(toMid)) {
|
||
return { ok: false, error: "exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required" };
|
||
}
|
||
let detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary";
|
||
if (detail !== "summary" && detail !== "full")
|
||
detail = "summary";
|
||
const body = {
|
||
max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10),
|
||
max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12),
|
||
layer: str2(args, "layer", "physical").trim() || "physical",
|
||
detail
|
||
};
|
||
if (fromUid)
|
||
body.from_ume_ne_id = fromUid;
|
||
else
|
||
body.from_managed_ne_id = fromMid;
|
||
if (toUid)
|
||
body.to_ume_ne_id = toUid;
|
||
else
|
||
body.to_managed_ne_id = toMid;
|
||
return client.post("/v1/topology/fabric/paths", body, signal, 30000);
|
||
}
|
||
function taskBriefNe(brief) {
|
||
const rec = asRecord(brief);
|
||
const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : "";
|
||
const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : "";
|
||
if (name && ip)
|
||
return `${name} (${ip})`;
|
||
return name || ip;
|
||
}
|
||
function slimMigrationProject(row) {
|
||
const mt = asRecord(row.monitor_template);
|
||
return {
|
||
id: row.id,
|
||
name: row.name,
|
||
status: row.status,
|
||
note: row.note,
|
||
monitor_template_id: row.monitor_template_id,
|
||
monitor_template_name: typeof mt.name === "string" ? mt.name : "",
|
||
old_task_id: row.old_task_id,
|
||
new_task_id: row.new_task_id,
|
||
old_hf_task_id: row.old_hf_task_id,
|
||
new_hf_task_id: row.new_hf_task_id,
|
||
old_ne: taskBriefNe(row.old_task),
|
||
new_ne: taskBriefNe(row.new_task),
|
||
hf_interval_sec: row.hf_interval_sec,
|
||
hf_start_at: row.hf_start_at,
|
||
hf_end_at: row.hf_end_at,
|
||
updated_at: row.updated_at
|
||
};
|
||
}
|
||
function matchesBizMonitorQ(row, q) {
|
||
if (!q)
|
||
return true;
|
||
const hay = [
|
||
row.id,
|
||
row.name,
|
||
row.note,
|
||
row.status,
|
||
row.ne_name,
|
||
row.ne_ip,
|
||
row.ne_id,
|
||
row.old_ne,
|
||
row.new_ne,
|
||
row.monitor_template_name,
|
||
row.purpose,
|
||
row.vendor
|
||
].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" ");
|
||
return hay.includes(q);
|
||
}
|
||
async function listBizMonitors(client, args, signal) {
|
||
const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all";
|
||
const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw;
|
||
if (kind !== "all" && kind !== "projects" && kind !== "tasks") {
|
||
return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" };
|
||
}
|
||
const purpose = str2(args, "purpose").trim();
|
||
const status = str2(args, "status").trim().toLowerCase();
|
||
const q = str2(args, "q").trim().toLowerCase();
|
||
const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500);
|
||
const wantProjects = kind === "all" || kind === "projects";
|
||
const wantTasks = kind === "all" || kind === "tasks";
|
||
const fetches = [];
|
||
if (wantProjects)
|
||
fetches.push(client.get("/v1/biz-migration/projects", {}, signal));
|
||
else
|
||
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
|
||
if (wantTasks) {
|
||
const params = {};
|
||
if (purpose)
|
||
params.purpose = purpose;
|
||
fetches.push(client.get("/v1/biz-state/tasks", params, signal));
|
||
} else {
|
||
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
|
||
}
|
||
const [projRes, taskRes] = await Promise.all(fetches);
|
||
if (wantProjects && projRes.ok === false)
|
||
return projRes;
|
||
if (wantTasks && taskRes.ok === false)
|
||
return taskRes;
|
||
const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : [];
|
||
const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : [];
|
||
let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => {
|
||
if (status && String(row.status || "").toLowerCase() !== status)
|
||
return false;
|
||
return matchesBizMonitorQ(row, q);
|
||
});
|
||
let tasks = taskItems.map((row) => asRecord(row)).filter((row) => {
|
||
if (status && String(row.status || "").toLowerCase() !== status)
|
||
return false;
|
||
return matchesBizMonitorQ(row, q);
|
||
});
|
||
const projectsTotal = projects.length;
|
||
const tasksTotal = tasks.length;
|
||
projects = projects.slice(0, limit);
|
||
tasks = tasks.slice(0, limit);
|
||
return {
|
||
ok: true,
|
||
data: {
|
||
kind,
|
||
projects: wantProjects ? projects : undefined,
|
||
tasks: wantTasks ? tasks : undefined,
|
||
counts: {
|
||
projects: wantProjects ? projectsTotal : 0,
|
||
tasks: wantTasks ? tasksTotal : 0,
|
||
projects_returned: wantProjects ? projects.length : 0,
|
||
tasks_returned: wantTasks ? tasks.length : 0
|
||
},
|
||
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
|
||
}
|
||
};
|
||
}
|
||
function slimCutoverBatch(row) {
|
||
return {
|
||
id: row.id,
|
||
project_id: row.project_id,
|
||
batch_label: row.batch_label,
|
||
status: row.status,
|
||
accept_status: row.accept_status,
|
||
accept_run_id: row.accept_run_id,
|
||
started_at: row.started_at,
|
||
ended_at: row.ended_at,
|
||
note: row.note,
|
||
created_at: row.created_at,
|
||
updated_at: row.updated_at
|
||
};
|
||
}
|
||
function slimCollectBatch(row, taskId = "") {
|
||
return {
|
||
id: row.id,
|
||
task_id: row.task_id || taskId,
|
||
status: row.status,
|
||
ne_name: row.ne_name,
|
||
command_count: row.command_count,
|
||
row_count: row.row_count,
|
||
is_baseline: row.is_baseline,
|
||
protected: row.protected,
|
||
message: row.message,
|
||
started_at: row.started_at,
|
||
ended_at: row.ended_at
|
||
};
|
||
}
|
||
function slimEvalRun(row) {
|
||
const summary = asRecord(row.summary);
|
||
const progress = asRecord(summary.progress);
|
||
return {
|
||
id: row.id,
|
||
batch_id: row.batch_id,
|
||
purpose: row.purpose,
|
||
old_batch_id: row.old_batch_id,
|
||
new_batch_id: row.new_batch_id,
|
||
created_at: row.created_at,
|
||
anomaly: summary.anomaly,
|
||
progress_ok: progress.ok ?? summary.progress_ok,
|
||
progress_total: progress.total ?? summary.progress_total
|
||
};
|
||
}
|
||
async function listBizMonitorBatches(client, args, signal) {
|
||
const projectId = str2(args, "project_id").trim();
|
||
const taskId = str2(args, "task_id").trim();
|
||
const batchId = str2(args, "batch_id").trim();
|
||
const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto";
|
||
const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1;
|
||
const status = str2(args, "status").trim().toLowerCase();
|
||
const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200);
|
||
const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50);
|
||
let kind = kindRaw;
|
||
if (kind === "auto") {
|
||
if (batchId && !projectId && !taskId)
|
||
kind = "runs";
|
||
else if (projectId && !taskId)
|
||
kind = "cutover";
|
||
else if (taskId && !projectId)
|
||
kind = "collect";
|
||
else if (projectId && taskId)
|
||
kind = "all";
|
||
else if (batchId)
|
||
kind = "runs";
|
||
else {
|
||
return {
|
||
ok: false,
|
||
error: "project_id_or_task_id_or_batch_id_required",
|
||
detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)."
|
||
};
|
||
}
|
||
}
|
||
if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) {
|
||
return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" };
|
||
}
|
||
const wantCutover = kind === "all" || kind === "cutover";
|
||
const wantCollect = kind === "all" || kind === "collect";
|
||
const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId));
|
||
if (wantCutover && !projectId) {
|
||
return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" };
|
||
}
|
||
if (wantCollect && !taskId) {
|
||
return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" };
|
||
}
|
||
if (kind === "runs" && !batchId) {
|
||
return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" };
|
||
}
|
||
let cutoverBatches = [];
|
||
let collectBatches = [];
|
||
let runs = [];
|
||
if (wantCutover) {
|
||
const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal);
|
||
if (res.ok === false)
|
||
return res;
|
||
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
|
||
cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => {
|
||
if (batchId && String(row.id || "") !== batchId)
|
||
return false;
|
||
if (status && String(row.status || "").toLowerCase() !== status)
|
||
return false;
|
||
return true;
|
||
}).slice(0, limit);
|
||
}
|
||
if (wantCollect) {
|
||
const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal);
|
||
if (res.ok === false)
|
||
return res;
|
||
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
|
||
collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => {
|
||
if (status && String(row.status || "").toLowerCase() !== status)
|
||
return false;
|
||
return true;
|
||
}).slice(0, limit);
|
||
}
|
||
if (wantRuns) {
|
||
const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0);
|
||
if (kind === "runs" && batchId) {
|
||
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal);
|
||
if (res.ok === false)
|
||
return res;
|
||
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
|
||
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit);
|
||
} else if (includeRuns && runBatchIds.length > 0) {
|
||
const settled = await Promise.all(runBatchIds.map(async (id) => {
|
||
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal);
|
||
if (res.ok === false)
|
||
return { batch_id: id, error: res, items: [] };
|
||
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
|
||
return {
|
||
batch_id: id,
|
||
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
|
||
};
|
||
}));
|
||
for (const block of settled) {
|
||
if ("error" in block && block.error) {
|
||
const failed = cutoverBatches.find((b) => b.id === block.batch_id);
|
||
if (failed)
|
||
failed.runs_error = asRecord(block.error).error || "runs_fetch_failed";
|
||
continue;
|
||
}
|
||
const target = cutoverBatches.find((b) => b.id === block.batch_id);
|
||
if (target)
|
||
target.runs = block.items;
|
||
for (const r of block.items)
|
||
runs.push(r);
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
ok: true,
|
||
data: {
|
||
kind,
|
||
project_id: projectId || undefined,
|
||
task_id: taskId || undefined,
|
||
batch_id: batchId || undefined,
|
||
cutover_batches: wantCutover ? cutoverBatches : undefined,
|
||
collect_batches: wantCollect ? collectBatches : undefined,
|
||
runs: kind === "runs" ? runs : undefined,
|
||
counts: {
|
||
cutover_batches: cutoverBatches.length,
|
||
collect_batches: collectBatches.length,
|
||
runs: runs.length
|
||
},
|
||
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
|
||
}
|
||
};
|
||
}
|
||
async function getBizMonitorContext(client, args, signal) {
|
||
const projectId = str2(args, "project_id").trim();
|
||
const taskId = str2(args, "task_id").trim();
|
||
if (!projectId && !taskId) {
|
||
return { ok: false, error: "project_id_or_task_id_required" };
|
||
}
|
||
const params = {};
|
||
if (projectId)
|
||
params.project_id = projectId;
|
||
if (taskId)
|
||
params.task_id = taskId;
|
||
return client.get("/v1/biz-migration/monitor-context", params, signal);
|
||
}
|
||
async function getBizMonitorBoard(client, args, signal) {
|
||
const batchId = str2(args, "batch_id").trim();
|
||
if (!batchId)
|
||
return { ok: false, error: "batch_id_required" };
|
||
const params = {};
|
||
const runId = str2(args, "run_id").trim();
|
||
if (runId)
|
||
params.run_id = runId;
|
||
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal);
|
||
}
|
||
async function listBizMonitorReds(client, args, signal) {
|
||
const projectId = str2(args, "project_id").trim();
|
||
if (!projectId)
|
||
return { ok: false, error: "project_id_required" };
|
||
const params = {};
|
||
putStr(params, args, ["status"]);
|
||
const limit = num2(args, "limit");
|
||
if (limit !== undefined)
|
||
params.limit = clampInt(limit, 200, 1, 500);
|
||
return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal);
|
||
}
|
||
async function getBizMonitorDiffs(client, args, signal) {
|
||
const runId = str2(args, "run_id").trim();
|
||
if (!runId)
|
||
return { ok: false, error: "run_id_required" };
|
||
const params = {};
|
||
putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]);
|
||
const limit = num2(args, "limit");
|
||
if (limit !== undefined)
|
||
params.limit = clampInt(limit, 200, 1, 500);
|
||
const offset = num2(args, "offset");
|
||
if (offset !== undefined)
|
||
params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
||
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal);
|
||
}
|
||
async function getBizCollectBatch(client, args, signal) {
|
||
const batchId = str2(args, "batch_id").trim();
|
||
if (!batchId)
|
||
return { ok: false, error: "batch_id_required" };
|
||
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal);
|
||
}
|
||
async function getBizCollectCommandRaw(client, args, signal) {
|
||
const batchId = str2(args, "batch_id").trim();
|
||
const commandId = str2(args, "command_id").trim();
|
||
if (!batchId || !commandId) {
|
||
return { ok: false, error: "batch_id_and_command_id_required" };
|
||
}
|
||
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal);
|
||
}
|
||
|
||
// src/netx/topology-handlers.ts
|
||
function asRecord2(value) {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
||
}
|
||
function str3(args, key, fallback = "") {
|
||
const v = args[key];
|
||
if (typeof v === "string")
|
||
return v;
|
||
if (typeof v === "number" || typeof v === "boolean")
|
||
return String(v);
|
||
return fallback;
|
||
}
|
||
function num3(args, key) {
|
||
const v = args[key];
|
||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||
}
|
||
function bool3(args, key) {
|
||
const v = args[key];
|
||
return typeof v === "boolean" ? v : undefined;
|
||
}
|
||
function strList2(args, key) {
|
||
const v = args[key];
|
||
if (!Array.isArray(v))
|
||
return [];
|
||
return v.map((x) => String(x).trim()).filter((x) => x.length > 0);
|
||
}
|
||
function clampInt2(value, fallback, min, max) {
|
||
const n = value === undefined ? fallback : Math.trunc(value);
|
||
return Math.max(min, Math.min(max, n));
|
||
}
|
||
function unwrap(out) {
|
||
if (out.ok !== true)
|
||
return out;
|
||
const data = asRecord2(out.data);
|
||
if (Object.keys(data).length === 0)
|
||
return { ok: true, data: out.data };
|
||
return { ok: true, ...data, data };
|
||
}
|
||
function filterFields(args) {
|
||
const out = {};
|
||
for (const key of ["keyword", "role", "vendor", "link_status"]) {
|
||
const v = str3(args, key).trim();
|
||
if (v)
|
||
out[key] = v;
|
||
}
|
||
return out;
|
||
}
|
||
function edgeEndpoints(edge) {
|
||
const a = str3(edge, "a_node_id") || str3(edge, "a");
|
||
const b = str3(edge, "b_node_id") || str3(edge, "b");
|
||
return [a.trim(), b.trim()];
|
||
}
|
||
function collapseEdgesToLinks(edges, includeNames = false) {
|
||
const buckets = new Map;
|
||
for (const raw of edges) {
|
||
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
||
continue;
|
||
const e = raw;
|
||
const [a, b] = edgeEndpoints(e);
|
||
if (!a || !b || a === b)
|
||
continue;
|
||
const lo = a < b ? a : b;
|
||
const hi = a < b ? b : a;
|
||
const key = `${lo}\x00${hi}`;
|
||
const row = buckets.get(key) ?? {
|
||
a_node_id: lo,
|
||
b_node_id: hi,
|
||
link_count: 0
|
||
};
|
||
row.link_count = Number(row.link_count || 0) + 1;
|
||
if (includeNames && !row.a_name) {
|
||
if (a === lo) {
|
||
row.a_name = str3(e, "a_name");
|
||
row.b_name = str3(e, "b_name");
|
||
} else {
|
||
row.a_name = str3(e, "b_name");
|
||
row.b_name = str3(e, "a_name");
|
||
}
|
||
}
|
||
buckets.set(key, row);
|
||
}
|
||
return [...buckets.values()];
|
||
}
|
||
function summarizeViewGraph(graph, sample = 20) {
|
||
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
|
||
const edges = Array.isArray(graph.edges) ? graph.edges : Array.isArray(graph.links) ? graph.links : [];
|
||
const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => {
|
||
const row = asRecord2(n);
|
||
return {
|
||
id: str3(row, "id") || str3(row, "fabric_node_id"),
|
||
name: str3(row, "name"),
|
||
x: row.x,
|
||
y: row.y,
|
||
level: row.level,
|
||
role: row.role
|
||
};
|
||
});
|
||
return {
|
||
ok: true,
|
||
view_id: str3(graph, "id") || str3(graph, "view_id"),
|
||
node_count: nodes.length,
|
||
edge_count: edges.length,
|
||
link_count: collapseEdgesToLinks(edges).length,
|
||
links: collapseEdgesToLinks(edges),
|
||
sample_nodes: sampleNodes,
|
||
detail: "summary"
|
||
};
|
||
}
|
||
function layoutRecipeUnavailable(action) {
|
||
return {
|
||
ok: false,
|
||
error: "layout_recipe_requires_netx_topology_mcp",
|
||
action,
|
||
hint: "Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside)."
|
||
};
|
||
}
|
||
async function getTopologyTree(client, args, signal) {
|
||
const out = unwrap(await client.get("/v1/topology/tree", undefined, signal));
|
||
if (out.ok !== true)
|
||
return out;
|
||
const compact = bool3(args, "compact") !== false;
|
||
const maxDepth = num3(args, "max_depth");
|
||
if (!compact && maxDepth === undefined)
|
||
return out;
|
||
return { ...out, compact, max_depth: maxDepth ?? null };
|
||
}
|
||
async function getTopologyView(client, args, signal) {
|
||
const viewId = str3(args, "view_id").trim();
|
||
if (!viewId)
|
||
return { ok: false, error: "view_id_required" };
|
||
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000));
|
||
if (out.ok !== true)
|
||
return out;
|
||
const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary";
|
||
if (detail === "full")
|
||
return { ...out, detail: "full" };
|
||
return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200));
|
||
}
|
||
async function createTopologyFolder(client, args, signal) {
|
||
const name = str3(args, "name").trim();
|
||
if (!name)
|
||
return { ok: false, error: "name_required" };
|
||
const body = { name };
|
||
const parentId = str3(args, "parent_id").trim();
|
||
if (parentId)
|
||
body.parent_id = parentId;
|
||
const locale = str3(args, "locale").trim();
|
||
if (locale)
|
||
body.locale = locale;
|
||
if (num3(args, "sort_order") !== undefined)
|
||
body.sort_order = Math.trunc(num3(args, "sort_order"));
|
||
return unwrap(await client.post("/v1/topology/folders", body, signal));
|
||
}
|
||
async function addTopologyViewNodes(client, args, signal) {
|
||
const viewId = str3(args, "view_id").trim();
|
||
if (!viewId)
|
||
return { ok: false, error: "view_id_required" };
|
||
if (strList2(args, "managed_ne_ids").length || strList2(args, "ume_ne_ids").length) {
|
||
return {
|
||
ok: false,
|
||
error: "fabric_nodes_only",
|
||
detail: "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids."
|
||
};
|
||
}
|
||
const filters = filterFields(args);
|
||
const fabricIds = strList2(args, "fabric_node_ids");
|
||
if (Object.keys(filters).length === 0 && fabricIds.length === 0) {
|
||
return {
|
||
ok: false,
|
||
error: "filter_or_fabric_node_ids_required",
|
||
detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids."
|
||
};
|
||
}
|
||
if (num3(args, "max_nodes") !== undefined) {
|
||
const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal));
|
||
if (got.ok === true) {
|
||
const data = asRecord2(got.data);
|
||
const filt = asRecord2(got.filter ?? data.filter);
|
||
const membership = asRecord2(filt.membership ?? {});
|
||
membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000);
|
||
filt.membership = membership;
|
||
await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal);
|
||
}
|
||
}
|
||
const layout = str3(args, "layout", "grid").trim() || "grid";
|
||
const body = {
|
||
managed_ne_ids: [],
|
||
layout,
|
||
...filters
|
||
};
|
||
if (fabricIds.length)
|
||
body.fabric_node_ids = fabricIds;
|
||
if (num3(args, "limit") !== undefined)
|
||
body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000);
|
||
if (num3(args, "offset") !== undefined)
|
||
body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER);
|
||
return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000));
|
||
}
|
||
async function removeTopologyViewNodes(client, args, signal) {
|
||
const viewId = str3(args, "view_id").trim();
|
||
if (!viewId)
|
||
return { ok: false, error: "view_id_required" };
|
||
const body = { ...filterFields(args) };
|
||
const fabricIds = strList2(args, "fabric_node_ids");
|
||
if (fabricIds.length)
|
||
body.fabric_node_ids = fabricIds;
|
||
return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal));
|
||
}
|
||
async function copyTopologyViewNodes(client, args, signal) {
|
||
const sourceId = str3(args, "source_view_id").trim();
|
||
const targetId = str3(args, "target_view_id").trim();
|
||
if (!sourceId || !targetId) {
|
||
return { ok: false, error: "source_view_id_and_target_view_id_required" };
|
||
}
|
||
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120000));
|
||
if (source.ok !== true)
|
||
return source;
|
||
const nodes = Array.isArray(source.nodes) ? source.nodes : [];
|
||
const limit = num3(args, "limit");
|
||
const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes;
|
||
const fabricIds = selected.map((n) => {
|
||
const row = asRecord2(n);
|
||
return str3(row, "id") || str3(row, "fabric_node_id");
|
||
}).filter(Boolean);
|
||
if (bool3(args, "dry_run") === true) {
|
||
return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId };
|
||
}
|
||
if (bool3(args, "clear_target") === true) {
|
||
await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, {
|
||
fabric_node_ids: "ALL",
|
||
clear_all: true
|
||
}, signal);
|
||
}
|
||
if (fabricIds.length === 0) {
|
||
return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId };
|
||
}
|
||
const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`, { fabric_node_ids: fabricIds, layout: "keep", managed_ne_ids: [] }, signal, 180000));
|
||
if (add.ok !== true)
|
||
return add;
|
||
if (bool3(args, "copy_positions") !== false) {
|
||
const ox = num3(args, "offset_x") ?? 0;
|
||
const oy = num3(args, "offset_y") ?? 0;
|
||
const positions = selected.map((n) => {
|
||
const row = asRecord2(n);
|
||
const id = str3(row, "id") || str3(row, "fabric_node_id");
|
||
return {
|
||
fabric_node_id: id,
|
||
x: Number(row.x || 0) + ox,
|
||
y: Number(row.y || 0) + oy
|
||
};
|
||
}).filter((p) => p.fabric_node_id);
|
||
if (positions.length) {
|
||
await client.patch(`/v1/topology/views/${encodeURIComponent(targetId)}/positions`, { positions }, signal, 120000);
|
||
}
|
||
}
|
||
return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) };
|
||
}
|
||
async function updateTopologyViewPositions(client, args, signal) {
|
||
const viewId = str3(args, "view_id").trim();
|
||
if (!viewId)
|
||
return { ok: false, error: "view_id_required" };
|
||
const body = {};
|
||
if (Array.isArray(args.positions))
|
||
body.positions = args.positions;
|
||
const layout = str3(args, "layout").trim();
|
||
if (layout)
|
||
body.layout = layout;
|
||
Object.assign(body, filterFields(args));
|
||
for (const key of ["offset_x", "offset_y", "cols", "gap_x", "gap_y"]) {
|
||
if (num3(args, key) !== undefined)
|
||
body[key] = num3(args, key);
|
||
}
|
||
const fabricIds = strList2(args, "fabric_node_ids");
|
||
if (fabricIds.length)
|
||
body.fabric_node_ids = fabricIds;
|
||
return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000));
|
||
}
|
||
async function projectTopologyNeighbors(client, args, signal) {
|
||
const viewId = str3(args, "view_id").trim();
|
||
if (!viewId)
|
||
return { ok: false, error: "view_id_required" };
|
||
const body = {};
|
||
const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids");
|
||
if (seeds.length)
|
||
body.seed_fabric_node_ids = seeds;
|
||
const mids = strList2(args, "managed_ne_ids");
|
||
if (mids.length)
|
||
body.managed_ne_ids = mids;
|
||
const region = str3(args, "region_folder_id").trim();
|
||
if (region)
|
||
body.region_folder_id = region;
|
||
const out = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, body, signal, 180000));
|
||
if (out.ok !== true)
|
||
return out;
|
||
const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary";
|
||
if (detail === "full" || detail === "raw" || detail === "graph") {
|
||
return { ...out, detail: "full", projected: true };
|
||
}
|
||
return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId };
|
||
}
|
||
async function queryTopologyFabricNodes(client, args, signal) {
|
||
let mode = str3(args, "mode").trim().toLowerCase();
|
||
const q = str3(args, "q").trim();
|
||
const keyword = str3(args, "keyword").trim();
|
||
if (!mode) {
|
||
if (bool3(args, "summary") === true)
|
||
mode = "summary";
|
||
else if (q)
|
||
mode = "search";
|
||
else
|
||
mode = "list";
|
||
}
|
||
if (mode === "summary" || mode === "stats" || mode === "count") {
|
||
const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal));
|
||
return out2.ok === true ? { ...out2, mode: "summary" } : out2;
|
||
}
|
||
if (mode === "search" || mode === "find") {
|
||
const needle = q || keyword;
|
||
if (!needle)
|
||
return { ok: false, error: "q_required", hint: "mode=search needs q (or keyword)." };
|
||
return unwrap(await client.get("/v1/topology/fabric/nodes/search", {
|
||
q: needle,
|
||
page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||
page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200)
|
||
}, signal));
|
||
}
|
||
const params = {
|
||
page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||
page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500)
|
||
};
|
||
for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) {
|
||
const v = str3(args, key).trim() || (key === "keyword" ? q : "");
|
||
if (v)
|
||
params[key] = v;
|
||
}
|
||
const out = unwrap(await client.get("/v1/topology/fabric/nodes", params, signal));
|
||
return out.ok === true ? { ...out, mode: "list" } : out;
|
||
}
|
||
async function classifyTopologyFabricNodes(client, args, signal) {
|
||
const action = str3(args, "action").trim().toLowerCase();
|
||
if (!action)
|
||
return { ok: false, error: "action_required" };
|
||
if (action === "match") {
|
||
const pattern = str3(args, "pattern").trim() || str3(args, "q").trim();
|
||
if (!pattern)
|
||
return { ok: false, error: "pattern_required" };
|
||
return unwrap(await client.post("/v1/topology/fabric/nodes/match", {
|
||
pattern,
|
||
match_field: str3(args, "match_field", "name").trim() || "name",
|
||
sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200)
|
||
}, signal));
|
||
}
|
||
if (action === "tag") {
|
||
const body = {
|
||
fabric_node_ids: strList2(args, "fabric_node_ids"),
|
||
dry_run: bool3(args, "dry_run") === true
|
||
};
|
||
const pattern = str3(args, "pattern").trim() || str3(args, "q").trim();
|
||
if (pattern)
|
||
body.pattern = pattern;
|
||
if (args.level !== undefined)
|
||
body.level = args.level;
|
||
const role = str3(args, "role").trim();
|
||
if (role)
|
||
body.role = role;
|
||
const region = str3(args, "region_folder_id").trim();
|
||
if (region)
|
||
body.region_folder_id = region;
|
||
if (bool3(args, "clear_region") === true)
|
||
body.clear_region = true;
|
||
return unwrap(await client.post("/v1/topology/fabric/nodes/tags/bulk", body, signal));
|
||
}
|
||
if (action === "patch") {
|
||
const nodeId = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim();
|
||
if (!nodeId)
|
||
return { ok: false, error: "fabric_node_id_required" };
|
||
const body = {};
|
||
if (args.level !== undefined)
|
||
body.level = args.level;
|
||
const role = str3(args, "role").trim();
|
||
if (role)
|
||
body.role = role;
|
||
const region = str3(args, "region_folder_id").trim();
|
||
if (region)
|
||
body.region_folder_id = region;
|
||
if (bool3(args, "clear_region") === true)
|
||
body.clear_region = true;
|
||
return unwrap(await client.patch(`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`, body, signal));
|
||
}
|
||
if (action === "unmatched") {
|
||
return unwrap(await client.get("/v1/topology/classify/unmatched", {
|
||
kind: str3(args, "kind", "any").trim() || "any",
|
||
page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||
page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500)
|
||
}, signal));
|
||
}
|
||
if (action === "preview_rules") {
|
||
return unwrap(await client.post("/v1/topology/classify/preview", {}, signal));
|
||
}
|
||
if (action === "apply_rules") {
|
||
const params = {};
|
||
if (bool3(args, "overwrite_manual") === true)
|
||
params.skip_manual = false;
|
||
else if (bool3(args, "skip_manual") !== undefined)
|
||
params.skip_manual = bool3(args, "skip_manual") !== false;
|
||
if (bool3(args, "fill_empty_only") === true)
|
||
params.fill_empty_only = true;
|
||
return unwrap(await client.post("/v1/topology/classify/apply", {}, signal));
|
||
}
|
||
if (action === "list_rules") {
|
||
return unwrap(await client.get("/v1/topology/classify/rules", undefined, signal));
|
||
}
|
||
return { ok: false, error: "unknown_action", action };
|
||
}
|
||
async function queryTopologyNeighborhood(client, args, signal) {
|
||
const nodeId = str3(args, "node_id").trim();
|
||
if (!nodeId)
|
||
return { ok: false, error: "node_id_required" };
|
||
const out = unwrap(await client.get("/v1/topology/fabric/neighborhood", {
|
||
node_id: nodeId,
|
||
depth: clampInt2(num3(args, "depth"), 1, 1, 3),
|
||
layer: str3(args, "layer", "physical").trim() || "physical"
|
||
}, signal));
|
||
if (out.ok !== true)
|
||
return out;
|
||
const edges = Array.isArray(out.edges) ? out.edges : [];
|
||
return { ...out, links: collapseEdgesToLinks(edges) };
|
||
}
|
||
async function queryTopologyEdges(client, args, signal) {
|
||
const params = {
|
||
page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||
page_size: clampInt2(num3(args, "page_size"), 100, 1, 500),
|
||
layer: str3(args, "layer", "physical").trim() || "physical"
|
||
};
|
||
for (const key of ["node_id", "keyword", "status", "source"]) {
|
||
const v = str3(args, key).trim();
|
||
if (v)
|
||
params[key] = v;
|
||
}
|
||
const out = unwrap(await client.get("/v1/topology/fabric/edges", params, signal));
|
||
if (out.ok !== true)
|
||
return out;
|
||
const detail = str3(args, "detail", "adjacency").trim().toLowerCase() || "adjacency";
|
||
const items = Array.isArray(out.items) ? out.items : Array.isArray(out.edges) ? out.edges : [];
|
||
if (detail === "ports")
|
||
return { ...out, detail: "ports", items };
|
||
const links = collapseEdgesToLinks(items, true);
|
||
return { ...out, detail: "adjacency", links, items: undefined };
|
||
}
|
||
async function suggestSinkHubs(client, args, signal) {
|
||
const viewId = str3(args, "source_view_id").trim() || str3(args, "view_id").trim();
|
||
if (!viewId)
|
||
return { ok: false, error: "source_view_id_required" };
|
||
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000));
|
||
if (out.ok !== true)
|
||
return out;
|
||
const nodes = Array.isArray(out.nodes) ? out.nodes : [];
|
||
const edges = Array.isArray(out.edges) ? out.edges : [];
|
||
const degree = new Map;
|
||
for (const raw of edges) {
|
||
const [a, b] = edgeEndpoints(asRecord2(raw));
|
||
if (!a || !b)
|
||
continue;
|
||
degree.set(a, (degree.get(a) || 0) + 1);
|
||
degree.set(b, (degree.get(b) || 0) + 1);
|
||
}
|
||
const exclude = new Set([
|
||
...strList2(args, "exclude_portal_ids"),
|
||
...strList2(args, "exclude_fabric_node_ids")
|
||
]);
|
||
const pick = clampInt2(num3(args, "pick"), 5, 1, 50);
|
||
const hubs = [...degree.entries()].filter(([id]) => !exclude.has(id)).sort((a, b) => b[1] - a[1]).slice(0, pick).map(([fabric_node_id, deg]) => {
|
||
const node = nodes.find((n) => {
|
||
const row2 = asRecord2(n);
|
||
return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id;
|
||
});
|
||
const row = asRecord2(node);
|
||
return {
|
||
fabric_node_id,
|
||
degree: deg,
|
||
name: str3(row, "name"),
|
||
level: row.level,
|
||
role: row.role
|
||
};
|
||
});
|
||
return {
|
||
ok: true,
|
||
source_view_id: viewId,
|
||
hubs,
|
||
hint: "Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP."
|
||
};
|
||
}
|
||
async function analyzeTopologyViewLayout(client, args, signal) {
|
||
const viewId = str3(args, "view_id").trim();
|
||
if (!viewId) {
|
||
return {
|
||
ok: false,
|
||
error: "view_id_required",
|
||
hint: "Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis."
|
||
};
|
||
}
|
||
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000));
|
||
if (out.ok !== true)
|
||
return out;
|
||
const nodes = Array.isArray(out.nodes) ? out.nodes : [];
|
||
const edges = Array.isArray(out.edges) ? out.edges : [];
|
||
let minX = Infinity;
|
||
let minY = Infinity;
|
||
let maxX = -Infinity;
|
||
let maxY = -Infinity;
|
||
let missingxy = 0;
|
||
for (const raw of nodes) {
|
||
const row = asRecord2(raw);
|
||
const x = Number(row.x);
|
||
const y = Number(row.y);
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||
missingxy += 1;
|
||
continue;
|
||
}
|
||
minX = Math.min(minX, x);
|
||
minY = Math.min(minY, y);
|
||
maxX = Math.max(maxX, x);
|
||
maxY = Math.max(maxY, y);
|
||
}
|
||
const degree = new Map;
|
||
for (const raw of edges) {
|
||
const [a, b] = edgeEndpoints(asRecord2(raw));
|
||
if (!a || !b)
|
||
continue;
|
||
degree.set(a, (degree.get(a) || 0) + 1);
|
||
degree.set(b, (degree.get(b) || 0) + 1);
|
||
}
|
||
const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([id, deg]) => ({ fabric_node_id: id, degree: deg }));
|
||
return {
|
||
ok: true,
|
||
view_id: viewId,
|
||
detail: str3(args, "detail", "summary") || "summary",
|
||
node_count: nodes.length,
|
||
edge_count: edges.length,
|
||
link_count: collapseEdgesToLinks(edges).length,
|
||
missing_xy: missingxy,
|
||
bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null,
|
||
hubs,
|
||
hint: "Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP."
|
||
};
|
||
}
|
||
async function sinkTopologyDualUnits(_client, args) {
|
||
return {
|
||
ok: false,
|
||
error: "sink_dual_units_requires_netx_topology_mcp",
|
||
source_view_id: str3(args, "source_view_id"),
|
||
sink_view_id: str3(args, "sink_view_id"),
|
||
hint: "sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP."
|
||
};
|
||
}
|
||
async function layoutTopologyView(client, args, signal) {
|
||
if (bool3(args, "catalog") === true) {
|
||
return {
|
||
ok: true,
|
||
catalog: true,
|
||
actions: [
|
||
"layout",
|
||
"layout_dual_unit",
|
||
"move_nodes",
|
||
"sink_nodes",
|
||
"orbit_sweep",
|
||
"level_bands",
|
||
"polish_crossings",
|
||
"clear_edge_hits",
|
||
"compact_bbox",
|
||
"pull_far_chains",
|
||
"align_reference",
|
||
"fix_overlaps",
|
||
"resolve_overlaps",
|
||
"untangle",
|
||
"straighten_channels",
|
||
"job_status",
|
||
"job_cancel"
|
||
],
|
||
dsh_supported: ["move_nodes", "sink_nodes", "catalog"],
|
||
recipes: ["rings", "corridor", "compact", "unstick"],
|
||
hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP."
|
||
};
|
||
}
|
||
const action = str3(args, "action", "layout").trim().toLowerCase() || "layout";
|
||
if (action === "job_status" || action === "job_cancel") {
|
||
return {
|
||
ok: false,
|
||
error: "layout_jobs_not_in_dsh_host",
|
||
hint: "Background layout jobs are owned by netx-topology MCP."
|
||
};
|
||
}
|
||
if (action === "move_nodes" || action === "sink_nodes") {
|
||
const toId = str3(args, "view_id").trim();
|
||
const fromId = str3(args, "source_view_id").trim();
|
||
const ids = strList2(args, "fabric_node_ids");
|
||
const params = asRecord2(args.params);
|
||
const fabricIds = ids.length ? ids : strList2(params, "fabric_node_ids");
|
||
if (!toId || !fromId) {
|
||
return { ok: false, error: "view_id_and_source_view_id_required" };
|
||
}
|
||
if (fabricIds.length === 0) {
|
||
return { ok: false, error: "fabric_node_ids_required" };
|
||
}
|
||
const copyPositions = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false;
|
||
const removeFromSource = bool3(params, "remove_from_source") !== false;
|
||
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000));
|
||
if (source.ok !== true)
|
||
return source;
|
||
const nodes = Array.isArray(source.nodes) ? source.nodes : [];
|
||
const wanted = new Set(fabricIds);
|
||
const selected = nodes.filter((n) => {
|
||
const row = asRecord2(n);
|
||
const id = str3(row, "id") || str3(row, "fabric_node_id");
|
||
return wanted.has(id);
|
||
});
|
||
const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(toId)}/nodes`, { fabric_node_ids: fabricIds, layout: copyPositions ? "keep" : "grid", managed_ne_ids: [] }, signal, 180000));
|
||
if (add.ok !== true)
|
||
return add;
|
||
if (copyPositions && selected.length) {
|
||
const ox = Number(params.offset_x || 0);
|
||
const oy = Number(params.offset_y || 0);
|
||
const positions = selected.map((n) => {
|
||
const row = asRecord2(n);
|
||
return {
|
||
fabric_node_id: str3(row, "id") || str3(row, "fabric_node_id"),
|
||
x: Number(row.x || 0) + ox,
|
||
y: Number(row.y || 0) + oy
|
||
};
|
||
}).filter((p) => p.fabric_node_id);
|
||
await client.patch(`/v1/topology/views/${encodeURIComponent(toId)}/positions`, { positions }, signal, 120000);
|
||
}
|
||
if (removeFromSource) {
|
||
await client.post(`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`, { fabric_node_ids: fabricIds }, signal);
|
||
}
|
||
return {
|
||
ok: true,
|
||
action,
|
||
moved: fabricIds.length,
|
||
source_view_id: fromId,
|
||
view_id: toId,
|
||
park: bool3(params, "park") === true || bool3(args, "park") === true
|
||
};
|
||
}
|
||
return layoutRecipeUnavailable(action);
|
||
}
|
||
|
||
// src/netx/tools.ts
|
||
var str4 = (description) => ({ type: "string", ...description ? { description } : {} });
|
||
var num4 = (description) => ({ type: "number", ...description ? { description } : {} });
|
||
var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} });
|
||
var strArr = (description) => ({
|
||
type: "array",
|
||
items: { type: "string" },
|
||
...description ? { description } : {}
|
||
});
|
||
var anyObj = (description) => ({
|
||
type: "object",
|
||
additionalProperties: true,
|
||
...description ? { description } : {}
|
||
});
|
||
function renderJson2(_args, value) {
|
||
return [{ type: "text", text: JSON.stringify(value, null, 0) }];
|
||
}
|
||
var jsonOut2 = {
|
||
schema: { type: "json" },
|
||
render: renderJson2
|
||
};
|
||
function tool2(name, description, parameters, handler, getClient, timeoutMs) {
|
||
return defineTool2({
|
||
name,
|
||
description,
|
||
parameters,
|
||
output: jsonOut2,
|
||
timeoutMs,
|
||
isConcurrencySafe: () => true,
|
||
async execute(args, exec) {
|
||
const result = await handler(getClient(), args, exec.signal);
|
||
if (result.ok === false) {
|
||
throw new Error(JSON.stringify(result));
|
||
}
|
||
return result;
|
||
}
|
||
});
|
||
}
|
||
function registerNetxTools(ctx, connection, options) {
|
||
const enabledGroups = options.forceGroups ?? groupsForPlane(connection.groups, options.plane, options.only);
|
||
const allow = toolNamesForGroups(enabledGroups);
|
||
if (allow.size === 0)
|
||
return () => {};
|
||
const client = createNetxClient({
|
||
apiUrl: connection.apiUrl,
|
||
lang: connection.lang,
|
||
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000),
|
||
getToken: () => getNetxConnection()?.token ?? ""
|
||
});
|
||
const getClient = () => client;
|
||
const t = connection.toolCallTimeoutMs;
|
||
const catalog = [
|
||
tool2("netx__queryNmsAlarms", "Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", {
|
||
severity: str4(),
|
||
ne_id: str4("Filter only; do not show UUID to users"),
|
||
host_name: str4("Filter by NE host_name"),
|
||
ne_name: str4("Legacy alias mapped to keyword"),
|
||
keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."),
|
||
time_from: str4("ISO time; filters last_seen_at >="),
|
||
time_to: str4("ISO time; filters last_seen_at <="),
|
||
page: num4(),
|
||
page_size: num4()
|
||
}, queryUmeAlarms, getClient, t),
|
||
tool2("netx__aggregateNmsAlarms", "Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", {
|
||
severity: str4("Optional perceived_severity filter (critical/major/minor/warning)."),
|
||
top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."),
|
||
exclude_missing_host: bool4("Omit missing host_name from by_ne."),
|
||
time_from: str4(),
|
||
time_to: str4(),
|
||
group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."),
|
||
group_by2: str4(),
|
||
is_cleared: str4(),
|
||
ne_id: str4(),
|
||
event_type: str4(),
|
||
keyword: str4(),
|
||
limit: num4()
|
||
}, aggregateUmeAlarms, getClient, t),
|
||
tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t),
|
||
tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", {
|
||
keyword: str4(),
|
||
page: num4(),
|
||
page_size: num4()
|
||
}, queryUmeNeInventory, getClient, t),
|
||
tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", {
|
||
ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." }
|
||
}, getUmeNe, getClient, t),
|
||
tool2("netx__queryNmsAlarmsRaw", "Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", {
|
||
severity: str4(),
|
||
is_cleared: str4(),
|
||
ne_id: str4(),
|
||
event_type: str4(),
|
||
keyword: str4(),
|
||
time_from: str4(),
|
||
time_to: str4(),
|
||
order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"),
|
||
order: str4("asc | desc"),
|
||
select_fields: strArr(),
|
||
field_preset: str4("brief | evidence | ne_debug"),
|
||
page: num4(),
|
||
page_size: num4()
|
||
}, queryUmeAlarmsRaw, getClient, t),
|
||
tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", {
|
||
group_by: { type: "string", required: true },
|
||
group_by2: str4(),
|
||
severity: str4(),
|
||
is_cleared: str4(),
|
||
ne_id: str4(),
|
||
event_type: str4(),
|
||
keyword: str4(),
|
||
time_from: str4(),
|
||
time_to: str4(),
|
||
exclude_missing_host: bool4(),
|
||
limit: num4()
|
||
}, aggregateUmeAlarmsRaw, getClient, t),
|
||
tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t),
|
||
tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", {
|
||
sql: { type: "string", required: true },
|
||
limit: num4(),
|
||
statement_timeout_ms: num4()
|
||
}, sqlQueryUme, getClient, t),
|
||
tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", {
|
||
keyword: str4(),
|
||
vendor: str4(),
|
||
connect_status: str4("unknown | testing | pass | fail"),
|
||
page: num4(),
|
||
page_size: num4()
|
||
}, listManagedNe, getClient, t),
|
||
tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", {
|
||
ne_id: str4("Managed NE id"),
|
||
managed_ne_id: str4("Alias for ne_id"),
|
||
id: str4("Alias for ne_id")
|
||
}, getManagedNe, getClient, t),
|
||
tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", {
|
||
ne_id: str4(),
|
||
nms_ne_id: str4("NMS inventory id; alias ume_ne_id"),
|
||
ume_ne_id: str4("Legacy alias of nms_ne_id"),
|
||
ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."),
|
||
nms_ne_ids: strArr("NMS inventory ids; alias ume_ne_ids"),
|
||
ume_ne_ids: strArr("Legacy alias of nms_ne_ids"),
|
||
targets: {
|
||
type: "array",
|
||
description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].",
|
||
items: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
properties: {
|
||
ne_id: str4(),
|
||
nms_ne_id: str4(),
|
||
ume_ne_id: str4(),
|
||
commands: strArr()
|
||
}
|
||
}
|
||
},
|
||
commands: strArr("Commands for single NE, or shared commands for batch."),
|
||
read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."),
|
||
concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."),
|
||
async: bool4("oclaw-only async hint; ignored by native REST client.")
|
||
}, execManagedNe, getClient, Math.max(t, 300000)),
|
||
tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", {
|
||
source: str4("managed | nms | ume | all"),
|
||
keyword: str4(),
|
||
page: num4(),
|
||
page_size: num4()
|
||
}, listCliTargets, getClient, t),
|
||
tool2("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", {
|
||
from_nms_ne_id: str4("NMS inventory id"),
|
||
from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"),
|
||
from_managed_ne_id: str4(),
|
||
to_nms_ne_id: str4("NMS inventory id"),
|
||
to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"),
|
||
to_managed_ne_id: str4(),
|
||
max_paths: num4(),
|
||
max_hops: num4(),
|
||
layer: str4(),
|
||
detail: str4("summary | full")
|
||
}, findTopologyPaths, getClient, t),
|
||
tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", {
|
||
kind: str4("all | projects | tasks (default all)"),
|
||
purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"),
|
||
status: str4("Filter by status (e.g. active / paused)"),
|
||
q: str4("Keyword on name / ne_name / note / id"),
|
||
limit: num4("Max rows per list (default 100, max 500)")
|
||
}, listBizMonitors, getClient, t),
|
||
tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", {
|
||
kind: str4("auto | all | cutover | collect | runs (default auto)"),
|
||
project_id: str4("Cutover project id → cutover_batches"),
|
||
task_id: str4("biz_state task id → collect_batches"),
|
||
batch_id: str4("Cutover batch id (filter or kind=runs)"),
|
||
include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"),
|
||
status: str4("Filter batch status"),
|
||
limit: num4("Max batches (default 50)"),
|
||
runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)")
|
||
}, listBizMonitorBatches, getClient, t),
|
||
tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", {
|
||
project_id: str4("Cutover / biz-migration project id"),
|
||
task_id: str4("biz_state task id (optional if project_id set)")
|
||
}, getBizMonitorContext, getClient, t),
|
||
tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", {
|
||
batch_id: { type: "string", required: true },
|
||
run_id: str4("Optional evaluate run id")
|
||
}, getBizMonitorBoard, getClient, t),
|
||
tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", {
|
||
project_id: { type: "string", required: true },
|
||
status: str4("open|carried|resolved or empty=all"),
|
||
limit: num4()
|
||
}, listBizMonitorReds, getClient, t),
|
||
tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", {
|
||
run_id: { type: "string", required: true },
|
||
metric_id: str4(),
|
||
sheet_id: str4(),
|
||
verdict: str4(),
|
||
color: str4("e.g. red"),
|
||
q: str4(),
|
||
limit: num4(),
|
||
offset: num4()
|
||
}, getBizMonitorDiffs, getClient, t),
|
||
tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", {
|
||
batch_id: { type: "string", required: true }
|
||
}, getBizCollectBatch, getClient, t),
|
||
tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", {
|
||
batch_id: { type: "string", required: true },
|
||
command_id: { type: "string", required: true }
|
||
}, getBizCollectCommandRaw, getClient, t),
|
||
tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t),
|
||
tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", {
|
||
view_id: { type: "string", required: true },
|
||
detail: str4("summary | full"),
|
||
sample: num4()
|
||
}, getTopologyView, getClient, t),
|
||
tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", {
|
||
name: { type: "string", required: true },
|
||
parent_id: str4(),
|
||
locale: str4("zh | en"),
|
||
sort_order: num4()
|
||
}, createTopologyFolder, getClient, t),
|
||
tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", {
|
||
view_id: { type: "string", required: true },
|
||
max_nodes: num4(),
|
||
keyword: str4(),
|
||
role: str4(),
|
||
vendor: str4(),
|
||
link_status: str4(),
|
||
limit: num4(),
|
||
offset: num4(),
|
||
fabric_node_ids: strArr(),
|
||
layout: str4("grid | keep")
|
||
}, addTopologyViewNodes, getClient, t),
|
||
tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", {
|
||
view_id: { type: "string", required: true },
|
||
keyword: str4(),
|
||
role: str4(),
|
||
vendor: str4(),
|
||
link_status: str4(),
|
||
fabric_node_ids: strArr()
|
||
}, removeTopologyViewNodes, getClient, t),
|
||
tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", {
|
||
source_view_id: { type: "string", required: true },
|
||
target_view_id: { type: "string", required: true },
|
||
copy_positions: bool4(),
|
||
clear_target: bool4(),
|
||
offset_x: num4(),
|
||
offset_y: num4(),
|
||
limit: num4(),
|
||
dry_run: bool4()
|
||
}, copyTopologyViewNodes, getClient, t),
|
||
tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", {
|
||
view_id: { type: "string", required: true },
|
||
positions: {
|
||
type: "array",
|
||
description: "Manual placements: [{ fabric_node_id, x, y }, …]",
|
||
items: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
properties: {
|
||
fabric_node_id: str4(),
|
||
x: num4(),
|
||
y: num4()
|
||
}
|
||
}
|
||
},
|
||
layout: str4("grid | offset | stack"),
|
||
keyword: str4(),
|
||
role: str4(),
|
||
vendor: str4(),
|
||
link_status: str4(),
|
||
fabric_node_ids: strArr(),
|
||
offset_x: num4(),
|
||
offset_y: num4()
|
||
}, updateTopologyViewPositions, getClient, t),
|
||
tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", {
|
||
view_id: { type: "string", required: true },
|
||
seed_fabric_node_ids: strArr(),
|
||
managed_ne_ids: strArr(),
|
||
region_folder_id: str4(),
|
||
detail: str4("summary | full"),
|
||
sample: num4()
|
||
}, projectTopologyNeighbors, getClient, t),
|
||
tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", {
|
||
mode: str4("summary | list | search"),
|
||
q: str4(),
|
||
keyword: str4(),
|
||
role: str4(),
|
||
level: str4(),
|
||
level_major: str4(),
|
||
region_folder_id: str4(),
|
||
link_status: str4(),
|
||
page: num4(),
|
||
page_size: num4(),
|
||
limit: num4(),
|
||
summary: bool4()
|
||
}, queryTopologyFabricNodes, getClient, t),
|
||
tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", {
|
||
action: { type: "string", required: true },
|
||
pattern: str4(),
|
||
q: str4(),
|
||
match_field: str4(),
|
||
sample_limit: num4(),
|
||
fabric_node_ids: strArr(),
|
||
fabric_node_id: str4(),
|
||
node_id: str4(),
|
||
level: str4(),
|
||
role: str4(),
|
||
region_folder_id: str4(),
|
||
clear_region: bool4(),
|
||
dry_run: bool4(),
|
||
kind: str4(),
|
||
page: num4(),
|
||
page_size: num4(),
|
||
skip_manual: bool4(),
|
||
overwrite_manual: bool4(),
|
||
fill_empty_only: bool4()
|
||
}, classifyTopologyFabricNodes, getClient, t),
|
||
tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", {
|
||
node_id: { type: "string", required: true },
|
||
depth: num4(),
|
||
layer: str4()
|
||
}, queryTopologyNeighborhood, getClient, t),
|
||
tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", {
|
||
node_id: str4(),
|
||
keyword: str4(),
|
||
layer: str4(),
|
||
status: str4(),
|
||
source: str4("lldp | ume | manual"),
|
||
detail: str4("adjacency | ports"),
|
||
page: num4(),
|
||
page_size: num4()
|
||
}, queryTopologyEdges, getClient, t),
|
||
tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", {
|
||
source_view_id: str4(),
|
||
view_id: str4(),
|
||
pick: num4(),
|
||
exclude_portal_ids: strArr(),
|
||
exclude_fabric_node_ids: strArr()
|
||
}, suggestSinkHubs, getClient, t),
|
||
tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", {
|
||
view_id: str4(),
|
||
folder_id: str4(),
|
||
detail: str4("summary | structure | hotspots | blocks | both"),
|
||
score_profile: str4(),
|
||
sight_limit: num4(),
|
||
max_views: num4(),
|
||
min_nodes: num4(),
|
||
max_nodes: num4(),
|
||
with_meta: bool4()
|
||
}, analyzeTopologyViewLayout, getClient, t),
|
||
tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", {
|
||
source_view_id: str4(),
|
||
sink_view_id: str4(),
|
||
max_units: num4(),
|
||
min_nodes: num4(),
|
||
max_nodes: num4(),
|
||
max_batch_nodes: num4(),
|
||
layout_batch: bool4(),
|
||
until_empty: bool4(),
|
||
dry_run: bool4()
|
||
}, sinkTopologyDualUnits, getClient, t),
|
||
tool2("netx__layoutTopologyView", "Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.", {
|
||
view_id: str4(),
|
||
action: str4(),
|
||
source_view_id: str4(),
|
||
recipe: str4(),
|
||
preset: str4(),
|
||
mode: str4("preview | apply"),
|
||
tune: bool4(),
|
||
params: anyObj("Action-specific options (move_nodes / recipes / polish)."),
|
||
catalog: bool4(),
|
||
fabric_node_ids: strArr(),
|
||
park: bool4(),
|
||
copy_positions: bool4()
|
||
}, layoutTopologyView, getClient, Math.max(t, 180000))
|
||
];
|
||
const disposers = catalog.filter((entry) => allow.has(entry.name)).map((entry) => ctx.tools.register(entry));
|
||
return () => {
|
||
for (const dispose of disposers)
|
||
dispose();
|
||
};
|
||
}
|
||
|
||
// src/netx/group-tools-plugin.ts
|
||
function applyGroupToolsPlugin(ctx, options) {
|
||
let unregisterTools;
|
||
let unregisterKbLocal;
|
||
let unregisterSkills;
|
||
let skillGeneration = 0;
|
||
const resolveGroups = () => {
|
||
const connection = getNetxConnection();
|
||
if (options.mode === "forced") {
|
||
return groupsForced(options.only ?? []);
|
||
}
|
||
return groupsForPlane(connection?.groups, "preset", options.only);
|
||
};
|
||
const remountTools = () => {
|
||
unregisterTools?.();
|
||
unregisterTools = undefined;
|
||
const connection = getNetxConnection();
|
||
if (connection === undefined) {
|
||
ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name);
|
||
return;
|
||
}
|
||
const enabled = resolveGroups();
|
||
if (enabled.length === 0) {
|
||
ctx.logger.info("%s: no groups enabled", options.name);
|
||
return;
|
||
}
|
||
unregisterTools = registerNetxTools(ctx, connection, {
|
||
plane: "preset",
|
||
only: options.mode === "forced" ? enabled : options.only,
|
||
forceGroups: options.mode === "forced" ? enabled : undefined
|
||
});
|
||
ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0);
|
||
};
|
||
const remountKbLocal = () => {
|
||
unregisterKbLocal?.();
|
||
unregisterKbLocal = undefined;
|
||
const connection = getNetxConnection();
|
||
if (connection === undefined)
|
||
return;
|
||
if (connection.groupKbInPreset === false)
|
||
return;
|
||
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext());
|
||
};
|
||
remountTools();
|
||
remountKbLocal();
|
||
const stopToolWatch = watchNetxConnection(() => {
|
||
remountTools();
|
||
remountKbLocal();
|
||
});
|
||
const stopKbToolWatch = watchKbContext(() => {
|
||
remountKbLocal();
|
||
});
|
||
ctx.inject(["skills"], (skillsCtx) => {
|
||
let unregisterKbSkill;
|
||
let unregisterKbPack;
|
||
let packGeneration = 0;
|
||
const remountSkills = () => {
|
||
const gen = ++skillGeneration;
|
||
unregisterSkills?.();
|
||
unregisterSkills = undefined;
|
||
if (getNetxConnection() === undefined)
|
||
return;
|
||
const enabled = resolveGroups();
|
||
registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => {
|
||
if (gen !== skillGeneration) {
|
||
dispose();
|
||
return;
|
||
}
|
||
unregisterSkills = dispose;
|
||
}).catch((error) => {
|
||
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
||
});
|
||
};
|
||
const remountKbSkill = () => {
|
||
unregisterKbSkill?.();
|
||
unregisterKbSkill = undefined;
|
||
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext());
|
||
};
|
||
const remountKbPack = () => {
|
||
const gen = ++packGeneration;
|
||
unregisterKbPack?.();
|
||
unregisterKbPack = undefined;
|
||
const connection = getNetxConnection();
|
||
const enabled = connection?.groupKbInPreset !== false;
|
||
registerKbPackSkills(skillsCtx, getKbContext(), {
|
||
enabled,
|
||
providerLabel: `${options.name}-kb-pack`
|
||
}).then((dispose) => {
|
||
if (gen !== packGeneration) {
|
||
dispose();
|
||
return;
|
||
}
|
||
unregisterKbPack = dispose;
|
||
}).catch((error) => {
|
||
skillsCtx.logger.warn("%s: kb pack skill register failed: %s", options.name, error);
|
||
});
|
||
};
|
||
remountSkills();
|
||
remountKbSkill();
|
||
remountKbPack();
|
||
const stopSkillWatch = watchNetxConnection(() => {
|
||
remountSkills();
|
||
remountKbPack();
|
||
});
|
||
const stopKbWatch = watchKbContext(() => {
|
||
remountKbSkill();
|
||
remountKbPack();
|
||
});
|
||
skillsCtx.effect(() => () => {
|
||
skillGeneration += 1;
|
||
packGeneration += 1;
|
||
stopSkillWatch();
|
||
stopKbWatch();
|
||
unregisterSkills?.();
|
||
unregisterSkills = undefined;
|
||
unregisterKbSkill?.();
|
||
unregisterKbSkill = undefined;
|
||
unregisterKbPack?.();
|
||
unregisterKbPack = undefined;
|
||
}, `${options.name}: dispose skills`);
|
||
});
|
||
ctx.effect(() => () => {
|
||
stopToolWatch();
|
||
stopKbToolWatch();
|
||
unregisterTools?.();
|
||
unregisterTools = undefined;
|
||
unregisterKbLocal?.();
|
||
unregisterKbLocal = undefined;
|
||
}, `${options.name}: dispose tools`);
|
||
}
|
||
|
||
// src/agent-tools-ops.ts
|
||
var name = "netxops-tools-ops";
|
||
var inject = ["tools"];
|
||
function apply(ctx) {
|
||
applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["ops"] });
|
||
}
|
||
export {
|
||
name,
|
||
inject,
|
||
apply
|
||
};
|