Ship capability groups (nms/common/topology) with shared netx skills.

One group maps to one skill and tools-*; rename NMS tools to *Nms*; sync playbooks from netx/skills.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-06 01:46:07 +08:00
parent 83c3796fbb
commit 3c9a383071
47 changed files with 11790 additions and 680 deletions

15
src/agent-tools-common.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* Forced common-group tools + skill (native managed CLI + findTopologyPaths).
*
* @module dsh-netxops/tools-common
*/
import type { Context } from '@deepseek-ai/cordis'
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
export const name = 'netxops-tools-common'
export const inject = ['tools']
export function apply(ctx: Context): void {
applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['common'] })
}

16
src/agent-tools-nms.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* Forced nms-group tools + skill (vendor NMS adapter; current provider zte-ume).
* Mount this export in any agent preset that needs NMS alarms/inventory.
*
* @module dsh-netxops/tools-nms
*/
import type { Context } from '@deepseek-ai/cordis'
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
export const name = 'netxops-tools-nms'
export const inject = ['tools']
export function apply(ctx: Context): void {
applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['nms'] })
}

View file

@ -0,0 +1,15 @@
/**
* Forced topology-group tools + skill (canvas / fabric / dual_unit / layout).
*
* @module dsh-netxops/tools-topology
*/
import type { Context } from '@deepseek-ai/cordis'
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
export const name = 'netxops-tools-topology'
export const inject = ['tools']
export function apply(ctx: Context): void {
applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['topology'] })
}

View file

@ -1,14 +1,11 @@
/**
* Agent-plane Netx Ops tools: register `netx__*` into the calling context's
* tool scope (the Netx Ops preset standing mount), so other presets do not see them.
* Agent-plane aggregate mount: all capability groups gated by Settings inPreset.
*
* @module dsh-netxops/tools
*/
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-tools'
import { getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
import { registerNetxTools } from './netx/tools.ts'
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
/** Cordis plugin name. */
export const name = 'netxops-tools'
@ -16,35 +13,7 @@ export const name = 'netxops-tools'
/** Tool registry must exist in this (preset-scoped) context. */
export const inject = ['tools']
/**
* Mount netx REST tools for the Netx Ops agent preset only.
*/
/** Mount enabled groups for the Netx Ops preset. */
export function apply(ctx: Context): void {
let unregister: (() => void) | undefined
const remount = (): void => {
unregister?.()
unregister = undefined
const connection = getNetxConnection()
if (connection === undefined) {
ctx.logger.warn('netxops-tools: no connection yet — waiting for host settings bridge')
return
}
unregister = registerNetxTools(ctx, connection)
const tokenConfigured = connection.token.trim().length > 0
ctx.logger.info(
'netxops-tools: registered netx__* for Ops preset → %s tokenConfigured=%s',
connection.apiUrl,
tokenConfigured,
)
}
remount()
const stopWatch = watchNetxConnection(() => { remount() })
ctx.effect(() => () => {
stopWatch()
unregister?.()
unregister = undefined
}, 'netxops-tools: dispose')
applyGroupToolsPlugin(ctx, { name, mode: 'settings' })
}

View file

@ -7,6 +7,11 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
import type { NetxopsCardFace, NetxopsCardState } from './controller.ts'
import type { NetxopsLocaleKey } from './locales.ts'
import type { CardFieldState } from './card-form.ts'
import {
imCatalogOptionKey,
parseImCatalogOptionKey,
type ImDeliveryCatalog,
} from './im-delivery-catalog.ts'
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
import { ensureStyles } from './styles.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
@ -88,6 +93,159 @@ function ValueField(props: {
)
}
function ImDeliveryPicker(props: {
catalog: ImDeliveryCatalog
botId: string
targetId: string
disabled: boolean
labels: {
target: string
none: string
manual: string
unavailable: string
botId: string
botHint: string
targetId: string
targetHint: string
overridden: string
reset: string
invalid: string
}
botField: CardFieldState
targetField: CardFieldState
onPick: (botId: string, targetId: string) => void
onEditBot: (text: string) => void
onEditTarget: (text: string) => void
onResetBot: () => void
onResetTarget: () => void
}) {
const options = props.catalog.options
const selectedKey = props.botId && props.targetId
? imCatalogOptionKey(props.botId, props.targetId)
: ''
const matched = options.some((row) => imCatalogOptionKey(row.botId, row.targetId) === selectedKey)
const [manual, setManual] = useState(() => !!(selectedKey && !matched))
useEffect(() => {
if (selectedKey && matched) setManual(false)
else if (selectedKey && !matched) setManual(true)
}, [selectedKey, matched])
const selectValue = manual ? '__manual__' : (matched ? selectedKey : '')
const showManual = manual || (selectedKey && !matched)
return (
<>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor="netxops-im-target">{props.labels.target}</label>
</div>
<select
id="netxops-im-target"
className="dsh-nx-input dsh-nx-select"
value={selectValue}
disabled={props.disabled}
onChange={(event) => {
const value = event.target.value
if (value === '__manual__') {
setManual(true)
return
}
setManual(false)
if (!value) {
props.onPick('', '')
return
}
const next = parseImCatalogOptionKey(value)
props.onPick(next.botId, next.targetId)
}}
>
<option value="">
{options.length === 0 ? props.labels.none : `— ${props.labels.target} —`}
</option>
{options.map((row) => (
<option
key={imCatalogOptionKey(row.botId, row.targetId)}
value={imCatalogOptionKey(row.botId, row.targetId)}
>
{`${row.name} · ${row.channel || 'im'} · ${row.targetId}`}
</option>
))}
<option value="__manual__">{props.labels.manual}</option>
</select>
{!props.catalog.available || options.length === 0
? <p className="dsh-nx-hint">{props.catalog.hint || props.labels.unavailable}</p>
: null}
</div>
{showManual
? (
<>
<ValueField
id="netxops-im-bot-id"
label={props.labels.botId}
hint={props.labels.botHint}
field={props.botField}
overriddenLabel={props.labels.overridden}
resetLabel={props.labels.reset}
invalidLabel={props.labels.invalid}
disabled={props.disabled}
onEdit={props.onEditBot}
onReset={props.onResetBot}
/>
<ValueField
id="netxops-im-target-id"
label={props.labels.targetId}
hint={props.labels.targetHint}
field={props.targetField}
overriddenLabel={props.labels.overridden}
resetLabel={props.labels.reset}
invalidLabel={props.labels.invalid}
disabled={props.disabled}
onEdit={props.onEditTarget}
onReset={props.onResetTarget}
/>
</>
)
: null}
</>
)
}
function CapabilityGroupBlock(props: {
title: string
inPresetLabel: string
publicLabel: string
inPreset: CardFieldState
published: CardFieldState
disabled: boolean
onEditInPreset: (checked: boolean) => void
onEditPublic: (checked: boolean) => void
}) {
return (
<div className="dsh-nx-groupBlock">
<div className="dsh-nx-groupTitle">{props.title}</div>
<div className="dsh-nx-groupChecks">
<label className="dsh-nx-checkRow">
<input
type="checkbox"
checked={props.inPreset.text === 'true'}
disabled={props.disabled}
onChange={(event) => { props.onEditInPreset(event.target.checked) }}
/>
<span>{props.inPresetLabel}</span>
</label>
<label className="dsh-nx-checkRow">
<input
type="checkbox"
checked={props.published.text === 'true'}
disabled={props.disabled}
onChange={(event) => { props.onEditPublic(event.target.checked) }}
/>
<span>{props.publicLabel}</span>
</label>
</div>
</div>
)
}
export function NetxopsCard(props: NetxopsCardProps) {
ensureStyles()
const { t } = props
@ -188,6 +346,66 @@ export function NetxopsCard(props: NetxopsCardProps) {
onEdit={(text) => { props.edit('lang', text) }}
onReset={() => { props.resetField('lang') }}
/>
<ValueField
id="netxops-nms-provider"
label={t('nmsProvider')}
hint={t('nmsProviderHint')}
field={state.nmsProvider}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('nmsProvider', text) }}
onReset={() => { props.resetField('nmsProvider') }}
/>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<span className="dsh-nx-label">{t('capabilityGroups')}</span>
</div>
<p className="dsh-nx-hint">{t('capabilityGroupsHint')}</p>
<CapabilityGroupBlock
title={t('groupNms')}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupNmsInPreset}
published={state.groupNmsPublic}
disabled={disabled}
onEditInPreset={(checked) => {
props.edit('groupNmsInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupNmsPublic', checked ? 'true' : 'false')
}}
/>
<CapabilityGroupBlock
title={t('groupCommon')}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupCommonInPreset}
published={state.groupCommonPublic}
disabled={disabled}
onEditInPreset={(checked) => {
props.edit('groupCommonInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupCommonPublic', checked ? 'true' : 'false')
}}
/>
<CapabilityGroupBlock
title={t('groupTopology')}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupTopologyInPreset}
published={state.groupTopologyPublic}
disabled={disabled}
onEditInPreset={(checked) => {
props.edit('groupTopologyInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupTopologyPublic', checked ? 'true' : 'false')
}}
/>
</div>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor="netxops-alarm-push">{t('alarmPushEnabled')}</label>
@ -290,29 +508,34 @@ export function NetxopsCard(props: NetxopsCardProps) {
<span>{t('alarmDeliverImHint')}</span>
</label>
</div>
<ValueField
id="netxops-im-bot-id"
label={t('imBotId')}
hint={t('imBotIdHint')}
field={state.imBotId}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
<ImDeliveryPicker
catalog={state.imDeliveryCatalog}
botId={state.imBotId.text}
targetId={state.imTargetId.text}
disabled={disabled}
onEdit={(text) => { props.edit('imBotId', text) }}
onReset={() => { props.resetField('imBotId') }}
/>
<ValueField
id="netxops-im-target-id"
label={t('imTargetId')}
hint={t('imTargetIdHint')}
field={state.imTargetId}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('imTargetId', text) }}
onReset={() => { props.resetField('imTargetId') }}
labels={{
target: t('imTarget'),
none: t('imTargetNone'),
manual: t('imTargetManual'),
unavailable: t('imCatalogUnavailable'),
botId: t('imBotId'),
botHint: t('imBotIdHint'),
targetId: t('imTargetId'),
targetHint: t('imTargetIdHint'),
overridden: t('overridden'),
reset: t('reset'),
invalid: t('invalid'),
}}
botField={state.imBotId}
targetField={state.imTargetId}
onPick={(botId, targetId) => {
props.edit('imBotId', botId)
props.edit('imTargetId', targetId)
}}
onEditBot={(text) => { props.edit('imBotId', text) }}
onEditTarget={(text) => { props.edit('imTargetId', text) }}
onResetBot={() => { props.resetField('imBotId') }}
onResetTarget={() => { props.resetField('imTargetId') }}
/>
<div className="dsh-nx-footer">
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}

