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

@ -59,6 +59,7 @@ export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly str
'netx__listManagedNe',
'netx__getManagedNe',
'netx__execManagedNe',
'netx__getNeExecJob',
'netx__listCliTargets',
'netx__findTopologyPaths',
]),

View file

@ -5,7 +5,55 @@
import { type NetxClient, quoteNeId, type NetxJson } from './http.ts'
import { omitUndefined } from './json-safe.ts'
const EXEC_MAX_COMMANDS = 5
const EXEC_MAX_COMMANDS_CAP = 50
const EXEC_MAX_COMMANDS_DEFAULT = 5
function execMaxCommands(): number {
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(): number {
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: unknown): boolean | undefined {
if (raw === undefined || raw === null) return undefined
if (typeof raw === 'boolean') return raw
const text = String(raw).trim().toLowerCase()
if (!text) return undefined
if (['1', 'true', 'yes', 'on'].includes(text)) return true
if (['0', 'false', 'no', 'off'].includes(text)) return false
return undefined
}
function countExecNeTargets(args: NetxJson): number {
let n = 0
for (const key of ['ne_ids', 'nms_ne_ids', 'ume_ne_ids'] as const) {
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 (str(args, 'ne_id').trim() || nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')) return 1
}
return n
}
function shouldRunExecAsync(args: NetxJson): boolean {
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
}
const UME_RAW_FIELD_PRESETS: Record<string, string[]> = {
brief: [
@ -218,6 +266,8 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
}
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
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')
@ -249,7 +299,7 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
if (neIds.length > 0) body.ne_ids = neIds
if (umeNeIds.length > 0) body.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' }
}
body.commands = sharedCommands
@ -257,6 +307,12 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
body.read_timeout_sec = clampInt(num(args, 'read_timeout_sec'), 60, 10, 120)
const concurrency = num(args, 'concurrency')
if (concurrency !== undefined) body.concurrency = clampInt(concurrency, 4, 1, 8)
if (wantAsync) {
const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
return Object.keys(data).length > 0 ? data : out
}
const out = await client.post('/v1/managed-ne/exec-batch', body, signal, 600_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
@ -279,7 +335,7 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, 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: NetxJson = {
@ -288,6 +344,12 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
}
if (neId) body.ne_id = neId
if (umeNeId) body.ume_ne_id = umeNeId
if (wantAsync) {
const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
return Object.keys(data).length > 0 ? data : out
}
const out = await client.post('/v1/managed-ne/exec', body, signal, 300_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
@ -297,6 +359,19 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
return { ok: true, data }
}
export async function getNeExecJob(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const jobId = str(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)
}
export async function listCliTargets(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),

View file

@ -253,7 +253,7 @@ export function registerNetxTools(
),
tool(
'netx__getManagedNe',
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.',
'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: str('Managed NE id'),
managed_ne_id: str('Alias for ne_id'),
@ -263,7 +263,7 @@ export function registerNetxTools(
),
tool(
'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.',
'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: str(),
nms_ne_id: str('NMS inventory id; alias ume_ne_id'),
@ -273,7 +273,7 @@ export function registerNetxTools(
ume_ne_ids: strArr('Legacy alias of nms_ne_ids'),
targets: {
type: 'array' as const,
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' as const,
additionalProperties: false,
@ -288,10 +288,18 @@ export function registerNetxTools(
commands: strArr('Commands for single NE, or shared commands for batch.'),
read_timeout_sec: num('Per-command read timeout (default 60; use 90–120 for slow show).'),
concurrency: num('Parallel NEs for batch mode (1–8, default 4).'),
async: bool('oclaw-only async hint; ignored by native REST client.'),
async: bool('true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).'),
},
H.execManagedNe, getClient, Math.max(t, 300_000),
),
tool(
'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: str('Job id from execManagedNe async response.'),
},
H.getNeExecJob, getClient, Math.max(t, 60_000),
),
tool(
'netx__listCliTargets',
'List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',