Sync dsh-netxops 0.1.41 with NetX async exec jobs and capability hints.

Add netx__getNeExecJob, route execManagedNe async to /exec-jobs, and document getManagedNe.capability for agents.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-06 18:32:17 +08:00
parent 4dd628b7cc
commit 30d4df23fa
14 changed files with 740 additions and 58 deletions

View file

@ -18,6 +18,7 @@ var TOOLS_BY_GROUP = Object.freeze({
"netx__listManagedNe",
"netx__getManagedNe",
"netx__execManagedNe",
"netx__getNeExecJob",
"netx__listCliTargets",
"netx__findTopologyPaths"
]),
@ -1133,7 +1134,60 @@ function quoteNeId(neId) {
}
// src/netx/handlers.ts
var EXEC_MAX_COMMANDS = 5;
var EXEC_MAX_COMMANDS_CAP = 50;
var EXEC_MAX_COMMANDS_DEFAULT = 5;
function execMaxCommands() {
const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ""), 10);
const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT;
return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n));
}
function asyncMinNes() {
const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ""), 10);
const n = Number.isFinite(raw) ? raw : 4;
return Math.max(0, Math.min(50, n));
}
function truthyAsyncFlag(raw) {
if (raw === undefined || raw === null)
return;
if (typeof raw === "boolean")
return raw;
const text = String(raw).trim().toLowerCase();
if (!text)
return;
if (["1", "true", "yes", "on"].includes(text))
return true;
if (["0", "false", "no", "off"].includes(text))
return false;
return;
}
function countExecNeTargets(args) {
let n = 0;
for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) {
const val = strList(args, key);
if (val.length > 0)
n = Math.max(n, val.length);
}
const targets = args.targets;
if (Array.isArray(targets)) {
n = Math.max(n, targets.filter((t) => typeof t === "object" && t !== null && !Array.isArray(t)).length);
}
if (n === 0) {
if (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id"))
return 1;
}
return n;
}
function shouldRunExecAsync(args) {
const flag = truthyAsyncFlag(args.async);
if (flag === false)
return false;
if (flag === true)
return true;
const minN = asyncMinNes();
if (minN <= 0)
return false;
return countExecNeTargets(args) >= minN;
}
var UME_RAW_FIELD_PRESETS = {
brief: [
"alarm_alarm_key",
@ -1375,6 +1429,8 @@ async function getManagedNe(client, args, signal) {
return out;
}
async function execManagedNe(client, args, signal) {
const maxCmds = execMaxCommands();
const wantAsync = shouldRunExecAsync(args);
const targetsRaw = args.targets;
const neIds = strList(args, "ne_ids");
const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids");
@ -1408,7 +1464,7 @@ async function execManagedNe(client, args, signal) {
if (umeNeIds.length > 0)
body2.ume_ne_ids = umeNeIds;
if (sharedCommands.length > 0) {
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
if (sharedCommands.length > maxCmds) {
return { ok: false, error: "too_many_commands", error_code: "too_many_commands" };
}
body2.commands = sharedCommands;
@ -1417,6 +1473,13 @@ async function execManagedNe(client, args, signal) {
const concurrency = num2(args, "concurrency");
if (concurrency !== undefined)
body2.concurrency = clampInt(concurrency, 4, 1, 8);
if (wantAsync) {
const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000);
if (out3.ok !== true)
return out3;
const data3 = asRecord(out3.data);
return Object.keys(data3).length > 0 ? data3 : out3;
}
const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000);
if (out2.ok !== true)
return out2;
@ -1439,7 +1502,7 @@ async function execManagedNe(client, args, signal) {
if (sharedCommands.length === 0) {
return { ok: false, error: "commands_required", error_code: "commands_required" };
}
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
if (sharedCommands.length > maxCmds) {
return { ok: false, error: "too_many_commands", error_code: "too_many_commands" };
}
const body = {
@ -1450,6 +1513,13 @@ async function execManagedNe(client, args, signal) {
body.ne_id = neId;
if (umeNeId)
body.ume_ne_id = umeNeId;
if (wantAsync) {
const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000);
if (out2.ok !== true)
return out2;
const data2 = asRecord(out2.data);
return Object.keys(data2).length > 0 ? data2 : out2;
}
const out = await client.post("/v1/managed-ne/exec", body, signal, 300000);
if (out.ok !== true)
return out;
@ -1459,6 +1529,18 @@ async function execManagedNe(client, args, signal) {
}
return { ok: true, data };
}
async function getNeExecJob(client, args, signal) {
const jobId = str2(args, "job_id").trim();
if (!jobId) {
return {
ok: false,
error: "job_id_required",
error_code: "job_id_required",
hint: "Pass job_id from execManagedNe async ack."
};
}
return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal);
}
async function listCliTargets(client, args, signal) {
const params = {
page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
@ -2614,12 +2696,12 @@ function registerNetxTools(ctx, connection, options) {
page: num4(),
page_size: num4()
}, listManagedNe, getClient, t),
tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", {
tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.", {
ne_id: str4("Managed NE id"),
managed_ne_id: str4("Alias for ne_id"),
id: str4("Alias for ne_id")
}, getManagedNe, getClient, t),
tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", {
tool2("netx__execManagedNe", "Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.", {
ne_id: str4(),
nms_ne_id: str4("NMS inventory id; alias ume_ne_id"),
ume_ne_id: str4("Legacy alias of nms_ne_id"),
@ -2628,7 +2710,7 @@ function registerNetxTools(ctx, connection, options) {
ume_ne_ids: strArr("Legacy alias of nms_ne_ids"),
targets: {
type: "array",
description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].",
description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].",
items: {
type: "object",
additionalProperties: false,
@ -2643,8 +2725,11 @@ function registerNetxTools(ctx, connection, options) {
commands: strArr("Commands for single NE, or shared commands for batch."),
read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."),
concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."),
async: bool4("oclaw-only async hint; ignored by native REST client.")
async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).")
}, execManagedNe, getClient, Math.max(t, 300000)),
tool2("netx__getNeExecJob", "Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.", {
job_id: str4("Job id from execManagedNe async response.")
}, getNeExecJob, getClient, Math.max(t, 60000)),
tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", {
source: str4("managed | nms | ume | all"),
keyword: str4(),