View file

@ -15,6 +15,11 @@ import {
type AlarmPushRpcCall,
type AlarmPushStatus,
} from './alarm-push-status-view.ts'
import {
EMPTY_IM_DELIVERY_CATALOG,
fetchImDeliveryCatalog,
type ImDeliveryCatalog,
} from './im-delivery-catalog.ts'
export const NETXOPS_NS = 'netxops'
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
@ -30,6 +35,13 @@ export interface NetxopsSettings {
alarmDeliverIm?: boolean
imBotId?: string
imTargetId?: string
groupNmsInPreset?: boolean
groupNmsPublic?: boolean
groupCommonInPreset?: boolean
groupCommonPublic?: boolean
groupTopologyInPreset?: boolean
groupTopologyPublic?: boolean
nmsProvider?: string
}
interface CredentialState {
@ -42,6 +54,13 @@ interface CredentialState {
export interface NetxopsCardState extends CardShell {
apiUrl: CardFieldState
lang: CardFieldState
groupNmsInPreset: CardFieldState
groupNmsPublic: CardFieldState
groupCommonInPreset: CardFieldState
groupCommonPublic: CardFieldState
groupTopologyInPreset: CardFieldState
groupTopologyPublic: CardFieldState
nmsProvider: CardFieldState
alarmPushEnabled: CardFieldState
alarmDeliverDsh: CardFieldState
alarmDeliverIm: CardFieldState
@ -53,6 +72,8 @@ export interface NetxopsCardState extends CardShell {
apiTokenRemoteReady: boolean
/** Host WSS status when Connection RPC is available; otherwise null. */
alarmPushStatus: AlarmPushStatus | null
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
imDeliveryCatalog: ImDeliveryCatalog
}
export interface NetxopsCardFace extends CardActions {
@ -77,8 +98,10 @@ export class NetxopsCardController {
}
private rpcCall: AlarmPushRpcCall | undefined
private alarmPushStatus: AlarmPushStatus | null = null
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
private pollTimer: ReturnType<typeof setInterval> | undefined
private pollInFlight = false
private catalogInFlight = false
constructor(
private readonly scope: SettingsScope<NetxopsSettings>,
@ -89,6 +112,13 @@ export class NetxopsCardController {
[
textField('apiUrl'),
textField('lang'),
textField('nmsProvider'),
booleanFieldPersistFalse('groupNmsInPreset'),
booleanField('groupNmsPublic'),
booleanFieldPersistFalse('groupCommonInPreset'),
booleanField('groupCommonPublic'),
booleanField('groupTopologyInPreset'),
booleanField('groupTopologyPublic'),
booleanField('alarmPushEnabled'),
booleanFieldPersistFalse('alarmDeliverDsh'),
booleanField('alarmDeliverIm'),
@ -122,19 +152,29 @@ export class NetxopsCardController {
this.rpcCall = call
if (call === undefined) {
this.stopStatusPoll()
let changed = false
if (this.alarmPushStatus !== null) {
this.alarmPushStatus = null
this.store.set(this.projection())
changed = true
}
if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) {
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
changed = true
}
if (changed) this.store.set(this.projection())
return
}
this.startStatusPoll()
void this.refreshAlarmPushStatus()
void this.refreshImDeliveryCatalog()
}
private startStatusPoll(): void {
if (this.pollTimer !== undefined) return
this.pollTimer = setInterval(() => { void this.refreshAlarmPushStatus() }, STATUS_POLL_MS)
this.pollTimer = setInterval(() => {
void this.refreshAlarmPushStatus()
void this.refreshImDeliveryCatalog()
}, STATUS_POLL_MS)
}
private stopStatusPoll(): void {
@ -168,11 +208,48 @@ export class NetxopsCardController {
}
}
private async refreshImDeliveryCatalog(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.catalogInFlight) return
this.catalogInFlight = true
try {
const next = await fetchImDeliveryCatalog(call)
const prev = this.imDeliveryCatalog
const sameOptions = prev.options.length === next.options.length
&& prev.options.every((row, index) => {
const other = next.options[index]
return other
&& row.botId === other.botId
&& row.targetId === other.targetId
&& row.name === other.name
&& row.channel === other.channel
})
if (
prev.available === next.available
&& prev.hint === next.hint
&& sameOptions
) return
this.imDeliveryCatalog = next
this.store.set(this.projection())
} catch {
// Keep last good snapshot; next poll retries.
} finally {
this.catalogInFlight = false
}
}
private projection(): NetxopsCardState {
return {
...this.form.shell(),
apiUrl: this.form.field('apiUrl'),
lang: this.form.field('lang'),
nmsProvider: this.form.field('nmsProvider'),
groupNmsInPreset: this.form.field('groupNmsInPreset'),
groupNmsPublic: this.form.field('groupNmsPublic'),
groupCommonInPreset: this.form.field('groupCommonInPreset'),
groupCommonPublic: this.form.field('groupCommonPublic'),
groupTopologyInPreset: this.form.field('groupTopologyInPreset'),
groupTopologyPublic: this.form.field('groupTopologyPublic'),
alarmPushEnabled: this.form.field('alarmPushEnabled'),
alarmDeliverDsh: this.form.field('alarmDeliverDsh'),
alarmDeliverIm: this.form.field('alarmDeliverIm'),
@ -183,6 +260,7 @@ export class NetxopsCardController {
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
apiTokenRemoteReady: this.credential.remoteReady,
alarmPushStatus: this.alarmPushStatus,
imDeliveryCatalog: this.imDeliveryCatalog,
}
}

View file

@ -0,0 +1,98 @@
/**
* Browser helper: load saved IM delivery targets via Netx Ops Connection RPC.
*/
import {
NETXOPS_RPC_CHANNEL,
type AlarmPushRpcCall,
} from './alarm-push-status-view.ts'
export const IM_DELIVERY_CATALOG_ENDPOINT = 'im-delivery.catalog'
export interface ImDeliveryCatalogOption {
botId: string
targetId: string
name: string
kind: string
channel: string
}
export interface ImDeliveryCatalog {
available: boolean
options: ImDeliveryCatalogOption[]
hint: string
loading: boolean
}
export const EMPTY_IM_DELIVERY_CATALOG: ImDeliveryCatalog = {
available: true,
options: [],
hint: '',
loading: false,
}
/**
* @param value - RPC catalog payload.
*/
export function asImDeliveryCatalog(value: unknown): ImDeliveryCatalog {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return { ...EMPTY_IM_DELIVERY_CATALOG, available: false }
}
const row = value as Record<string, unknown>
const raw = Array.isArray(row.options) ? row.options : []
const options: ImDeliveryCatalogOption[] = []
for (const entry of raw) {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
const item = entry as Record<string, unknown>
const botId = typeof item.botId === 'string' ? item.botId.trim() : ''
const targetId = typeof item.targetId === 'string' ? item.targetId.trim() : ''
if (!botId || !targetId) continue
options.push({
botId,
targetId,
name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : targetId,
kind: typeof item.kind === 'string' ? item.kind : '',
channel: typeof item.channel === 'string' ? item.channel : 'im',
})
}
return {
available: row.available !== false,
options,
hint: typeof row.hint === 'string' ? row.hint : '',
loading: false,
}
}
/**
* @param call - `ctx.connection.rpc.call`.
* @param signal - optional abort.
*/
export async function fetchImDeliveryCatalog(
call: AlarmPushRpcCall,
signal?: AbortSignal,
): Promise<ImDeliveryCatalog> {
const result = await call(NETXOPS_RPC_CHANNEL, IM_DELIVERY_CATALOG_ENDPOINT, {}, signal)
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asImDeliveryCatalog((result as { value?: unknown }).value)
}
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) {
return {
...EMPTY_IM_DELIVERY_CATALOG,
available: false,
hint: String((result as { error?: { message?: string } }).error?.message ?? 'rpc failed'),
}
}
return asImDeliveryCatalog(result)
}
/** Stable select value for one catalog row. */
export function imCatalogOptionKey(botId: string, targetId: string): string {
return `${botId}::${targetId}`
}
/** Parse {@link imCatalogOptionKey}. */
export function parseImCatalogOptionKey(value: string): { botId: string, targetId: string } {
const at = value.indexOf('::')
if (at <= 0) return { botId: '', targetId: '' }
return { botId: value.slice(0, at), targetId: value.slice(at + 2) }
}

View file

@ -7,6 +7,15 @@ export type NetxopsLocaleKey =
| 'apiUrlHint'
| 'lang'
| 'langHint'
| 'capabilityGroups'
| 'capabilityGroupsHint'
| 'nmsProvider'
| 'nmsProviderHint'
| 'groupNms'
| 'groupCommon'
| 'groupTopology'
| 'groupInPreset'
| 'groupPublic'
| 'alarmPushEnabled'
| 'alarmPushEnabledHint'
| 'alarmPushStatus'
@ -26,6 +35,10 @@ export type NetxopsLocaleKey =
| 'imBotIdHint'
| 'imTargetId'
| 'imTargetIdHint'
| 'imTarget'
| 'imTargetNone'
| 'imTargetManual'
| 'imCatalogUnavailable'
| 'apiToken'
| 'apiTokenHint'
| 'apiTokenUnavailable'
@ -45,11 +58,20 @@ export type NetxopsLocaleKey =
export const en: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'UME API endpoint and bearer token for netx__* tools.',
description: 'netx API, bearer token, and capability groups (nms / common / topology).',
apiUrl: 'API URL',
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)',
lang: 'Language',
langHint: 'Response language hint (zh / en).',
capabilityGroups: 'Capability groups',
capabilityGroupsHint: 'One group ↔ one skill (netx-nms / netx-common / netx-topology). Defaults: nms + common in preset; topology and all public off. Save, then open a new session. Other agents can mount dsh-netxops/tools-nms|common|topology.',
nmsProvider: 'nms provider',
nmsProviderHint: 'Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).',
groupNms: 'nms — skill netx-nms (alarms / inventory / SQL)',
groupCommon: 'common — skill netx-common (managed CLI + findTopologyPaths)',
groupTopology: 'topology — skill netx-topology (canvas / fabric / dual_unit)',
groupInPreset: 'In Netx Ops preset',
groupPublic: 'Publish to other presets',
alarmPushEnabled: 'Key-alarm push',
alarmPushEnabledHint: 'Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.',
alarmPushStatus: 'Push link',
@ -64,11 +86,15 @@ export const en: Record<NetxopsLocaleKey, string> = {
alarmDeliverDsh: 'Deliver to DSH session',
alarmDeliverDshHint: 'Open/follow the sticky「Netx 关键告警」session on this Host.',
alarmDeliverIm: 'Deliver to WhatsApp / IM',
alarmDeliverImHint: 'Requires dsh-im-ops. Paste botId + targetId from IM → 投递设置 → 复制调用参数.',
alarmDeliverImHint: 'Requires dsh-im-ops ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).',
imBotId: 'IM Bot ID',
imBotIdHint: 'Opaque botId from IM delivery settings (not a phone number).',
imBotIdHint: 'Filled from the picker, or enter manually.',
imTargetId: 'IM Target ID',
imTargetIdHint: 'Opaque targetId alias (e.g. release-alerts). Create the target in IM first.',
imTargetIdHint: 'Filled from the picker, or enter manually.',
imTarget: 'Delivery target',
imTargetNone: '(Create a target in IM delivery settings first)',
imTargetManual: 'Enter manually…',
imCatalogUnavailable: 'Cannot load targets — install/update dsh-im-ops (≥ops.24) and save at least one delivery target.',
apiToken: 'API token',
apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.',
apiTokenUnavailable: 'This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.',
@ -89,11 +115,20 @@ export const en: Record<NetxopsLocaleKey, string> = {
export const zh: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'UME API 地址与 Bearer Token,供 netx__* 工具使用。',
description: 'netx API、Bearer Token,以及能力组(nms / common / topology)。',
apiUrl: 'API 地址',
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)',
lang: '语言',
langHint: '响应语言提示(zh / en)。',
capabilityGroups: '能力组',
capabilityGroupsHint: '一组一个 skill(netx-nms / netx-common / netx-topology)。默认:nms + common 进入预设;topology 与全部公开关闭。保存后请新开会话。其他 Agent 可挂 dsh-netxops/tools-nms|common|topology。',
nmsProvider: 'nms provider',
nmsProviderHint: '厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。',
groupNms: 'nms — skill netx-nms(告警 / 库存 / SQL)',
groupCommon: 'common — skill netx-common(纳管 CLI + findTopologyPaths)',
groupTopology: 'topology — skill netx-topology(画布 / Fabric / dual_unit)',
groupInPreset: '在 Netx Ops 预设中启用',
groupPublic: '对其他预设公开',
alarmPushEnabled: '关键告警推送',
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。',
alarmPushStatus: '推送链路',
@ -108,11 +143,15 @@ export const zh: Record<NetxopsLocaleKey, string> = {
alarmDeliverDsh: '投递到 DSH 会话',
alarmDeliverDshHint: '在本机打开/续写 sticky「Netx 关键告警」会话。',
alarmDeliverIm: '投递到 WhatsApp / IM',
alarmDeliverImHint: '需要已安装 dsh-im-ops。请先在 IM「投递设置」新建目标,再粘贴「复制调用参数」里的 botId / targetId。',
alarmDeliverImHint: '需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。',
imBotId: 'IM Bot ID',
imBotIdHint: '来自 IM 投递设置的不透明 botId(不是手机号)。',
imBotIdHint: '由下拉自动填入,也可手动改。',
imTargetId: 'IM Target ID',
imTargetIdHint: '投递目标别名(如 release-alerts)。请先在 IM 侧创建目标。',
imTargetIdHint: '由下拉自动填入,也可手动改。',
imTarget: '投递目标',
imTargetNone: '(请先在 IM「投递设置」新建目标)',
imTargetManual: '手动填写…',
imCatalogUnavailable: '无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。',
apiToken: 'API Token',
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。',

View file

@ -30,11 +30,15 @@ const CSS = `
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
.dsh-nx-select{appearance:auto;cursor:pointer}
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
.dsh-nx-checkRow input{margin-top:2px;flex:none}
.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:8px 0 4px}
.dsh-nx-groupTitle{font-size:12px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:6px;padding-left:2px}
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}

View file

@ -1,7 +1,8 @@
/**
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push) + credentials
* (NETX_API_TOKEN) publish a connection snapshot; the Ops preset mounts
* `netx__*` into its own tool scope (`dsh-netxops/tools`).
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups)
* + credentials (NETX_API_TOKEN) publish a connection snapshot. The Ops preset
* mounts selected `netx__*` groups via `dsh-netxops/tools`; groups marked public
* also register on the host tool/skill layer for other presets.
*
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
@ -27,7 +28,14 @@ import {
} from './netx/alarm-push-status.ts'
import { deliverAlarmToIm } from './netx/alarm-im.ts'
import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts'
import { publishNetxConnection } from './netx/runtime.ts'
import {
capabilityGroupsFromSettings,
groupsForPlane,
type NetxCapabilityGroupSettingsFields,
} from './netx/capability-groups.ts'
import { registerGroupSkills } from './netx/group-skills.ts'
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
import { registerNetxTools } from './netx/tools.ts'
/** Cordis plugin name. */
export const name = 'netxops'
@ -78,6 +86,23 @@ export interface Config {
imBotId: string
/** Opaque target id from the same copy payload. */
imTargetId: string
/**
* NMS provider adapter id. Supported today: `zte-ume`
* (REST `/v1/ume/*`; model tools are generic `netx__*Nms*`).
*/
nmsProvider: string
/** nms tools/skills in the Netx Ops preset (default on). */
groupNmsInPreset: boolean
/** Publish nms tools/skills to other presets via the host layer (default off). */
groupNmsPublic: boolean
/** common tools/skills in the Netx Ops preset (default on). */
groupCommonInPreset: boolean
/** Publish common tools/skills to other presets (default off). */
groupCommonPublic: boolean
/** topology canvas / layout tools in the Netx Ops preset (default off). */
groupTopologyInPreset: boolean
/** Publish topology tools/skills to other presets (default off). */
groupTopologyPublic: boolean
}
export const Config: z<Config> = z.object({
@ -91,6 +116,13 @@ export const Config: z<Config> = z.object({
alarmDeliverIm: z.boolean().default(false),
imBotId: z.string().default(''),
imTargetId: z.string().default(''),
nmsProvider: z.string().default('zte-ume'),
groupNmsInPreset: z.boolean().default(true),
groupNmsPublic: z.boolean().default(false),
groupCommonInPreset: z.boolean().default(true),
groupCommonPublic: z.boolean().default(false),
groupTopologyInPreset: z.boolean().default(false),
groupTopologyPublic: z.boolean().default(false),
})
/** Package root (parent of `lib/` or `src/` depending on launch). */
@ -238,11 +270,19 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const apiUrl = current.apiUrl.replace(/\/$/, '')
const tokenConfigured = token.trim().length > 0
const groups = capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields)
if (current.nmsProvider && current.nmsProvider !== 'zte-ume') {
ctx.logger.warn(
'netxops: nmsProvider=%s is not implemented yet; using zte-ume REST adapter',
current.nmsProvider,
)
}
publishNetxConnection({
apiUrl,
token,
lang: current.lang,
toolCallTimeoutMs: current.toolCallTimeoutMs,
groups,
})
restartAlarmPush(current, apiUrl, token)
if (!tokenConfigured) {
@ -253,11 +293,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
)
} else {
ctx.logger.info(
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s',
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]',
apiUrl,
current.alarmPushEnabled === true,
current.alarmDeliverDsh !== false,
current.alarmDeliverIm === true,
groupsForPlane(groups, 'public').join(',') || '(none)',
)
}
}).catch((error) => {
@ -280,7 +321,63 @@ export function apply(ctx: Context, config: Config = Config({})): void {
if (String(ref) === source().tokenCredentialRef) publish()
})
// Browser card polls alarm-push WSS status through Connection RPC.
// Optional host-layer publish: groups with `public=true` become visible to other presets.
ctx.inject(['tools'], (toolsCtx) => {
let unregisterTools: (() => void) | undefined
const remountPublicTools = (): void => {
unregisterTools?.()
unregisterTools = undefined
const connection = getNetxConnection()
if (!connection) return
const enabled = groupsForPlane(connection.groups, 'public')
unregisterTools = registerNetxTools(toolsCtx, connection, { plane: 'public' })
toolsCtx.logger.info(
'netxops: host public tools groups=[%s]',
enabled.join(',') || '(none)',
)
}
remountPublicTools()
const stopWatch = watchNetxConnection(() => { remountPublicTools() })
toolsCtx.effect(() => () => {
stopWatch()
unregisterTools?.()
}, 'netxops: dispose public tools')
})
ctx.inject(['skills'], (skillsCtx) => {
let unregisterSkills: (() => void) | undefined
let generation = 0
const remountPublicSkills = (): void => {
const gen = ++generation
unregisterSkills?.()
unregisterSkills = undefined
const connection = getNetxConnection()
if (!connection) return
const enabled = groupsForPlane(connection.groups, 'public')
void registerGroupSkills(skillsCtx, enabled, 'netxops-public').then((dispose) => {
if (gen !== generation) {
dispose()
return
}
unregisterSkills = dispose
skillsCtx.logger.info(
'netxops: host public skills groups=[%s]',
enabled.join(',') || '(none)',
)
}).catch((error) => {
skillsCtx.logger.warn('netxops: public skill register failed: %s', error)
})
}
remountPublicSkills()
const stopWatch = watchNetxConnection(() => { remountPublicSkills() })
skillsCtx.effect(() => () => {
generation += 1
stopWatch()
unregisterSkills?.()
}, 'netxops: dispose public skills')
})
// Browser card polls alarm-push WSS status + IM delivery catalog through Connection RPC.
ctx.inject(['connection'], (connCtx) => {
const rpc = connCtx.connection?.rpc
if (!rpc || typeof rpc.handle !== 'function') {
@ -291,10 +388,46 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const dispose = rpc.handle(
NETXOPS_RPC_CHANNEL,
async (endpoint: string) => {
if (endpoint !== 'alarm-push.status') {
return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } }
if (endpoint === 'alarm-push.status') {
return { ok: true, value: getAlarmPushStatus() }
}
return { ok: true, value: getAlarmPushStatus() }
if (endpoint === 'im-delivery.catalog') {
type DshImCatalog = { listDeliveryCatalog?: () => Promise<unknown> }
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined
: undefined
const im = fromGet ?? (ctx as { dshIm?: DshImCatalog }).dshIm
if (!im || typeof im.listDeliveryCatalog !== 'function') {
return {
ok: true,
value: {
available: false,
options: [],
hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker',
},
}
}
try {
const options = await im.listDeliveryCatalog()
return {
ok: true,
value: {
available: true,
options: Array.isArray(options) ? options : [],
},
}
} catch (error) {
return {
ok: true,
value: {
available: false,
options: [],
hint: error instanceof Error ? error.message : String(error),
},
}
}
}
return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } }
},
)
return () => { void dispose() }

View file

@ -62,7 +62,7 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string
const label = String(payload.rule_label ?? payload.native_probable_cause ?? '关键告警').trim()
if (lang.startsWith('en')) {
return [
`[UME ${actionEn[action] ?? (action || 'Alarm')}] ${label}`,
`[NMS ${actionEn[action] ?? (action || 'Alarm')}] ${label}`,
`Device: ${device}`,
`Object: ${String(payload.object_name ?? '-').trim()}`,
`Severity: ${String(payload.perceived_severity ?? '-').trim()}`,
@ -75,7 +75,7 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string
].join('\n')
}
return [
`[UME ${actionZh[action] ?? (action || '告警')}] ${label}`,
`[NMS ${actionZh[action] ?? (action || '告警')}] ${label}`,
`设备: ${device}`,
`对象: ${String(payload.object_name ?? '-').trim()}`,
`级别: ${String(payload.perceived_severity ?? '-').trim()}`,

View file

@ -0,0 +1,176 @@
/**
* Netx Ops capability groups: selective tool/skill exposure.
*
* Rule: **one group ↔ one skill** (dirs under `netx/skills/<group>/`).
*
* - `nms` → skill `netx-nms` — vendor NMS adapter (zte-ume behind generic names)
* - `common` → skill `netx-common` — managed CLI + findTopologyPaths
* - `topology` → skill `netx-topology` — canvas / fabric / dual_unit / layout recipes
*/
/** Stable group ids used in settings, skill dirs, and registration filters. */
export type NetxCapabilityGroupId = 'nms' | 'common' | 'topology'
/** Per-group exposure knobs. */
export interface NetxGroupExposure {
/** Mount into the Netx Ops agent preset scope. */
inPreset: boolean
/** Mount on the host tool/skill layer so other presets can see the group. */
public: boolean
}
/** Full capability-group policy published with the connection snapshot. */
export type NetxCapabilityGroups = Record<NetxCapabilityGroupId, NetxGroupExposure>
/** Flat settings fields (Plugins card + schemastery Config). */
export interface NetxCapabilityGroupSettingsFields {
groupNmsInPreset: boolean
groupNmsPublic: boolean
groupCommonInPreset: boolean
groupCommonPublic: boolean
groupTopologyInPreset: boolean
groupTopologyPublic: boolean
}
/**
* Default: nms + common in Ops preset; topology and all public off.
*/
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
nms: Object.freeze({ inPreset: true, public: false }),
common: Object.freeze({ inPreset: true, public: false }),
topology: Object.freeze({ inPreset: false, public: false }),
})
/**
* Model-facing tool names (`netx__*`) owned by each group.
* nms tools use generic `Nms` names; HTTP still hits the configured NMS provider adapter.
*/
export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly string[]>> = Object.freeze({
nms: Object.freeze([
'netx__queryNmsAlarms',
'netx__aggregateNmsAlarms',
'netx__runNmsDiagnostics',
'netx__queryNmsNeInventory',
'netx__getNmsNe',
'netx__queryNmsAlarmsRaw',
'netx__aggregateNmsAlarmsRaw',
'netx__listNmsAlarmFields',
'netx__sqlQueryNms',
]),
common: Object.freeze([
'netx__listManagedNe',
'netx__getManagedNe',
'netx__execManagedNe',
'netx__listCliTargets',
'netx__findTopologyPaths',
]),
topology: Object.freeze([
'netx__getTopologyTree',
'netx__getTopologyView',
'netx__createTopologyFolder',
'netx__addTopologyViewNodes',
'netx__removeTopologyViewNodes',
'netx__copyTopologyViewNodes',
'netx__updateTopologyViewPositions',
'netx__projectTopologyNeighbors',
'netx__queryTopologyFabricNodes',
'netx__classifyTopologyFabricNodes',
'netx__queryTopologyNeighborhood',
'netx__queryTopologyEdges',
'netx__layoutTopologyView',
'netx__suggestSinkHubs',
'netx__analyzeTopologyViewLayout',
'netx__sinkTopologyDualUnits',
]),
})
/** Skill directory name under skills root / `presets/netxops/skills/<group>/`. */
export const SKILL_DIR_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, string>> = Object.freeze({
nms: 'nms',
common: 'common',
topology: 'topology',
})
export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.freeze([
'nms',
'common',
'topology',
])
/**
* Build group policy from flat settings / Config fields.
* Accepts legacy `groupManagedNe*` as aliases of `groupCommon*`.
* Legacy `groupTopologyLayout*` ORs into `topology` (layout tools now live in topology).
*/
export function capabilityGroupsFromSettings(
fields: Partial<NetxCapabilityGroupSettingsFields & {
groupManagedNeInPreset?: boolean
groupManagedNePublic?: boolean
groupTopologyLayoutInPreset?: boolean
groupTopologyLayoutPublic?: boolean
}> | null | undefined,
): NetxCapabilityGroups {
const src = fields ?? {}
const commonInPreset = src.groupCommonInPreset !== undefined
? src.groupCommonInPreset !== false
: src.groupManagedNeInPreset !== false
const commonPublic = src.groupCommonPublic === true
|| src.groupManagedNePublic === true
return {
nms: {
inPreset: src.groupNmsInPreset !== false,
public: src.groupNmsPublic === true,
},
common: {
inPreset: commonInPreset,
public: commonPublic,
},
topology: {
inPreset: src.groupTopologyInPreset === true
|| src.groupTopologyLayoutInPreset === true,
public: src.groupTopologyPublic === true
|| src.groupTopologyLayoutPublic === true,
},
}
}
/**
* Groups enabled for one registration plane.
* @param groups - published policy.
* @param plane - preset scope vs host public layer.
* @param only - when set, intersect with this allow-list (per-export tools packages).
*/
export function groupsForPlane(
groups: NetxCapabilityGroups | undefined,
plane: 'preset' | 'public',
only?: readonly NetxCapabilityGroupId[],
): NetxCapabilityGroupId[] {
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS
const enabled = CAPABILITY_GROUP_IDS.filter((id) => (
plane === 'preset' ? policy[id].inPreset : policy[id].public
))
if (!only || only.length === 0) return enabled
const allow = new Set(only)
return enabled.filter((id) => allow.has(id))
}
/**
* Force-enable the listed groups (for `dsh-netxops/tools-<group>` mounts that
* intentionally ignore Ops-preset inPreset flags).
*/
export function groupsForced(
only: readonly NetxCapabilityGroupId[],
): NetxCapabilityGroupId[] {
return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id))
}
/**
* Tool name allow-list for the given groups.
*/
export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set<string> {
const names = new Set<string>()
for (const id of groupIds) {
for (const tool of TOOLS_BY_GROUP[id]) names.add(tool)
}
return names
}

