From b235957c34d1d255ec9b436baebf3410c428fae1 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 20 Sep 2026 15:26:46 +0800 Subject: [PATCH] Add listBizMonitorBatches so agents can discover cutover/collect batch and run ids. Co-authored-by: Cursor --- docs/TOOL_MAP.md | 1 + lib/agent-tools-biz-monitor.js | 184 ++++++++++++++- lib/agent-tools-common.js | 184 ++++++++++++++- lib/agent-tools-nms.js | 184 ++++++++++++++- lib/agent-tools-ops.js | 184 ++++++++++++++- lib/agent-tools-topology.js | 184 ++++++++++++++- lib/agent-tools.js | 184 ++++++++++++++- lib/index.js | 184 ++++++++++++++- .../biz-monitor/netx-biz-monitor/SKILL.md | 22 +- src/netx/capability-groups.ts | 1 + src/netx/handlers.ts | 215 +++++++++++++++++- src/netx/tools.ts | 17 +- 12 files changed, 1518 insertions(+), 26 deletions(-) diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 5df6038..0bcaa34 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -28,6 +28,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) | +| `listBizMonitorBatches` | Cutover / collect batch ids (+ optional evaluate runs) | | `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 f8a36d9..b31986f 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { 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." + 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)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { 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 use getBizMonitorContext.", { + 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)") diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index e2db01e..f711889 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { 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." + 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)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { 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 use getBizMonitorContext.", { + 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)") diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index e026c33..5d3c406 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { 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." + 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)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { 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 use getBizMonitorContext.", { + 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)") diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 8d6213d..3687d5a 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { 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." + 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)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { 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 use getBizMonitorContext.", { + 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)") diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 88d6662..5c01f5c 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { 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." + 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)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { 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 use getBizMonitorContext.", { + 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)") diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 3b49e7e..2ca382a 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { 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." + 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)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { 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 use getBizMonitorContext.", { + 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)") diff --git a/lib/index.js b/lib/index.js index 3a5ac0c..d11f6e7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -612,6 +612,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -3835,7 +3836,176 @@ async function listBizMonitors(client, args, signal) { 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." + 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)." } }; } @@ -4725,13 +4895,23 @@ function registerNetxTools(ctx, connection, options) { 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 use getBizMonitorContext.", { + 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)") 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 e3fa593..ebd44a9 100644 --- a/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md +++ b/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md @@ -2,10 +2,10 @@ name: netx-biz-monitor description: >- Overnight cutover / biz_state monitor analysis via netxops host tools - (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__listBizMonitors / listBizMonitorBatches / getBizMonitor*). List + projects/tasks then batches when no id is given; 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(割接 / 业务监控只读分析) @@ -18,6 +18,7 @@ description: >- | 工具 | 用途 | |------|------| | `netx__listBizMonitors` | **入口目录**:列出割接/监控对比项目 + 业务监控任务(**无需** project_id) | +| `netx__listBizMonitorBatches` | **批次目录**:`project_id`→割接批次;`task_id`→采集批次;`kind=runs`→evaluate run | | `netx__getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 | | `netx__getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 | | `netx__listBizMonitorReds` | 红单 + **evidence** | @@ -37,12 +38,13 @@ description: >- ## 推荐顺序 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` 现场核对 +2. `listBizMonitorBatches(project_id)` 或 `(task_id)` — 拿到 **完整 batch_id**;需要 run 时再 `kind=runs` / `include_runs` +3. `getBizMonitorContext(project_id)` — 弄清盯什么 +4. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped +5. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)` +6. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤? +7. 需要原文 → `getBizCollectCommandRaw` +8. 像真障 → `execManagedNe` 现场核对 ## Verdict 速查(skill 解释,工具不硬编码) diff --git a/src/netx/capability-groups.ts b/src/netx/capability-groups.ts index 0312125..a9e0ab4 100644 --- a/src/netx/capability-groups.ts +++ b/src/netx/capability-groups.ts @@ -82,6 +82,7 @@ export const TOOLS_BY_GROUP: Readonly { + const projectId = str(args, 'project_id').trim() + const taskId = str(args, 'task_id').trim() + const batchId = str(args, 'batch_id').trim() + const kindRaw = str(args, 'kind', 'auto').trim().toLowerCase() || 'auto' + const includeRuns = args.include_runs === true || args.include_runs === 'true' || args.include_runs === 1 + const status = str(args, 'status').trim().toLowerCase() + const limit = clampInt(num(args, 'limit') ?? 50, 50, 1, 200) + const runsLimit = clampInt(num(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: NetxJson[] = [] + let collectBatches: NetxJson[] = [] + let runs: NetxJson[] = [] + + 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 as unknown[]) + : [] + 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 as unknown[]) + : [] + 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 as unknown[]) + : [] + 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: [] as NetxJson[] } + const items = Array.isArray(asRecord(res.data).items) + ? (asRecord(res.data).items as unknown[]) + : [] + 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) { + // Keep listing batches even if one runs fetch fails; attach error note. + 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).', }, } } diff --git a/src/netx/tools.ts b/src/netx/tools.ts index baaf53b..70a4857 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -319,7 +319,7 @@ export function registerNetxTools( // ── 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.', + '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: str('all | projects | tasks (default all)'), purpose: str('Filter tasks: portrait | cutover_hf | empty=all'), @@ -329,6 +329,21 @@ export function registerNetxTools( }, H.listBizMonitors, getClient, t, ), + tool( + '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: str('auto | all | cutover | collect | runs (default auto)'), + project_id: str('Cutover project id → cutover_batches'), + task_id: str('biz_state task id → collect_batches'), + batch_id: str('Cutover batch id (filter or kind=runs)'), + include_runs: bool('Attach recent evaluate runs onto listed cutover batches'), + status: str('Filter batch status'), + limit: num('Max batches (default 50)'), + runs_limit: num('Max runs per batch when include_runs / kind=runs (default 10)'), + }, + H.listBizMonitorBatches, getClient, t, + ), tool( '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.',