From c1d07f7509cbbd0e08eed5e030e7d9697d4a2f12 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 20 Sep 2026 15:04:14 +0800 Subject: [PATCH] Add listBizMonitors catalog tool so agents can discover projects/tasks without an id. Co-authored-by: Cursor --- docs/TOOL_MAP.md | 1 + lib/agent-tools-biz-monitor.js | 120 ++++++++++++++++- lib/agent-tools-common.js | 120 ++++++++++++++++- lib/agent-tools-nms.js | 120 ++++++++++++++++- lib/agent-tools-ops.js | 120 ++++++++++++++++- lib/agent-tools-topology.js | 120 ++++++++++++++++- lib/agent-tools.js | 120 ++++++++++++++++- lib/index.js | 120 ++++++++++++++++- .../biz-monitor/netx-biz-monitor/SKILL.md | 25 ++-- src/netx/capability-groups.ts | 1 + src/netx/handlers.ts | 122 ++++++++++++++++++ src/netx/tools.ts | 14 +- 12 files changed, 984 insertions(+), 19 deletions(-) diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 5b474be..5df6038 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -27,6 +27,7 @@ Host tools call netx REST directly (**not** netx-mcp). Token needs `biz-monitor: | Tool | Role | |------|------| +| `listBizMonitors` | Catalog: cutover projects + biz_state tasks (no id required) | | `getBizMonitorContext` | Project + templates + normalize + port map + tasks | | `getBizMonitorBoard` | Batch / evaluate progress | | `listBizMonitorReds` / `getBizMonitorDiffs` | Reds & diffs with evidence (raw A/B + show cmd) | diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index 6e03ec2..f8a36d9 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { 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 for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + 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 use 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__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), diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 87e839a..e2db01e 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { 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 for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + 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 use 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__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), diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 25dc70f..e026c33 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { 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 for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + 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 use 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__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), diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 9da224f..8d6213d 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { 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 for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + 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 use 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__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), diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 295479e..88d6662 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { 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 for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + 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 use 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__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), diff --git a/lib/agent-tools.js b/lib/agent-tools.js index ba073e2..3b49e7e 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { 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 for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + 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 use 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__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), diff --git a/lib/index.js b/lib/index.js index 6415f41..3a5ac0c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -611,6 +611,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -3728,6 +3729,116 @@ async function findTopologyPaths(client, args, signal) { 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 for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -4614,7 +4725,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + 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 use 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__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), diff --git a/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md b/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md index 76dd1fd..e3fa593 100644 --- a/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md +++ b/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md @@ -2,20 +2,22 @@ name: netx-biz-monitor description: >- Overnight cutover / biz_state monitor analysis via netxops host tools - (netx__getBizMonitor*). Given a project_id or task_id, read definitions, - board, reds/diffs with evidence (device-raw A/B, show commands), and judge - tool false-positive vs real business fault. Does not create templates/tasks. + (netx__listBizMonitors / netx__getBizMonitor*). List projects/tasks first + when no id is given; then read definitions, board, reds/diffs with evidence + (device-raw A/B, show commands), and judge tool false-positive vs real + business fault. Does not create templates/tasks. --- # netx-biz-monitor(割接 / 业务监控只读分析) -人配好监控与任务;AI 当晚用 **project_id**(或 task_id)做归因。 +人配好监控与任务;AI 当晚做归因。 工具在 **netxops**(`netx__*`),直连 netx REST,**不是** netx-mcp。 ## 工具(能力组 `bizMonitor`) | 工具 | 用途 | |------|------| +| `netx__listBizMonitors` | **入口目录**:列出割接/监控对比项目 + 业务监控任务(**无需** project_id) | | `netx__getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 | | `netx__getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 | | `netx__listBizMonitorReds` | 红单 + **evidence** | @@ -25,7 +27,7 @@ description: >- 交叉验证真障时再用 ops:`netx__execManagedNe` / 拓扑路径。 -## 关键约定(必守) +## 字段约定(必守) - **`old_key` / `new_key`**:设备采集原文 **A/B**(大屏与结论用这个) - **`match_*` / evidence.iface.normalized|map_***:内部配对,**不要**把映射后的 BB 说成两侧端口 @@ -34,12 +36,13 @@ description: >- ## 推荐顺序 -1. `getBizMonitorContext(project_id)` — 弄清盯什么 -2. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped -3. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)` -4. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤? -5. 需要原文 → `getBizCollectCommandRaw` -6. 像真障 → `execManagedNe` 现场核对 +1. 无 id → `listBizMonitors`(`kind=projects|tasks|all`,可加 `q` / `purpose`) +2. `getBizMonitorContext(project_id)` — 弄清盯什么 +3. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped +4. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)` +5. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤? +6. 需要原文 → `getBizCollectCommandRaw` +7. 像真障 → `execManagedNe` 现场核对 ## Verdict 速查(skill 解释,工具不硬编码) diff --git a/src/netx/capability-groups.ts b/src/netx/capability-groups.ts index faa15f6..0312125 100644 --- a/src/netx/capability-groups.ts +++ b/src/netx/capability-groups.ts @@ -81,6 +81,7 @@ export const TOOLS_BY_GROUP: Readonly (typeof v === 'string' || typeof v === 'number' ? String(v).toLowerCase() : '')) + .join(' ') + return hay.includes(q) +} + +/** + * Catalog entry: list cutover projects and/or biz_state tasks (no project_id required). + * Use this first when the user did not give an id; then call getBizMonitorContext. + */ +export async function listBizMonitors( + client: NetxClient, + args: NetxJson, + signal?: AbortSignal, +): Promise { + const kindRaw = str(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 = str(args, 'purpose').trim() + const status = str(args, 'status').trim().toLowerCase() + const q = str(args, 'q').trim().toLowerCase() + const limit = clampInt(num(args, 'limit') ?? 100, 100, 1, 500) + + const wantProjects = kind === 'all' || kind === 'projects' + const wantTasks = kind === 'all' || kind === 'tasks' + + const fetches: Array> = [] + if (wantProjects) fetches.push(client.get('/v1/biz-migration/projects', {}, signal)) + else fetches.push(Promise.resolve({ ok: true, data: { items: [] } })) + if (wantTasks) { + const params: Record = {} + 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 as unknown[]) + : [] + const taskItems = Array.isArray(asRecord(taskRes.data).items) + ? (asRecord(taskRes.data).items as unknown[]) + : [] + + 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 for the full definition bundle.', + }, + } +} + /** Fat cutover / biz_state definition bundle (templates, mapping, tasks). */ export async function getBizMonitorContext( client: NetxClient, diff --git a/src/netx/tools.ts b/src/netx/tools.ts index d4e4f7e..baaf53b 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -317,9 +317,21 @@ export function registerNetxTools( H.findTopologyPaths, getClient, t, ), // ── bizMonitor group (cutover / biz_state read via netx REST) ───────── + tool( + '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 use getBizMonitorContext.', + { + kind: str('all | projects | tasks (default all)'), + purpose: str('Filter tasks: portrait | cutover_hf | empty=all'), + status: str('Filter by status (e.g. active / paused)'), + q: str('Keyword on name / ne_name / note / id'), + limit: num('Max rows per list (default 100, max 500)'), + }, + H.listBizMonitors, getClient, t, + ), tool( 'netx__getBizMonitorContext', - 'Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.', + '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: str('Cutover / biz-migration project id'), task_id: str('biz_state task id (optional if project_id set)'),