196
src/netx/group-skills.ts Normal file
View file

@ -0,0 +1,196 @@
/**
* Register Ops playbook skills for enabled capability groups from disk.
*/
import { existsSync } from 'node:fs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import {
CAPABILITY_GROUP_IDS,
SKILL_DIR_BY_GROUP,
type NetxCapabilityGroupId,
} from './capability-groups.ts'
interface ParsedSkill {
name: string
description: string
content: string
path: string
directory: string
}
/**
* Package `presets/netxops/skills` root, or a shared netx skills checkout.
*
* Resolution order:
* 1. `NETX_SKILLS_ROOT` (canonical `netx/skills`)
* 2. Sibling `../netx/skills` when developing next to the netx repo
* 3. Bundled `presets/netxops/skills` inside this package
*/
export function opsSkillsRoot(): string {
const envRoot = process.env.NETX_SKILLS_ROOT?.trim()
if (envRoot && existsSync(envRoot)) return envRoot
const here = dirname(fileURLToPath(import.meta.url))
const siblingCandidates = [
// src/netx → ../../../netx/skills ; lib → ../../netx/skills
join(here, '..', '..', '..', 'netx', 'skills'),
join(here, '..', '..', 'netx', 'skills'),
]
for (const candidate of siblingCandidates) {
if (existsSync(candidate)) return candidate
}
const packaged = [
join(here, '..', 'presets', 'netxops', 'skills'),
join(here, '..', '..', 'presets', 'netxops', 'skills'),
]
for (const candidate of packaged) {
if (existsSync(candidate)) return candidate
}
return packaged[0]
}
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
if (!raw.startsWith('---')) return undefined
const end = raw.indexOf('\n---', 3)
if (end < 0) return undefined
const yaml = raw.slice(3, end).replace(/^\r?\n/, '')
const body = raw.slice(end + 4).replace(/^\r?\n/, '')
const data: Record<string, unknown> = {}
const lines = yaml.split(/\r?\n/)
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line)
if (nameMatch) {
data.name = stripQuotes(nameMatch[1].trim())
continue
}
const descMatch = /^description\s*:\s*(.*)$/.exec(line)
if (!descMatch) continue
const head = descMatch[1].trim()
if (head === '>' || head === '>-' || head === '|' || head === '|-') {
const parts: string[] = []
while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) {
i += 1
parts.push(lines[i].trim())
}
data.description = parts.filter(Boolean).join(' ')
continue
}
data.description = stripQuotes(head)
}
return { data, body }
}
function stripQuotes(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1)
}
return value
}
async function loadSkillBundle(dir: string): Promise<ParsedSkill | null> {
const skillPath = join(dir, 'SKILL.md')
let raw: string
try {
raw = await readFile(skillPath, 'utf8')
} catch {
return null
}
const parsed = parseFrontmatter(raw)
if (!parsed) return null
const name = typeof parsed.data.name === 'string' ? parsed.data.name.trim() : ''
const description = typeof parsed.data.description === 'string'
? parsed.data.description.trim()
: ''
if (!name || !description) return null
return {
name,
description,
content: parsed.body.trimStart(),
path: skillPath,
directory: dir,
}
}
async function loadGroupSkills(
skillsRoot: string,
groupId: NetxCapabilityGroupId,
): Promise<ParsedSkill[]> {
const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId])
let entries: string[]
try {
entries = await readdir(groupDir)
} catch {
return []
}
const skills: ParsedSkill[] = []
for (const entry of entries) {
const full = join(groupDir, entry)
let isDir = false
try {
isDir = (await stat(full)).isDirectory()
} catch {
continue
}
if (!isDir) continue
const skill = await loadSkillBundle(full)
if (skill) skills.push(skill)
}
return skills
}
/**
* Register playbook skills for the given capability groups into `ctx.skills`.
* @param ctx - context with a skills registry (preset or host layer).
* @param groupIds - enabled groups for this plane.
* @param providerLabel - `provider` stamp on each registration.
* @returns disposer that unregisters every skill.
*/
export async function registerGroupSkills(
ctx: Context,
groupIds: readonly NetxCapabilityGroupId[],
providerLabel: string,
): Promise<() => void> {
const skillsApi = (ctx as { skills?: { register: (skill: {
name: string
description: string
content: string
path?: string
resourceBase?: { kind: 'directory'; path: string }
provider?: string
source: string
}) => () => void } }).skills
if (!skillsApi || typeof skillsApi.register !== 'function') {
return () => {}
}
const root = opsSkillsRoot()
const disposers: Array<() => void> = []
const enabled = new Set(groupIds)
for (const groupId of CAPABILITY_GROUP_IDS) {
if (!enabled.has(groupId)) continue
const skills = await loadGroupSkills(root, groupId)
for (const skill of skills) {
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider: providerLabel,
source: 'custom',
}))
}
}
return () => {
for (const dispose of disposers) dispose()
}
}

View file

@ -0,0 +1,107 @@
/**
* Shared agent-plane mount: tools + skills for one or more capability groups.
*/
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-tools'
import {
groupsForced,
groupsForPlane,
type NetxCapabilityGroupId,
} from './capability-groups.ts'
import { registerGroupSkills } from './group-skills.ts'
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
import { registerNetxTools } from './tools.ts'
export interface GroupToolsPluginOptions {
/** Cordis plugin name. */
name: string
/**
* `settings` — honor inPreset/public from connection (Ops aggregate mount).
* `forced` — always register `only` groups when connection exists (per-group export).
*/
mode: 'settings' | 'forced'
/** Group allow-list; omit in settings mode to use all groups. */
only?: readonly NetxCapabilityGroupId[]
}
/**
* Apply a group-scoped tools/skills plugin on the calling (usually preset) context.
*/
export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOptions): void {
let unregisterTools: (() => void) | undefined
let unregisterSkills: (() => void) | undefined
let skillGeneration = 0
const resolveGroups = (): NetxCapabilityGroupId[] => {
const connection = getNetxConnection()
if (options.mode === 'forced') {
return groupsForced(options.only ?? [])
}
return groupsForPlane(connection?.groups, 'preset', options.only)
}
const remountTools = (): void => {
unregisterTools?.()
unregisterTools = undefined
const connection = getNetxConnection()
if (connection === undefined) {
ctx.logger.warn('%s: no connection yet — waiting for host settings bridge', options.name)
return
}
const enabled = resolveGroups()
if (enabled.length === 0) {
ctx.logger.info('%s: no groups enabled', options.name)
return
}
unregisterTools = registerNetxTools(ctx, connection, {
plane: 'preset',
only: options.mode === 'forced' ? enabled : options.only,
forceGroups: options.mode === 'forced' ? enabled : undefined,
})
ctx.logger.info(
'%s: groups=[%s] → %s tokenConfigured=%s',
options.name,
enabled.join(',') || '(none)',
connection.apiUrl,
connection.token.trim().length > 0,
)
}
remountTools()
const stopToolWatch = watchNetxConnection(() => { remountTools() })
ctx.inject(['skills'], (skillsCtx) => {
const remountSkills = (): void => {
const gen = ++skillGeneration
unregisterSkills?.()
unregisterSkills = undefined
if (getNetxConnection() === undefined) return
const enabled = resolveGroups()
void registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => {
if (gen !== skillGeneration) {
dispose()
return
}
unregisterSkills = dispose
}).catch((error) => {
skillsCtx.logger.warn('%s: skill register failed: %s', options.name, error)
})
}
remountSkills()
const stopSkillWatch = watchNetxConnection(() => { remountSkills() })
skillsCtx.effect(() => () => {
skillGeneration += 1
stopSkillWatch()
unregisterSkills?.()
unregisterSkills = undefined
}, `${options.name}: dispose skills`)
})
ctx.effect(() => () => {
stopToolWatch()
unregisterTools?.()
unregisterTools = undefined
}, `${options.name}: dispose tools`)
}

View file

@ -38,6 +38,16 @@ function str(args: NetxJson, key: string, fallback = ''): string {
return fallback
}
/** Prefer generic `nms_*` aliases; fall back to historical `ume_*` wire names. */
function nmsOrUme(args: NetxJson, nmsKey: string, umeKey: string): string {
return str(args, nmsKey).trim() || str(args, umeKey).trim()
}
function nmsOrUmeList(args: NetxJson, nmsKey: string, umeKey: string): string[] {
const primary = strList(args, nmsKey)
return primary.length > 0 ? primary : strList(args, umeKey)
}
function num(args: NetxJson, key: string): number | undefined {
const v = args[key]
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
@ -189,7 +199,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
ok: false,
error: 'ne_id_required',
error_code: 'ne_id_required',
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.',
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.',
example: { ne_id: '<managed-ne-uuid-from-listManagedNe>' },
}
}
@ -199,7 +209,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
if (detail.includes('404') || detail.includes('not_found') || detail.includes('not found') || out.error === 'netx_http_404') {
return {
...out,
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe.',
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe.',
}
}
}
@ -209,7 +219,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const targetsRaw = args.targets
const neIds = strList(args, 'ne_ids')
const umeNeIds = strList(args, 'ume_ne_ids')
const umeNeIds = nmsOrUmeList(args, 'nms_ne_ids', 'ume_ne_ids')
const sharedCommands = strList(args, 'commands')
const multi = (Array.isArray(targetsRaw) && targetsRaw.length > 0)
|| neIds.length > 0
@ -224,7 +234,7 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
const row = t as NetxJson
const item: NetxJson = {}
const neId = str(row, 'ne_id').trim()
const umeNeId = str(row, 'ume_ne_id').trim()
const umeNeId = nmsOrUme(row, 'nms_ne_id', 'ume_ne_id')
if (neId) item.ne_id = neId
if (umeNeId) item.ume_ne_id = umeNeId
const cmds = Array.isArray(row.commands)
@ -256,13 +266,13 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
}
const neId = str(args, 'ne_id').trim()
const umeNeId = str(args, 'ume_ne_id').trim()
const umeNeId = nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')
if (Boolean(neId) === Boolean(umeNeId)) {
return {
ok: false,
error: 'exactly_one_of_ne_id_or_ume_ne_id_required',
error_code: 'exactly_one_of_ne_id_or_ume_ne_id_required',
hint: 'For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server.',
error: 'exactly_one_of_ne_id_or_nms_ne_id_required',
error_code: 'exactly_one_of_ne_id_or_nms_ne_id_required',
hint: 'For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server.',
}
}
if (sharedCommands.length === 0) {
@ -296,15 +306,15 @@ export async function listCliTargets(client: NetxClient, args: NetxJson, signal?
}
export async function findTopologyPaths(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const fromUid = str(args, 'from_ume_ne_id').trim()
const fromUid = nmsOrUme(args, 'from_nms_ne_id', 'from_ume_ne_id')
const fromMid = str(args, 'from_managed_ne_id').trim()
const toUid = str(args, 'to_ume_ne_id').trim()
const toUid = nmsOrUme(args, 'to_nms_ne_id', 'to_ume_ne_id')
const toMid = str(args, 'to_managed_ne_id').trim()
if (Boolean(fromUid) === Boolean(fromMid)) {
return { ok: false, error: 'exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required' }
return { ok: false, error: 'exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required' }
}
if (Boolean(toUid) === Boolean(toMid)) {
return { ok: false, error: 'exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required' }
return { ok: false, error: 'exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required' }
}
let detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
if (detail !== 'summary' && detail !== 'full') detail = 'summary'

View file

@ -121,6 +121,9 @@ export function createNetxClient(connection: NetxConnection) {
post(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
return request('POST', path, { body, signal, timeoutMs })
},
patch(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
return request('PATCH', path, { body, signal, timeoutMs })
},
}
}

View file

@ -4,15 +4,24 @@
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import {
groupsForPlane,
toolNamesForGroups,
type NetxCapabilityGroupId,
type NetxCapabilityGroups,
} from './capability-groups.ts'
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
import { getNetxConnection } from './runtime.ts'
import * as H from './handlers.ts'
import * as T from './topology-handlers.ts'
export interface NetxToolConnection {
apiUrl: string
token: string
lang: string
toolCallTimeoutMs: number
/** Capability-group exposure; omitted → package defaults. */
groups?: NetxCapabilityGroups
}
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
@ -60,14 +69,36 @@ function tool(
})
}
export interface RegisterNetxToolsOptions {
/**
* `preset` — groups with `inPreset` (Netx Ops preset scope).
* `public` — groups with `public` (host layer for other presets).
*/
plane: 'preset' | 'public'
/** Optional intersect with settings-enabled groups. */
only?: readonly NetxCapabilityGroupId[]
/** When set, register exactly these groups (ignore plane flags). */
forceGroups?: readonly NetxCapabilityGroupId[]
}
/**
* Register all Netx Ops tools against the current connection snapshot.
* Register Netx Ops tools for one exposure plane against the connection snapshot.
* Bearer is read from the live process store on each request (not frozen here).
* @param ctx - host context with `tools`.
* @param connection - apiUrl / token / lang / timeout (apiUrl/lang/timeout used for client base).
* @param ctx - context with `tools`.
* @param connection - apiUrl / token / lang / timeout / groups.
* @param options - which plane's group flags to honor.
* @returns disposer that unregisters every tool.
*/
export function registerNetxTools(ctx: Context, connection: NetxToolConnection): () => void {
export function registerNetxTools(
ctx: Context,
connection: NetxToolConnection,
options: RegisterNetxToolsOptions,
): () => void {
const enabledGroups = options.forceGroups
?? groupsForPlane(connection.groups, options.plane, options.only)
const allow = toolNamesForGroups(enabledGroups)
if (allow.size === 0) return () => {}
const client = createNetxClient({
apiUrl: connection.apiUrl,
lang: connection.lang,
@ -77,10 +108,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
const getClient = () => client
const t = connection.toolCallTimeoutMs
const disposers = [
ctx.tools.register(tool(
'netx__queryUmeAlarms',
'Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.',
const catalog = [
tool(
'netx__queryNmsAlarms',
'Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.',
{
severity: str(),
ne_id: str('Filter only; do not show UUID to users'),
@ -93,10 +124,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
page_size: num(),
},
H.queryUmeAlarms, getClient, t,
)),
ctx.tools.register(tool(
'netx__aggregateUmeAlarms',
'Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.',
),
tool(
'netx__aggregateNmsAlarms',
'Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.',
{
severity: str('Optional perceived_severity filter (critical/major/minor/warning).'),
top_ne: num('Max NE buckets (default 50). Ignored when group_by is set.'),
@ -112,34 +143,34 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
limit: num(),
},
H.aggregateUmeAlarms, getClient, t,
)),
ctx.tools.register(tool(
'netx__runUmeDiagnostics',
'UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
),
tool(
'netx__runNmsDiagnostics',
'NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
{},
H.runUmeDiagnostics, getClient, t,
)),
ctx.tools.register(tool(
'netx__queryUmeNeInventory',
'Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).',
),
tool(
'netx__queryNmsNeInventory',
'Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).',
{
keyword: str(),
page: num(),
page_size: num(),
},
H.queryUmeNeInventory, getClient, t,
)),
ctx.tools.register(tool(
'netx__getUmeNe',
'Get single UME NE detail by ne_id (UUID).',
),
tool(
'netx__getNmsNe',
'Get single NMS NE detail by ne_id (UUID).',
{
ne_id: { type: 'string' as const, required: true as const, description: 'UME inventory ne_id (UUID).' },
ne_id: { type: 'string' as const, required: true as const, description: 'NMS inventory ne_id (UUID).' },
},
H.getUmeNe, getClient, t,
)),
ctx.tools.register(tool(
'netx__queryUmeAlarmsRaw',
'Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.',
),
tool(
'netx__queryNmsAlarmsRaw',
'Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.',
{
severity: str(),
is_cleared: str(),
@ -156,10 +187,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
page_size: num(),
},
H.queryUmeAlarmsRaw, getClient, t,
)),
ctx.tools.register(tool(
'netx__aggregateUmeAlarmsRaw',
'Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.',
),
tool(
'netx__aggregateNmsAlarmsRaw',
'Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.',
{
group_by: { type: 'string' as const, required: true as const },
group_by2: str(),
@ -174,24 +205,24 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
limit: num(),
},
H.aggregateUmeAlarmsRaw, getClient, t,
)),
ctx.tools.register(tool(
'netx__listUmeAlarmFields',
'List available fields for UME raw alarm queries.',
),
tool(
'netx__listNmsAlarmFields',
'List available fields for NMS raw alarm queries.',
{},
H.listUmeAlarmFields, getClient, t,
)),
ctx.tools.register(tool(
'netx__sqlQueryUme',
'Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.',
),
tool(
'netx__sqlQueryNms',
'Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.',
{
sql: { type: 'string' as const, required: true as const },
limit: num(),
statement_timeout_ms: num(),
},
H.sqlQueryUme, getClient, t,
)),
ctx.tools.register(tool(
),
tool(
'netx__listManagedNe',
'List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.',
{
@ -202,25 +233,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
page_size: num(),
},
H.listManagedNe, getClient, t,
)),
ctx.tools.register(tool(
),
tool(
'netx__getManagedNe',
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.',
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.',
{
ne_id: str('Managed NE id'),
managed_ne_id: str('Alias for ne_id'),
id: str('Alias for ne_id'),
},
H.getManagedNe, getClient, t,
)),
ctx.tools.register(tool(
),
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.',
{
ne_id: str(),
ume_ne_id: str(),
nms_ne_id: str('NMS inventory id; alias ume_ne_id'),
ume_ne_id: str('Legacy alias of nms_ne_id'),
ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'),
ume_ne_ids: strArr('UME inventory ne_ids for concurrent batch (shared commands).'),
nms_ne_ids: strArr('NMS inventory ids; alias ume_ne_ids'),
ume_ne_ids: strArr('Legacy alias of nms_ne_ids'),
targets: {
type: 'array' as const,
description: 'Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].',
@ -240,25 +273,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
async: bool('oclaw-only async hint; ignored by native REST client.'),
},
H.execManagedNe, getClient, Math.max(t, 300_000),
)),
ctx.tools.register(tool(
),
tool(
'netx__listCliTargets',
'List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
'List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
{
source: str('managed | ume | all'),
source: str('managed | nms | ume | all'),
keyword: str(),
page: num(),
page_size: num(),
},
H.listCliTargets, getClient, t,
)),
ctx.tools.register(tool(
),
tool(
'netx__findTopologyPaths',
'Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
'Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
{
from_ume_ne_id: str(),
from_nms_ne_id: str('NMS inventory id'),
from_ume_ne_id: str('Legacy alias of from_nms_ne_id'),
from_managed_ne_id: str(),
to_ume_ne_id: str(),
to_nms_ne_id: str('NMS inventory id'),
to_ume_ne_id: str('Legacy alias of to_nms_ne_id'),
to_managed_ne_id: str(),
max_paths: num(),
max_hops: num(),
@ -266,9 +301,249 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
detail: str('summary | full'),
},
H.findTopologyPaths, getClient, t,
)),
),
// ── topology group (netx-topology canvas / fabric) ─────────────────────
tool(
'netx__getTopologyTree',
'Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.',
{ compact: bool(), max_depth: num() },
T.getTopologyTree, getClient, t,
),
tool(
'netx__getTopologyView',
'Get a topology view by view_id. Default detail=summary with sample_nodes + links[].',
{
view_id: { type: 'string' as const, required: true as const },
detail: str('summary | full'),
sample: num(),
},
T.getTopologyView, getClient, t,
),
tool(
'netx__createTopologyFolder',
'Create topology folders / region canvases. Only way to create canvases (ne:write).',
{
name: { type: 'string' as const, required: true as const },
parent_id: str(),
locale: str('zh | en'),
sort_order: num(),
},
T.createTopologyFolder, getClient, t,
),
tool(
'netx__addTopologyViewNodes',
'Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).',
{
view_id: { type: 'string' as const, required: true as const },
max_nodes: num(),
keyword: str(),
role: str(),
vendor: str(),
link_status: str(),
limit: num(),
offset: num(),
fabric_node_ids: strArr(),
layout: str('grid | keep'),
},
T.addTopologyViewNodes, getClient, t,
),
tool(
'netx__removeTopologyViewNodes',
'Remove placements from a view (not fabric) by filters or fabric_node_ids.',
{
view_id: { type: 'string' as const, required: true as const },
keyword: str(),
role: str(),
vendor: str(),
link_status: str(),
fabric_node_ids: strArr(),
},
T.removeTopologyViewNodes, getClient, t,
),
tool(
'netx__copyTopologyViewNodes',
'Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).',
{
source_view_id: { type: 'string' as const, required: true as const },
target_view_id: { type: 'string' as const, required: true as const },
copy_positions: bool(),
clear_target: bool(),
offset_x: num(),
offset_y: num(),
limit: num(),
dry_run: bool(),
},
T.copyTopologyViewNodes, getClient, t,
),
tool(
'netx__updateTopologyViewPositions',
'Move nodes on a view via positions[] or layout=grid|offset|stack + filters.',
{
view_id: { type: 'string' as const, required: true as const },
positions: { type: 'array' as const, items: { type: 'object' as const } },
layout: str('grid | offset | stack'),
keyword: str(),
role: str(),
vendor: str(),
link_status: str(),
fabric_node_ids: strArr(),
offset_x: num(),
offset_y: num(),
},
T.updateTopologyViewPositions, getClient, t,
),
tool(
'netx__projectTopologyNeighbors',
'Project existing fabric neighbors of on-view nodes onto the canvas.',
{
view_id: { type: 'string' as const, required: true as const },
seed_fabric_node_ids: strArr(),
managed_ne_ids: strArr(),
region_folder_id: str(),
detail: str('summary | full'),
sample: num(),
},
T.projectTopologyNeighbors, getClient, t,
),
tool(
'netx__queryTopologyFabricNodes',
'Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).',
{
mode: str('summary | list | search'),
q: str(),
keyword: str(),
role: str(),
level: str(),
level_major: str(),
region_folder_id: str(),
link_status: str(),
page: num(),
page_size: num(),
limit: num(),
summary: bool(),
},
T.queryTopologyFabricNodes, getClient, t,
),
tool(
'netx__classifyTopologyFabricNodes',
'Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.',
{
action: { type: 'string' as const, required: true as const },
pattern: str(),
q: str(),
match_field: str(),
sample_limit: num(),
fabric_node_ids: strArr(),
fabric_node_id: str(),
node_id: str(),
level: str(),
role: str(),
region_folder_id: str(),
clear_region: bool(),
dry_run: bool(),
kind: str(),
page: num(),
page_size: num(),
skip_manual: bool(),
overwrite_manual: bool(),
fill_empty_only: bool(),
},
T.classifyTopologyFabricNodes, getClient, t,
),
tool(
'netx__queryTopologyNeighborhood',
'Neighborhood around a fabric node (depth 1–3) with compact links[].',
{
node_id: { type: 'string' as const, required: true as const },
depth: num(),
layer: str(),
},
T.queryTopologyNeighborhood, getClient, t,
),
tool(
'netx__queryTopologyEdges',
'Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).',
{
node_id: str(),
keyword: str(),
layer: str(),
status: str(),
source: str('lldp | ume | manual'),
detail: str('adjacency | ports'),
page: num(),
page_size: num(),
},
T.queryTopologyEdges, getClient, t,
),
tool(
'netx__suggestSinkHubs',
'Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).',
{
source_view_id: str(),
view_id: str(),
pick: num(),
exclude_portal_ids: strArr(),
exclude_fabric_node_ids: strArr(),
},
T.suggestSinkHubs, getClient, t,
),
tool(
'netx__analyzeTopologyViewLayout',
'Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.',
{
view_id: str(),
folder_id: str(),
detail: str('summary | structure | hotspots | blocks | both'),
score_profile: str(),
sight_limit: num(),
max_views: num(),
min_nodes: num(),
max_nodes: num(),
with_meta: bool(),
},
T.analyzeTopologyViewLayout, getClient, t,
),
tool(
'netx__sinkTopologyDualUnits',
'Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).',
{
source_view_id: str(),
sink_view_id: str(),
max_units: num(),
min_nodes: num(),
max_nodes: num(),
max_batch_nodes: num(),
layout_batch: bool(),
until_empty: bool(),
dry_run: bool(),
},
T.sinkTopologyDualUnits, getClient, t,
),
tool(
'netx__layoutTopologyView',
'Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.',
{
view_id: str(),
action: str(),
source_view_id: str(),
recipe: str(),
preset: str(),
mode: str('preview | apply'),
tune: bool(),
params: { type: 'object' as const },
catalog: bool(),
fabric_node_ids: strArr(),
park: bool(),
copy_positions: bool(),
},
T.layoutTopologyView, getClient, Math.max(t, 180_000),
),
]
const disposers = catalog
.filter((entry) => allow.has(entry.name))
.map((entry) => ctx.tools.register(entry))
return () => {
for (const dispose of disposers) dispose()
}

View file

@ -0,0 +1,668 @@
/**
* Topology canvas / fabric handlers — HTTP port of netx-topology-mcp tools.
* Layout recipes (dual_unit / orbit / polish) stay MCP-local; DSH exposes HTTP
* CRUD + fabric query + move_nodes, and returns a clear hint for recipe actions.
*/
import { type NetxClient, type NetxJson } from './http.ts'
function asRecord(value: unknown): NetxJson {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as NetxJson
: {}
}
function str(args: NetxJson, key: string, fallback = ''): string {
const v = args[key]
if (typeof v === 'string') return v
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
return fallback
}
function num(args: NetxJson, key: string): number | undefined {
const v = args[key]
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
}
function bool(args: NetxJson, key: string): boolean | undefined {
const v = args[key]
return typeof v === 'boolean' ? v : undefined
}
function strList(args: NetxJson, key: string): string[] {
const v = args[key]
if (!Array.isArray(v)) return []
return v.map((x) => String(x).trim()).filter((x) => x.length > 0)
}
function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
const n = value === undefined ? fallback : Math.trunc(value)
return Math.max(min, Math.min(max, n))
}
function unwrap(out: NetxJson): NetxJson {
if (out.ok !== true) return out
const data = asRecord(out.data)
if (Object.keys(data).length === 0) return { ok: true, data: out.data }
return { ok: true, ...data, data }
}
function filterFields(args: NetxJson): NetxJson {
const out: NetxJson = {}
for (const key of ['keyword', 'role', 'vendor', 'link_status'] as const) {
const v = str(args, key).trim()
if (v) out[key] = v
}
return out
}
function edgeEndpoints(edge: NetxJson): [string, string] {
const a = str(edge, 'a_node_id') || str(edge, 'a')
const b = str(edge, 'b_node_id') || str(edge, 'b')
return [a.trim(), b.trim()]
}
function collapseEdgesToLinks(edges: unknown[], includeNames = false): NetxJson[] {
const buckets = new Map<string, NetxJson>()
for (const raw of edges) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
const e = raw as NetxJson
const [a, b] = edgeEndpoints(e)
if (!a || !b || a === b) continue
const lo = a < b ? a : b
const hi = a < b ? b : a
const key = `${lo}\0${hi}`
const row = buckets.get(key) ?? {
a_node_id: lo,
b_node_id: hi,
link_count: 0,
}
row.link_count = Number(row.link_count || 0) + 1
if (includeNames && !row.a_name) {
if (a === lo) {
row.a_name = str(e, 'a_name')
row.b_name = str(e, 'b_name')
} else {
row.a_name = str(e, 'b_name')
row.b_name = str(e, 'a_name')
}
}
buckets.set(key, row)
}
return [...buckets.values()]
}
function summarizeViewGraph(graph: NetxJson, sample = 20): NetxJson {
const nodes = Array.isArray(graph.nodes) ? graph.nodes : []
const edges = Array.isArray(graph.edges) ? graph.edges : (Array.isArray(graph.links) ? graph.links : [])
const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => {
const row = asRecord(n)
return {
id: str(row, 'id') || str(row, 'fabric_node_id'),
name: str(row, 'name'),
x: row.x,
y: row.y,
level: row.level,
role: row.role,
}
})
return {
ok: true,
view_id: str(graph, 'id') || str(graph, 'view_id'),
node_count: nodes.length,
edge_count: edges.length,
link_count: collapseEdgesToLinks(edges).length,
links: collapseEdgesToLinks(edges),
sample_nodes: sampleNodes,
detail: 'summary',
}
}
function layoutRecipeUnavailable(action: string): NetxJson {
return {
ok: false,
error: 'layout_recipe_requires_netx_topology_mcp',
action,
hint: 'Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside).',
}
}
/** GET /v1/topology/tree */
export async function getTopologyTree(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const out = unwrap(await client.get('/v1/topology/tree', undefined, signal))
if (out.ok !== true) return out
const compact = bool(args, 'compact') !== false
const maxDepth = num(args, 'max_depth')
if (!compact && maxDepth === undefined) return out
// Tree is already usable; pass through with flags for the model.
return { ...out, compact, max_depth: maxDepth ?? null }
}
/** GET /v1/topology/views/{id} */
export async function getTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
if (out.ok !== true) return out
const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
if (detail === 'full') return { ...out, detail: 'full' }
return summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 200))
}
/** POST /v1/topology/folders */
export async function createTopologyFolder(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const name = str(args, 'name').trim()
if (!name) return { ok: false, error: 'name_required' }
const body: NetxJson = { name }
const parentId = str(args, 'parent_id').trim()
if (parentId) body.parent_id = parentId
const locale = str(args, 'locale').trim()
if (locale) body.locale = locale
if (num(args, 'sort_order') !== undefined) body.sort_order = Math.trunc(num(args, 'sort_order')!)
return unwrap(await client.post('/v1/topology/folders', body, signal))
}
/** POST /v1/topology/views/{id}/nodes */
export async function addTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
if (strList(args, 'managed_ne_ids').length || strList(args, 'ume_ne_ids').length) {
return {
ok: false,
error: 'fabric_nodes_only',
detail: 'Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.',
}
}
const filters = filterFields(args)
const fabricIds = strList(args, 'fabric_node_ids')
if (Object.keys(filters).length === 0 && fabricIds.length === 0) {
return {
ok: false,
error: 'filter_or_fabric_node_ids_required',
detail: 'Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.',
}
}
if (num(args, 'max_nodes') !== undefined) {
const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal))
if (got.ok === true) {
const data = asRecord(got.data)
const filt = asRecord(got.filter ?? data.filter)
const membership = asRecord(filt.membership ?? {})
membership.max_nodes = clampInt(num(args, 'max_nodes'), 2000, 1, 2000)
filt.membership = membership
await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal)
}
}
const layout = str(args, 'layout', 'grid').trim() || 'grid'
const body: NetxJson = {
managed_ne_ids: [],
layout,
...filters,
}
if (fabricIds.length) body.fabric_node_ids = fabricIds
if (num(args, 'limit') !== undefined) body.limit = clampInt(num(args, 'limit'), 500, 1, 2000)
if (num(args, 'offset') !== undefined) body.offset = clampInt(num(args, 'offset'), 0, 0, Number.MAX_SAFE_INTEGER)
return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180_000))
}
/** POST /v1/topology/views/{id}/nodes/remove */
export async function removeTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
const body: NetxJson = { ...filterFields(args) }
const fabricIds = strList(args, 'fabric_node_ids')
if (fabricIds.length) body.fabric_node_ids = fabricIds
return unwrap(await client.post(
`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`,
body,
signal,
))
}
/** Clone placements from source view onto target view. */
export async function copyTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const sourceId = str(args, 'source_view_id').trim()
const targetId = str(args, 'target_view_id').trim()
if (!sourceId || !targetId) {
return { ok: false, error: 'source_view_id_and_target_view_id_required' }
}
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120_000))
if (source.ok !== true) return source
const nodes = Array.isArray(source.nodes) ? source.nodes : []
const limit = num(args, 'limit')
const selected = typeof limit === 'number' ? nodes.slice(0, clampInt(limit, nodes.length, 1, 2000)) : nodes
const fabricIds = selected.map((n) => {
const row = asRecord(n)
return str(row, 'id') || str(row, 'fabric_node_id')
}).filter(Boolean)
if (bool(args, 'dry_run') === true) {
return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }
}
if (bool(args, 'clear_target') === true) {
await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, {
fabric_node_ids: 'ALL',
clear_all: true,
}, signal)
}
if (fabricIds.length === 0) {
return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }
}
const add = unwrap(await client.post(
`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`,
{ fabric_node_ids: fabricIds, layout: 'keep', managed_ne_ids: [] },
signal,
180_000,
))
if (add.ok !== true) return add
if (bool(args, 'copy_positions') !== false) {
const ox = num(args, 'offset_x') ?? 0
const oy = num(args, 'offset_y') ?? 0
const positions = selected.map((n) => {
const row = asRecord(n)
const id = str(row, 'id') || str(row, 'fabric_node_id')
return {
fabric_node_id: id,
x: Number(row.x || 0) + ox,
y: Number(row.y || 0) + oy,
}
}).filter((p) => p.fabric_node_id)
if (positions.length) {
await client.patch(
`/v1/topology/views/${encodeURIComponent(targetId)}/positions`,
{ positions },
signal,
120_000,
)
}
}
return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord(add) }
}
/** PATCH /v1/topology/views/{id}/positions */
export async function updateTopologyViewPositions(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
const body: NetxJson = {}
if (Array.isArray(args.positions)) body.positions = args.positions
const layout = str(args, 'layout').trim()
if (layout) body.layout = layout
Object.assign(body, filterFields(args))
for (const key of ['offset_x', 'offset_y', 'cols', 'gap_x', 'gap_y'] as const) {
if (num(args, key) !== undefined) body[key] = num(args, key)
}
const fabricIds = strList(args, 'fabric_node_ids')
if (fabricIds.length) body.fabric_node_ids = fabricIds
return unwrap(await client.patch(
`/v1/topology/views/${encodeURIComponent(viewId)}/positions`,
body,
signal,
120_000,
))
}
/** POST .../project-neighbors */
export async function projectTopologyNeighbors(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
const body: NetxJson = {}
const seeds = strList(args, 'seed_fabric_node_ids').length
? strList(args, 'seed_fabric_node_ids')
: strList(args, 'fabric_node_ids')
if (seeds.length) body.seed_fabric_node_ids = seeds
const mids = strList(args, 'managed_ne_ids')
if (mids.length) body.managed_ne_ids = mids
const region = str(args, 'region_folder_id').trim()
if (region) body.region_folder_id = region
const out = unwrap(await client.post(
`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,
body,
signal,
180_000,
))
if (out.ok !== true) return out
const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
if (detail === 'full' || detail === 'raw' || detail === 'graph') {
return { ...out, detail: 'full', projected: true }
}
return { ...summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 100)), projected: true, view_id: viewId }
}
/** Fabric inventory summary|list|search */
export async function queryTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
let mode = str(args, 'mode').trim().toLowerCase()
const q = str(args, 'q').trim()
const keyword = str(args, 'keyword').trim()
if (!mode) {
if (bool(args, 'summary') === true) mode = 'summary'
else if (q) mode = 'search'
else mode = 'list'
}
if (mode === 'summary' || mode === 'stats' || mode === 'count') {
const out = unwrap(await client.get('/v1/topology/fabric/summary', undefined, signal))
return out.ok === true ? { ...out, mode: 'summary' } : out
}
if (mode === 'search' || mode === 'find') {
const needle = q || keyword
if (!needle) return { ok: false, error: 'q_required', hint: 'mode=search needs q (or keyword).' }
return unwrap(await client.get('/v1/topology/fabric/nodes/search', {
q: needle,
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 200),
}, signal))
}
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 500),
}
for (const key of ['keyword', 'role', 'level', 'level_major', 'region_folder_id', 'link_status'] as const) {
const v = str(args, key).trim() || (key === 'keyword' ? q : '')
if (v) params[key] = v
}
const out = unwrap(await client.get('/v1/topology/fabric/nodes', params, signal))
return out.ok === true ? { ...out, mode: 'list' } : out
}
/** Classify / tag fabric nodes */
export async function classifyTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const action = str(args, 'action').trim().toLowerCase()
if (!action) return { ok: false, error: 'action_required' }
if (action === 'match') {
const pattern = str(args, 'pattern').trim() || str(args, 'q').trim()
if (!pattern) return { ok: false, error: 'pattern_required' }
return unwrap(await client.post('/v1/topology/fabric/nodes/match', {
pattern,
match_field: str(args, 'match_field', 'name').trim() || 'name',
sample_limit: clampInt(num(args, 'sample_limit'), 50, 1, 200),
}, signal))
}
if (action === 'tag') {
const body: NetxJson = {
fabric_node_ids: strList(args, 'fabric_node_ids'),
dry_run: bool(args, 'dry_run') === true,
}
const pattern = str(args, 'pattern').trim() || str(args, 'q').trim()
if (pattern) body.pattern = pattern
if (args.level !== undefined) body.level = args.level
const role = str(args, 'role').trim()
if (role) body.role = role
const region = str(args, 'region_folder_id').trim()
if (region) body.region_folder_id = region
if (bool(args, 'clear_region') === true) body.clear_region = true
return unwrap(await client.post('/v1/topology/fabric/nodes/tags/bulk', body, signal))
}
if (action === 'patch') {
const nodeId = str(args, 'fabric_node_id').trim() || str(args, 'node_id').trim()
if (!nodeId) return { ok: false, error: 'fabric_node_id_required' }
const body: NetxJson = {}
if (args.level !== undefined) body.level = args.level
const role = str(args, 'role').trim()
if (role) body.role = role
const region = str(args, 'region_folder_id').trim()
if (region) body.region_folder_id = region
if (bool(args, 'clear_region') === true) body.clear_region = true
return unwrap(await client.patch(
`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`,
body,
signal,
))
}
if (action === 'unmatched') {
return unwrap(await client.get('/v1/topology/classify/unmatched', {
kind: str(args, 'kind', 'any').trim() || 'any',
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 500),
}, signal))
}
if (action === 'preview_rules') {
return unwrap(await client.post('/v1/topology/classify/preview', {}, signal))
}
if (action === 'apply_rules') {
const params: Record<string, string | number | boolean> = {}
if (bool(args, 'overwrite_manual') === true) params.skip_manual = false
else if (bool(args, 'skip_manual') !== undefined) params.skip_manual = bool(args, 'skip_manual') !== false
if (bool(args, 'fill_empty_only') === true) params.fill_empty_only = true
return unwrap(await client.post('/v1/topology/classify/apply', {}, signal))
}
if (action === 'list_rules') {
return unwrap(await client.get('/v1/topology/classify/rules', undefined, signal))
}
return { ok: false, error: 'unknown_action', action }
}
/** GET /v1/topology/fabric/neighborhood */
export async function queryTopologyNeighborhood(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const nodeId = str(args, 'node_id').trim()
if (!nodeId) return { ok: false, error: 'node_id_required' }
const out = unwrap(await client.get('/v1/topology/fabric/neighborhood', {
node_id: nodeId,
depth: clampInt(num(args, 'depth'), 1, 1, 3),
layer: str(args, 'layer', 'physical').trim() || 'physical',
}, signal))
if (out.ok !== true) return out
const edges = Array.isArray(out.edges) ? out.edges : []
return { ...out, links: collapseEdgesToLinks(edges) }
}
/** GET /v1/topology/fabric/edges */
export async function queryTopologyEdges(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),
page_size: clampInt(num(args, 'page_size'), 100, 1, 500),
layer: str(args, 'layer', 'physical').trim() || 'physical',
}
for (const key of ['node_id', 'keyword', 'status', 'source'] as const) {
const v = str(args, key).trim()
if (v) params[key] = v
}
const out = unwrap(await client.get('/v1/topology/fabric/edges', params, signal))
if (out.ok !== true) return out
const detail = str(args, 'detail', 'adjacency').trim().toLowerCase() || 'adjacency'
const items = Array.isArray(out.items) ? out.items : (Array.isArray(out.edges) ? out.edges : [])
if (detail === 'ports') return { ...out, detail: 'ports', items }
const links = collapseEdgesToLinks(items, true)
return { ...out, detail: 'adjacency', links, items: undefined }
}
/** Simple degree-ranked hub suggestions (HTTP-only stand-in). */
export async function suggestSinkHubs(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'source_view_id').trim() || str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'source_view_id_required' }
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
if (out.ok !== true) return out
const nodes = Array.isArray(out.nodes) ? out.nodes : []
const edges = Array.isArray(out.edges) ? out.edges : []
const degree = new Map<string, number>()
for (const raw of edges) {
const [a, b] = edgeEndpoints(asRecord(raw))
if (!a || !b) continue
degree.set(a, (degree.get(a) || 0) + 1)
degree.set(b, (degree.get(b) || 0) + 1)
}
const exclude = new Set([
...strList(args, 'exclude_portal_ids'),
...strList(args, 'exclude_fabric_node_ids'),
])
const pick = clampInt(num(args, 'pick'), 5, 1, 50)
const hubs = [...degree.entries()]
.filter(([id]) => !exclude.has(id))
.sort((a, b) => b[1] - a[1])
.slice(0, pick)
.map(([fabric_node_id, deg]) => {
const node = nodes.find((n) => {
const row = asRecord(n)
return (str(row, 'id') || str(row, 'fabric_node_id')) === fabric_node_id
})
const row = asRecord(node)
return {
fabric_node_id,
degree: deg,
name: str(row, 'name'),
level: row.level,
role: row.role,
}
})
return {
ok: true,
source_view_id: viewId,
hubs,
hint: 'Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP.',
}
}
/** Basic layout QA from view graph (no Python layout_stats). */
export async function analyzeTopologyViewLayout(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) {
return {
ok: false,
error: 'view_id_required',
hint: 'Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis.',
}
}
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
if (out.ok !== true) return out
const nodes = Array.isArray(out.nodes) ? out.nodes : []
const edges = Array.isArray(out.edges) ? out.edges : []
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
let missingxy = 0
for (const raw of nodes) {
const row = asRecord(raw)
const x = Number(row.x)
const y = Number(row.y)
if (!Number.isFinite(x) || !Number.isFinite(y)) {
missingxy += 1
continue
}
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
}
const degree = new Map<string, number>()
for (const raw of edges) {
const [a, b] = edgeEndpoints(asRecord(raw))
if (!a || !b) continue
degree.set(a, (degree.get(a) || 0) + 1)
degree.set(b, (degree.get(b) || 0) + 1)
}
const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
.map(([id, deg]) => ({ fabric_node_id: id, degree: deg }))
return {
ok: true,
view_id: viewId,
detail: str(args, 'detail', 'summary') || 'summary',
node_count: nodes.length,
edge_count: edges.length,
link_count: collapseEdgesToLinks(edges).length,
missing_xy: missingxy,
bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null,
hubs,
hint: 'Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP.',
}
}
/** Dual-unit sink requires MCP layout engine. */
export async function sinkTopologyDualUnits(_client: NetxClient, args: NetxJson): Promise<NetxJson> {
return {
ok: false,
error: 'sink_dual_units_requires_netx_topology_mcp',
source_view_id: str(args, 'source_view_id'),
sink_view_id: str(args, 'sink_view_id'),
hint: 'sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP.',
}
}
/** Layout tool: catalog + move_nodes over HTTP; recipes deferred to MCP. */
export async function layoutTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
if (bool(args, 'catalog') === true) {
return {
ok: true,
catalog: true,
actions: [
'layout', 'layout_dual_unit', 'move_nodes', 'sink_nodes', 'orbit_sweep', 'level_bands',
'polish_crossings', 'clear_edge_hits', 'compact_bbox', 'pull_far_chains', 'align_reference',
'fix_overlaps', 'resolve_overlaps', 'untangle', 'straighten_channels', 'job_status', 'job_cancel',
],
dsh_supported: ['move_nodes', 'sink_nodes', 'catalog'],
recipes: ['rings', 'corridor', 'compact', 'unstick'],
hint: 'Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP.',
}
}
const action = str(args, 'action', 'layout').trim().toLowerCase() || 'layout'
if (action === 'job_status' || action === 'job_cancel') {
return {
ok: false,
error: 'layout_jobs_not_in_dsh_host',
hint: 'Background layout jobs are owned by netx-topology MCP.',
}
}
if (action === 'move_nodes' || action === 'sink_nodes') {
const toId = str(args, 'view_id').trim()
const fromId = str(args, 'source_view_id').trim()
const ids = strList(args, 'fabric_node_ids')
const params = asRecord(args.params)
const fabricIds = ids.length ? ids : strList(params, 'fabric_node_ids')
if (!toId || !fromId) {
return { ok: false, error: 'view_id_and_source_view_id_required' }
}
if (fabricIds.length === 0) {
return { ok: false, error: 'fabric_node_ids_required' }
}
const copyPositions = bool(params, 'copy_positions') !== false
&& bool(args, 'copy_positions') !== false
const removeFromSource = bool(params, 'remove_from_source') !== false
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120_000))
if (source.ok !== true) return source
const nodes = Array.isArray(source.nodes) ? source.nodes : []
const wanted = new Set(fabricIds)
const selected = nodes.filter((n) => {
const row = asRecord(n)
const id = str(row, 'id') || str(row, 'fabric_node_id')
return wanted.has(id)
})
const add = unwrap(await client.post(
`/v1/topology/views/${encodeURIComponent(toId)}/nodes`,
{ fabric_node_ids: fabricIds, layout: copyPositions ? 'keep' : 'grid', managed_ne_ids: [] },
signal,
180_000,
))
if (add.ok !== true) return add
if (copyPositions && selected.length) {
const ox = Number(params.offset_x || 0)
const oy = Number(params.offset_y || 0)
const positions = selected.map((n) => {
const row = asRecord(n)
return {
fabric_node_id: str(row, 'id') || str(row, 'fabric_node_id'),
x: Number(row.x || 0) + ox,
y: Number(row.y || 0) + oy,
}
}).filter((p) => p.fabric_node_id)
await client.patch(
`/v1/topology/views/${encodeURIComponent(toId)}/positions`,
{ positions },
signal,
120_000,
)
}
if (removeFromSource) {
await client.post(
`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`,
{ fabric_node_ids: fabricIds },
signal,
)
}
return {
ok: true,
action,
moved: fabricIds.length,
source_view_id: fromId,
view_id: toId,
park: bool(params, 'park') === true || bool(args, 'park') === true,
}
}
return layoutRecipeUnavailable(action)
}