From da08c389493066c1983beeb440709eebc483c8b3 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 15 Sep 2026 22:37:18 +0800 Subject: [PATCH 01/10] Fix key-alarm IM delivery blocked by DSH session sink. Run session and WhatsApp/IM sinks in parallel so a failing or slow sticky session no longer skips proactive IM delivery (0.1.34). Co-authored-by: Cursor --- lib/index.js | 33 ++++++++++++++---- package.json | 2 +- src/index.ts | 16 ++++----- src/netx/alarm-dispatch.ts | 54 +++++++++++++++++++++++++++++ test/alarm-dispatch.test.mjs | 67 ++++++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 src/netx/alarm-dispatch.ts create mode 100644 test/alarm-dispatch.test.mjs diff --git a/lib/index.js b/lib/index.js index 9047449..bf912fd 100644 --- a/lib/index.js +++ b/lib/index.js @@ -546,6 +546,29 @@ function resetAlarmSession() { disposeSticky(previous); } +// src/netx/alarm-dispatch.ts +async function dispatchAlarmToSinks(ctx, payload, options, sinks = {}) { + const toSession = sinks.toSession ?? deliverAlarmToSession; + const toIm = sinks.toIm ?? deliverAlarmToIm; + const jobs = []; + if (options.deliverDsh) { + jobs.push(toSession(ctx, payload, options.lang)); + } + const imOptions = { + enabled: options.deliverIm, + targets: options.imTargets, + lang: options.lang + }; + jobs.push(toIm(ctx, payload, imOptions)); + const results = await Promise.allSettled(jobs); + for (const result of results) { + if (result.status === "rejected") { + ctx.logger.warn("netxops alarm-push: sink failed: %s", result.reason); + } + } + return results; +} + // src/netx/capability-groups.ts var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ ops: Object.freeze({ inPreset: true, public: false }), @@ -4558,12 +4581,10 @@ function apply(ctx, config = Config({})) { token, logger: ctx.logger, onAlarm: async (payload) => { - if (deliverDsh) { - await deliverAlarmToSession(ctx, payload, lang); - } - await deliverAlarmToIm(ctx, payload, { - enabled: deliverIm, - targets: imTargets, + await dispatchAlarmToSinks(ctx, payload, { + deliverDsh, + deliverIm, + imTargets, lang }); } diff --git a/package.json b/package.json index 2b2c34a..e78849d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.33", + "version": "0.1.34", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/src/index.ts b/src/index.ts index 5d18000..a91e974 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,8 +35,8 @@ import { publishAlarmPushStatus, resetAlarmPushStatus, } from './netx/alarm-push-status.ts' -import { deliverAlarmToIm } from './netx/alarm-im.ts' -import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts' +import { dispatchAlarmToSinks } from './netx/alarm-dispatch.ts' +import { resetAlarmSession } from './netx/alarm-session.ts' import { capabilityGroupsFromSettings, groupsForPlane, @@ -307,18 +307,18 @@ export function apply(ctx: Context, config: Config = Config({})): void { imBotId: current.imBotId ?? '', imTargetId: current.imTargetId ?? '', }) + // Prefer target list presence; UI also mirrors this into alarmDeliverIm. const deliverIm = imTargets.length > 0 stopAlarmPush = startAlarmPushClient({ apiUrl, token, logger: ctx.logger, onAlarm: async (payload) => { - if (deliverDsh) { - await deliverAlarmToSession(ctx, payload, lang) - } - await deliverAlarmToIm(ctx, payload, { - enabled: deliverIm, - targets: imTargets, + // Parallel sinks: a stuck/failing DSH sticky session must not block WhatsApp/IM. + await dispatchAlarmToSinks(ctx, payload, { + deliverDsh, + deliverIm, + imTargets, lang, }) }, diff --git a/src/netx/alarm-dispatch.ts b/src/netx/alarm-dispatch.ts new file mode 100644 index 0000000..888254d --- /dev/null +++ b/src/netx/alarm-dispatch.ts @@ -0,0 +1,54 @@ +/** + * Fan-out one key-alarm to optional DSH session + IM sinks. + * Sinks run in parallel so a failing/slow session path cannot block WhatsApp/IM. + */ + +import type { Context } from '@deepseek-ai/cordis' +import { deliverAlarmToIm, type AlarmImDeliveryOptions } from './alarm-im.ts' +import { deliverAlarmToSession } from './alarm-session.ts' +import type { KeyAlarmPayload } from './alarm-push.ts' +import type { ImDeliveryTarget } from './im-targets.ts' + +export interface AlarmSinkDispatchOptions { + deliverDsh: boolean + deliverIm: boolean + imTargets: readonly ImDeliveryTarget[] + lang: string +} + +export interface AlarmSinkFns { + toSession?: typeof deliverAlarmToSession + toIm?: typeof deliverAlarmToIm +} + +/** + * Deliver to every enabled sink; never let one sink's rejection cancel the others. + * @returns settled results in order: [session?, im] (session omitted when deliverDsh is false). + */ +export async function dispatchAlarmToSinks( + ctx: Context, + payload: KeyAlarmPayload, + options: AlarmSinkDispatchOptions, + sinks: AlarmSinkFns = {}, +): Promise[]> { + const toSession = sinks.toSession ?? deliverAlarmToSession + const toIm = sinks.toIm ?? deliverAlarmToIm + const jobs: Array> = [] + if (options.deliverDsh) { + jobs.push(toSession(ctx, payload, options.lang)) + } + const imOptions: AlarmImDeliveryOptions = { + enabled: options.deliverIm, + targets: options.imTargets, + lang: options.lang, + } + jobs.push(toIm(ctx, payload, imOptions)) + + const results = await Promise.allSettled(jobs) + for (const result of results) { + if (result.status === 'rejected') { + ctx.logger.warn('netxops alarm-push: sink failed: %s', result.reason) + } + } + return results +} diff --git a/test/alarm-dispatch.test.mjs b/test/alarm-dispatch.test.mjs new file mode 100644 index 0000000..155b802 --- /dev/null +++ b/test/alarm-dispatch.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { dispatchAlarmToSinks } from '../src/netx/alarm-dispatch.ts' + +test('dispatchAlarmToSinks still delivers IM when DSH session rejects', async () => { + let imCalls = 0 + const warnings = [] + const ctx = { + logger: { + warn: (...args) => { warnings.push(args.map(String).join(' ')) }, + info() {}, + }, + } + const results = await dispatchAlarmToSinks( + ctx, + { + action: 'inserted', + rule_label: 'Power Down', + alarm_key: 'k1', + ne: { host_name: 'PE1' }, + }, + { + deliverDsh: true, + deliverIm: true, + imTargets: [{ botId: 'bot', targetId: 'tgt' }], + lang: 'zh', + }, + { + toSession: async () => { + throw new Error('sticky session exploded') + }, + toIm: async () => { + imCalls += 1 + }, + }, + ) + assert.equal(imCalls, 1) + assert.equal(results.length, 2) + assert.equal(results[0].status, 'rejected') + assert.equal(results[1].status, 'fulfilled') + assert.ok(warnings.some((line) => line.includes('sink failed'))) +}) + +test('dispatchAlarmToSinks skips session job when deliverDsh is false', async () => { + let sessionCalls = 0 + let imCalls = 0 + const ctx = { logger: { warn() {}, info() {} } } + const results = await dispatchAlarmToSinks( + ctx, + { action: 'inserted', alarm_key: 'k1' }, + { + deliverDsh: false, + deliverIm: true, + imTargets: [{ botId: 'bot', targetId: 'tgt' }], + lang: 'zh', + }, + { + toSession: async () => { sessionCalls += 1 }, + toIm: async () => { imCalls += 1 }, + }, + ) + assert.equal(sessionCalls, 0) + assert.equal(imCalls, 1) + assert.equal(results.length, 1) + assert.equal(results[0].status, 'fulfilled') +}) From 62d3f565ce9958adec4a17661573ae74aaa753cc Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 16 Sep 2026 00:39:40 +0800 Subject: [PATCH 02/10] Fix KB local tool schemas so Netx Ops preset can mount. DSH defineTool expects a flat parameter map; full JSON Schema objects broke hosts with KB configured (0.1.35). Co-authored-by: Cursor --- lib/agent-tools-common.js | 100 ++++++++++++++---------------------- lib/agent-tools-nms.js | 100 ++++++++++++++---------------------- lib/agent-tools-ops.js | 100 ++++++++++++++---------------------- lib/agent-tools-topology.js | 100 ++++++++++++++---------------------- lib/agent-tools.js | 100 ++++++++++++++---------------------- lib/index.js | 100 ++++++++++++++---------------------- package.json | 2 +- src/netx/kb-local-tools.ts | 95 +++++++++++++--------------------- 8 files changed, 272 insertions(+), 425 deletions(-) diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 681b622..4b52ae1 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -672,6 +672,11 @@ function watchKbContext(listener) { var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); var num = (description) => ({ type: "number", ...description ? { description } : {} }); +var reqStr = (description) => ({ + type: "string", + required: true, + ...description ? { description } : {} +}); function renderJson(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } @@ -708,20 +713,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { return () => {}; const entries = [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { - type: "object", - additionalProperties: false, - required: ["bucket", "slug", "body"], - properties: { - bucket: { - type: "string", - enum: ["note", "rca_review", "ai_trace"], - description: "Memory subdirectory" - }, - slug: str("Short filename stem (no path separators)"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + bucket: { + type: "string", + required: true, + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: reqStr("Short filename stem (no path separators)"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -730,16 +731,11 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { - type: "object", - additionalProperties: false, - required: ["slug", "body"], - properties: { - slug: str("Case stem (DRAFT- prefix added if missing)"), - body: str("Markdown body (RCA-ish draft)"), - domain: str("Optional fault-domain subfolder under drafts/"), - date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), - overwrite: bool("Replace if the target file already exists") - } + slug: reqStr("Case stem (DRAFT- prefix added if missing)"), + body: reqStr("Markdown body (RCA-ish draft)"), + domain: str("Optional fault-domain subfolder under drafts/"), + date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), + overwrite: bool("Replace if the target file already exists") }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -748,20 +744,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { - type: "object", - additionalProperties: false, - required: ["kind", "slug", "body"], - properties: { - kind: { - type: "string", - enum: ["theory", "improvement"], - description: "theory = knowledge fix; improvement = tools/skills/process" - }, - slug: str("Short filename stem"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + kind: { + type: "string", + required: true, + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: reqStr("Short filename stem"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -770,38 +762,24 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { - type: "object", - additionalProperties: false, - required: ["path", "body"], - properties: { - path: str("Absolute path or path relative to KB local root"), - body: str("New full markdown body") - } + path: reqStr("Absolute path or path relative to KB local root"), + body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { - type: "object", - additionalProperties: false, - required: ["path"], - properties: { - path: str("Absolute path or path relative to KB local root") - } + path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { - type: "object", - additionalProperties: false, - properties: { - root: { - type: "string", - enum: ["memories", "drafts", "suggestions", "all"], - description: "Subtree to list (default all writable)" - }, - limit: num("Max entries 1–200 (default 50)") - } + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "all"], + description: "Subtree to list (default all writable)" + }, + limit: num("Max entries 1–200 (default 50)") }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index ec63a46..39829c8 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -672,6 +672,11 @@ function watchKbContext(listener) { var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); var num = (description) => ({ type: "number", ...description ? { description } : {} }); +var reqStr = (description) => ({ + type: "string", + required: true, + ...description ? { description } : {} +}); function renderJson(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } @@ -708,20 +713,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { return () => {}; const entries = [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { - type: "object", - additionalProperties: false, - required: ["bucket", "slug", "body"], - properties: { - bucket: { - type: "string", - enum: ["note", "rca_review", "ai_trace"], - description: "Memory subdirectory" - }, - slug: str("Short filename stem (no path separators)"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + bucket: { + type: "string", + required: true, + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: reqStr("Short filename stem (no path separators)"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -730,16 +731,11 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { - type: "object", - additionalProperties: false, - required: ["slug", "body"], - properties: { - slug: str("Case stem (DRAFT- prefix added if missing)"), - body: str("Markdown body (RCA-ish draft)"), - domain: str("Optional fault-domain subfolder under drafts/"), - date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), - overwrite: bool("Replace if the target file already exists") - } + slug: reqStr("Case stem (DRAFT- prefix added if missing)"), + body: reqStr("Markdown body (RCA-ish draft)"), + domain: str("Optional fault-domain subfolder under drafts/"), + date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), + overwrite: bool("Replace if the target file already exists") }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -748,20 +744,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { - type: "object", - additionalProperties: false, - required: ["kind", "slug", "body"], - properties: { - kind: { - type: "string", - enum: ["theory", "improvement"], - description: "theory = knowledge fix; improvement = tools/skills/process" - }, - slug: str("Short filename stem"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + kind: { + type: "string", + required: true, + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: reqStr("Short filename stem"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -770,38 +762,24 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { - type: "object", - additionalProperties: false, - required: ["path", "body"], - properties: { - path: str("Absolute path or path relative to KB local root"), - body: str("New full markdown body") - } + path: reqStr("Absolute path or path relative to KB local root"), + body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { - type: "object", - additionalProperties: false, - required: ["path"], - properties: { - path: str("Absolute path or path relative to KB local root") - } + path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { - type: "object", - additionalProperties: false, - properties: { - root: { - type: "string", - enum: ["memories", "drafts", "suggestions", "all"], - description: "Subtree to list (default all writable)" - }, - limit: num("Max entries 1–200 (default 50)") - } + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "all"], + description: "Subtree to list (default all writable)" + }, + limit: num("Max entries 1–200 (default 50)") }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 0050eab..72b1002 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -672,6 +672,11 @@ function watchKbContext(listener) { var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); var num = (description) => ({ type: "number", ...description ? { description } : {} }); +var reqStr = (description) => ({ + type: "string", + required: true, + ...description ? { description } : {} +}); function renderJson(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } @@ -708,20 +713,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { return () => {}; const entries = [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { - type: "object", - additionalProperties: false, - required: ["bucket", "slug", "body"], - properties: { - bucket: { - type: "string", - enum: ["note", "rca_review", "ai_trace"], - description: "Memory subdirectory" - }, - slug: str("Short filename stem (no path separators)"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + bucket: { + type: "string", + required: true, + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: reqStr("Short filename stem (no path separators)"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -730,16 +731,11 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { - type: "object", - additionalProperties: false, - required: ["slug", "body"], - properties: { - slug: str("Case stem (DRAFT- prefix added if missing)"), - body: str("Markdown body (RCA-ish draft)"), - domain: str("Optional fault-domain subfolder under drafts/"), - date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), - overwrite: bool("Replace if the target file already exists") - } + slug: reqStr("Case stem (DRAFT- prefix added if missing)"), + body: reqStr("Markdown body (RCA-ish draft)"), + domain: str("Optional fault-domain subfolder under drafts/"), + date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), + overwrite: bool("Replace if the target file already exists") }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -748,20 +744,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { - type: "object", - additionalProperties: false, - required: ["kind", "slug", "body"], - properties: { - kind: { - type: "string", - enum: ["theory", "improvement"], - description: "theory = knowledge fix; improvement = tools/skills/process" - }, - slug: str("Short filename stem"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + kind: { + type: "string", + required: true, + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: reqStr("Short filename stem"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -770,38 +762,24 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { - type: "object", - additionalProperties: false, - required: ["path", "body"], - properties: { - path: str("Absolute path or path relative to KB local root"), - body: str("New full markdown body") - } + path: reqStr("Absolute path or path relative to KB local root"), + body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { - type: "object", - additionalProperties: false, - required: ["path"], - properties: { - path: str("Absolute path or path relative to KB local root") - } + path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { - type: "object", - additionalProperties: false, - properties: { - root: { - type: "string", - enum: ["memories", "drafts", "suggestions", "all"], - description: "Subtree to list (default all writable)" - }, - limit: num("Max entries 1–200 (default 50)") - } + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "all"], + description: "Subtree to list (default all writable)" + }, + limit: num("Max entries 1–200 (default 50)") }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index f709df5..c9d1803 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -672,6 +672,11 @@ function watchKbContext(listener) { var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); var num = (description) => ({ type: "number", ...description ? { description } : {} }); +var reqStr = (description) => ({ + type: "string", + required: true, + ...description ? { description } : {} +}); function renderJson(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } @@ -708,20 +713,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { return () => {}; const entries = [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { - type: "object", - additionalProperties: false, - required: ["bucket", "slug", "body"], - properties: { - bucket: { - type: "string", - enum: ["note", "rca_review", "ai_trace"], - description: "Memory subdirectory" - }, - slug: str("Short filename stem (no path separators)"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + bucket: { + type: "string", + required: true, + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: reqStr("Short filename stem (no path separators)"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -730,16 +731,11 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { - type: "object", - additionalProperties: false, - required: ["slug", "body"], - properties: { - slug: str("Case stem (DRAFT- prefix added if missing)"), - body: str("Markdown body (RCA-ish draft)"), - domain: str("Optional fault-domain subfolder under drafts/"), - date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), - overwrite: bool("Replace if the target file already exists") - } + slug: reqStr("Case stem (DRAFT- prefix added if missing)"), + body: reqStr("Markdown body (RCA-ish draft)"), + domain: str("Optional fault-domain subfolder under drafts/"), + date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), + overwrite: bool("Replace if the target file already exists") }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -748,20 +744,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { - type: "object", - additionalProperties: false, - required: ["kind", "slug", "body"], - properties: { - kind: { - type: "string", - enum: ["theory", "improvement"], - description: "theory = knowledge fix; improvement = tools/skills/process" - }, - slug: str("Short filename stem"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + kind: { + type: "string", + required: true, + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: reqStr("Short filename stem"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -770,38 +762,24 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { - type: "object", - additionalProperties: false, - required: ["path", "body"], - properties: { - path: str("Absolute path or path relative to KB local root"), - body: str("New full markdown body") - } + path: reqStr("Absolute path or path relative to KB local root"), + body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { - type: "object", - additionalProperties: false, - required: ["path"], - properties: { - path: str("Absolute path or path relative to KB local root") - } + path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { - type: "object", - additionalProperties: false, - properties: { - root: { - type: "string", - enum: ["memories", "drafts", "suggestions", "all"], - description: "Subtree to list (default all writable)" - }, - limit: num("Max entries 1–200 (default 50)") - } + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "all"], + description: "Subtree to list (default all writable)" + }, + limit: num("Max entries 1–200 (default 50)") }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined diff --git a/lib/agent-tools.js b/lib/agent-tools.js index ff64468..6faf0f6 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -672,6 +672,11 @@ function watchKbContext(listener) { var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); var num = (description) => ({ type: "number", ...description ? { description } : {} }); +var reqStr = (description) => ({ + type: "string", + required: true, + ...description ? { description } : {} +}); function renderJson(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } @@ -708,20 +713,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { return () => {}; const entries = [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { - type: "object", - additionalProperties: false, - required: ["bucket", "slug", "body"], - properties: { - bucket: { - type: "string", - enum: ["note", "rca_review", "ai_trace"], - description: "Memory subdirectory" - }, - slug: str("Short filename stem (no path separators)"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + bucket: { + type: "string", + required: true, + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: reqStr("Short filename stem (no path separators)"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -730,16 +731,11 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { - type: "object", - additionalProperties: false, - required: ["slug", "body"], - properties: { - slug: str("Case stem (DRAFT- prefix added if missing)"), - body: str("Markdown body (RCA-ish draft)"), - domain: str("Optional fault-domain subfolder under drafts/"), - date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), - overwrite: bool("Replace if the target file already exists") - } + slug: reqStr("Case stem (DRAFT- prefix added if missing)"), + body: reqStr("Markdown body (RCA-ish draft)"), + domain: str("Optional fault-domain subfolder under drafts/"), + date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), + overwrite: bool("Replace if the target file already exists") }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -748,20 +744,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { - type: "object", - additionalProperties: false, - required: ["kind", "slug", "body"], - properties: { - kind: { - type: "string", - enum: ["theory", "improvement"], - description: "theory = knowledge fix; improvement = tools/skills/process" - }, - slug: str("Short filename stem"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + kind: { + type: "string", + required: true, + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: reqStr("Short filename stem"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -770,38 +762,24 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { - type: "object", - additionalProperties: false, - required: ["path", "body"], - properties: { - path: str("Absolute path or path relative to KB local root"), - body: str("New full markdown body") - } + path: reqStr("Absolute path or path relative to KB local root"), + body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { - type: "object", - additionalProperties: false, - required: ["path"], - properties: { - path: str("Absolute path or path relative to KB local root") - } + path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { - type: "object", - additionalProperties: false, - properties: { - root: { - type: "string", - enum: ["memories", "drafts", "suggestions", "all"], - description: "Subtree to list (default all writable)" - }, - limit: num("Max entries 1–200 (default 50)") - } + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "all"], + description: "Subtree to list (default all writable)" + }, + limit: num("Max entries 1–200 (default 50)") }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined diff --git a/lib/index.js b/lib/index.js index bf912fd..bbe596d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1526,6 +1526,11 @@ function resetKbContext() { var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); var num = (description) => ({ type: "number", ...description ? { description } : {} }); +var reqStr = (description) => ({ + type: "string", + required: true, + ...description ? { description } : {} +}); function renderJson(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } @@ -1562,20 +1567,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { return () => {}; const entries = [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { - type: "object", - additionalProperties: false, - required: ["bucket", "slug", "body"], - properties: { - bucket: { - type: "string", - enum: ["note", "rca_review", "ai_trace"], - description: "Memory subdirectory" - }, - slug: str("Short filename stem (no path separators)"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + bucket: { + type: "string", + required: true, + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: reqStr("Short filename stem (no path separators)"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -1584,16 +1585,11 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { - type: "object", - additionalProperties: false, - required: ["slug", "body"], - properties: { - slug: str("Case stem (DRAFT- prefix added if missing)"), - body: str("Markdown body (RCA-ish draft)"), - domain: str("Optional fault-domain subfolder under drafts/"), - date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), - overwrite: bool("Replace if the target file already exists") - } + slug: reqStr("Case stem (DRAFT- prefix added if missing)"), + body: reqStr("Markdown body (RCA-ish draft)"), + domain: str("Optional fault-domain subfolder under drafts/"), + date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), + overwrite: bool("Replace if the target file already exists") }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -1602,20 +1598,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { - type: "object", - additionalProperties: false, - required: ["kind", "slug", "body"], - properties: { - kind: { - type: "string", - enum: ["theory", "improvement"], - description: "theory = knowledge fix; improvement = tools/skills/process" - }, - slug: str("Short filename stem"), - body: str("Full markdown body"), - date: str("Optional YYYY-MM-DD (default today)"), - overwrite: bool("Replace if the target file already exists") - } + kind: { + type: "string", + required: true, + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: reqStr("Short filename stem"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -1624,38 +1616,24 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { overwrite: args.overwrite === true })), tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { - type: "object", - additionalProperties: false, - required: ["path", "body"], - properties: { - path: str("Absolute path or path relative to KB local root"), - body: str("New full markdown body") - } + path: reqStr("Absolute path or path relative to KB local root"), + body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { - type: "object", - additionalProperties: false, - required: ["path"], - properties: { - path: str("Absolute path or path relative to KB local root") - } + path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { - type: "object", - additionalProperties: false, - properties: { - root: { - type: "string", - enum: ["memories", "drafts", "suggestions", "all"], - description: "Subtree to list (default all writable)" - }, - limit: num("Max entries 1–200 (default 50)") - } + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "all"], + description: "Subtree to list (default all writable)" + }, + limit: num("Max entries 1–200 (default 50)") }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined diff --git a/package.json b/package.json index e78849d..3b75b50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.34", + "version": "0.1.35", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/src/netx/kb-local-tools.ts b/src/netx/kb-local-tools.ts index 57ce322..77ff825 100644 --- a/src/netx/kb-local-tools.ts +++ b/src/netx/kb-local-tools.ts @@ -20,6 +20,11 @@ import { getKbContext } from './kb-runtime.ts' const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) }) const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) }) const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) }) +const reqStr = (description?: string) => ({ + type: 'string' as const, + required: true as const, + ...(description ? { description } : {}), +}) function renderJson(_args: unknown, value: unknown) { return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }] @@ -37,6 +42,7 @@ function liveSnapshot(): KbSnapshot { function tool( name: string, description: string, + /** DSH `defineTool` expects a flat property map — not a full JSON Schema object. */ parameters: Record, execute: (args: Record) => Promise | unknown, ) { @@ -79,20 +85,16 @@ export function registerKbLocalTools( + 'Default create-only; set overwrite=true to replace same filename. ' + 'Do not use workspace Write — KB is outside the sandbox.', { - type: 'object', - additionalProperties: false, - required: ['bucket', 'slug', 'body'], - properties: { - bucket: { - type: 'string', - enum: ['note', 'rca_review', 'ai_trace'], - description: 'Memory subdirectory', - }, - slug: str('Short filename stem (no path separators)'), - body: str('Full markdown body'), - date: str('Optional YYYY-MM-DD (default today)'), - overwrite: bool('Replace if the target file already exists'), + bucket: { + type: 'string' as const, + required: true as const, + enum: ['note', 'rca_review', 'ai_trace'], + description: 'Memory subdirectory', }, + slug: reqStr('Short filename stem (no path separators)'), + body: reqStr('Full markdown body'), + date: str('Optional YYYY-MM-DD (default today)'), + overwrite: bool('Replace if the target file already exists'), }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket as 'note' | 'rca_review' | 'ai_trace', @@ -108,16 +110,11 @@ export function registerKbLocalTools( + 'Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. ' + 'Set overwrite=true to replace. Not for identity/ or formal RCA.', { - type: 'object', - additionalProperties: false, - required: ['slug', 'body'], - properties: { - slug: str('Case stem (DRAFT- prefix added if missing)'), - body: str('Markdown body (RCA-ish draft)'), - domain: str('Optional fault-domain subfolder under drafts/'), - date: str('Optional event date YYYY-MM-DD or YYYYMMDD'), - overwrite: bool('Replace if the target file already exists'), - }, + slug: reqStr('Case stem (DRAFT- prefix added if missing)'), + body: reqStr('Markdown body (RCA-ish draft)'), + domain: str('Optional fault-domain subfolder under drafts/'), + date: str('Optional event date YYYY-MM-DD or YYYYMMDD'), + overwrite: bool('Replace if the target file already exists'), }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ''), @@ -132,20 +129,16 @@ export function registerKbLocalTools( `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + 'Use for theory corrections or tool/process improvements — never rewrite identity/.', { - type: 'object', - additionalProperties: false, - required: ['kind', 'slug', 'body'], - properties: { - kind: { - type: 'string', - enum: ['theory', 'improvement'], - description: 'theory = knowledge fix; improvement = tools/skills/process', - }, - slug: str('Short filename stem'), - body: str('Full markdown body'), - date: str('Optional YYYY-MM-DD (default today)'), - overwrite: bool('Replace if the target file already exists'), + kind: { + type: 'string' as const, + required: true as const, + enum: ['theory', 'improvement'], + description: 'theory = knowledge fix; improvement = tools/skills/process', }, + slug: reqStr('Short filename stem'), + body: reqStr('Full markdown body'), + date: str('Optional YYYY-MM-DD (default today)'), + overwrite: bool('Replace if the target file already exists'), }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind as 'theory' | 'improvement', @@ -161,13 +154,8 @@ export function registerKbLocalTools( + `(absolute path or path relative to ${localRoot}). ` + 'Cannot touch identity/ or outside paths.local. Drafts keep status: draft.', { - type: 'object', - additionalProperties: false, - required: ['path', 'body'], - properties: { - path: str('Absolute path or path relative to KB local root'), - body: str('New full markdown body'), - }, + path: reqStr('Absolute path or path relative to KB local root'), + body: reqStr('New full markdown body'), }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ''), @@ -179,12 +167,7 @@ export function registerKbLocalTools( `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { - type: 'object', - additionalProperties: false, - required: ['path'], - properties: { - path: str('Absolute path or path relative to KB local root'), - }, + path: reqStr('Absolute path or path relative to KB local root'), }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? ''), @@ -195,16 +178,12 @@ export function registerKbLocalTools( `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + 'Does not include identity/. Read contents via absolute paths from the listing.', { - type: 'object', - additionalProperties: false, - properties: { - root: { - type: 'string', - enum: ['memories', 'drafts', 'suggestions', 'all'], - description: 'Subtree to list (default all writable)', - }, - limit: num('Max entries 1–200 (default 50)'), + root: { + type: 'string' as const, + enum: ['memories', 'drafts', 'suggestions', 'all'], + description: 'Subtree to list (default all writable)', }, + limit: num('Max entries 1–200 (default 50)'), }, (args) => listLocalFiles(liveSnapshot(), { root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'all' | undefined) ?? 'all', From 0f31a6648af7139d511112f63f217379b37518f7 Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 16 Sep 2026 06:42:22 +0800 Subject: [PATCH 03/10] Add refs product KB and dual-plane local evolution (0.1.37). Site writes device profiles/inventory via kbWriteRef; persona and kb-context require read-then-write refs plus memories for reusable experience. Co-authored-by: Cursor --- docs/KNOWLEDGE_BASE.md | 6 +- docs/TOOL_MAP.md | 7 ++- lib/agent-tools-common.js | 102 ++++++++++++++++++++++++++----- lib/agent-tools-nms.js | 102 ++++++++++++++++++++++++++----- lib/agent-tools-ops.js | 102 ++++++++++++++++++++++++++----- lib/agent-tools-topology.js | 102 ++++++++++++++++++++++++++----- lib/agent-tools.js | 102 ++++++++++++++++++++++++++----- lib/index.js | 102 ++++++++++++++++++++++++++----- package.json | 2 +- presets/netxops/PERSONA.md | 20 +++++- presets/netxops/agent.cordis.yml | 20 +++++- src/netx/kb-context-skill.ts | 25 +++++--- src/netx/kb-local-ops.ts | 47 +++++++++++++- src/netx/kb-local-path.ts | 37 ++++++++++- src/netx/kb-local-tools.ts | 53 ++++++++++++---- test/kb-local.test.mjs | 59 ++++++++++++------ 16 files changed, 756 insertions(+), 132 deletions(-) diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index 4cdd832..e4cda44 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -7,7 +7,7 @@ Operator-subset knowledge packages are wired through **netxops settings**, not i | Surface | Responsibility | |---------|----------------| | `netx-ops` | Live netx evidence (alarms / inventory / CLI) — **pure netx**, no KB playbooks | -| `kb-context` | Identity annotation from MANIFEST (`KB_*`) | +| `kb-context` | MANIFEST annotation (`KB_*`) + dual-plane write rules | | `_skills/kb-*` | Shared operator KB triage / ingest / retrieve / export / package | | `paths.localSkills` | Operator-local skills (alongside `_skills/`; same toggles) | @@ -38,9 +38,9 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` / | Channel | Fields | |---------|--------| | `process.env` | `KB_ROOT`, `KB_LOCAL` (when `paths.local` set), `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON of `has*`), `KB_STATUS` | -| Skill `kb-context` | Same identity + `kbLocal` + local create/update/delete tool names | +| Skill `kb-context` | Same MANIFEST fields + `kbLocal` + dual-plane write rules (refs / memories / drafts / suggestions) | | Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane | -| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `memories\|drafts\|suggestions` only (`identity/` host persona, agent read-only) | +| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteRef` / `WriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `refs\|memories\|drafts\|suggestions`. HQ pack is read-only; evolve site product knowledge with `kbWriteRef` (per-`host_name` PROFILE) and capture experience with `kbWriteMemory` as it appears. | RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths). diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index e84243b..d43c18e 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -35,13 +35,14 @@ Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`. ## knowledge base → `groupKb*` (not ops/topology) -Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/memories|drafts|suggestions` only — **not** `identity/`. +Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/refs|memories|drafts|suggestions`. | Tool | Role | |------|------| -| `kbWriteMemory` | New diary entry under `memories/{日常笔记\|排障复盘\|AI思维链}/` (`overwrite` optional) | +| `kbWriteRef` | Site product KB under `refs/{inventory\|devices\|topology\|business\|commands\|handbooks}/`. `devices` requires `device=host_name`; primary profile slug=`PROFILE`. | +| `kbWriteMemory` | Diary under `memories/{日常笔记\|排障复盘\|AI思维链}/` — write as soon as reusable experience appears. | | `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) | | `kbWriteSuggestion` | New file under `suggestions/{theory\|improvement}/` | | `kbUpdateLocal` | Replace body of an existing jailed file | | `kbDeleteLocal` | Delete one jailed file | -| `kbListLocal` | List recent `.md` under writable trees | +| `kbListLocal` | List recent `.md` under writable trees (`root` may be `refs`) | diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 4b52ae1..ff7357f 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 39829c8..3fc88b0 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 72b1002..0a9adad 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index c9d1803..dad42ed 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 6faf0f6..d79da17 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/index.js b/lib/index.js index bbe596d..89f35d9 100644 --- a/lib/index.js +++ b/lib/index.js @@ -814,7 +814,16 @@ import { join as join3, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -874,6 +883,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -947,6 +960,19 @@ function draftDir(domain) { return "drafts"; return join3("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join3("refs", "devices", host); + } + return join3("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -957,18 +983,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -1379,6 +1416,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync4(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -1584,7 +1640,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -1597,7 +1653,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -1615,22 +1671,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/package.json b/package.json index 3b75b50..093a859 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.35", + "version": "0.1.37", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/presets/netxops/PERSONA.md b/presets/netxops/PERSONA.md index f60ba14..5e99b20 100644 --- a/presets/netxops/PERSONA.md +++ b/presets/netxops/PERSONA.md @@ -33,7 +33,25 @@ ## 工具 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 -- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。 +- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等)。 + +## 知识库双平面(KB 已配置时 — 铁律) +- **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。 +- **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。 +- **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。 + +### refs — 本网产品知识(可进化核心) +- 排障涉及某网元时:**先读** `refs/devices//`;没有则 netx 取证后立刻 `kbWriteRef` 建档/补全。 +- `area=devices` 必须带 `device=host_name`(禁 UUID);画像主文件 slug=`PROFILE`。 +- 台账/汇总 → `inventory`;跨机拓扑 → `topology`;业务域 → `business`;本网命令集 → `commands`;速查 → `handbooks`。 +- **设备事实写 refs,过程经验写 memories**,不要把台账塞进日记。 + +### memories — 随手记 +任务中一旦出现可复用价值(经验、踩坑、口诀、误判纠正),立刻 `kbWriteMemory`;宁短勿丢。 +- `note` / `rca_review` / `ai_trace` + +### drafts / suggestions +未闭环案例 → `kbWriteDraft`;总部通用层有误或工具/流程改进 → `kbWriteSuggestion`。 ## 委派(默认 subagent,活多再拆) diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index 36fb4a2..224da38 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -46,7 +46,25 @@ ## 工具 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 - - 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。 + - 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等)。 + + ## 知识库双平面(KB 已配置时 — 铁律) + - **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。 + - **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。 + - **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。 + + ### refs — 本网产品知识(可进化核心) + - 排障涉及某网元时:**先读** `refs/devices//`;没有则 netx 取证后立刻 `kbWriteRef` 建档/补全。 + - `area=devices` 必须带 `device=host_name`(禁 UUID);画像主文件 slug=`PROFILE`。 + - 台账/汇总 → `inventory`;跨机拓扑 → `topology`;业务域 → `business`;本网命令集 → `commands`;速查 → `handbooks`。 + - **设备事实写 refs,过程经验写 memories**,不要把台账塞进日记。 + + ### memories — 随手记 + 任务中一旦出现可复用价值(经验、踩坑、口诀、误判纠正),立刻 `kbWriteMemory`;宁短勿丢。 + - `note` / `rca_review` / `ai_trace` + + ### drafts / suggestions + 未闭环案例 → `kbWriteDraft`;总部通用层有误或工具/流程改进 → `kbWriteSuggestion`。 ## 委派(默认 subagent,活多再拆) 你本人逻辑上全能。默认自己干;只有活明显偏多、且可并行时,才用 `subagent`(必要时 `subagent_fork`)拆开干。 diff --git a/src/netx/kb-context-skill.ts b/src/netx/kb-context-skill.ts index 0267996..1ae7c2c 100644 --- a/src/netx/kb-context-skill.ts +++ b/src/netx/kb-context-skill.ts @@ -22,12 +22,23 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string const localGuide = localRoot ? [ '', - '**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,', - '`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.', - 'They jail writes to `memories/` / `drafts/` / `suggestions/` only.', - 'Do **not** use workspace Write/bash for KB paths (sandbox).', - '`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;', - 'file boundary issues as `suggestions/` instead.', + '### Dual plane (mandatory)', + '', + '- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).', + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + '- **All KB writes via host tools** (workspace Write/bash cannot reach KB):', + ' `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /', + ' `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.', + '', + '### refs — site product knowledge (evolve every task)', + '- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.', + '- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.', + '- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.', + '- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).', + '', + '### memories — write immediately when valuable', + '- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.', + '- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`.', ] : [ '', @@ -35,7 +46,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string ] return { description: - 'Operator knowledge-base context for this Host (paths + identity from MANIFEST).', + 'Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).', content: [ '## Knowledge base (configured)', '', diff --git a/src/netx/kb-local-ops.ts b/src/netx/kb-local-ops.ts index 94f1cfb..9c5af13 100644 --- a/src/netx/kb-local-ops.ts +++ b/src/netx/kb-local-ops.ts @@ -18,12 +18,15 @@ import { draftFileName, memoryDir, memoryFileName, + refDir, + refFileName, resolveExistingWritableFile, resolveKbLocalRoot, resolveWritableLocalPath, suggestionDir, suggestionFileName, type MemoryBucket, + type RefArea, type SuggestionKind, KB_LOCAL_WRITABLE_ROOTS, type KbLocalWritableRoot, @@ -168,7 +171,49 @@ export function createSuggestion( } /** - * Replace body of an existing file under memories|drafts|suggestions. + * Create/overwrite site product-knowledge under `refs/` + * (inventory / devices / topology / business / commands / handbooks). + */ +export function createRef( + snapshot: KbSnapshot, + args: { + area: RefArea + slug: string + body: string + /** Required when area=devices — use host_name. */ + device?: string + overwrite?: boolean + }, +): KbLocalWriteResult { + const localRoot = requireLocalRoot(snapshot) + const name = refFileName(args.slug) + const target = resolveWritableLocalPath( + localRoot, + refDir(args.area, args.device), + name, + ) + if (existsSync(target.absolutePath) && !args.overwrite) { + throw new Error( + `file already exists (pass overwrite=true to replace): ${target.relativePath}`, + ) + } + mkdirSync(dirname(target.absolutePath), { recursive: true }) + const existed = existsSync(target.absolutePath) + writeFileSync( + target.absolutePath, + args.body.endsWith('\n') ? args.body : `${args.body}\n`, + 'utf8', + ) + return { + ok: true, + action: existed ? 'updated' : 'created', + absolutePath: target.absolutePath, + relativePath: target.relativePath, + } +} + +/** + * Replace body of an existing file under memories|drafts|suggestions|refs. * Drafts keep/force `status: draft` in frontmatter. */ export function updateLocalFile( diff --git a/src/netx/kb-local-path.ts b/src/netx/kb-local-path.ts index b86fac3..97fb2bb 100644 --- a/src/netx/kb-local-path.ts +++ b/src/netx/kb-local-path.ts @@ -1,6 +1,6 @@ /** * Path jail for operator-subset `_local` agent writes. - * Writable trees: memories / drafts / suggestions only (not identity / local_skills). + * Writable: memories / drafts / suggestions / refs (not local_skills). */ import { existsSync, realpathSync, statSync } from 'node:fs' @@ -12,6 +12,7 @@ export const KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ 'memories', 'drafts', 'suggestions', + 'refs', ] as const) export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number] @@ -19,6 +20,18 @@ export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number] export type MemoryBucket = 'note' | 'rca_review' | 'ai_trace' export type SuggestionKind = 'theory' | 'improvement' +/** Site product-knowledge areas under `refs/`. */ +export const REF_AREAS = Object.freeze([ + 'inventory', + 'devices', + 'topology', + 'business', + 'commands', + 'handbooks', +] as const) + +export type RefArea = (typeof REF_AREAS)[number] + export const MEMORY_BUCKET_DIR: Readonly> = Object.freeze({ note: '日常笔记', rca_review: '排障复盘', @@ -110,6 +123,11 @@ export function suggestionFileName(opts: { return `${date}-${sanitizeSlug(opts.slug)}.md` } +export function refFileName(slug: string): string { + const base = sanitizeSlug(slug, 'note') + return base.toLowerCase().endsWith('.md') ? base : `${base}.md` +} + /** * Resolve a candidate path and assert it lies under a writable local subtree. * Parent dirs need not exist yet (create path); uses resolve + prefix check. @@ -207,3 +225,20 @@ export function draftDir(domain?: string): string { if (!d) return 'drafts' return join('drafts', sanitizeSlug(d, 'misc')) } + +/** + * `refs//…` — for `devices`, require `device` → `refs/devices//`. + */ +export function refDir(area: RefArea, device?: string): string { + if (!(REF_AREAS as readonly string[]).includes(area)) { + throw new Error(`invalid refs area: ${area}`) + } + if (area === 'devices') { + const host = sanitizeSlug(device?.trim() || '', '') + if (!host) { + throw new Error('refs/devices requires device (host_name)') + } + return join('refs', 'devices', host) + } + return join('refs', area) +} diff --git a/src/netx/kb-local-tools.ts b/src/netx/kb-local-tools.ts index 77ff825..3dc08f1 100644 --- a/src/netx/kb-local-tools.ts +++ b/src/netx/kb-local-tools.ts @@ -1,6 +1,6 @@ /** * Host-side tools for writing under MANIFEST `paths.local` - * (memories / drafts / suggestions). Bypasses workspace sandbox. + * (memories / drafts / suggestions / refs). Bypasses workspace sandbox. */ import type { Context } from '@deepseek-ai/cordis' @@ -9,12 +9,13 @@ import type { KbSnapshot } from './kb-manifest.ts' import { createDraft, createMemory, + createRef, createSuggestion, deleteLocalFile, listLocalFiles, updateLocalFile, } from './kb-local-ops.ts' -import { kbLocalToolsEnabled, resolveKbLocalRoot } from './kb-local-path.ts' +import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts' import { getKbContext } from './kb-runtime.ts' const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) }) @@ -42,7 +43,7 @@ function liveSnapshot(): KbSnapshot { function tool( name: string, description: string, - /** DSH `defineTool` expects a flat property map — not a full JSON Schema object. */ + /** DSH `defineTool` expects a flat parameter map — not a full JSON Schema object. */ parameters: Record, execute: (args: Record) => Promise | unknown, ) { @@ -108,7 +109,7 @@ export function registerKbLocalTools( 'netx__kbWriteDraft', `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + 'Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. ' - + 'Set overwrite=true to replace. Not for identity/ or formal RCA.', + + 'Set overwrite=true to replace. Not for formal RCA.', { slug: reqStr('Case stem (DRAFT- prefix added if missing)'), body: reqStr('Markdown body (RCA-ish draft)'), @@ -127,7 +128,7 @@ export function registerKbLocalTools( tool( 'netx__kbWriteSuggestion', `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` - + 'Use for theory corrections or tool/process improvements — never rewrite identity/.', + + 'Use for HQ pack/theory corrections or tool/process improvements.', { kind: { type: 'string' as const, @@ -148,11 +149,37 @@ export function registerKbLocalTools( overwrite: args.overwrite === true, }), ), + tool( + 'netx__kbWriteRef', + `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + + 'area=inventory|devices|topology|business|commands|handbooks. ' + + 'For devices: require device=host_name; default slug PROFILE for the ops profile. ' + + 'Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.', + { + area: { + type: 'string' as const, + required: true as const, + enum: ['inventory', 'devices', 'topology', 'business', 'commands', 'handbooks'], + description: 'refs/ subtree', + }, + slug: reqStr('Filename stem (e.g. PROFILE, neighbors, ledger)'), + body: reqStr('Full markdown body'), + device: str('Required when area=devices — host_name (never UUID)'), + overwrite: bool('Replace if the target file already exists'), + }, + (args) => createRef(liveSnapshot(), { + area: args.area as RefArea, + slug: String(args.slug ?? ''), + body: String(args.body ?? ''), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true, + }), + ), tool( 'netx__kbUpdateLocal', - `Replace the body of an existing file under memories|drafts|suggestions ` + `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` - + 'Cannot touch identity/ or outside paths.local. Drafts keep status: draft.', + + 'Drafts keep status: draft.', { path: reqStr('Absolute path or path relative to KB local root'), body: reqStr('New full markdown body'), @@ -164,8 +191,8 @@ export function registerKbLocalTools( ), tool( 'netx__kbDeleteLocal', - `Delete one existing markdown under memories|drafts|suggestions ` - + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, + `Delete one existing markdown under memories|drafts|suggestions|refs ` + + `(absolute or relative to ${localRoot}).`, { path: reqStr('Absolute path or path relative to KB local root'), }, @@ -175,18 +202,18 @@ export function registerKbLocalTools( ), tool( 'netx__kbListLocal', - `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` - + 'Does not include identity/. Read contents via absolute paths from the listing.', + `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + + 'Read contents via absolute paths from the listing.', { root: { type: 'string' as const, - enum: ['memories', 'drafts', 'suggestions', 'all'], + enum: ['memories', 'drafts', 'suggestions', 'refs', 'all'], description: 'Subtree to list (default all writable)', }, limit: num('Max entries 1–200 (default 50)'), }, (args) => listLocalFiles(liveSnapshot(), { - root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'all' | undefined) ?? 'all', + root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'refs' | 'all' | undefined) ?? 'all', limit: typeof args.limit === 'number' ? args.limit : undefined, }), ), diff --git a/test/kb-local.test.mjs b/test/kb-local.test.mjs index 4f09e8f..35e1494 100644 --- a/test/kb-local.test.mjs +++ b/test/kb-local.test.mjs @@ -3,7 +3,7 @@ */ import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' @@ -11,6 +11,7 @@ import test from 'node:test' import { createDraft, createMemory, + createRef, createSuggestion, deleteLocalFile, listLocalFiles, @@ -62,13 +63,13 @@ test('draftFileName forces DRAFT- prefix', () => { assert.match(name, /^DRAFT-20260915-jakarta-down\.md$/) }) -test('resolveWritableLocalPath rejects identity', () => { +test('resolveWritableLocalPath rejects non-writable roots', () => { withTemp((root) => { const local = join(root, '_local') mkdirSync(local, { recursive: true }) assert.throws( - () => resolveWritableLocalPath(local, 'identity', '话术.md'), - /identity|allowed under/, + () => resolveWritableLocalPath(local, 'local_skills', 'x.md'), + /allowed under/, ) }) }) @@ -129,26 +130,50 @@ test('draft injects status: draft frontmatter', () => { }) }) -test('suggestion + refuse identity path update', () => { +test('createRef device PROFILE + list refs', () => { withTemp((root) => { const s = snap(root) - const idDir = join(root, '_local', 'identity') - mkdirSync(idDir, { recursive: true }) - writeFileSync(join(idDir, '话术与边界.md'), 'secret\n', 'utf8') - createSuggestion(s, { + mkdirSync(join(root, '_local'), { recursive: true }) + assert.throws( + () => createRef(s, { area: 'devices', slug: 'PROFILE', body: '# x\n' }), + /requires device/, + ) + const created = createRef(s, { + area: 'devices', + device: 'JKT-PE-01', + slug: 'PROFILE', + body: '# JKT-PE-01\n\nrole: PE\n', + }) + assert.match(created.relativePath, /^refs\/devices\/JKT-PE-01\/PROFILE\.md$/) + assert.equal(readFileSync(created.absolutePath, 'utf8'), '# JKT-PE-01\n\nrole: PE\n') + + createRef(s, { + area: 'inventory', + slug: 'ledger', + body: '# ledger\n', + }) + const listed = listLocalFiles(s, { root: 'refs' }) + assert.equal(listed.entries.length, 2) + + updateLocalFile(s, { + path: created.relativePath, + body: '# JKT-PE-01\n\nrole: PE\nupdated: true\n', + }) + assert.match(readFileSync(created.absolutePath, 'utf8'), /updated: true/) + }) +}) + +test('suggestion create works', () => { + withTemp((root) => { + const s = snap(root) + mkdirSync(join(root, '_local'), { recursive: true }) + const created = createSuggestion(s, { kind: 'improvement', slug: 'tool-x', body: '# sug\n', date: '2026-09-15', }) - assert.throws( - () => updateLocalFile(s, { - path: 'identity/话术与边界.md', - body: 'hacked\n', - }), - /identity|allowed under/, - ) - assert.equal(readFileSync(join(idDir, '话术与边界.md'), 'utf8'), 'secret\n') + assert.match(created.relativePath, /suggestions\/improvement\//) }) }) From 1b4c4027237b778dda72599f0e473babc3de6bee Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 16 Sep 2026 12:50:04 +0800 Subject: [PATCH 04/10] Fix KB settings badge stuck on unconfigured after bind (0.1.38). Reload Host kbRoot after browse/save so the status badge tracks the live package instead of a stale snapshot. Co-authored-by: Cursor --- docs/KNOWLEDGE_BASE.md | 2 +- lib/client.js | 111 +++++++++++++++++++++++++++++------ lib/index.js | 14 +++++ package.json | 2 +- src/client/card-form.ts | 4 +- src/client/controller.ts | 102 ++++++++++++++++++++++++++------ src/client/kb-status-view.ts | 50 +++++++++++++--- src/client/locales.ts | 4 +- src/index.ts | 22 +++++++ 9 files changed, 261 insertions(+), 50 deletions(-) diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index e4cda44..b15f53b 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -42,7 +42,7 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` / | Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane | | Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteRef` / `WriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `refs\|memories\|drafts\|suggestions`. HQ pack is read-only; evolve site product knowledge with `kbWriteRef` (per-`host_name` PROFILE) and capture experience with `kbWriteMemory` as it appears. | -RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths). +RPC (channel `/netxops`): `kb.status` (published snapshot), `kb.reload` (re-resolve live `kbRoot` + republish), `kb.resolve` with `{ path }` (preview unsaved paths). ## Pack skill registration diff --git a/lib/client.js b/lib/client.js index 44e2215..436e37b 100644 --- a/lib/client.js +++ b/lib/client.js @@ -591,6 +591,7 @@ function unconfiguredKbSnapshot() { // src/client/kb-status-view.ts var NETXOPS_RPC_CHANNEL2 = "/netxops"; var KB_STATUS_ENDPOINT = "kb.status"; +var KB_RELOAD_ENDPOINT = "kb.reload"; var KB_RESOLVE_ENDPOINT = "kb.resolve"; function asKbSnapshot(value) { const empty = unconfiguredKbSnapshot(); @@ -624,22 +625,44 @@ function asKbSnapshot(value) { errorMessage: typeof row.errorMessage === "string" ? row.errorMessage : "" }; } +function asRpcOk(result) { + return result !== null && typeof result === "object" && result.ok === true; +} async function fetchKbStatus(call, signal) { const result = await call(NETXOPS_RPC_CHANNEL2, KB_STATUS_ENDPOINT, {}, signal); - if (result !== null && typeof result === "object" && result.ok === true) { + if (asRpcOk(result)) { return asKbSnapshot(result.value); } - return unconfiguredKbSnapshot(); + if (result !== null && typeof result === "object" && result.ok === false) { + const message = String(result.error?.message ?? "kb.status rpc failed"); + return { ...unconfiguredKbSnapshot(), status: "error", errorMessage: message }; + } + return { + ...unconfiguredKbSnapshot(), + status: "error", + errorMessage: "kb.status unavailable" + }; +} +async function reloadKbStatus(call, signal) { + const result = await call(NETXOPS_RPC_CHANNEL2, KB_RELOAD_ENDPOINT, {}, signal); + if (asRpcOk(result)) { + return asKbSnapshot(result.value); + } + return fetchKbStatus(call, signal); } async function resolveKbPath(call, path, signal) { let result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal); - if (!(result !== null && typeof result === "object" && result.ok === true)) { + if (!asRpcOk(result)) { result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { args: { path } }, signal); } - if (result !== null && typeof result === "object" && result.ok === true) { + if (asRpcOk(result)) { return asKbSnapshot(result.value); } - return unconfiguredKbSnapshot(); + return { + ...unconfiguredKbSnapshot(), + status: "error", + errorMessage: "kb.resolve unavailable" + }; } function kbStatusTone(status) { if (status === "configured") @@ -1886,7 +1909,7 @@ class CardForm { } async store(field, value) { await this.scope.set(field, value); - return this.userLayer()?.[field] === value; + return this.sectionValue(field) === value || this.userLayer()?.[field] === value; } stage(field, edit) { this.staged.set(field, edit); @@ -1945,7 +1968,9 @@ class NetxopsCardController { kbDirectoryPicker; kbBrowseInFlight = false; kbStatusInFlight = false; + kbReloadInFlight = false; kbUiError = null; + lastSyncedKbRoot = undefined; imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }; sessionsExportStatus = null; sessionsExportBusy = false; @@ -1983,6 +2008,7 @@ class NetxopsCardController { scope.subscribe(() => { this.readCredential(); this.refreshKbPreview(); + this.syncKbRootFromSettings(); }); this.readCredential(); } @@ -2026,7 +2052,7 @@ class NetxopsCardController { } this.startStatusPoll(); this.refreshAlarmPushStatus(); - this.refreshKbStatus(); + this.reloadKbFromHost(); this.refreshKbPreview(); this.refreshImDeliveryCatalog(); this.refreshSessionsExportStatus(); @@ -2114,15 +2140,42 @@ class NetxopsCardController { this.kbStatusInFlight = true; try { const next = await fetchKbStatus(call); - const prev = this.kbStatus; - if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage) - return; - this.kbStatus = next; - this.store.set(this.projection()); + this.applyKbSnapshot(next); } catch {} finally { this.kbStatusInFlight = false; } } + async reloadKbFromHost() { + const call = this.rpcCall; + if (call === undefined || this.kbReloadInFlight) + return; + this.kbReloadInFlight = true; + try { + const next = await reloadKbStatus(call); + this.applyKbSnapshot(next); + this.lastSyncedKbRoot = (this.scope.getSnapshot().value?.kbRoot ?? "").trim(); + } catch { + this.refreshKbStatus(); + } finally { + this.kbReloadInFlight = false; + } + } + applyKbSnapshot(next) { + const prev = this.kbStatus; + if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage) + return; + this.kbStatus = next; + this.store.set(this.projection()); + } + async syncKbRootFromSettings() { + if (this.rpcCall === undefined) + return; + const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim(); + if (saved === this.lastSyncedKbRoot) + return; + this.lastSyncedKbRoot = saved; + await this.reloadKbFromHost(); + } async refreshKbPreview() { const call = this.rpcCall; if (call === undefined) @@ -2145,6 +2198,30 @@ class NetxopsCardController { this.store.set(this.projection()); } catch {} } + async commitKbRoot(path) { + const trimmed = path.trim(); + this.kbUiError = null; + this.store.set(this.projection()); + await this.form.save(); + const shell = this.form.shell(); + const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim(); + if (shell.failed || saved !== trimmed) { + this.kbUiError = saved === trimmed ? "kbRoot save failed" : `kbRoot save did not land (saved="${saved || "(empty)"}")`; + this.store.set(this.projection()); + const call = this.rpcCall; + if (call !== undefined && trimmed !== "") { + try { + this.kbPreview = await resolveKbPath(call, trimmed); + this.store.set(this.projection()); + } catch {} + } + return; + } + this.kbPreview = null; + this.lastSyncedKbRoot = undefined; + await this.reloadKbFromHost(); + this.refreshKbPreview(); + } browseKbRoot() { if (this.kbBrowseInFlight) return; @@ -2153,11 +2230,7 @@ class NetxopsCardController { this.store.set(this.projection()); const applyPath = (path) => { this.form.actions().edit("kbRoot", path); - this.refreshKbPreview(); - this.form.save().then(() => { - this.refreshKbStatus(); - this.refreshKbPreview(); - }); + this.commitKbRoot(path); }; const fail = (error) => { this.kbUiError = error instanceof Error ? error.message : String(error); @@ -2406,7 +2479,7 @@ var en = { kbBrowse: "Browse…", kbBrowseUnavailable: "Directory picker unavailable — paste an absolute folder path, then Save.", kbBrowseFailed: "Browse failed: {detail}", - kbSaveHint: "Browse writes and saves automatically. Manual paste still needs Save.", + kbSaveHint: "Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.", kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}", kbStatusUnconfigured: "Knowledge base: not configured (pure netx)", kbStatusError: "Knowledge base: error — {detail}", @@ -2483,7 +2556,7 @@ var zh = { kbBrowse: "浏览…", kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对目录路径后点保存。", kbBrowseFailed: "浏览失败:{detail}", - kbSaveHint: "点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。", + kbSaveHint: "点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。", kbStatusConfigured: "知识库: {operator}({country}) v{version}", kbStatusUnconfigured: "知识库: 未配置(纯 netx)", kbStatusError: "知识库: 错误 — {detail}", diff --git a/lib/index.js b/lib/index.js index 89f35d9..ab46a11 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4878,6 +4878,20 @@ function apply(ctx, config = Config({})) { if (endpoint === "kb.status") { return { ok: true, value: getKbContext() }; } + if (endpoint === "kb.reload") { + const current = source(); + const kb = resolveKbRoot(current.kbRoot ?? ""); + publishKbContext(kb); + applyKbEnv(kb); + if (kb.status === "configured") { + connCtx.logger.info("netxops: kb.reload → %s (%s / %s v%s)", kb.realRoot, kb.operatorName, kb.country, kb.version); + } else if (kb.status === "error") { + connCtx.logger.warn("netxops: kb.reload error — %s", kb.errorMessage); + } else { + connCtx.logger.info("netxops: kb.reload → unconfigured (kbRoot empty)"); + } + return { ok: true, value: kb }; + } if (endpoint === "kb.resolve") { const path = extractKbResolvePath(payload); return { ok: true, value: resolveKbRoot(path) }; diff --git a/package.json b/package.json index 093a859..5e286e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.37", + "version": "0.1.38", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/src/client/card-form.ts b/src/client/card-form.ts index 8cff4cb..8da547a 100644 --- a/src/client/card-form.ts +++ b/src/client/card-form.ts @@ -218,7 +218,9 @@ export class CardForm { private async store(field: string, value: unknown): Promise { await this.scope.set(field, value) - return this.userLayer()?.[field] === value + // `settings.mutate` can settle without throwing on a refused write; confirm + // via the resolved section value (user layer alone can lag or omit keys). + return this.sectionValue(field) === value || this.userLayer()?.[field] === value } private stage(field: string, edit: StagedEdit): void { diff --git a/src/client/controller.ts b/src/client/controller.ts index 813fc9f..edb9ad3 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -17,6 +17,7 @@ import { } from './alarm-push-status-view.ts' import { fetchKbStatus, + reloadKbStatus, resolveKbPath, type KbSnapshot, } from './kb-status-view.ts' @@ -155,7 +156,9 @@ export class NetxopsCardController { private kbDirectoryPicker: DirectoryPickerRemote | undefined private kbBrowseInFlight = false private kbStatusInFlight = false + private kbReloadInFlight = false private kbUiError: string | null = null + private lastSyncedKbRoot: string | undefined = undefined private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG } private sessionsExportStatus: SessionsExportStatus | null = null private sessionsExportBusy = false @@ -199,6 +202,7 @@ export class NetxopsCardController { scope.subscribe(() => { void this.readCredential() void this.refreshKbPreview() + void this.syncKbRootFromSettings() }) void this.readCredential() } @@ -246,7 +250,7 @@ export class NetxopsCardController { } this.startStatusPoll() void this.refreshAlarmPushStatus() - void this.refreshKbStatus() + void this.reloadKbFromHost() void this.refreshKbPreview() void this.refreshImDeliveryCatalog() void this.refreshSessionsExportStatus() @@ -364,18 +368,7 @@ export class NetxopsCardController { this.kbStatusInFlight = true try { const next = await fetchKbStatus(call) - const prev = this.kbStatus - if ( - prev - && prev.status === next.status - && prev.realRoot === next.realRoot - && prev.operatorName === next.operatorName - && prev.country === next.country - && prev.version === next.version - && prev.errorMessage === next.errorMessage - ) return - this.kbStatus = next - this.store.set(this.projection()) + this.applyKbSnapshot(next) } catch { // Keep last good snapshot; next poll retries. } finally { @@ -383,6 +376,50 @@ export class NetxopsCardController { } } + /** + * Ask Host to re-resolve kbRoot from live settings and republish. + * Prefer this after browse/save so the badge cannot stick on a stale snapshot. + */ + private async reloadKbFromHost(): Promise { + const call = this.rpcCall + if (call === undefined || this.kbReloadInFlight) return + this.kbReloadInFlight = true + try { + const next = await reloadKbStatus(call) + this.applyKbSnapshot(next) + this.lastSyncedKbRoot = (this.scope.getSnapshot().value?.kbRoot ?? '').trim() + } catch { + // Fall back to published status poll. + void this.refreshKbStatus() + } finally { + this.kbReloadInFlight = false + } + } + + private applyKbSnapshot(next: KbSnapshot): void { + const prev = this.kbStatus + if ( + prev + && prev.status === next.status + && prev.realRoot === next.realRoot + && prev.operatorName === next.operatorName + && prev.country === next.country + && prev.version === next.version + && prev.errorMessage === next.errorMessage + ) return + this.kbStatus = next + this.store.set(this.projection()) + } + + /** When settings document kbRoot changes, force Host reload (covers footer Save / Reset). */ + private async syncKbRootFromSettings(): Promise { + if (this.rpcCall === undefined) return + const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim() + if (saved === this.lastSyncedKbRoot) return + this.lastSyncedKbRoot = saved + await this.reloadKbFromHost() + } + private async refreshKbPreview(): Promise { const call = this.rpcCall if (call === undefined) return @@ -416,6 +453,38 @@ export class NetxopsCardController { } } + /** + * Persist kbRoot then force Host reload so the badge matches the bound package. + */ + private async commitKbRoot(path: string): Promise { + const trimmed = path.trim() + this.kbUiError = null + this.store.set(this.projection()) + await this.form.save() + const shell = this.form.shell() + const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim() + if (shell.failed || saved !== trimmed) { + this.kbUiError = saved === trimmed + ? 'kbRoot save failed' + : `kbRoot save did not land (saved="${saved || '(empty)'}")` + this.store.set(this.projection()) + const call = this.rpcCall + if (call !== undefined && trimmed !== '') { + try { + this.kbPreview = await resolveKbPath(call, trimmed) + this.store.set(this.projection()) + } catch { + // keep error banner + } + } + return + } + this.kbPreview = null + this.lastSyncedKbRoot = undefined + await this.reloadKbFromHost() + void this.refreshKbPreview() + } + /** * Open the Host OS directory chooser (or /api directoryPicker/pick fallback) * and write the path into kbRoot. @@ -428,12 +497,7 @@ export class NetxopsCardController { const applyPath = (path: string): void => { this.form.actions().edit('kbRoot', path) - void this.refreshKbPreview() - // Selecting a folder should land immediately — don't require a separate Save. - void this.form.save().then(() => { - void this.refreshKbStatus() - void this.refreshKbPreview() - }) + void this.commitKbRoot(path) } const fail = (error: unknown): void => { diff --git a/src/client/kb-status-view.ts b/src/client/kb-status-view.ts index 68a9c29..205990e 100644 --- a/src/client/kb-status-view.ts +++ b/src/client/kb-status-view.ts @@ -10,6 +10,7 @@ import type { AlarmPushRpcCall } from './alarm-push-status-view.ts' export const NETXOPS_RPC_CHANNEL = '/netxops' export const KB_STATUS_ENDPOINT = 'kb.status' +export const KB_RELOAD_ENDPOINT = 'kb.reload' export const KB_RESOLVE_ENDPOINT = 'kb.resolve' export type { KbSnapshot, KbStatus } @@ -50,6 +51,10 @@ export function asKbSnapshot(value: unknown): KbSnapshot { } } +function asRpcOk(result: unknown): result is { ok: true; value?: unknown } { + return result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true +} + /** * Fetch the published KB snapshot from the Host. */ @@ -58,10 +63,37 @@ export async function fetchKbStatus( signal?: AbortSignal, ): Promise { const result = await call(NETXOPS_RPC_CHANNEL, KB_STATUS_ENDPOINT, {}, signal) - if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { - return asKbSnapshot((result as { value?: unknown }).value) + if (asRpcOk(result)) { + return asKbSnapshot(result.value) } - return unconfiguredKbSnapshot() + // Do not pretend "unconfigured" on transport/shape miss — keep empty identity + // but mark error so the badge is not stuck on the soft "pure netx" copy. + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) { + const message = String( + (result as { error?: { message?: string } }).error?.message ?? 'kb.status rpc failed', + ) + return { ...unconfiguredKbSnapshot(), status: 'error', errorMessage: message } + } + return { + ...unconfiguredKbSnapshot(), + status: 'error', + errorMessage: 'kb.status unavailable', + } +} + +/** + * Force Host to re-resolve `kbRoot` from live settings and republish. + */ +export async function reloadKbStatus( + call: AlarmPushRpcCall, + signal?: AbortSignal, +): Promise { + const result = await call(NETXOPS_RPC_CHANNEL, KB_RELOAD_ENDPOINT, {}, signal) + if (asRpcOk(result)) { + return asKbSnapshot(result.value) + } + // Older hosts without kb.reload — fall back to status / resolve. + return fetchKbStatus(call, signal) } /** @@ -74,13 +106,17 @@ export async function resolveKbPath( ): Promise { // Prefer flat `{ path }` (netxops channel); also try `{ args: { path } }`. let result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal) - if (!(result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true)) { + if (!asRpcOk(result)) { result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { args: { path } }, signal) } - if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { - return asKbSnapshot((result as { value?: unknown }).value) + if (asRpcOk(result)) { + return asKbSnapshot(result.value) + } + return { + ...unconfiguredKbSnapshot(), + status: 'error', + errorMessage: 'kb.resolve unavailable', } - return unconfiguredKbSnapshot() } /** Badge tone for the settings card. */ diff --git a/src/client/locales.ts b/src/client/locales.ts index 9bafc3d..472c6e7 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -106,7 +106,7 @@ export const en: Record = { kbBrowse: 'Browse…', kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.', kbBrowseFailed: 'Browse failed: {detail}', - kbSaveHint: 'Browse writes and saves automatically. Manual paste still needs Save.', + kbSaveHint: 'Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.', kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}', kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)', kbStatusError: 'Knowledge base: error — {detail}', @@ -184,7 +184,7 @@ export const zh: Record = { kbBrowse: '浏览…', kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。', kbBrowseFailed: '浏览失败:{detail}', - kbSaveHint: '点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。', + kbSaveHint: '点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。', kbStatusConfigured: '知识库: {operator}({country}) v{version}', kbStatusUnconfigured: '知识库: 未配置(纯 netx)', kbStatusError: '知识库: 错误 — {detail}', diff --git a/src/index.ts b/src/index.ts index a91e974..6848ab1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -624,6 +624,28 @@ export function apply(ctx: Context, config: Config = Config({})): void { if (endpoint === 'kb.status') { return { ok: true, value: getKbContext() } } + if (endpoint === 'kb.reload') { + // Re-read live settings and publish — used by the settings card after + // browse/save so the badge does not stick on a stale unconfigured snapshot. + const current = source() + const kb = resolveKbRoot(current.kbRoot ?? '') + publishKbContext(kb) + applyKbEnv(kb) + if (kb.status === 'configured') { + connCtx.logger.info( + 'netxops: kb.reload → %s (%s / %s v%s)', + kb.realRoot, + kb.operatorName, + kb.country, + kb.version, + ) + } else if (kb.status === 'error') { + connCtx.logger.warn('netxops: kb.reload error — %s', kb.errorMessage) + } else { + connCtx.logger.info('netxops: kb.reload → unconfigured (kbRoot empty)') + } + return { ok: true, value: kb } + } if (endpoint === 'kb.resolve') { const path = extractKbResolvePath(payload) return { ok: true, value: resolveKbRoot(path) } From f7801db296b0bd0d1a419baecdbe1e873e619c91 Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 16 Sep 2026 14:39:55 +0800 Subject: [PATCH 05/10] Mount /netxops RPC on webServer for DSH 0.1.5 (0.1.39). connection.rpc.handle no longer registers custom channels when webServer is not on the connection ctx, which left the KB settings badge stuck on unconfigured. Co-authored-by: Cursor --- lib/index.js | 349 ++++++++++++++++++++++++++++-------- package.json | 2 +- src/index.ts | 196 +++++++++++--------- src/netx/netxops-web-rpc.ts | 239 ++++++++++++++++++++++++ 4 files changed, 621 insertions(+), 165 deletions(-) create mode 100644 src/netx/netxops-web-rpc.ts diff --git a/lib/index.js b/lib/index.js index ab46a11..c3e2de1 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3043,6 +3043,196 @@ async function sessionsExportHeadResponse(ctx, request) { }); } +// src/netx/netxops-web-rpc.ts +var NETXOPS_RPC_BODY_MAX = 8 * 1024 * 1024; +var ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/; +var INVALID_REQUEST_RPC_ID = "invalid-request"; +var LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1"]); +function endpointFromPath(channel, pathname) { + if (!pathname.startsWith(`${channel}/`)) + return; + const endpoint = pathname.slice(channel.length + 1); + if (endpoint.split("/").some((seg) => seg === "" || seg === "." || seg === ".." || !ENDPOINT_SEGMENT_PATTERN.test(seg))) { + return; + } + return endpoint; +} +function serverResponseJson(rpcId, result) { + return JSON.stringify({ type: "server-response", rpcId, result }); +} +function isTrustedLoopbackRequest(req) { + const host = req.headers?.host; + if (!host) + return false; + const hostName = host.split(":")[0]; + if (!LOOPBACK_HOSTNAMES.has(hostName ?? "")) + return false; + if (req.headers["sec-fetch-site"] === "cross-site") + return false; + const origin = req.headers.origin; + if (origin === undefined) + return true; + try { + return new URL(origin).host === host; + } catch { + return false; + } +} +function netxopsFetchHandler(channel, handler, log) { + return { + async fetch(request) { + const endpoint = endpointFromPath(channel, new URL(request.url).pathname); + if (request.method !== "POST" || endpoint === undefined) { + return new Response("not found", { status: 404 }); + } + const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/json") { + return new Response("content type must be application/json", { status: 415 }); + } + let body; + try { + body = await request.json(); + } catch { + return new Response("body is not JSON", { status: 400 }); + } + const row = body; + const rpcId = row && typeof row.rpcId === "string" ? row.rpcId : INVALID_REQUEST_RPC_ID; + const method = row && typeof row.method === "string" ? row.method : null; + if (rpcId === INVALID_REQUEST_RPC_ID || method === null) { + return new Response(serverResponseJson(INVALID_REQUEST_RPC_ID, { + ok: false, + error: { code: "bad-request", message: "invalid client-request message", details: { issues: [] } } + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (method !== endpoint) { + return new Response(serverResponseJson(rpcId, { + ok: false, + error: { + code: "bad-request", + message: `method ${JSON.stringify(method)} does not match endpoint ${JSON.stringify(endpoint)}`, + details: { issues: [] } + } + }), { status: 200, headers: { "content-type": "application/json" } }); + } + try { + const result = await handler(endpoint, row?.payload, request.signal); + return new Response(serverResponseJson(rpcId, result), { + status: 200, + headers: { "content-type": "application/json" } + }); + } catch (error) { + log.error?.("netxops: rpc %s failed: %s", endpoint, error instanceof Error ? error.message : String(error)); + return new Response(`handler failure: ${String(error)}`, { status: 500 }); + } + } + }; +} +async function httpBridge(req, res, fetchHandler, maxBodyBytes) { + const abort = new AbortController; + res.on("close", () => { + if (!res.writableEnded) + abort.abort(); + }); + const declaredLen = req.headers["content-length"]; + if (declaredLen !== undefined && Number(declaredLen) > maxBodyBytes) { + res.writeHead(413, { connection: "close" }); + res.end(); + req.destroy(); + return; + } + const chunks = []; + let received = 0; + for await (const chunk of req) { + const buffer = chunk; + received += buffer.byteLength; + if (received > maxBodyBytes) { + res.writeHead(413, { connection: "close" }); + res.end(); + req.destroy(); + return; + } + chunks.push(buffer); + } + const url = `http://${req.headers.host ?? "127.0.0.1"}${req.url ?? "/"}`; + const request = new Request(url, { + method: req.method ?? "GET", + headers: Object.fromEntries(Object.entries(req.headers).filter(([, value]) => typeof value === "string")), + ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, + signal: abort.signal + }); + const response = await fetchHandler.fetch(request); + const headers = Object.fromEntries(response.headers.entries()); + res.writeHead(response.status, headers); + if (response.body === null) { + res.end(); + return; + } + for await (const chunk of response.body) { + if (!res.write(chunk)) { + await new Promise((resolve3) => { + const done = () => { + res.off("drain", done); + res.off("close", done); + resolve3(); + }; + res.once("drain", done); + res.once("close", done); + }); + } + } + res.end(); +} +function mountNetxopsWebRoute(ctx, channel, handler) { + const webServer = ctx.webServer; + if (!webServer || typeof webServer.register !== "function") + return null; + const connection = ctx.connection; + const fetchHandler = netxopsFetchHandler(channel, handler, ctx.logger); + const route = { + kind: "prefix", + path: channel, + handler: async (req, res) => { + let rejection; + if (typeof connection?.requestRejection === "function") { + try { + rejection = connection.requestRejection(req); + } catch { + rejection = 403; + } + } else if (!isTrustedLoopbackRequest(req)) { + rejection = 403; + } + if (rejection !== undefined) { + res.writeHead(rejection, { "content-type": "text/plain; charset=utf-8" }); + res.end(rejection === 401 ? "unauthorized" : "forbidden"); + return; + } + await httpBridge(req, res, fetchHandler, NETXOPS_RPC_BODY_MAX); + } + }; + const registered = webServer.register(route); + if (typeof registered === "function") { + return () => { + try { + registered(); + } catch {} + }; + } + if (registered && typeof registered.then === "function") { + let done = false; + return () => { + if (done) + return; + done = true; + registered.then((dispose) => { + if (typeof dispose === "function") + dispose(); + }).catch(() => {}); + }; + } + return () => {}; +} + // src/netx/tools.ts import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; @@ -4864,83 +5054,92 @@ function apply(ctx, config = Config({})) { unregisterKbPack?.(); }, "netxops: dispose public skills"); }); + const handleNetxopsRpc = async (endpoint, payload) => { + if (endpoint === "alarm-push.status") { + return { ok: true, value: getAlarmPushStatus() }; + } + if (endpoint === "kb.status") { + return { ok: true, value: getKbContext() }; + } + if (endpoint === "kb.reload") { + const current = source(); + const kb = resolveKbRoot(current.kbRoot ?? ""); + publishKbContext(kb); + applyKbEnv(kb); + if (kb.status === "configured") { + ctx.logger.info("netxops: kb.reload → %s (%s / %s v%s)", kb.realRoot, kb.operatorName, kb.country, kb.version); + } else if (kb.status === "error") { + ctx.logger.warn("netxops: kb.reload error — %s", kb.errorMessage); + } else { + ctx.logger.info("netxops: kb.reload → unconfigured (kbRoot empty)"); + } + return { ok: true, value: kb }; + } + if (endpoint === "kb.resolve") { + const path = extractKbResolvePath(payload); + return { ok: true, value: resolveKbRoot(path) }; + } + if (endpoint === "sessions.export.status") { + const value = await getSessionsExportStatus(ctx); + return { ok: true, value }; + } + if (endpoint === "im-delivery.catalog") { + const fromGet = typeof ctx.get === "function" ? ctx.get("dshIm") : undefined; + const im = fromGet ?? ctx.dshIm; + if (!im || typeof im.listDeliveryCatalog !== "function") { + return { + ok: true, + value: { + available: false, + options: [], + reasonCode: "im_catalog_unavailable", + 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." } }; + }; + ctx.inject(["webServer", "connection"], (mountCtx) => { + mountCtx.effect(() => { + const direct = mountNetxopsWebRoute(mountCtx, NETXOPS_RPC_CHANNEL, (endpoint, payload) => handleNetxopsRpc(endpoint, payload)); + if (direct !== null) { + mountCtx.logger.info("netxops: /netxops rpc mounted on webServer"); + return direct; + } + const rpc = mountCtx.connection?.rpc; + if (rpc && typeof rpc.handle === "function") { + mountCtx.logger.info("netxops: /netxops rpc mounted via connection.rpc.handle (legacy)"); + const legacy = rpc.handle(NETXOPS_RPC_CHANNEL, (endpoint, payload) => handleNetxopsRpc(endpoint, payload)); + return () => { + legacy(); + }; + } + mountCtx.logger.warn("netxops: /netxops rpc unavailable — settings card status disabled"); + return () => {}; + }, "netxops: /netxops web rpc"); + }); ctx.inject(["connection"], (connCtx) => { const connection = connCtx.connection; - const rpc = connection?.rpc; - if (!rpc || typeof rpc.handle !== "function") { - connCtx.logger.warn("netxops: connection.rpc.handle unavailable — alarm status UI disabled"); - } else { - connCtx.effect(() => { - const dispose = rpc.handle(NETXOPS_RPC_CHANNEL, async (endpoint, payload) => { - if (endpoint === "alarm-push.status") { - return { ok: true, value: getAlarmPushStatus() }; - } - if (endpoint === "kb.status") { - return { ok: true, value: getKbContext() }; - } - if (endpoint === "kb.reload") { - const current = source(); - const kb = resolveKbRoot(current.kbRoot ?? ""); - publishKbContext(kb); - applyKbEnv(kb); - if (kb.status === "configured") { - connCtx.logger.info("netxops: kb.reload → %s (%s / %s v%s)", kb.realRoot, kb.operatorName, kb.country, kb.version); - } else if (kb.status === "error") { - connCtx.logger.warn("netxops: kb.reload error — %s", kb.errorMessage); - } else { - connCtx.logger.info("netxops: kb.reload → unconfigured (kbRoot empty)"); - } - return { ok: true, value: kb }; - } - if (endpoint === "kb.resolve") { - const path = extractKbResolvePath(payload); - return { ok: true, value: resolveKbRoot(path) }; - } - if (endpoint === "sessions.export.status") { - const value = await getSessionsExportStatus(ctx); - return { ok: true, value }; - } - if (endpoint === "im-delivery.catalog") { - const fromGet = typeof ctx.get === "function" ? ctx.get("dshIm") : undefined; - const im = fromGet ?? ctx.dshIm; - if (!im || typeof im.listDeliveryCatalog !== "function") { - return { - ok: true, - value: { - available: false, - options: [], - reasonCode: "im_catalog_unavailable", - 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 () => { - dispose(); - }; - }, "netxops: alarm-push status rpc"); - } const fetchApi = connection?.fetch; if (!fetchApi || typeof fetchApi.register !== "function") { connCtx.logger.warn("netxops: connection.fetch.register unavailable — sessions export download disabled"); diff --git a/package.json b/package.json index 5e286e4..b2b53c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.38", + "version": "0.1.39", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/src/index.ts b/src/index.ts index 6848ab1..e7896ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,7 @@ import { sessionsExportHeadResponse, sessionsExportResponse, } from './netx/session-export.ts' +import { mountNetxopsWebRoute } from './netx/netxops-web-rpc.ts' import { registerNetxTools } from './netx/tools.ts' /** Cordis plugin name. */ @@ -595,7 +596,112 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, 'netxops: dispose public skills') }) - // Browser card: alarm-push status + IM catalog (RPC) and all-sessions ZIP (Fetch). + const handleNetxopsRpc = async (endpoint: string, payload?: unknown): Promise => { + if (endpoint === 'alarm-push.status') { + return { ok: true, value: getAlarmPushStatus() } + } + if (endpoint === 'kb.status') { + return { ok: true, value: getKbContext() } + } + if (endpoint === 'kb.reload') { + const current = source() + const kb = resolveKbRoot(current.kbRoot ?? '') + publishKbContext(kb) + applyKbEnv(kb) + if (kb.status === 'configured') { + ctx.logger.info( + 'netxops: kb.reload → %s (%s / %s v%s)', + kb.realRoot, + kb.operatorName, + kb.country, + kb.version, + ) + } else if (kb.status === 'error') { + ctx.logger.warn('netxops: kb.reload error — %s', kb.errorMessage) + } else { + ctx.logger.info('netxops: kb.reload → unconfigured (kbRoot empty)') + } + return { ok: true, value: kb } + } + if (endpoint === 'kb.resolve') { + const path = extractKbResolvePath(payload) + return { ok: true, value: resolveKbRoot(path) } + } + if (endpoint === 'sessions.export.status') { + const value = await getSessionsExportStatus(ctx) + return { ok: true, value } + } + if (endpoint === 'im-delivery.catalog') { + type DshImCatalog = { listDeliveryCatalog?: () => Promise } + 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: [], + reasonCode: 'im_catalog_unavailable', + 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.' } } + } + + // DSH ≥0.1.5: mount /netxops on this plugin's webServer inject (connection.rpc.handle no longer registers). + ctx.inject(['webServer', 'connection'], (mountCtx) => { + mountCtx.effect(() => { + const direct = mountNetxopsWebRoute( + mountCtx, + NETXOPS_RPC_CHANNEL, + (endpoint, payload) => handleNetxopsRpc(endpoint, payload), + ) + if (direct !== null) { + mountCtx.logger.info('netxops: /netxops rpc mounted on webServer') + return direct + } + type RpcHandle = ( + channel: string, + handler: (endpoint: string, payload?: unknown) => Promise, + ) => (() => void) | Promise + const rpc = (mountCtx.connection as { rpc?: { handle?: RpcHandle } } | undefined)?.rpc + if (rpc && typeof rpc.handle === 'function') { + mountCtx.logger.info('netxops: /netxops rpc mounted via connection.rpc.handle (legacy)') + const legacy = rpc.handle( + NETXOPS_RPC_CHANNEL, + (endpoint, payload) => handleNetxopsRpc(endpoint, payload), + ) + return () => { void legacy() } + } + mountCtx.logger.warn('netxops: /netxops rpc unavailable — settings card status disabled') + return () => {} + }, 'netxops: /netxops web rpc') + }) + + // Browser card: sessions export download via shared /api Fetch route. ctx.inject(['connection'], (connCtx) => { const connection = connCtx.connection as { rpc?: { handle?: ( @@ -610,94 +716,6 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) => () => Promise } } | undefined - const rpc = connection?.rpc - if (!rpc || typeof rpc.handle !== 'function') { - connCtx.logger.warn('netxops: connection.rpc.handle unavailable — alarm status UI disabled') - } else { - connCtx.effect(() => { - const dispose = rpc.handle( - NETXOPS_RPC_CHANNEL, - async (endpoint: string, payload?: unknown) => { - if (endpoint === 'alarm-push.status') { - return { ok: true, value: getAlarmPushStatus() } - } - if (endpoint === 'kb.status') { - return { ok: true, value: getKbContext() } - } - if (endpoint === 'kb.reload') { - // Re-read live settings and publish — used by the settings card after - // browse/save so the badge does not stick on a stale unconfigured snapshot. - const current = source() - const kb = resolveKbRoot(current.kbRoot ?? '') - publishKbContext(kb) - applyKbEnv(kb) - if (kb.status === 'configured') { - connCtx.logger.info( - 'netxops: kb.reload → %s (%s / %s v%s)', - kb.realRoot, - kb.operatorName, - kb.country, - kb.version, - ) - } else if (kb.status === 'error') { - connCtx.logger.warn('netxops: kb.reload error — %s', kb.errorMessage) - } else { - connCtx.logger.info('netxops: kb.reload → unconfigured (kbRoot empty)') - } - return { ok: true, value: kb } - } - if (endpoint === 'kb.resolve') { - const path = extractKbResolvePath(payload) - return { ok: true, value: resolveKbRoot(path) } - } - if (endpoint === 'sessions.export.status') { - const value = await getSessionsExportStatus(ctx) - return { ok: true, value } - } - if (endpoint === 'im-delivery.catalog') { - type DshImCatalog = { listDeliveryCatalog?: () => Promise } - 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: [], - reasonCode: 'im_catalog_unavailable', - 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() } - }, 'netxops: alarm-push status rpc') - } - const fetchApi = connection?.fetch if (!fetchApi || typeof fetchApi.register !== 'function') { connCtx.logger.warn('netxops: connection.fetch.register unavailable — sessions export download disabled') diff --git a/src/netx/netxops-web-rpc.ts b/src/netx/netxops-web-rpc.ts new file mode 100644 index 0000000..b478607 --- /dev/null +++ b/src/netx/netxops-web-rpc.ts @@ -0,0 +1,239 @@ +/** + * Mount `/netxops/*` on the plugin's own `webServer` inject (DSH ≥0.1.5). + * + * `connection.rpc.handle('/netxops')` registers via the connection plugin ctx, + * which no longer injects `webServer` — the route never mounts and the settings + * card sees HTTP 405 → "未配置". Same pattern as dsh-pocket `web-rpc.js`. + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { IncomingMessage, ServerResponse } from 'node:http' + +/** Max buffered JSON body for control-plane RPC (status / kb.resolve paths). */ +const NETXOPS_RPC_BODY_MAX = 8 * 1024 * 1024 + +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ +const INVALID_REQUEST_RPC_ID = 'invalid-request' +const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1']) + +export type NetxopsRpcHandler = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => Promise + +function endpointFromPath(channel: string, pathname: string): string | undefined { + if (!pathname.startsWith(`${channel}/`)) return undefined + const endpoint = pathname.slice(channel.length + 1) + if (endpoint.split('/').some((seg) => + seg === '' || seg === '.' || seg === '..' || !ENDPOINT_SEGMENT_PATTERN.test(seg))) { + return undefined + } + return endpoint +} + +function serverResponseJson(rpcId: string, result: unknown): string { + return JSON.stringify({ type: 'server-response', rpcId, result }) +} + +function isTrustedLoopbackRequest(req: IncomingMessage): boolean { + const host = req.headers?.host + if (!host) return false + const hostName = host.split(':')[0] + if (!LOOPBACK_HOSTNAMES.has(hostName ?? '')) return false + if (req.headers['sec-fetch-site'] === 'cross-site') return false + const origin = req.headers.origin + if (origin === undefined) return true + try { + return new URL(origin).host === host + } catch { + return false + } +} + +function netxopsFetchHandler( + channel: string, + handler: NetxopsRpcHandler, + log: Pick, +) { + return { + async fetch(request: Request): Promise { + const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + if (request.method !== 'POST' || endpoint === undefined) { + return new Response('not found', { status: 404 }) + } + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/json') { + return new Response('content type must be application/json', { status: 415 }) + } + let body: unknown + try { + body = await request.json() + } catch { + return new Response('body is not JSON', { status: 400 }) + } + const row = body as { rpcId?: unknown; method?: unknown; payload?: unknown } | null + const rpcId = row && typeof row.rpcId === 'string' ? row.rpcId : INVALID_REQUEST_RPC_ID + const method = row && typeof row.method === 'string' ? row.method : null + if (rpcId === INVALID_REQUEST_RPC_ID || method === null) { + return new Response( + serverResponseJson(INVALID_REQUEST_RPC_ID, { + ok: false, + error: { code: 'bad-request', message: 'invalid client-request message', details: { issues: [] } }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + if (method !== endpoint) { + return new Response( + serverResponseJson(rpcId, { + ok: false, + error: { + code: 'bad-request', + message: `method ${JSON.stringify(method)} does not match endpoint ${JSON.stringify(endpoint)}`, + details: { issues: [] }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + try { + const result = await handler(endpoint, row?.payload, request.signal) + return new Response(serverResponseJson(rpcId, result), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } catch (error) { + log.error?.('netxops: rpc %s failed: %s', endpoint, error instanceof Error ? error.message : String(error)) + return new Response(`handler failure: ${String(error)}`, { status: 500 }) + } + }, + } +} + +async function httpBridge( + req: IncomingMessage, + res: ServerResponse, + fetchHandler: { fetch: (request: Request) => Promise }, + maxBodyBytes: number, +): Promise { + const abort = new AbortController() + res.on('close', () => { + if (!res.writableEnded) abort.abort() + }) + + const declaredLen = req.headers['content-length'] + if (declaredLen !== undefined && Number(declaredLen) > maxBodyBytes) { + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return + } + const chunks: Buffer[] = [] + let received = 0 + for await (const chunk of req) { + const buffer = chunk as Buffer + received += buffer.byteLength + if (received > maxBodyBytes) { + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return + } + chunks.push(buffer) + } + + const url = `http://${req.headers.host ?? '127.0.0.1'}${req.url ?? '/'}` + const request = new Request(url, { + method: req.method ?? 'GET', + headers: Object.fromEntries( + Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][], + ), + ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, + signal: abort.signal, + }) + + const response = await fetchHandler.fetch(request) + const headers = Object.fromEntries(response.headers.entries()) + res.writeHead(response.status, headers) + if (response.body === null) { + res.end() + return + } + for await (const chunk of response.body) { + if (!res.write(chunk)) { + await new Promise((resolve) => { + const done = (): void => { + res.off('drain', done) + res.off('close', done) + resolve() + } + res.once('drain', done) + res.once('close', done) + }) + } + } + res.end() +} + +type ConnectionRejection = { requestRejection?: (req: IncomingMessage) => number | undefined } + +/** + * Register prefix route `channel` on `ctx.webServer` when available. + * @returns disposer, or null when `webServer` is not on this inject ctx. + */ +export function mountNetxopsWebRoute( + ctx: Context & { webServer?: { register: (route: unknown) => unknown }; connection?: ConnectionRejection }, + channel: string, + handler: NetxopsRpcHandler, +): (() => void) | null { + const webServer = ctx.webServer + if (!webServer || typeof webServer.register !== 'function') return null + + const connection = ctx.connection + const fetchHandler = netxopsFetchHandler(channel, handler, ctx.logger) + const route = { + kind: 'prefix' as const, + path: channel, + handler: async (req: IncomingMessage, res: ServerResponse) => { + let rejection: number | undefined + if (typeof connection?.requestRejection === 'function') { + try { + rejection = connection.requestRejection(req) + } catch { + rejection = 403 + } + } else if (!isTrustedLoopbackRequest(req)) { + rejection = 403 + } + if (rejection !== undefined) { + res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' }) + res.end(rejection === 401 ? 'unauthorized' : 'forbidden') + return + } + await httpBridge(req, res, fetchHandler, NETXOPS_RPC_BODY_MAX) + }, + } + + const registered = webServer.register(route) + if (typeof registered === 'function') { + return () => { + try { + registered() + } catch { + // already disposed + } + } + } + if (registered && typeof (registered as Promise).then === 'function') { + let done = false + return () => { + if (done) return + done = true + void (registered as Promise<(() => void) | void>).then((dispose) => { + if (typeof dispose === 'function') dispose() + }).catch(() => {}) + } + } + return () => {} +} From 0934e104095dec67104cd6b0c488287b8849d6c0 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 19 Sep 2026 22:48:42 +0800 Subject: [PATCH 06/10] Add bizMonitor host tools for cutover analysis via netx REST (0.1.40). Expose getBizMonitor* / getBizCollect* on the Ops preset with skill netx-biz-monitor; keep tooling in netxops instead of netx-mcp. Co-authored-by: Cursor --- docs/INSTALL.md | 8 +- docs/TOOL_MAP.md | 14 +- lib/agent-tools-common.js | 117 +++++++++++++++- lib/agent-tools-nms.js | 117 +++++++++++++++- lib/agent-tools-ops.js | 117 +++++++++++++++- lib/agent-tools-topology.js | 117 +++++++++++++++- lib/agent-tools.js | 117 +++++++++++++++- lib/client.js | 20 +++ lib/index.js | 127 +++++++++++++++++- package.json | 4 +- presets/netxops/skills/README.md | 10 +- .../biz-monitor/netx-biz-monitor/SKILL.md | 61 +++++++++ scripts/build-host.mjs | 1 + scripts/sync-skills-from-netx.ps1 | 2 +- src/agent-tools-biz-monitor.ts | 15 +++ src/client/NetxopsCard.tsx | 14 ++ src/client/controller.ts | 8 ++ src/client/locales.ts | 3 + src/index.ts | 6 + src/netx/capability-groups.ts | 33 ++++- src/netx/handlers.ts | 98 ++++++++++++++ src/netx/tools.ts | 61 +++++++++ test/capability-groups.test.mjs | 14 +- 23 files changed, 1022 insertions(+), 62 deletions(-) create mode 100644 presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md create mode 100644 src/agent-tools-biz-monitor.ts diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 41029e0..53bde4c 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,4 +1,4 @@ -# Install Netx Ops on DeepSeek Harness +# Install Netx Ops on DeepSeek Harness ## What `dsh plugin add` gives you @@ -16,9 +16,9 @@ You do **not** run `link-preset.ps1` for normal use. That script is only a manua ## Still required outside the npm package 1. **netx API** reachable (default `http://127.0.0.1:8890`) — the data plane. -2. **API token** with scopes matching the tools you use (`alarms:read`, `ne:read`, `ne:exec`, `sql:query`, …). +2. **API token** with scopes matching the tools you use (`alarms:read`, `ne:read`, `ne:exec`, `biz-monitor:read`, `sql:query`, …). -No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MCP hosts can still use `python -m netx_mcp` separately.) +No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MCP hosts can still use `python -m netx_mcp` separately for NMS/CLI — **not** for biz-monitor; that is netxops host tools only.) ## Clone / `pnpm install`(仓库源码) @@ -67,7 +67,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net 1. Plugins card **Netx Ops** visible. 2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail). 3. Agent presets → Custom → **Netx Ops**. -4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Canvas / dual_unit need **topology** (`netx-topology`). +4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Cutover analysis: `netx__getBizMonitorContext` … (**bizMonitor** → `netx-biz-monitor`). Canvas / dual_unit need **topology** (`netx-topology`). 5. Export section shows a session count; **下载 ZIP** starts a browser download. 6. Optional knowledge-base badge shows operator/country/version when `kbRoot` + MANIFEST are valid. diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index d43c18e..5b474be 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -6,9 +6,10 @@ Tools register by capability group. **One group ↔ one skill.** |-------|-------|------------------------| | **ops** | `netx-ops` | on | | **topology** | `netx-topology` | off | +| **bizMonitor** | `netx-biz-monitor` | on | Public flags mount the same group on the host for other presets. -Forced mounts: `dsh-netxops/tools-ops` | `tools-topology` (legacy aliases: `tools-nms` / `tools-common` → ops). +Forced mounts: `dsh-netxops/tools-ops` | `tools-topology` | `tools-biz-monitor` (legacy aliases: `tools-nms` / `tools-common` → ops). Model names: `netx__`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` still hits `/v1/ume/*`. @@ -20,6 +21,17 @@ Model names: `netx__`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` | `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | Managed CLI (login / show) | | `findTopologyPaths` | Fabric path lookup | +## bizMonitor → `netx-biz-monitor` + +Host tools call netx REST directly (**not** netx-mcp). Token needs `biz-monitor:read`. + +| Tool | Role | +|------|------| +| `getBizMonitorContext` | Project + templates + normalize + port map + tasks | +| `getBizMonitorBoard` | Batch / evaluate progress | +| `listBizMonitorReds` / `getBizMonitorDiffs` | Reds & diffs with evidence (raw A/B + show cmd) | +| `getBizCollectBatch` / `getBizCollectCommandRaw` | Collect audit + full CLI raw | + ## topology → `netx-topology` | Tool | Role | diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index ff7357f..87e839a 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -1,7 +1,8 @@ // src/netx/capability-groups.ts var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ ops: Object.freeze({ inPreset: true, public: false }), - topology: Object.freeze({ inPreset: false, public: false }) + topology: Object.freeze({ inPreset: false, public: false }), + bizMonitor: Object.freeze({ inPreset: true, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -37,15 +38,25 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" + ]), + bizMonitor: Object.freeze([ + "netx__getBizMonitorContext", + "netx__getBizMonitorBoard", + "netx__listBizMonitorReds", + "netx__getBizMonitorDiffs", + "netx__getBizCollectBatch", + "netx__getBizCollectCommandRaw" ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ ops: "ops", - topology: "topology" + topology: "topology", + bizMonitor: "biz-monitor" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology" + "topology", + "bizMonitor" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -60,12 +71,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const names = new Set; + const out = new Set; for (const id of groupIds) { - for (const tool of TOOLS_BY_GROUP[id]) - names.add(tool); + for (const name of TOOLS_BY_GROUP[id] ?? []) + out.add(name); } - return names; + return out; } // src/netx/group-skills.ts @@ -1458,6 +1469,68 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +async function getBizMonitorContext(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + if (!projectId && !taskId) { + return { ok: false, error: "project_id_or_task_id_required" }; + } + const params = {}; + if (projectId) + params.project_id = projectId; + if (taskId) + params.task_id = taskId; + return client.get("/v1/biz-migration/monitor-context", params, signal); +} +async function getBizMonitorBoard(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + const params = {}; + const runId = str2(args, "run_id").trim(); + if (runId) + params.run_id = runId; + return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); +} +async function listBizMonitorReds(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + if (!projectId) + return { ok: false, error: "project_id_required" }; + const params = {}; + putStr(params, args, ["status"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal); +} +async function getBizMonitorDiffs(client, args, signal) { + const runId = str2(args, "run_id").trim(); + if (!runId) + return { ok: false, error: "run_id_required" }; + const params = {}; + putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + const offset = num2(args, "offset"); + if (offset !== undefined) + params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER); + return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal); +} +async function getBizCollectBatch(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); +} +async function getBizCollectCommandRaw(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + const commandId = str2(args, "command_id").trim(); + if (!batchId || !commandId) { + return { ok: false, error: "batch_id_and_command_id_required" }; + } + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal); +} // src/netx/topology-handlers.ts function asRecord2(value) { @@ -2282,6 +2355,36 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + project_id: str4("Cutover / biz-migration project id"), + task_id: str4("biz_state task id (optional if project_id set)") + }, getBizMonitorContext, getClient, t), + tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { + batch_id: { type: "string", required: true }, + run_id: str4("Optional evaluate run id") + }, getBizMonitorBoard, getClient, t), + tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", { + project_id: { type: "string", required: true }, + status: str4("open|carried|resolved or empty=all"), + limit: num4() + }, listBizMonitorReds, getClient, t), + tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", { + run_id: { type: "string", required: true }, + metric_id: str4(), + sheet_id: str4(), + verdict: str4(), + color: str4("e.g. red"), + q: str4(), + limit: num4(), + offset: num4() + }, getBizMonitorDiffs, getClient, t), + tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { + batch_id: { type: "string", required: true } + }, getBizCollectBatch, getClient, t), + tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { + batch_id: { type: "string", required: true }, + command_id: { type: "string", required: true } + }, getBizCollectCommandRaw, getClient, t), tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { view_id: { type: "string", required: true }, diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 3fc88b0..25dc70f 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -1,7 +1,8 @@ // src/netx/capability-groups.ts var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ ops: Object.freeze({ inPreset: true, public: false }), - topology: Object.freeze({ inPreset: false, public: false }) + topology: Object.freeze({ inPreset: false, public: false }), + bizMonitor: Object.freeze({ inPreset: true, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -37,15 +38,25 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" + ]), + bizMonitor: Object.freeze([ + "netx__getBizMonitorContext", + "netx__getBizMonitorBoard", + "netx__listBizMonitorReds", + "netx__getBizMonitorDiffs", + "netx__getBizCollectBatch", + "netx__getBizCollectCommandRaw" ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ ops: "ops", - topology: "topology" + topology: "topology", + bizMonitor: "biz-monitor" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology" + "topology", + "bizMonitor" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -60,12 +71,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const names = new Set; + const out = new Set; for (const id of groupIds) { - for (const tool of TOOLS_BY_GROUP[id]) - names.add(tool); + for (const name of TOOLS_BY_GROUP[id] ?? []) + out.add(name); } - return names; + return out; } // src/netx/group-skills.ts @@ -1458,6 +1469,68 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +async function getBizMonitorContext(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + if (!projectId && !taskId) { + return { ok: false, error: "project_id_or_task_id_required" }; + } + const params = {}; + if (projectId) + params.project_id = projectId; + if (taskId) + params.task_id = taskId; + return client.get("/v1/biz-migration/monitor-context", params, signal); +} +async function getBizMonitorBoard(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + const params = {}; + const runId = str2(args, "run_id").trim(); + if (runId) + params.run_id = runId; + return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); +} +async function listBizMonitorReds(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + if (!projectId) + return { ok: false, error: "project_id_required" }; + const params = {}; + putStr(params, args, ["status"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal); +} +async function getBizMonitorDiffs(client, args, signal) { + const runId = str2(args, "run_id").trim(); + if (!runId) + return { ok: false, error: "run_id_required" }; + const params = {}; + putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + const offset = num2(args, "offset"); + if (offset !== undefined) + params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER); + return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal); +} +async function getBizCollectBatch(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); +} +async function getBizCollectCommandRaw(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + const commandId = str2(args, "command_id").trim(); + if (!batchId || !commandId) { + return { ok: false, error: "batch_id_and_command_id_required" }; + } + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal); +} // src/netx/topology-handlers.ts function asRecord2(value) { @@ -2282,6 +2355,36 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + project_id: str4("Cutover / biz-migration project id"), + task_id: str4("biz_state task id (optional if project_id set)") + }, getBizMonitorContext, getClient, t), + tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { + batch_id: { type: "string", required: true }, + run_id: str4("Optional evaluate run id") + }, getBizMonitorBoard, getClient, t), + tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", { + project_id: { type: "string", required: true }, + status: str4("open|carried|resolved or empty=all"), + limit: num4() + }, listBizMonitorReds, getClient, t), + tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", { + run_id: { type: "string", required: true }, + metric_id: str4(), + sheet_id: str4(), + verdict: str4(), + color: str4("e.g. red"), + q: str4(), + limit: num4(), + offset: num4() + }, getBizMonitorDiffs, getClient, t), + tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { + batch_id: { type: "string", required: true } + }, getBizCollectBatch, getClient, t), + tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { + batch_id: { type: "string", required: true }, + command_id: { type: "string", required: true } + }, getBizCollectCommandRaw, getClient, t), tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { view_id: { type: "string", required: true }, diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 0a9adad..9da224f 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -1,7 +1,8 @@ // src/netx/capability-groups.ts var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ ops: Object.freeze({ inPreset: true, public: false }), - topology: Object.freeze({ inPreset: false, public: false }) + topology: Object.freeze({ inPreset: false, public: false }), + bizMonitor: Object.freeze({ inPreset: true, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -37,15 +38,25 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" + ]), + bizMonitor: Object.freeze([ + "netx__getBizMonitorContext", + "netx__getBizMonitorBoard", + "netx__listBizMonitorReds", + "netx__getBizMonitorDiffs", + "netx__getBizCollectBatch", + "netx__getBizCollectCommandRaw" ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ ops: "ops", - topology: "topology" + topology: "topology", + bizMonitor: "biz-monitor" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology" + "topology", + "bizMonitor" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -60,12 +71,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const names = new Set; + const out = new Set; for (const id of groupIds) { - for (const tool of TOOLS_BY_GROUP[id]) - names.add(tool); + for (const name of TOOLS_BY_GROUP[id] ?? []) + out.add(name); } - return names; + return out; } // src/netx/group-skills.ts @@ -1458,6 +1469,68 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +async function getBizMonitorContext(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + if (!projectId && !taskId) { + return { ok: false, error: "project_id_or_task_id_required" }; + } + const params = {}; + if (projectId) + params.project_id = projectId; + if (taskId) + params.task_id = taskId; + return client.get("/v1/biz-migration/monitor-context", params, signal); +} +async function getBizMonitorBoard(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + const params = {}; + const runId = str2(args, "run_id").trim(); + if (runId) + params.run_id = runId; + return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); +} +async function listBizMonitorReds(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + if (!projectId) + return { ok: false, error: "project_id_required" }; + const params = {}; + putStr(params, args, ["status"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal); +} +async function getBizMonitorDiffs(client, args, signal) { + const runId = str2(args, "run_id").trim(); + if (!runId) + return { ok: false, error: "run_id_required" }; + const params = {}; + putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + const offset = num2(args, "offset"); + if (offset !== undefined) + params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER); + return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal); +} +async function getBizCollectBatch(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); +} +async function getBizCollectCommandRaw(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + const commandId = str2(args, "command_id").trim(); + if (!batchId || !commandId) { + return { ok: false, error: "batch_id_and_command_id_required" }; + } + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal); +} // src/netx/topology-handlers.ts function asRecord2(value) { @@ -2282,6 +2355,36 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + project_id: str4("Cutover / biz-migration project id"), + task_id: str4("biz_state task id (optional if project_id set)") + }, getBizMonitorContext, getClient, t), + tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { + batch_id: { type: "string", required: true }, + run_id: str4("Optional evaluate run id") + }, getBizMonitorBoard, getClient, t), + tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", { + project_id: { type: "string", required: true }, + status: str4("open|carried|resolved or empty=all"), + limit: num4() + }, listBizMonitorReds, getClient, t), + tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", { + run_id: { type: "string", required: true }, + metric_id: str4(), + sheet_id: str4(), + verdict: str4(), + color: str4("e.g. red"), + q: str4(), + limit: num4(), + offset: num4() + }, getBizMonitorDiffs, getClient, t), + tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { + batch_id: { type: "string", required: true } + }, getBizCollectBatch, getClient, t), + tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { + batch_id: { type: "string", required: true }, + command_id: { type: "string", required: true } + }, getBizCollectCommandRaw, getClient, t), tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { view_id: { type: "string", required: true }, diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index dad42ed..295479e 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -1,7 +1,8 @@ // src/netx/capability-groups.ts var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ ops: Object.freeze({ inPreset: true, public: false }), - topology: Object.freeze({ inPreset: false, public: false }) + topology: Object.freeze({ inPreset: false, public: false }), + bizMonitor: Object.freeze({ inPreset: true, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -37,15 +38,25 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" + ]), + bizMonitor: Object.freeze([ + "netx__getBizMonitorContext", + "netx__getBizMonitorBoard", + "netx__listBizMonitorReds", + "netx__getBizMonitorDiffs", + "netx__getBizCollectBatch", + "netx__getBizCollectCommandRaw" ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ ops: "ops", - topology: "topology" + topology: "topology", + bizMonitor: "biz-monitor" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology" + "topology", + "bizMonitor" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -60,12 +71,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const names = new Set; + const out = new Set; for (const id of groupIds) { - for (const tool of TOOLS_BY_GROUP[id]) - names.add(tool); + for (const name of TOOLS_BY_GROUP[id] ?? []) + out.add(name); } - return names; + return out; } // src/netx/group-skills.ts @@ -1458,6 +1469,68 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +async function getBizMonitorContext(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + if (!projectId && !taskId) { + return { ok: false, error: "project_id_or_task_id_required" }; + } + const params = {}; + if (projectId) + params.project_id = projectId; + if (taskId) + params.task_id = taskId; + return client.get("/v1/biz-migration/monitor-context", params, signal); +} +async function getBizMonitorBoard(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + const params = {}; + const runId = str2(args, "run_id").trim(); + if (runId) + params.run_id = runId; + return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); +} +async function listBizMonitorReds(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + if (!projectId) + return { ok: false, error: "project_id_required" }; + const params = {}; + putStr(params, args, ["status"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal); +} +async function getBizMonitorDiffs(client, args, signal) { + const runId = str2(args, "run_id").trim(); + if (!runId) + return { ok: false, error: "run_id_required" }; + const params = {}; + putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + const offset = num2(args, "offset"); + if (offset !== undefined) + params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER); + return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal); +} +async function getBizCollectBatch(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); +} +async function getBizCollectCommandRaw(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + const commandId = str2(args, "command_id").trim(); + if (!batchId || !commandId) { + return { ok: false, error: "batch_id_and_command_id_required" }; + } + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal); +} // src/netx/topology-handlers.ts function asRecord2(value) { @@ -2282,6 +2355,36 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + project_id: str4("Cutover / biz-migration project id"), + task_id: str4("biz_state task id (optional if project_id set)") + }, getBizMonitorContext, getClient, t), + tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { + batch_id: { type: "string", required: true }, + run_id: str4("Optional evaluate run id") + }, getBizMonitorBoard, getClient, t), + tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", { + project_id: { type: "string", required: true }, + status: str4("open|carried|resolved or empty=all"), + limit: num4() + }, listBizMonitorReds, getClient, t), + tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", { + run_id: { type: "string", required: true }, + metric_id: str4(), + sheet_id: str4(), + verdict: str4(), + color: str4("e.g. red"), + q: str4(), + limit: num4(), + offset: num4() + }, getBizMonitorDiffs, getClient, t), + tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { + batch_id: { type: "string", required: true } + }, getBizCollectBatch, getClient, t), + tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { + batch_id: { type: "string", required: true }, + command_id: { type: "string", required: true } + }, getBizCollectCommandRaw, getClient, t), tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { view_id: { type: "string", required: true }, diff --git a/lib/agent-tools.js b/lib/agent-tools.js index d79da17..ba073e2 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -1,7 +1,8 @@ // src/netx/capability-groups.ts var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ ops: Object.freeze({ inPreset: true, public: false }), - topology: Object.freeze({ inPreset: false, public: false }) + topology: Object.freeze({ inPreset: false, public: false }), + bizMonitor: Object.freeze({ inPreset: true, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -37,15 +38,25 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" + ]), + bizMonitor: Object.freeze([ + "netx__getBizMonitorContext", + "netx__getBizMonitorBoard", + "netx__listBizMonitorReds", + "netx__getBizMonitorDiffs", + "netx__getBizCollectBatch", + "netx__getBizCollectCommandRaw" ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ ops: "ops", - topology: "topology" + topology: "topology", + bizMonitor: "biz-monitor" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology" + "topology", + "bizMonitor" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -60,12 +71,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const names = new Set; + const out = new Set; for (const id of groupIds) { - for (const tool of TOOLS_BY_GROUP[id]) - names.add(tool); + for (const name of TOOLS_BY_GROUP[id] ?? []) + out.add(name); } - return names; + return out; } // src/netx/group-skills.ts @@ -1458,6 +1469,68 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +async function getBizMonitorContext(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + if (!projectId && !taskId) { + return { ok: false, error: "project_id_or_task_id_required" }; + } + const params = {}; + if (projectId) + params.project_id = projectId; + if (taskId) + params.task_id = taskId; + return client.get("/v1/biz-migration/monitor-context", params, signal); +} +async function getBizMonitorBoard(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + const params = {}; + const runId = str2(args, "run_id").trim(); + if (runId) + params.run_id = runId; + return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); +} +async function listBizMonitorReds(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + if (!projectId) + return { ok: false, error: "project_id_required" }; + const params = {}; + putStr(params, args, ["status"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal); +} +async function getBizMonitorDiffs(client, args, signal) { + const runId = str2(args, "run_id").trim(); + if (!runId) + return { ok: false, error: "run_id_required" }; + const params = {}; + putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + const offset = num2(args, "offset"); + if (offset !== undefined) + params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER); + return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal); +} +async function getBizCollectBatch(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); +} +async function getBizCollectCommandRaw(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + const commandId = str2(args, "command_id").trim(); + if (!batchId || !commandId) { + return { ok: false, error: "batch_id_and_command_id_required" }; + } + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal); +} // src/netx/topology-handlers.ts function asRecord2(value) { @@ -2282,6 +2355,36 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + project_id: str4("Cutover / biz-migration project id"), + task_id: str4("biz_state task id (optional if project_id set)") + }, getBizMonitorContext, getClient, t), + tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { + batch_id: { type: "string", required: true }, + run_id: str4("Optional evaluate run id") + }, getBizMonitorBoard, getClient, t), + tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", { + project_id: { type: "string", required: true }, + status: str4("open|carried|resolved or empty=all"), + limit: num4() + }, listBizMonitorReds, getClient, t), + tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", { + run_id: { type: "string", required: true }, + metric_id: str4(), + sheet_id: str4(), + verdict: str4(), + color: str4("e.g. red"), + q: str4(), + limit: num4(), + offset: num4() + }, getBizMonitorDiffs, getClient, t), + tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { + batch_id: { type: "string", required: true } + }, getBizCollectBatch, getClient, t), + tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { + batch_id: { type: "string", required: true }, + command_id: { type: "string", required: true } + }, getBizCollectCommandRaw, getClient, t), tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { view_id: { type: "string", required: true }, diff --git a/lib/client.js b/lib/client.js index 436e37b..346f59a 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1451,6 +1451,20 @@ function NetxopsCard(props) { onEditPublic: (checked) => { props.edit("groupTopologyPublic", checked ? "true" : "false"); } + }), + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupBizMonitor"), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupBizMonitorInPreset, + published: state.groupBizMonitorPublic, + disabled, + onEditInPreset: (checked) => { + props.edit("groupBizMonitorInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupBizMonitorPublic", checked ? "true" : "false"); + } }) ] }), @@ -1995,6 +2009,8 @@ class NetxopsCardController { booleanField("groupOpsPublic"), booleanField("groupTopologyInPreset"), booleanField("groupTopologyPublic"), + booleanFieldPersistFalse("groupBizMonitorInPreset"), + booleanField("groupBizMonitorPublic"), booleanFieldPersistFalse("groupKbInPreset"), booleanField("groupKbPublic"), booleanField("alarmPushEnabled"), @@ -2312,6 +2328,8 @@ class NetxopsCardController { groupOpsPublic: this.form.field("groupOpsPublic"), groupTopologyInPreset: this.form.field("groupTopologyInPreset"), groupTopologyPublic: this.form.field("groupTopologyPublic"), + groupBizMonitorInPreset: this.form.field("groupBizMonitorInPreset"), + groupBizMonitorPublic: this.form.field("groupBizMonitorPublic"), groupKbInPreset: this.form.field("groupKbInPreset"), groupKbPublic: this.form.field("groupKbPublic"), alarmPushEnabled: this.form.field("alarmPushEnabled"), @@ -2471,6 +2489,7 @@ var en = { nmsProviderHint: "zte-ume", groupOps: "ops", groupTopology: "topology", + groupBizMonitor: "bizMonitor", groupKb: "Knowledge skills", groupInPreset: "In Netx Ops preset", groupPublic: "Publish to other presets", @@ -2548,6 +2567,7 @@ var zh = { nmsProviderHint: "zte-ume", groupOps: "ops", groupTopology: "topology", + groupBizMonitor: "bizMonitor", groupKb: "知识库技能", groupInPreset: "在 Netx Ops 预设中启用", groupPublic: "对其他预设公开", diff --git a/lib/index.js b/lib/index.js index c3e2de1..6415f41 100644 --- a/lib/index.js +++ b/lib/index.js @@ -572,7 +572,8 @@ async function dispatchAlarmToSinks(ctx, payload, options, sinks = {}) { // src/netx/capability-groups.ts var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ ops: Object.freeze({ inPreset: true, public: false }), - topology: Object.freeze({ inPreset: false, public: false }) + topology: Object.freeze({ inPreset: false, public: false }), + bizMonitor: Object.freeze({ inPreset: true, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -608,15 +609,25 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" + ]), + bizMonitor: Object.freeze([ + "netx__getBizMonitorContext", + "netx__getBizMonitorBoard", + "netx__listBizMonitorReds", + "netx__getBizMonitorDiffs", + "netx__getBizCollectBatch", + "netx__getBizCollectCommandRaw" ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ ops: "ops", - topology: "topology" + topology: "topology", + bizMonitor: "biz-monitor" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology" + "topology", + "bizMonitor" ]); function capabilityGroupsFromSettings(fields) { const src = fields ?? {}; @@ -633,6 +644,10 @@ function capabilityGroupsFromSettings(fields) { opsInPreset = legacy.some((v) => v === true); } } + let bizMonitorInPreset = true; + if (src.groupBizMonitorInPreset !== undefined) { + bizMonitorInPreset = src.groupBizMonitorInPreset !== false; + } return { ops: { inPreset: opsInPreset, @@ -641,6 +656,10 @@ function capabilityGroupsFromSettings(fields) { topology: { inPreset: src.groupTopologyInPreset === true || src.groupTopologyLayoutInPreset === true, public: src.groupTopologyPublic === true || src.groupTopologyLayoutPublic === true + }, + bizMonitor: { + inPreset: bizMonitorInPreset, + public: src.groupBizMonitorPublic === true } }; } @@ -653,12 +672,12 @@ function groupsForPlane(groups, plane, only) { return enabled.filter((id) => allow.has(id)); } function toolNamesForGroups(groupIds) { - const names = new Set; + const out = new Set; for (const id of groupIds) { - for (const tool of TOOLS_BY_GROUP[id]) - names.add(tool); + for (const name of TOOLS_BY_GROUP[id] ?? []) + out.add(name); } - return names; + return out; } // src/netx/group-skills.ts @@ -3709,6 +3728,68 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +async function getBizMonitorContext(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + if (!projectId && !taskId) { + return { ok: false, error: "project_id_or_task_id_required" }; + } + const params = {}; + if (projectId) + params.project_id = projectId; + if (taskId) + params.task_id = taskId; + return client.get("/v1/biz-migration/monitor-context", params, signal); +} +async function getBizMonitorBoard(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + const params = {}; + const runId = str2(args, "run_id").trim(); + if (runId) + params.run_id = runId; + return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); +} +async function listBizMonitorReds(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + if (!projectId) + return { ok: false, error: "project_id_required" }; + const params = {}; + putStr(params, args, ["status"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal); +} +async function getBizMonitorDiffs(client, args, signal) { + const runId = str2(args, "run_id").trim(); + if (!runId) + return { ok: false, error: "run_id_required" }; + const params = {}; + putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + const offset = num2(args, "offset"); + if (offset !== undefined) + params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER); + return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal); +} +async function getBizCollectBatch(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); +} +async function getBizCollectCommandRaw(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + const commandId = str2(args, "command_id").trim(); + if (!batchId || !commandId) { + return { ok: false, error: "batch_id_and_command_id_required" }; + } + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal); +} // src/netx/topology-handlers.ts function asRecord2(value) { @@ -4533,6 +4614,36 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + project_id: str4("Cutover / biz-migration project id"), + task_id: str4("biz_state task id (optional if project_id set)") + }, getBizMonitorContext, getClient, t), + tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { + batch_id: { type: "string", required: true }, + run_id: str4("Optional evaluate run id") + }, getBizMonitorBoard, getClient, t), + tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", { + project_id: { type: "string", required: true }, + status: str4("open|carried|resolved or empty=all"), + limit: num4() + }, listBizMonitorReds, getClient, t), + tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", { + run_id: { type: "string", required: true }, + metric_id: str4(), + sheet_id: str4(), + verdict: str4(), + color: str4("e.g. red"), + q: str4(), + limit: num4(), + offset: num4() + }, getBizMonitorDiffs, getClient, t), + tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { + batch_id: { type: "string", required: true } + }, getBizCollectBatch, getClient, t), + tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { + batch_id: { type: "string", required: true }, + command_id: { type: "string", required: true } + }, getBizCollectCommandRaw, getClient, t), tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { view_id: { type: "string", required: true }, @@ -4734,6 +4845,8 @@ var Config = z.object({ groupOpsPublic: z.boolean().default(false), groupTopologyInPreset: z.boolean().default(false), groupTopologyPublic: z.boolean().default(false), + groupBizMonitorInPreset: z.boolean().default(true), + groupBizMonitorPublic: z.boolean().default(false), kbRoot: z.string().default(""), groupKbInPreset: z.boolean().default(true), groupKbPublic: z.boolean().default(false) diff --git a/package.json b/package.json index b2b53c8..7bbc103 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.39", + "version": "0.1.40", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", @@ -13,6 +13,7 @@ "./tools-nms": "./lib/agent-tools-nms.js", "./tools-common": "./lib/agent-tools-common.js", "./tools-topology": "./lib/agent-tools-topology.js", + "./tools-biz-monitor": "./lib/agent-tools-biz-monitor.js", "./client": "./lib/client.js", "./package.json": "./package.json" }, @@ -24,6 +25,7 @@ "lib/agent-tools-nms.js", "lib/agent-tools-common.js", "lib/agent-tools-topology.js", + "lib/agent-tools-biz-monitor.js", "lib/client.js", "presets/", "cordis.patch.yml", diff --git a/presets/netxops/skills/README.md b/presets/netxops/skills/README.md index 83d5b5e..8e52795 100644 --- a/presets/netxops/skills/README.md +++ b/presets/netxops/skills/README.md @@ -1,14 +1,14 @@ -# Mirrored from netx/skills — do not long-edit here +# Mirrored from netx/skills 鈥?do not long-edit here Canonical: `D:\project\chatgpt\netx\skills` -```powershell +`powershell powershell -File .\scripts\sync-skills-from-netx.ps1 -``` +` Runtime prefers `NETX_SKILLS_ROOT` or sibling `../netx/skills`. -Groups: **ops** (`netx-ops`) + **topology** (`netx-topology`). One group ↔ one skill. +Groups: **ops** (`netx-ops`) + **topology** (`netx-topology`). One group 鈫?one skill. **Keep netx-ops pure netx** (alarms / inventory / CLI). Knowledge-base triage belongs in -operator-subset `_skills/kb-*` packs registered by the plugin — do **not** write KB linkage +operator-subset `_skills/kb-*` packs registered by the plugin 鈥?do **not** write KB linkage back into `netx-ops`. diff --git a/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md b/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md new file mode 100644 index 0000000..76dd1fd --- /dev/null +++ b/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md @@ -0,0 +1,61 @@ +--- +name: netx-biz-monitor +description: >- + Overnight cutover / biz_state monitor analysis via netxops host tools + (netx__getBizMonitor*). Given a project_id or task_id, read definitions, + board, reds/diffs with evidence (device-raw A/B, show commands), and judge + tool false-positive vs real business fault. Does not create templates/tasks. +--- + +# netx-biz-monitor(割接 / 业务监控只读分析) + +人配好监控与任务;AI 当晚用 **project_id**(或 task_id)做归因。 +工具在 **netxops**(`netx__*`),直连 netx REST,**不是** netx-mcp。 + +## 工具(能力组 `bizMonitor`) + +| 工具 | 用途 | +|------|------| +| `netx__getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 | +| `netx__getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 | +| `netx__listBizMonitorReds` | 红单 + **evidence** | +| `netx__getBizMonitorDiffs` | run 明细(可滤 color=red) | +| `netx__getBizCollectBatch` | 某次采集:commands / metrics | +| `netx__getBizCollectCommandRaw` | 单命令全量 show 输出 | + +交叉验证真障时再用 ops:`netx__execManagedNe` / 拓扑路径。 + +## 关键约定(必守) + +- **`old_key` / `new_key`**:设备采集原文 **A/B**(大屏与结论用这个) +- **`match_*` / evidence.iface.normalized|map_***:内部配对,**不要**把映射后的 BB 说成两侧端口 +- **evidence.*.command.raw_command**:实际 show 命令 +- **evidence.*.device / collect**:哪台设备、何时采的 + +## 推荐顺序 + +1. `getBizMonitorContext(project_id)` — 弄清盯什么 +2. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped +3. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)` +4. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤? +5. 需要原文 → `getBizCollectCommandRaw` +6. 像真障 → `execManagedNe` 现场核对 + +## Verdict 速查(skill 解释,工具不硬编码) + +| verdict | 含义(窗口内) | +|---------|----------------| +| migrated | expect 项已迁到新侧 | +| anomaly | 异常(多为红) | +| unfinished | expect 未完成(验收常红) | +| migrating / expected / ok | 过程态 / 正常 | +| lost / not_involved | 丢失或无关 | + +## 误报优先查 + +1. `parse_status` 非 ok / 行数为 0 +2. board:`current_missing` / `collect_skipped` +3. 端口映射漏配、归一化前后不一致导致假 anomaly +4. 再谈真实业务问题 + +配置整改与关红单由人做;本 skill **只读分析**。 diff --git a/scripts/build-host.mjs b/scripts/build-host.mjs index c4ab112..bd30554 100644 --- a/scripts/build-host.mjs +++ b/scripts/build-host.mjs @@ -34,6 +34,7 @@ const entries = [ { entry: join(root, 'src/agent-tools-nms.ts'), out: join(outDir, 'agent-tools-nms.js') }, { entry: join(root, 'src/agent-tools-common.ts'), out: join(outDir, 'agent-tools-common.js') }, { entry: join(root, 'src/agent-tools-topology.ts'), out: join(outDir, 'agent-tools-topology.js') }, + { entry: join(root, 'src/agent-tools-biz-monitor.ts'), out: join(outDir, 'agent-tools-biz-monitor.js') }, ] mkdirSync(outDir, { recursive: true }) diff --git a/scripts/sync-skills-from-netx.ps1 b/scripts/sync-skills-from-netx.ps1 index c979546..f9d0142 100644 --- a/scripts/sync-skills-from-netx.ps1 +++ b/scripts/sync-skills-from-netx.ps1 @@ -17,7 +17,7 @@ $src = Join-Path $NetxRoot "skills" $dst = Join-Path $opsRoot "presets\netxops\skills" if (-not (Test-Path $src)) { throw "missing $src" } -foreach ($group in @("ops", "topology")) { +foreach ($group in @("ops", "topology", "biz-monitor")) { $from = Join-Path $src $group $to = Join-Path $dst $group if (-not (Test-Path $from)) { throw "missing group $from" } diff --git a/src/agent-tools-biz-monitor.ts b/src/agent-tools-biz-monitor.ts new file mode 100644 index 0000000..3040b19 --- /dev/null +++ b/src/agent-tools-biz-monitor.ts @@ -0,0 +1,15 @@ +/** + * Forced bizMonitor-group tools + skill (cutover / biz_state read analysis). + * + * @module dsh-netxops/tools-biz-monitor + */ + +import type { Context } from '@deepseek-ai/cordis' +import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts' + +export const name = 'netxops-tools-biz-monitor' +export const inject = ['tools'] + +export function apply(ctx: Context): void { + applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['bizMonitor'] }) +} diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index 7943898..6201df3 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -564,6 +564,20 @@ export function NetxopsCard(props: NetxopsCardProps) { props.edit('groupTopologyPublic', checked ? 'true' : 'false') }} /> + { + props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupBizMonitorPublic', checked ? 'true' : 'false') + }} + />
diff --git a/src/client/controller.ts b/src/client/controller.ts index edb9ad3..da93e4b 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -57,6 +57,8 @@ export interface NetxopsSettings { groupOpsPublic?: boolean groupTopologyInPreset?: boolean groupTopologyPublic?: boolean + groupBizMonitorInPreset?: boolean + groupBizMonitorPublic?: boolean nmsProvider?: string /** Operator-subset knowledge package root (Host-local path). */ kbRoot?: string @@ -82,6 +84,8 @@ export interface NetxopsCardState extends CardShell { groupOpsPublic: CardFieldState groupTopologyInPreset: CardFieldState groupTopologyPublic: CardFieldState + groupBizMonitorInPreset: CardFieldState + groupBizMonitorPublic: CardFieldState nmsProvider: CardFieldState kbRoot: CardFieldState groupKbInPreset: CardFieldState @@ -187,6 +191,8 @@ export class NetxopsCardController { booleanField('groupOpsPublic'), booleanField('groupTopologyInPreset'), booleanField('groupTopologyPublic'), + booleanFieldPersistFalse('groupBizMonitorInPreset'), + booleanField('groupBizMonitorPublic'), booleanFieldPersistFalse('groupKbInPreset'), booleanField('groupKbPublic'), booleanField('alarmPushEnabled'), @@ -599,6 +605,8 @@ export class NetxopsCardController { groupOpsPublic: this.form.field('groupOpsPublic'), groupTopologyInPreset: this.form.field('groupTopologyInPreset'), groupTopologyPublic: this.form.field('groupTopologyPublic'), + groupBizMonitorInPreset: this.form.field('groupBizMonitorInPreset'), + groupBizMonitorPublic: this.form.field('groupBizMonitorPublic'), groupKbInPreset: this.form.field('groupKbInPreset'), groupKbPublic: this.form.field('groupKbPublic'), alarmPushEnabled: this.form.field('alarmPushEnabled'), diff --git a/src/client/locales.ts b/src/client/locales.ts index 472c6e7..154daf1 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -21,6 +21,7 @@ export type NetxopsLocaleKey = | 'nmsProviderHint' | 'groupOps' | 'groupTopology' + | 'groupBizMonitor' | 'groupKb' | 'groupInPreset' | 'groupPublic' @@ -98,6 +99,7 @@ export const en: Record = { nmsProviderHint: 'zte-ume', groupOps: 'ops', groupTopology: 'topology', + groupBizMonitor: 'bizMonitor', groupKb: 'Knowledge skills', groupInPreset: 'In Netx Ops preset', groupPublic: 'Publish to other presets', @@ -176,6 +178,7 @@ export const zh: Record = { nmsProviderHint: 'zte-ume', groupOps: 'ops', groupTopology: 'topology', + groupBizMonitor: 'bizMonitor', groupKb: '知识库技能', groupInPreset: '在 Netx Ops 预设中启用', groupPublic: '对其他预设公开', diff --git a/src/index.ts b/src/index.ts index e7896ab..475e92f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -152,6 +152,10 @@ export interface Config { groupTopologyInPreset: boolean /** Publish topology tools/skills to other presets (default off). */ groupTopologyPublic: boolean + /** biz-monitor (cutover / biz_state read) tools in the Netx Ops preset (default on). */ + groupBizMonitorInPreset: boolean + /** Publish biz-monitor tools/skills to other presets (default off). */ + groupBizMonitorPublic: boolean /** * Absolute (or Host-local) path to an operator-subset knowledge package. * Empty = unconfigured. Plugin locates `MANIFEST.json` (≤3 levels) and @@ -183,6 +187,8 @@ export const Config: z = z.object({ groupOpsPublic: z.boolean().default(false), groupTopologyInPreset: z.boolean().default(false), groupTopologyPublic: z.boolean().default(false), + groupBizMonitorInPreset: z.boolean().default(true), + groupBizMonitorPublic: z.boolean().default(false), kbRoot: z.string().default(''), groupKbInPreset: z.boolean().default(true), groupKbPublic: z.boolean().default(false), diff --git a/src/netx/capability-groups.ts b/src/netx/capability-groups.ts index 2c85ed2..faa15f6 100644 --- a/src/netx/capability-groups.ts +++ b/src/netx/capability-groups.ts @@ -5,10 +5,11 @@ * * - `ops` → skill `netx-ops` — NMS alarms/inventory/SQL + managed CLI + findTopologyPaths * - `topology` → skill `netx-topology` — canvas / fabric / dual_unit / layout + * - `bizMonitor` → skill `netx-biz-monitor` — cutover / biz_state read + overnight analysis */ /** Stable group ids used in settings, skill dirs, and registration filters. */ -export type NetxCapabilityGroupId = 'ops' | 'topology' +export type NetxCapabilityGroupId = 'ops' | 'topology' | 'bizMonitor' /** Per-group exposure knobs. */ export interface NetxGroupExposure { @@ -27,14 +28,17 @@ export interface NetxCapabilityGroupSettingsFields { groupOpsPublic: boolean groupTopologyInPreset: boolean groupTopologyPublic: boolean + groupBizMonitorInPreset: boolean + groupBizMonitorPublic: boolean } /** - * Default: ops in Ops preset; topology and all public off. + * Default: ops in Ops preset; topology / bizMonitor and all public off. */ export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({ ops: Object.freeze({ inPreset: true, public: false }), topology: Object.freeze({ inPreset: false, public: false }), + bizMonitor: Object.freeze({ inPreset: true, public: false }), }) /** @@ -76,17 +80,27 @@ export const TOOLS_BY_GROUP: Readonly/`. */ export const SKILL_DIR_BY_GROUP: Readonly> = Object.freeze({ ops: 'ops', topology: 'topology', + bizMonitor: 'biz-monitor', }) export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.freeze([ 'ops', 'topology', + 'bizMonitor', ]) /** @@ -126,6 +140,11 @@ export function capabilityGroupsFromSettings( } } + let bizMonitorInPreset = true + if (src.groupBizMonitorInPreset !== undefined) { + bizMonitorInPreset = src.groupBizMonitorInPreset !== false + } + return { ops: { inPreset: opsInPreset, @@ -140,6 +159,10 @@ export function capabilityGroupsFromSettings( public: src.groupTopologyPublic === true || src.groupTopologyLayoutPublic === true, }, + bizMonitor: { + inPreset: bizMonitorInPreset, + public: src.groupBizMonitorPublic === true, + }, } } @@ -177,9 +200,9 @@ export function groupsForced( * Tool name allow-list for the given groups. */ export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set { - const names = new Set() + const out = new Set() for (const id of groupIds) { - for (const tool of TOOLS_BY_GROUP[id]) names.add(tool) + for (const name of TOOLS_BY_GROUP[id] ?? []) out.add(name) } - return names + return out } diff --git a/src/netx/handlers.ts b/src/netx/handlers.ts index c8f4108..aeca0cd 100644 --- a/src/netx/handlers.ts +++ b/src/netx/handlers.ts @@ -330,3 +330,101 @@ export async function findTopologyPaths(client: NetxClient, args: NetxJson, sign else body.to_managed_ne_id = toMid return client.post('/v1/topology/fabric/paths', body, signal, 30_000) } + +// ── bizMonitor group (cutover / biz_state read) ───────────────────────────── + +/** Fat cutover / biz_state definition bundle (templates, mapping, tasks). */ +export async function getBizMonitorContext( + client: NetxClient, + args: NetxJson, + signal?: AbortSignal, +): Promise { + const projectId = str(args, 'project_id').trim() + const taskId = str(args, 'task_id').trim() + if (!projectId && !taskId) { + return { ok: false, error: 'project_id_or_task_id_required' } + } + const params: Record = {} + if (projectId) params.project_id = projectId + if (taskId) params.task_id = taskId + return client.get('/v1/biz-migration/monitor-context', params, signal) +} + +/** Cutover batch board + latest evaluate run summary. */ +export async function getBizMonitorBoard( + client: NetxClient, + args: NetxJson, + signal?: AbortSignal, +): Promise { + const batchId = str(args, 'batch_id').trim() + if (!batchId) return { ok: false, error: 'batch_id_required' } + const params: Record = {} + const runId = str(args, 'run_id').trim() + if (runId) params.run_id = runId + return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal) +} + +/** Red tickets with evidence (raw A/B + show command). */ +export async function listBizMonitorReds( + client: NetxClient, + args: NetxJson, + signal?: AbortSignal, +): Promise { + const projectId = str(args, 'project_id').trim() + if (!projectId) return { ok: false, error: 'project_id_required' } + const params: Record = {} + putStr(params, args, ['status']) + const limit = num(args, 'limit') + if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500) + return client.get( + `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, + params, + signal, + ) +} + +/** Paged evaluate diffs (old_key/new_key = device-raw A/B). */ +export async function getBizMonitorDiffs( + client: NetxClient, + args: NetxJson, + signal?: AbortSignal, +): Promise { + const runId = str(args, 'run_id').trim() + if (!runId) return { ok: false, error: 'run_id_required' } + const params: Record = {} + putStr(params, args, ['metric_id', 'sheet_id', 'verdict', 'color', 'q']) + const limit = num(args, 'limit') + if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500) + const offset = num(args, 'offset') + if (offset !== undefined) params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER) + return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal) +} + +/** One biz_state collect batch (commands + metrics). */ +export async function getBizCollectBatch( + client: NetxClient, + args: NetxJson, + signal?: AbortSignal, +): Promise { + const batchId = str(args, 'batch_id').trim() + if (!batchId) return { ok: false, error: 'batch_id_required' } + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal) +} + +/** Full CLI raw_text for one collect command. */ +export async function getBizCollectCommandRaw( + client: NetxClient, + args: NetxJson, + signal?: AbortSignal, +): Promise { + const batchId = str(args, 'batch_id').trim() + const commandId = str(args, 'command_id').trim() + if (!batchId || !commandId) { + return { ok: false, error: 'batch_id_and_command_id_required' } + } + return client.get( + `/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, + {}, + signal, + ) +} diff --git a/src/netx/tools.ts b/src/netx/tools.ts index fb255be..d4e4f7e 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -316,6 +316,67 @@ export function registerNetxTools( }, H.findTopologyPaths, getClient, t, ), + // ── bizMonitor group (cutover / biz_state read via netx REST) ───────── + tool( + 'netx__getBizMonitorContext', + 'Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.', + { + project_id: str('Cutover / biz-migration project id'), + task_id: str('biz_state task id (optional if project_id set)'), + }, + H.getBizMonitorContext, getClient, t, + ), + tool( + 'netx__getBizMonitorBoard', + 'Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.', + { + batch_id: { type: 'string' as const, required: true as const }, + run_id: str('Optional evaluate run id'), + }, + H.getBizMonitorBoard, getClient, t, + ), + tool( + 'netx__listBizMonitorReds', + 'Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).', + { + project_id: { type: 'string' as const, required: true as const }, + status: str('open|carried|resolved or empty=all'), + limit: num(), + }, + H.listBizMonitorReds, getClient, t, + ), + tool( + 'netx__getBizMonitorDiffs', + 'Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.', + { + run_id: { type: 'string' as const, required: true as const }, + metric_id: str(), + sheet_id: str(), + verdict: str(), + color: str('e.g. red'), + q: str(), + limit: num(), + offset: num(), + }, + H.getBizMonitorDiffs, getClient, t, + ), + tool( + 'netx__getBizCollectBatch', + 'One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.', + { + batch_id: { type: 'string' as const, required: true as const }, + }, + H.getBizCollectBatch, getClient, t, + ), + tool( + 'netx__getBizCollectCommandRaw', + 'Full CLI raw_text for one collect command inside a batch (deep dive).', + { + batch_id: { type: 'string' as const, required: true as const }, + command_id: { type: 'string' as const, required: true as const }, + }, + H.getBizCollectCommandRaw, getClient, t, + ), // ── topology group (netx-topology canvas / fabric) ───────────────────── tool( 'netx__getTopologyTree', diff --git a/test/capability-groups.test.mjs b/test/capability-groups.test.mjs index f2954cf..8cf37c3 100644 --- a/test/capability-groups.test.mjs +++ b/test/capability-groups.test.mjs @@ -15,19 +15,21 @@ import { TOOLS_BY_GROUP, } from '../src/netx/capability-groups.ts' -test('defaults: ops in preset; topology and public off', () => { +test('defaults: ops + bizMonitor in preset; topology and public off', () => { assert.deepEqual(DEFAULT_CAPABILITY_GROUPS, { ops: { inPreset: true, public: false }, topology: { inPreset: false, public: false }, + bizMonitor: { inPreset: true, public: false }, }) - assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops']) + assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops', 'bizMonitor']) assert.deepEqual(groupsForPlane(undefined, 'public'), []) }) -test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas', () => { +test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => { assert.deepEqual(SKILL_DIR_BY_GROUP, { ops: 'ops', topology: 'topology', + bizMonitor: 'biz-monitor', }) assert.ok(TOOLS_BY_GROUP.ops.includes('netx__findTopologyPaths')) assert.ok(TOOLS_BY_GROUP.ops.includes('netx__execManagedNe')) @@ -36,11 +38,14 @@ test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas' assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView')) assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs')) assert.ok(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits')) + assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext')) + assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizCollectCommandRaw')) }) test('capabilityGroupsFromSettings honors explicit false for inPreset defaults', () => { const groups = capabilityGroupsFromSettings({ groupOpsInPreset: false, + groupBizMonitorInPreset: false, groupTopologyInPreset: true, groupTopologyPublic: true, }) @@ -49,6 +54,7 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults', assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView')) assert.ok(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits')) assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false) + assert.equal(toolNamesForGroups(['topology']).has('netx__getBizMonitorContext'), false) }) test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into topology', () => { @@ -61,6 +67,7 @@ test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into t assert.equal(groups.ops.inPreset, false) assert.equal(groups.ops.public, true) assert.equal(groups.topology.inPreset, true) + assert.equal(groups.bizMonitor.inPreset, true) }) test('legacy any-true turns ops inPreset on', () => { @@ -74,4 +81,5 @@ test('legacy any-true turns ops inPreset on', () => { test('groupsForced ignores settings for per-export mounts', () => { assert.deepEqual(groupsForced(['ops']), ['ops']) assert.deepEqual(groupsForced(['ops', 'topology']), ['ops', 'topology']) + assert.deepEqual(groupsForced(['bizMonitor']), ['bizMonitor']) }) From 5a2ab85d141accc5b1351f556318c354f6bbc669 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 19 Sep 2026 22:49:03 +0800 Subject: [PATCH 07/10] Track biz-monitor host tool bundle artifact. Co-authored-by: Cursor --- .gitignore | 2 + lib/agent-tools-biz-monitor.js | 2704 ++++++++++++++++++++++++++++++++ 2 files changed, 2706 insertions(+) create mode 100644 lib/agent-tools-biz-monitor.js diff --git a/.gitignore b/.gitignore index 0d4b35e..e635103 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,11 @@ dist/ lib/* !lib/index.js !lib/agent-tools.js +!lib/agent-tools-ops.js !lib/agent-tools-nms.js !lib/agent-tools-common.js !lib/agent-tools-topology.js +!lib/agent-tools-biz-monitor.js !lib/client.js *.log .DS_Store diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js new file mode 100644 index 0000000..6e03ec2 --- /dev/null +++ b/lib/agent-tools-biz-monitor.js @@ -0,0 +1,2704 @@ +// src/netx/capability-groups.ts +var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ + ops: Object.freeze({ inPreset: true, public: false }), + topology: Object.freeze({ inPreset: false, public: false }), + bizMonitor: Object.freeze({ inPreset: true, public: false }) +}); +var TOOLS_BY_GROUP = Object.freeze({ + ops: Object.freeze([ + "netx__queryNmsAlarms", + "netx__aggregateNmsAlarms", + "netx__runNmsDiagnostics", + "netx__queryNmsNeInventory", + "netx__getNmsNe", + "netx__queryNmsAlarmsRaw", + "netx__aggregateNmsAlarmsRaw", + "netx__listNmsAlarmFields", + "netx__sqlQueryNms", + "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" + ]), + bizMonitor: Object.freeze([ + "netx__getBizMonitorContext", + "netx__getBizMonitorBoard", + "netx__listBizMonitorReds", + "netx__getBizMonitorDiffs", + "netx__getBizCollectBatch", + "netx__getBizCollectCommandRaw" + ]) +}); +var SKILL_DIR_BY_GROUP = Object.freeze({ + ops: "ops", + topology: "topology", + bizMonitor: "biz-monitor" +}); +var CAPABILITY_GROUP_IDS = Object.freeze([ + "ops", + "topology", + "bizMonitor" +]); +function groupsForPlane(groups, plane, only) { + 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)); +} +function groupsForced(only) { + const normalized = only.map((id) => id === "nms" || id === "common" ? "ops" : id); + return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); +} +function toolNamesForGroups(groupIds) { + const out = new Set; + for (const id of groupIds) { + for (const name of TOOLS_BY_GROUP[id] ?? []) + out.add(name); + } + return out; +} + +// src/netx/group-skills.ts +import { existsSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +function opsSkillsRoot() { + const envRoot = process.env.NETX_SKILLS_ROOT?.trim(); + if (envRoot && existsSync(envRoot)) + return envRoot; + const here = dirname(fileURLToPath(import.meta.url)); + const siblingCandidates = [ + 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) { + if (!raw.startsWith("---")) + return; + const end = raw.indexOf(` +---`, 3); + if (end < 0) + return; + const yaml = raw.slice(3, end).replace(/^\r?\n/, ""); + const body = raw.slice(end + 4).replace(/^\r?\n/, ""); + const data = {}; + 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 = []; + 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) { + if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + return value; +} +async function loadSkillBundle(dir) { + const skillPath = join(dir, "SKILL.md"); + let raw; + 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, groupId) { + const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId]); + let entries; + try { + entries = await readdir(groupDir); + } catch { + return []; + } + const skills = []; + 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; +} +async function registerGroupSkills(ctx, groupIds, providerLabel) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = opsSkillsRoot(); + const disposers = []; + 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(); + }; +} + +// src/netx/kb-local-path.ts +import { existsSync as existsSync2, realpathSync, statSync } from "node:fs"; +import { join as join2, relative, resolve, sep } from "node:path"; +var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ + "memories", + "drafts", + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" +]); +var MEMORY_BUCKET_DIR = Object.freeze({ + note: "日常笔记", + rca_review: "排障复盘", + ai_trace: "AI思维链" +}); +function resolveKbLocalRoot(snapshot) { + if (snapshot.status !== "configured") + return null; + const rel = snapshot.paths.local?.trim(); + if (!rel) + return null; + return resolve(snapshot.realRoot, rel); +} +function kbLocalToolsEnabled(snapshot) { + return resolveKbLocalRoot(snapshot) !== null; +} +function sanitizeSlug(raw, fallback = "entry") { + const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + return cleaned || fallback; +} +function todayIsoDate(now = new Date) { + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} +function toCompactDate(isoOrEmpty, now = new Date) { + const iso = (isoOrEmpty?.trim() || todayIsoDate(now)).replace(/-/g, ""); + if (!/^\d{8}$/.test(iso)) { + throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${isoOrEmpty}`); + } + return iso; +} +function normalizeIsoDate(raw, now = new Date) { + const t = raw?.trim(); + if (!t) + return todayIsoDate(now); + if (/^\d{4}-\d{2}-\d{2}$/.test(t)) + return t; + if (/^\d{8}$/.test(t)) { + return `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}`; + } + throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${raw}`); +} +function memoryFileName(opts) { + const date = normalizeIsoDate(opts.date, opts.now); + return `${date}-${sanitizeSlug(opts.slug)}.md`; +} +function draftFileName(opts) { + const compact = toCompactDate(opts.date, opts.now); + const slug = sanitizeSlug(opts.slug); + const base = slug.toUpperCase().startsWith("DRAFT-") ? slug : `DRAFT-${compact}-${slug}`; + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} +function suggestionFileName(opts) { + const date = normalizeIsoDate(opts.date, opts.now); + return `${date}-${sanitizeSlug(opts.slug)}.md`; +} +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} +function resolveWritableLocalPath(localRoot, ...segments) { + if (segments.some((s) => s.includes("\x00"))) { + throw new Error("path segment contains NUL"); + } + const absolutePath = resolve(localRoot, ...segments); + const rel = relative(localRoot, absolutePath); + if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { + throw new Error(`path escapes local root: ${absolutePath}`); + } + const top = rel.split(/[/\\]/)[0]; + if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { + throw new Error(`writes only allowed under ${KB_LOCAL_WRITABLE_ROOTS.join("|")}/ (got ${top}/)`); + } + return { + absolutePath, + relativePath: rel.split(sep).join("/"), + writableRoot: top + }; +} +function resolveExistingWritableFile(localRoot, pathArg) { + const trimmed = pathArg.trim(); + if (!trimmed) + throw new Error("path is empty"); + const candidate = resolve(trimmed); + let absolutePath; + const localResolved = resolve(localRoot); + const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); + if (underLocal) { + absolutePath = candidate; + } else { + absolutePath = resolve(localRoot, trimmed); + } + const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); + try { + if (existsSync2(checked.absolutePath)) { + const realFile = realpathSync(checked.absolutePath); + const realLocal = realpathSync(localRoot); + const realRel = relative(realLocal, realFile); + if (!realRel || realRel.startsWith("..") || realRel.split(/[/\\]/).includes("..")) { + throw new Error(`path escapes local root after realpath: ${realFile}`); + } + const top = realRel.split(/[/\\]/)[0]; + if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { + throw new Error(`path not under writable roots: ${realRel}`); + } + if (!statSync(realFile).isFile()) { + throw new Error(`not a file: ${realFile}`); + } + return { + absolutePath: realFile, + relativePath: realRel.split(sep).join("/"), + writableRoot: top + }; + } + } catch (error) { + if (error instanceof Error && /escapes|writable|not a file/.test(error.message)) { + throw error; + } + } + return checked; +} +function memoryDir(bucket) { + return join2("memories", MEMORY_BUCKET_DIR[bucket]); +} +function suggestionDir(kind) { + return join2("suggestions", kind); +} +function draftDir(domain) { + const d = domain?.trim(); + if (!d) + return "drafts"; + return join2("drafts", sanitizeSlug(d, "misc")); +} +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} + +// src/netx/kb-context-skill.ts +var SKILL_NAME = "kb-context"; +function skillBody(snapshot) { + if (snapshot.status === "configured") { + const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; + const localRoot = resolveKbLocalRoot(snapshot); + const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; + const localGuide = localRoot ? [ + "", + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." + ] : [ + "", + "No local write tools until `paths.local` is present in MANIFEST." + ]; + return { + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", + content: [ + "## Knowledge base (configured)", + "", + "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", + "Do not invent another operator or country.", + "", + `| Field | Value |`, + `| --- | --- |`, + `| kbStatus | configured |`, + `| kbRoot | \`${snapshot.realRoot}\` |`, + localRow, + `| kbOperator | ${snapshot.operatorName} |`, + `| kbCountry | ${snapshot.country} |`, + `| kbVersion | ${snapshot.version} |`, + `| kbContent (on) | ${flags} |`, + ...localGuide, + "", + "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", + `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, + "plus paths.localSkills when present — use those skills for KB triage;", + "keep netx-ops for live netx evidence only." + ].join(` +`) + }; + } + const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; + return { + description: "Knowledge-base context is unavailable — stay on pure netx evidence.", + content: [ + "## Knowledge base (unavailable)", + "", + `kbStatus=${snapshot.status}. ${reason}`, + "", + "**Do not invent an operator, country, or KB paths.**", + "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", + "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." + ].join(` +`) + }; +} +function registerKbContextSkill(ctx, snapshot) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const body = skillBody(snapshot); + return skillsApi.register({ + name: SKILL_NAME, + description: body.description, + content: body.content, + provider: "netxops-kb", + source: "custom" + }); +} + +// src/netx/kb-local-tools.ts +import { defineTool } from "@deepseek-ai/dsh-tools"; + +// src/netx/kb-local-ops.ts +import { + existsSync as existsSync3, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync as statSync2, + writeFileSync +} from "node:fs"; +import { dirname as dirname2, join as join3 } from "node:path"; +function requireLocalRoot(snapshot) { + const root = resolveKbLocalRoot(snapshot); + if (!root) { + throw new Error("KB local root unavailable (configure kbRoot + MANIFEST paths.local)"); + } + return root; +} +function ensureDraftBody(body) { + const trimmed = body.replace(/^\uFEFF/, ""); + if (/^---\r?\n[\s\S]*?\r?\nstatus:\s*draft\b/m.test(trimmed) || /^---\r?\n[\s\S]*?\nstatus:\s*["']?draft["']?\s*$/m.test(trimmed)) { + return trimmed.endsWith(` +`) ? trimmed : `${trimmed} +`; + } + if (trimmed.startsWith("---")) { + const end = trimmed.indexOf(` +---`, 3); + if (end !== -1) { + const fm = trimmed.slice(0, end + 4); + const rest = trimmed.slice(end + 4); + if (/\nstatus:\s*/.test(fm)) + return trimmed.endsWith(` +`) ? trimmed : `${trimmed} +`; + const injected = fm.replace(/^---\r?\n/, `--- +status: draft +`); + const out = `${injected}${rest}`; + return out.endsWith(` +`) ? out : `${out} +`; + } + } + return `--- +status: draft +--- + +${trimmed.endsWith(` +`) ? trimmed : `${trimmed} +`}`; +} +function createMemory(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = memoryFileName({ date: args.date, slug: args.slug }); + const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} +function createDraft(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = draftFileName({ date: args.date, slug: args.slug }); + const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} +function createSuggestion(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = suggestionFileName({ date: args.date, slug: args.slug }); + const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} +function updateLocalFile(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const target = resolveExistingWritableFile(localRoot, args.path); + if (!existsSync3(target.absolutePath)) { + throw new Error(`file not found: ${target.relativePath}`); + } + const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(` +`) ? args.body : `${args.body} +`; + writeFileSync(target.absolutePath, body, "utf8"); + return { + ok: true, + action: "updated", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} +function deleteLocalFile(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const target = resolveExistingWritableFile(localRoot, args.path); + if (!existsSync3(target.absolutePath)) { + throw new Error(`file not found: ${target.relativePath}`); + } + rmSync(target.absolutePath, { force: false }); + return { + ok: true, + action: "deleted", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} +function walkFiles(dir, baseLocal, out) { + let entries; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const name of entries) { + if (name === "." || name === "..") + continue; + const full = join3(dir, name); + let st; + try { + st = statSync2(full); + } catch { + continue; + } + if (st.isDirectory()) { + walkFiles(full, baseLocal, out); + } else if (st.isFile() && name.toLowerCase().endsWith(".md")) { + const rel = full.slice(baseLocal.length).replace(/^[/\\]/, "").split(/[/\\]/).join("/"); + out.push({ + absolutePath: full, + relativePath: rel, + mtimeMs: st.mtimeMs, + size: st.size + }); + } + } +} +function listLocalFiles(snapshot, args = {}) { + const localRoot = requireLocalRoot(snapshot); + const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; + const entries = []; + for (const r of roots) { + walkFiles(join3(localRoot, r), localRoot, entries); + } + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); + const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); + return { + ok: true, + localRoot, + entries: entries.slice(0, limit) + }; +} + +// src/netx/kb-manifest.ts +var EMPTY_CONTENT = { + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + paths: {}, + errorMessage: "" + }; +} + +// src/netx/kb-runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; + root[STORE_KEY] = current; + } + return current; +} +function getKbContext() { + const snap = store().snapshot; + return { + ...snap, + content: { ...snap.content }, + paths: { ...snap.paths } + }; +} +function watchKbContext(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/kb-local-tools.ts +var str = (description) => ({ type: "string", ...description ? { description } : {} }); +var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); +var num = (description) => ({ type: "number", ...description ? { description } : {} }); +var reqStr = (description) => ({ + type: "string", + required: true, + ...description ? { description } : {} +}); +function renderJson(_args, value) { + return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +} +var jsonOut = { + schema: { type: "json" }, + render: renderJson +}; +function liveSnapshot() { + return getKbContext(); +} +function tool(name, description, parameters, execute) { + return defineTool({ + name, + description, + parameters, + output: jsonOut, + timeoutMs: 30000, + isConcurrencySafe: () => false, + async execute(args) { + try { + return await execute(args); + } catch (error) { + throw new Error(error instanceof Error ? error.message : String(error)); + } + } + }); +} +function registerKbLocalTools(ctx, snapshot = getKbContext()) { + if (!kbLocalToolsEnabled(snapshot)) + return () => {}; + const localRoot = resolveKbLocalRoot(snapshot); + const toolsApi = ctx.tools; + if (!toolsApi || typeof toolsApi.register !== "function") + return () => {}; + const entries = [ + tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { + bucket: { + type: "string", + required: true, + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: reqStr("Short filename stem (no path separators)"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createMemory(liveSnapshot(), { + bucket: args.bucket, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + date: args.date != null ? String(args.date) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { + slug: reqStr("Case stem (DRAFT- prefix added if missing)"), + body: reqStr("Markdown body (RCA-ish draft)"), + domain: str("Optional fault-domain subfolder under drafts/"), + date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createDraft(liveSnapshot(), { + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + domain: args.domain != null ? String(args.domain) : undefined, + date: args.date != null ? String(args.date) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { + kind: { + type: "string", + required: true, + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: reqStr("Short filename stem"), + body: reqStr("Full markdown body"), + date: str("Optional YYYY-MM-DD (default today)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createSuggestion(liveSnapshot(), { + kind: args.kind, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + date: args.date != null ? String(args.date) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { + path: reqStr("Absolute path or path relative to KB local root"), + body: reqStr("New full markdown body") + }, (args) => updateLocalFile(liveSnapshot(), { + path: String(args.path ?? ""), + body: String(args.body ?? "") + })), + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { + path: reqStr("Absolute path or path relative to KB local root") + }, (args) => deleteLocalFile(liveSnapshot(), { + path: String(args.path ?? "") + })), + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "refs", "all"], + description: "Subtree to list (default all writable)" + }, + limit: num("Max entries 1–200 (default 50)") + }, (args) => listLocalFiles(liveSnapshot(), { + root: args.root ?? "all", + limit: typeof args.limit === "number" ? args.limit : undefined + })) + ]; + const disposers = entries.map((entry) => toolsApi.register(entry)); + ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot); + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/netx/kb-pack-skills.ts +import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; +import { join as join4 } from "node:path"; +function kbPackSkillsEnabled(snapshot) { + return snapshot.status === "configured" && snapshot.content.hasSkills === true; +} +function resolveKbSkillsRoot(snapshot) { + const rel = snapshot.paths.skills?.trim() || "_skills"; + return join4(snapshot.realRoot, rel); +} +function resolveKbLocalSkillsRoot(snapshot) { + const rel = snapshot.paths.localSkills?.trim(); + if (!rel) + return null; + return join4(snapshot.realRoot, rel); +} +async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { + let entries; + try { + entries = await readdir2(root); + } catch { + if (options.logMissing) { + ctx.logger.info("netxops: kb %s dir missing at %s — skip", options.label, root); + } + return []; + } + if (entries.length === 0) + return []; + const disposers = []; + for (const entry of entries) { + const full = join4(root, entry); + let isDir = false; + try { + isDir = (await stat2(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + let skill; + try { + skill = await loadSkillBundle(full); + } catch (error) { + ctx.logger.warn("netxops: kb %s skill load failed at %s: %s", options.label, full, error instanceof Error ? error.message : String(error)); + continue; + } + if (!skill) { + ctx.logger.warn("netxops: kb %s skill skipped (invalid SKILL.md): %s", options.label, full); + continue; + } + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + } + return disposers; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const provider = options.providerLabel ?? "netxops-kb-pack"; + const disposers = []; + const skillsRoot = resolveKbSkillsRoot(snapshot); + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { + logMissing: true, + label: "pack" + })); + const localRoot = resolveKbLocalSkillsRoot(snapshot); + if (localRoot) { + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { + logMissing: false, + label: "local" + })); + } + if (disposers.length > 0) { + ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider); + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/netx/runtime.ts +var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); +function store2() { + const root = globalThis; + let current = root[STORE_KEY2]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY2] = current; + } + return current; +} +function getNetxConnection() { + return store2().connection; +} +function watchNetxConnection(listener) { + const state = store2(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/tools.ts +import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; + +// src/netx/http.ts +var PROTOCOL_KEY_ZH_TO_EN = { + 其他: "Other", + 时钟: "Clock", + "OTN/光": "OTN/Optical", + 电源: "Power" +}; +function localizePayload(lang, data) { + if (!lang.trim().toLowerCase().startsWith("en")) + return data; + const proto = data.protocol_summary; + if (!Array.isArray(proto)) + return data; + for (const row of proto) { + if (typeof row !== "object" || row === null || Array.isArray(row)) + continue; + const rec = row; + const key = typeof rec.key === "string" ? rec.key : ""; + const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; + if (mapped !== undefined) + rec.key = mapped; + } + return data; +} +function encodeQuery(params) { + const sp = new URLSearchParams; + for (const [key, value] of Object.entries(params)) { + sp.set(key, String(value)); + } + const q = sp.toString(); + return q.length > 0 ? `?${q}` : ""; +} +function createNetxClient(connection) { + const base = connection.apiUrl.replace(/\/$/, ""); + const langParams = () => { + const lang = connection.lang.trim().toLowerCase(); + if (lang.startsWith("en")) + return { lang: "en" }; + return {}; + }; + async function request(method, path, options = {}) { + const token = connection.getToken().trim(); + if (token.length === 0) { + return { + ok: false, + error: "netx_token_missing", + detail: "Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token)." + }; + } + const merged = { ...langParams(), ...options.params }; + const url = `${base}${path}${encodeQuery(merged)}`; + const timeoutMs = options.timeoutMs ?? connection.timeoutMs; + const controller = new AbortController; + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const onOuterAbort = () => { + controller.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + try { + const headers = { + accept: "application/json", + authorization: `Bearer ${token}` + }; + if (options.body !== undefined) + headers["content-type"] = "application/json"; + const init = { + method, + headers, + signal: controller.signal + }; + if (options.body !== undefined) + init.body = JSON.stringify(options.body); + const resp = await fetch(url, init); + const text = await resp.text(); + if (!resp.ok) { + return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }; + } + const data = text.length > 0 ? JSON.parse(text) : {}; + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + return { ok: true, data: localizePayload(connection.lang, data) }; + } + return { ok: true, data: { raw: data } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onOuterAbort); + } + } + return { + get(path, params, signal, timeoutMs) { + return request("GET", path, { params, signal, timeoutMs }); + }, + post(path, body, signal, timeoutMs) { + return request("POST", path, { body, signal, timeoutMs }); + }, + patch(path, body, signal, timeoutMs) { + return request("PATCH", path, { body, signal, timeoutMs }); + } + }; +} +function quoteNeId(neId) { + return encodeURIComponent(neId.trim()); +} + +// src/netx/handlers.ts +var EXEC_MAX_COMMANDS = 5; +var UME_RAW_FIELD_PRESETS = { + brief: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_perceived_severity", + "alarm_event_type", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_exists" + ], + evidence: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_object_name", + "alarm_event_type", + "alarm_native_probable_cause", + "alarm_perceived_severity", + "alarm_is_cleared", + "alarm_time_created", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_connection_status", + "ne_exists" + ], + ne_debug: [ + "alarm_alarm_key", + "alarm_ne_id", + "alarm_perceived_severity", + "alarm_last_seen_at", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_ipv6_address", + "ne_device_level", + "ne_host_name", + "ne_connection_status", + "ne_admin_status", + "ne_address_type", + "ne_maintain_status", + "ne_exists" + ] +}; +function asRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str2(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function nmsOrUme(args, nmsKey, umeKey) { + return str2(args, nmsKey).trim() || str2(args, umeKey).trim(); +} +function nmsOrUmeList(args, nmsKey, umeKey) { + const primary = strList(args, nmsKey); + return primary.length > 0 ? primary : strList(args, umeKey); +} +function num2(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool2(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function putStr(params, args, keys) { + for (const key of keys) { + const v = str2(args, key).trim(); + if (v) + params[key] = v; + } +} +async function queryUmeAlarms(client, args, signal) { + let page = clampInt(num2(args, "page"), 1, 1, 2); + const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500); + const params = { page, page_size: pageSize }; + putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); + const keyword = str2(args, "keyword").trim(); + const neName = str2(args, "ne_name").trim(); + if (keyword) + params.keyword = keyword; + else if (neName) + params.keyword = neName; + return client.get("/v1/ume/alarms", params, signal); +} +async function aggregateUmeAlarmsRaw(client, args, signal) { + const params = {}; + putStr(params, args, [ + "group_by", + "group_by2", + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "limit" + ]); + if ("exclude_missing_host" in args) { + const flag = bool2(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + return client.get("/v1/ume/alarms/aggregate/raw", params, signal); +} +async function aggregateUmeAlarms(client, args, signal) { + if (str2(args, "group_by").trim()) + return aggregateUmeAlarmsRaw(client, args, signal); + const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); + const params = { top_ne: topNe }; + if ("exclude_missing_host" in args) { + const flag = bool2(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + putStr(params, args, ["severity", "time_from", "time_to"]); + return client.get("/v1/ume/alarms/aggregate", params, signal); +} +async function runUmeDiagnostics(client, _args, signal) { + return client.get("/v1/ume/diagnostics", undefined, signal); +} +async function queryUmeNeInventory(client, args, signal) { + const params = { + page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num2(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["keyword"]); + return client.get("/v1/ume/inventory/ne", params, signal); +} +async function getUmeNe(client, args, signal) { + const neId = str2(args, "ne_id").trim(); + if (!neId) + return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; + return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); +} +async function queryUmeAlarmsRaw(client, args, signal) { + const params = { + page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num2(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, [ + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "order_by", + "order" + ]); + let fields = strList(args, "select_fields"); + if (fields.length === 0) { + const preset = str2(args, "field_preset").trim().toLowerCase(); + fields = UME_RAW_FIELD_PRESETS[preset] ?? []; + } + if (fields.length > 0) + params.select_fields = fields.join(","); + return client.get("/v1/ume/alarms/raw", params, signal); +} +async function listUmeAlarmFields(client, _args, signal) { + return client.get("/v1/ume/alarms/fields", undefined, signal); +} +async function sqlQueryUme(client, args, signal) { + const sql = str2(args, "sql").trim(); + if (!sql) + return { ok: false, error: "sql_required" }; + const limit = clampInt(num2(args, "limit"), 200, 1, 2000); + const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000); + return client.post("/v1/sql/ume_query", { + sql, + limit, + statement_timeout_ms: statementTimeoutMs + }, signal, 60000); +} +async function listManagedNe(client, args, signal) { + const keyword = str2(args, "keyword").trim(); + const vendor = str2(args, "vendor").trim(); + const connectStatus = str2(args, "connect_status").trim(); + if (!(keyword || vendor || connectStatus)) { + return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; + } + if (keyword && keyword.length < 2) { + return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; + } + const params = { + page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num2(args, "page_size"), 20, 1, 100) + }; + if (keyword) + params.keyword = keyword; + if (vendor) + params.vendor = vendor; + if (connectStatus) + params.connect_status = connectStatus; + return client.get("/v1/managed-ne", params, signal); +} +async function getManagedNe(client, args, signal) { + const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(args, "id")).trim(); + if (!neId) { + return { + ok: false, + error: "ne_id_required", + error_code: "ne_id_required", + 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: "" } + }; + } + const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); + if (out.ok === false) { + const detail = `${str2(out, "detail")}${str2(out, "error")}`.toLowerCase(); + 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 an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe." + }; + } + } + return out; +} +async function execManagedNe(client, args, signal) { + const targetsRaw = args.targets; + const neIds = strList(args, "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 || umeNeIds.length > 0; + if (multi) { + const body2 = {}; + if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { + const cleaned = []; + for (const t of targetsRaw) { + if (typeof t !== "object" || t === null || Array.isArray(t)) + continue; + const row = t; + const item = {}; + const neId2 = str2(row, "ne_id").trim(); + const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); + if (neId2) + item.ne_id = neId2; + if (umeNeId2) + item.ume_ne_id = umeNeId2; + const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; + if (cmds.length > 0) + item.commands = cmds; + if (Object.keys(item).length > 0) + cleaned.push(item); + } + body2.targets = cleaned; + } + if (neIds.length > 0) + body2.ne_ids = neIds; + if (umeNeIds.length > 0) + body2.ume_ne_ids = umeNeIds; + if (sharedCommands.length > 0) { + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + body2.commands = sharedCommands; + } + body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120); + const concurrency = num2(args, "concurrency"); + if (concurrency !== undefined) + body2.concurrency = clampInt(concurrency, 4, 1, 8); + const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + if (data2.ok === false) { + return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; + } + return { ok: true, data: data2 }; + } + const neId = str2(args, "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_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) { + return { ok: false, error: "commands_required", error_code: "commands_required" }; + } + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + const body = { + commands: sharedCommands, + read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) + }; + if (neId) + body.ne_id = neId; + if (umeNeId) + body.ume_ne_id = umeNeId; + const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); + if (out.ok !== true) + return out; + const data = asRecord(out.data); + if (data.ok === false) { + return { ok: false, data, error: str2(data, "error", "exec_failed") }; + } + return { ok: true, data }; +} +async function listCliTargets(client, args, signal) { + const params = { + page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num2(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["source", "keyword"]); + return client.get("/v1/cli/targets", params, signal); +} +async function findTopologyPaths(client, args, signal) { + const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); + const fromMid = str2(args, "from_managed_ne_id").trim(); + const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); + const toMid = str2(args, "to_managed_ne_id").trim(); + if (Boolean(fromUid) === Boolean(fromMid)) { + 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_nms_ne_id_or_to_managed_ne_id_required" }; + } + let detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail !== "summary" && detail !== "full") + detail = "summary"; + const body = { + max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10), + max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12), + layer: str2(args, "layer", "physical").trim() || "physical", + detail + }; + if (fromUid) + body.from_ume_ne_id = fromUid; + else + body.from_managed_ne_id = fromMid; + if (toUid) + body.to_ume_ne_id = toUid; + else + body.to_managed_ne_id = toMid; + return client.post("/v1/topology/fabric/paths", body, signal, 30000); +} +async function getBizMonitorContext(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + if (!projectId && !taskId) { + return { ok: false, error: "project_id_or_task_id_required" }; + } + const params = {}; + if (projectId) + params.project_id = projectId; + if (taskId) + params.task_id = taskId; + return client.get("/v1/biz-migration/monitor-context", params, signal); +} +async function getBizMonitorBoard(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + const params = {}; + const runId = str2(args, "run_id").trim(); + if (runId) + params.run_id = runId; + return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); +} +async function listBizMonitorReds(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + if (!projectId) + return { ok: false, error: "project_id_required" }; + const params = {}; + putStr(params, args, ["status"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal); +} +async function getBizMonitorDiffs(client, args, signal) { + const runId = str2(args, "run_id").trim(); + if (!runId) + return { ok: false, error: "run_id_required" }; + const params = {}; + putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); + const limit = num2(args, "limit"); + if (limit !== undefined) + params.limit = clampInt(limit, 200, 1, 500); + const offset = num2(args, "offset"); + if (offset !== undefined) + params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER); + return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal); +} +async function getBizCollectBatch(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + if (!batchId) + return { ok: false, error: "batch_id_required" }; + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); +} +async function getBizCollectCommandRaw(client, args, signal) { + const batchId = str2(args, "batch_id").trim(); + const commandId = str2(args, "command_id").trim(); + if (!batchId || !commandId) { + return { ok: false, error: "batch_id_and_command_id_required" }; + } + return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal); +} + +// src/netx/topology-handlers.ts +function asRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str3(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function num3(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool3(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList2(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt2(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function unwrap(out) { + if (out.ok !== true) + return out; + const data = asRecord2(out.data); + if (Object.keys(data).length === 0) + return { ok: true, data: out.data }; + return { ok: true, ...data, data }; +} +function filterFields(args) { + const out = {}; + for (const key of ["keyword", "role", "vendor", "link_status"]) { + const v = str3(args, key).trim(); + if (v) + out[key] = v; + } + return out; +} +function edgeEndpoints(edge) { + const a = str3(edge, "a_node_id") || str3(edge, "a"); + const b = str3(edge, "b_node_id") || str3(edge, "b"); + return [a.trim(), b.trim()]; +} +function collapseEdgesToLinks(edges, includeNames = false) { + const buckets = new Map; + for (const raw of edges) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const e = raw; + 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}\x00${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 = str3(e, "a_name"); + row.b_name = str3(e, "b_name"); + } else { + row.a_name = str3(e, "b_name"); + row.b_name = str3(e, "a_name"); + } + } + buckets.set(key, row); + } + return [...buckets.values()]; +} +function summarizeViewGraph(graph, sample = 20) { + 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 = asRecord2(n); + return { + id: str3(row, "id") || str3(row, "fabric_node_id"), + name: str3(row, "name"), + x: row.x, + y: row.y, + level: row.level, + role: row.role + }; + }); + return { + ok: true, + view_id: str3(graph, "id") || str3(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) { + 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)." + }; +} +async function getTopologyTree(client, args, signal) { + const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); + if (out.ok !== true) + return out; + const compact = bool3(args, "compact") !== false; + const maxDepth = num3(args, "max_depth"); + if (!compact && maxDepth === undefined) + return out; + return { ...out, compact, max_depth: maxDepth ?? null }; +} +async function getTopologyView(client, args, signal) { + const viewId = str3(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, 120000)); + if (out.ok !== true) + return out; + const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full") + return { ...out, detail: "full" }; + return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); +} +async function createTopologyFolder(client, args, signal) { + const name = str3(args, "name").trim(); + if (!name) + return { ok: false, error: "name_required" }; + const body = { name }; + const parentId = str3(args, "parent_id").trim(); + if (parentId) + body.parent_id = parentId; + const locale = str3(args, "locale").trim(); + if (locale) + body.locale = locale; + if (num3(args, "sort_order") !== undefined) + body.sort_order = Math.trunc(num3(args, "sort_order")); + return unwrap(await client.post("/v1/topology/folders", body, signal)); +} +async function addTopologyViewNodes(client, args, signal) { + const viewId = str3(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + if (strList2(args, "managed_ne_ids").length || strList2(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 = strList2(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 (num3(args, "max_nodes") !== undefined) { + const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); + if (got.ok === true) { + const data = asRecord2(got.data); + const filt = asRecord2(got.filter ?? data.filter); + const membership = asRecord2(filt.membership ?? {}); + membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000); + filt.membership = membership; + await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); + } + } + const layout = str3(args, "layout", "grid").trim() || "grid"; + const body = { + managed_ne_ids: [], + layout, + ...filters + }; + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + if (num3(args, "limit") !== undefined) + body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000); + if (num3(args, "offset") !== undefined) + body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); +} +async function removeTopologyViewNodes(client, args, signal) { + const viewId = str3(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = { ...filterFields(args) }; + const fabricIds = strList2(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)); +} +async function copyTopologyViewNodes(client, args, signal) { + const sourceId = str3(args, "source_view_id").trim(); + const targetId = str3(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, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const limit = num3(args, "limit"); + const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; + const fabricIds = selected.map((n) => { + const row = asRecord2(n); + return str3(row, "id") || str3(row, "fabric_node_id"); + }).filter(Boolean); + if (bool3(args, "dry_run") === true) { + return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; + } + if (bool3(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, 180000)); + if (add.ok !== true) + return add; + if (bool3(args, "copy_positions") !== false) { + const ox = num3(args, "offset_x") ?? 0; + const oy = num3(args, "offset_y") ?? 0; + const positions = selected.map((n) => { + const row = asRecord2(n); + const id = str3(row, "id") || str3(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, 120000); + } + } + return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; +} +async function updateTopologyViewPositions(client, args, signal) { + const viewId = str3(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + if (Array.isArray(args.positions)) + body.positions = args.positions; + const layout = str3(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"]) { + if (num3(args, key) !== undefined) + body[key] = num3(args, key); + } + const fabricIds = strList2(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, 120000)); +} +async function projectTopologyNeighbors(client, args, signal) { + const viewId = str3(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); + if (seeds.length) + body.seed_fabric_node_ids = seeds; + const mids = strList2(args, "managed_ne_ids"); + if (mids.length) + body.managed_ne_ids = mids; + const region = str3(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, 180000)); + if (out.ok !== true) + return out; + const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full" || detail === "raw" || detail === "graph") { + return { ...out, detail: "full", projected: true }; + } + return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; +} +async function queryTopologyFabricNodes(client, args, signal) { + let mode = str3(args, "mode").trim().toLowerCase(); + const q = str3(args, "q").trim(); + const keyword = str3(args, "keyword").trim(); + if (!mode) { + if (bool3(args, "summary") === true) + mode = "summary"; + else if (q) + mode = "search"; + else + mode = "list"; + } + if (mode === "summary" || mode === "stats" || mode === "count") { + const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); + return out2.ok === true ? { ...out2, mode: "summary" } : out2; + } + 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: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200) + }, signal)); + } + const params = { + page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) + }; + for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { + const v = str3(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; +} +async function classifyTopologyFabricNodes(client, args, signal) { + const action = str3(args, "action").trim().toLowerCase(); + if (!action) + return { ok: false, error: "action_required" }; + if (action === "match") { + const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); + if (!pattern) + return { ok: false, error: "pattern_required" }; + return unwrap(await client.post("/v1/topology/fabric/nodes/match", { + pattern, + match_field: str3(args, "match_field", "name").trim() || "name", + sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200) + }, signal)); + } + if (action === "tag") { + const body = { + fabric_node_ids: strList2(args, "fabric_node_ids"), + dry_run: bool3(args, "dry_run") === true + }; + const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); + if (pattern) + body.pattern = pattern; + if (args.level !== undefined) + body.level = args.level; + const role = str3(args, "role").trim(); + if (role) + body.role = role; + const region = str3(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool3(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 = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "fabric_node_id_required" }; + const body = {}; + if (args.level !== undefined) + body.level = args.level; + const role = str3(args, "role").trim(); + if (role) + body.role = role; + const region = str3(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool3(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: str3(args, "kind", "any").trim() || "any", + page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num3(args, "page_size") ?? num3(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 = {}; + if (bool3(args, "overwrite_manual") === true) + params.skip_manual = false; + else if (bool3(args, "skip_manual") !== undefined) + params.skip_manual = bool3(args, "skip_manual") !== false; + if (bool3(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 }; +} +async function queryTopologyNeighborhood(client, args, signal) { + const nodeId = str3(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: clampInt2(num3(args, "depth"), 1, 1, 3), + layer: str3(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) }; +} +async function queryTopologyEdges(client, args, signal) { + const params = { + page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num3(args, "page_size"), 100, 1, 500), + layer: str3(args, "layer", "physical").trim() || "physical" + }; + for (const key of ["node_id", "keyword", "status", "source"]) { + const v = str3(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 = str3(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 }; +} +async function suggestSinkHubs(client, args, signal) { + const viewId = str3(args, "source_view_id").trim() || str3(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, 120000)); + 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; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(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([ + ...strList2(args, "exclude_portal_ids"), + ...strList2(args, "exclude_fabric_node_ids") + ]); + const pick = clampInt2(num3(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 row2 = asRecord2(n); + return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; + }); + const row = asRecord2(node); + return { + fabric_node_id, + degree: deg, + name: str3(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." + }; +} +async function analyzeTopologyViewLayout(client, args, signal) { + const viewId = str3(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, 120000)); + 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 = asRecord2(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; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(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: str3(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." + }; +} +async function sinkTopologyDualUnits(_client, args) { + return { + ok: false, + error: "sink_dual_units_requires_netx_topology_mcp", + source_view_id: str3(args, "source_view_id"), + sink_view_id: str3(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." + }; +} +async function layoutTopologyView(client, args, signal) { + if (bool3(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 = str3(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 = str3(args, "view_id").trim(); + const fromId = str3(args, "source_view_id").trim(); + const ids = strList2(args, "fabric_node_ids"); + const params = asRecord2(args.params); + const fabricIds = ids.length ? ids : strList2(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 = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false; + const removeFromSource = bool3(params, "remove_from_source") !== false; + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); + 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 = asRecord2(n); + const id = str3(row, "id") || str3(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, 180000)); + 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 = asRecord2(n); + return { + fabric_node_id: str3(row, "id") || str3(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, 120000); + } + 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: bool3(params, "park") === true || bool3(args, "park") === true + }; + } + return layoutRecipeUnavailable(action); +} + +// src/netx/tools.ts +var str4 = (description) => ({ type: "string", ...description ? { description } : {} }); +var num4 = (description) => ({ type: "number", ...description ? { description } : {} }); +var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +var strArr = (description) => ({ + type: "array", + items: { type: "string" }, + ...description ? { description } : {} +}); +var anyObj = (description) => ({ + type: "object", + additionalProperties: true, + ...description ? { description } : {} +}); +function renderJson2(_args, value) { + return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +} +var jsonOut2 = { + schema: { type: "json" }, + render: renderJson2 +}; +function tool2(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool2({ + name, + description, + parameters, + output: jsonOut2, + timeoutMs, + isConcurrencySafe: () => true, + async execute(args, exec) { + const result = await handler(getClient(), args, exec.signal); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); + } + return result; + } + }); +} +function registerNetxTools(ctx, connection, options) { + 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, + timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000), + getToken: () => getNetxConnection()?.token ?? "" + }); + const getClient = () => client; + const t = connection.toolCallTimeoutMs; + const catalog = [ + tool2("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: str4(), + ne_id: str4("Filter only; do not show UUID to users"), + host_name: str4("Filter by NE host_name"), + ne_name: str4("Legacy alias mapped to keyword"), + keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), + time_from: str4("ISO time; filters last_seen_at >="), + time_to: str4("ISO time; filters last_seen_at <="), + page: num4(), + page_size: num4() + }, queryUmeAlarms, getClient, t), + tool2("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: str4("Optional perceived_severity filter (critical/major/minor/warning)."), + top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."), + exclude_missing_host: bool4("Omit missing host_name from by_ne."), + time_from: str4(), + time_to: str4(), + group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."), + group_by2: str4(), + is_cleared: str4(), + ne_id: str4(), + event_type: str4(), + keyword: str4(), + limit: num4() + }, aggregateUmeAlarms, getClient, t), + tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), + tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { + keyword: str4(), + page: num4(), + page_size: num4() + }, queryUmeNeInventory, getClient, t), + tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } + }, getUmeNe, getClient, t), + tool2("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: str4(), + is_cleared: str4(), + ne_id: str4(), + event_type: str4(), + keyword: str4(), + time_from: str4(), + time_to: str4(), + order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"), + order: str4("asc | desc"), + select_fields: strArr(), + field_preset: str4("brief | evidence | ne_debug"), + page: num4(), + page_size: num4() + }, queryUmeAlarmsRaw, getClient, t), + tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + group_by: { type: "string", required: true }, + group_by2: str4(), + severity: str4(), + is_cleared: str4(), + ne_id: str4(), + event_type: str4(), + keyword: str4(), + time_from: str4(), + time_to: str4(), + exclude_missing_host: bool4(), + limit: num4() + }, aggregateUmeAlarmsRaw, getClient, t), + tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), + tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + sql: { type: "string", required: true }, + limit: num4(), + statement_timeout_ms: num4() + }, sqlQueryUme, getClient, t), + tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { + keyword: str4(), + vendor: str4(), + connect_status: str4("unknown | testing | pass | fail"), + page: num4(), + page_size: num4() + }, listManagedNe, getClient, t), + tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + ne_id: str4("Managed NE id"), + managed_ne_id: str4("Alias for ne_id"), + id: str4("Alias for ne_id") + }, getManagedNe, getClient, t), + tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + ne_id: str4(), + nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), + ume_ne_id: str4("Legacy alias of nms_ne_id"), + ne_ids: strArr("Managed 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", + description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + items: { + type: "object", + additionalProperties: false, + properties: { + ne_id: str4(), + nms_ne_id: str4(), + ume_ne_id: str4(), + commands: strArr() + } + } + }, + commands: strArr("Commands for single NE, or shared commands for batch."), + read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), + concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), + async: bool4("oclaw-only async hint; ignored by native REST client.") + }, execManagedNe, getClient, Math.max(t, 300000)), + tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { + source: str4("managed | nms | ume | all"), + keyword: str4(), + page: num4(), + page_size: num4() + }, listCliTargets, getClient, t), + tool2("netx__findTopologyPaths", "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_nms_ne_id: str4("NMS inventory id"), + from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"), + from_managed_ne_id: str4(), + to_nms_ne_id: str4("NMS inventory id"), + to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"), + to_managed_ne_id: str4(), + max_paths: num4(), + max_hops: num4(), + layer: str4(), + detail: str4("summary | full") + }, findTopologyPaths, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + project_id: str4("Cutover / biz-migration project id"), + task_id: str4("biz_state task id (optional if project_id set)") + }, getBizMonitorContext, getClient, t), + tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { + batch_id: { type: "string", required: true }, + run_id: str4("Optional evaluate run id") + }, getBizMonitorBoard, getClient, t), + tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", { + project_id: { type: "string", required: true }, + status: str4("open|carried|resolved or empty=all"), + limit: num4() + }, listBizMonitorReds, getClient, t), + tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", { + run_id: { type: "string", required: true }, + metric_id: str4(), + sheet_id: str4(), + verdict: str4(), + color: str4("e.g. red"), + q: str4(), + limit: num4(), + offset: num4() + }, getBizMonitorDiffs, getClient, t), + tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { + batch_id: { type: "string", required: true } + }, getBizCollectBatch, getClient, t), + tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { + batch_id: { type: "string", required: true }, + command_id: { type: "string", required: true } + }, getBizCollectCommandRaw, getClient, t), + tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), + tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + view_id: { type: "string", required: true }, + detail: str4("summary | full"), + sample: num4() + }, getTopologyView, getClient, t), + tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + name: { type: "string", required: true }, + parent_id: str4(), + locale: str4("zh | en"), + sort_order: num4() + }, createTopologyFolder, getClient, t), + tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + view_id: { type: "string", required: true }, + max_nodes: num4(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), + limit: num4(), + offset: num4(), + fabric_node_ids: strArr(), + layout: str4("grid | keep") + }, addTopologyViewNodes, getClient, t), + tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + view_id: { type: "string", required: true }, + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), + fabric_node_ids: strArr() + }, removeTopologyViewNodes, getClient, t), + tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + source_view_id: { type: "string", required: true }, + target_view_id: { type: "string", required: true }, + copy_positions: bool4(), + clear_target: bool4(), + offset_x: num4(), + offset_y: num4(), + limit: num4(), + dry_run: bool4() + }, copyTopologyViewNodes, getClient, t), + tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + view_id: { type: "string", required: true }, + positions: { + type: "array", + description: "Manual placements: [{ fabric_node_id, x, y }, …]", + items: { + type: "object", + additionalProperties: false, + properties: { + fabric_node_id: str4(), + x: num4(), + y: num4() + } + } + }, + layout: str4("grid | offset | stack"), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), + fabric_node_ids: strArr(), + offset_x: num4(), + offset_y: num4() + }, updateTopologyViewPositions, getClient, t), + tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + view_id: { type: "string", required: true }, + seed_fabric_node_ids: strArr(), + managed_ne_ids: strArr(), + region_folder_id: str4(), + detail: str4("summary | full"), + sample: num4() + }, projectTopologyNeighbors, getClient, t), + tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { + mode: str4("summary | list | search"), + q: str4(), + keyword: str4(), + role: str4(), + level: str4(), + level_major: str4(), + region_folder_id: str4(), + link_status: str4(), + page: num4(), + page_size: num4(), + limit: num4(), + summary: bool4() + }, queryTopologyFabricNodes, getClient, t), + tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + action: { type: "string", required: true }, + pattern: str4(), + q: str4(), + match_field: str4(), + sample_limit: num4(), + fabric_node_ids: strArr(), + fabric_node_id: str4(), + node_id: str4(), + level: str4(), + role: str4(), + region_folder_id: str4(), + clear_region: bool4(), + dry_run: bool4(), + kind: str4(), + page: num4(), + page_size: num4(), + skip_manual: bool4(), + overwrite_manual: bool4(), + fill_empty_only: bool4() + }, classifyTopologyFabricNodes, getClient, t), + tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + node_id: { type: "string", required: true }, + depth: num4(), + layer: str4() + }, queryTopologyNeighborhood, getClient, t), + tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { + node_id: str4(), + keyword: str4(), + layer: str4(), + status: str4(), + source: str4("lldp | ume | manual"), + detail: str4("adjacency | ports"), + page: num4(), + page_size: num4() + }, queryTopologyEdges, getClient, t), + tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { + source_view_id: str4(), + view_id: str4(), + pick: num4(), + exclude_portal_ids: strArr(), + exclude_fabric_node_ids: strArr() + }, suggestSinkHubs, getClient, t), + tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { + view_id: str4(), + folder_id: str4(), + detail: str4("summary | structure | hotspots | blocks | both"), + score_profile: str4(), + sight_limit: num4(), + max_views: num4(), + min_nodes: num4(), + max_nodes: num4(), + with_meta: bool4() + }, analyzeTopologyViewLayout, getClient, t), + tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { + source_view_id: str4(), + sink_view_id: str4(), + max_units: num4(), + min_nodes: num4(), + max_nodes: num4(), + max_batch_nodes: num4(), + layout_batch: bool4(), + until_empty: bool4(), + dry_run: bool4() + }, sinkTopologyDualUnits, getClient, t), + tool2("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: str4(), + action: str4(), + source_view_id: str4(), + recipe: str4(), + preset: str4(), + mode: str4("preview | apply"), + tune: bool4(), + params: anyObj("Action-specific options (move_nodes / recipes / polish)."), + catalog: bool4(), + fabric_node_ids: strArr(), + park: bool4(), + copy_positions: bool4() + }, layoutTopologyView, getClient, Math.max(t, 180000)) + ]; + const disposers = catalog.filter((entry) => allow.has(entry.name)).map((entry) => ctx.tools.register(entry)); + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/netx/group-tools-plugin.ts +function applyGroupToolsPlugin(ctx, options) { + let unregisterTools; + let unregisterKbLocal; + let unregisterSkills; + let skillGeneration = 0; + const resolveGroups = () => { + const connection = getNetxConnection(); + if (options.mode === "forced") { + return groupsForced(options.only ?? []); + } + return groupsForPlane(connection?.groups, "preset", options.only); + }; + const remountTools = () => { + 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); + }; + const remountKbLocal = () => { + unregisterKbLocal?.(); + unregisterKbLocal = undefined; + const connection = getNetxConnection(); + if (connection === undefined) + return; + if (connection.groupKbInPreset === false) + return; + unregisterKbLocal = registerKbLocalTools(ctx, getKbContext()); + }; + remountTools(); + remountKbLocal(); + const stopToolWatch = watchNetxConnection(() => { + remountTools(); + remountKbLocal(); + }); + const stopKbToolWatch = watchKbContext(() => { + remountKbLocal(); + }); + ctx.inject(["skills"], (skillsCtx) => { + let unregisterKbSkill; + let unregisterKbPack; + let packGeneration = 0; + const remountSkills = () => { + const gen = ++skillGeneration; + unregisterSkills?.(); + unregisterSkills = undefined; + if (getNetxConnection() === undefined) + return; + const enabled = resolveGroups(); + 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); + }); + }; + const remountKbSkill = () => { + unregisterKbSkill?.(); + unregisterKbSkill = undefined; + unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); + }; + const remountKbPack = () => { + const gen = ++packGeneration; + unregisterKbPack?.(); + unregisterKbPack = undefined; + const connection = getNetxConnection(); + const enabled = connection?.groupKbInPreset !== false; + registerKbPackSkills(skillsCtx, getKbContext(), { + enabled, + providerLabel: `${options.name}-kb-pack` + }).then((dispose) => { + if (gen !== packGeneration) { + dispose(); + return; + } + unregisterKbPack = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: kb pack skill register failed: %s", options.name, error); + }); + }; + remountSkills(); + remountKbSkill(); + remountKbPack(); + const stopSkillWatch = watchNetxConnection(() => { + remountSkills(); + remountKbPack(); + }); + const stopKbWatch = watchKbContext(() => { + remountKbSkill(); + remountKbPack(); + }); + skillsCtx.effect(() => () => { + skillGeneration += 1; + packGeneration += 1; + stopSkillWatch(); + stopKbWatch(); + unregisterSkills?.(); + unregisterSkills = undefined; + unregisterKbSkill?.(); + unregisterKbSkill = undefined; + unregisterKbPack?.(); + unregisterKbPack = undefined; + }, `${options.name}: dispose skills`); + }); + ctx.effect(() => () => { + stopToolWatch(); + stopKbToolWatch(); + unregisterTools?.(); + unregisterTools = undefined; + unregisterKbLocal?.(); + unregisterKbLocal = undefined; + }, `${options.name}: dispose tools`); +} + +// src/agent-tools-biz-monitor.ts +var name = "netxops-tools-biz-monitor"; +var inject = ["tools"]; +function apply(ctx) { + applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["bizMonitor"] }); +} +export { + name, + inject, + apply +}; From c1d07f7509cbbd0e08eed5e030e7d9697d4a2f12 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 20 Sep 2026 15:04:14 +0800 Subject: [PATCH 08/10] Add listBizMonitors catalog tool so agents can discover projects/tasks without an id. Co-authored-by: Cursor --- docs/TOOL_MAP.md | 1 + lib/agent-tools-biz-monitor.js | 120 ++++++++++++++++- lib/agent-tools-common.js | 120 ++++++++++++++++- lib/agent-tools-nms.js | 120 ++++++++++++++++- lib/agent-tools-ops.js | 120 ++++++++++++++++- lib/agent-tools-topology.js | 120 ++++++++++++++++- lib/agent-tools.js | 120 ++++++++++++++++- lib/index.js | 120 ++++++++++++++++- .../biz-monitor/netx-biz-monitor/SKILL.md | 25 ++-- src/netx/capability-groups.ts | 1 + src/netx/handlers.ts | 122 ++++++++++++++++++ src/netx/tools.ts | 14 +- 12 files changed, 984 insertions(+), 19 deletions(-) diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 5b474be..5df6038 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -27,6 +27,7 @@ Host tools call netx REST directly (**not** netx-mcp). Token needs `biz-monitor: | Tool | Role | |------|------| +| `listBizMonitors` | Catalog: cutover projects + biz_state tasks (no id required) | | `getBizMonitorContext` | Project + templates + normalize + port map + tasks | | `getBizMonitorBoard` | Batch / evaluate progress | | `listBizMonitorReds` / `getBizMonitorDiffs` | Reds & diffs with evidence (raw A/B + show cmd) | diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index 6e03ec2..f8a36d9 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +function taskBriefNe(brief) { + const rec = asRecord(brief); + const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : ""; + const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : ""; + if (name && ip) + return `${name} (${ip})`; + return name || ip; +} +function slimMigrationProject(row) { + const mt = asRecord(row.monitor_template); + return { + id: row.id, + name: row.name, + status: row.status, + note: row.note, + monitor_template_id: row.monitor_template_id, + monitor_template_name: typeof mt.name === "string" ? mt.name : "", + old_task_id: row.old_task_id, + new_task_id: row.new_task_id, + old_hf_task_id: row.old_hf_task_id, + new_hf_task_id: row.new_hf_task_id, + old_ne: taskBriefNe(row.old_task), + new_ne: taskBriefNe(row.new_task), + hf_interval_sec: row.hf_interval_sec, + hf_start_at: row.hf_start_at, + hf_end_at: row.hf_end_at, + updated_at: row.updated_at + }; +} +function matchesBizMonitorQ(row, q) { + if (!q) + return true; + const hay = [ + row.id, + row.name, + row.note, + row.status, + row.ne_name, + row.ne_ip, + row.ne_id, + row.old_ne, + row.new_ne, + row.monitor_template_name, + row.purpose, + row.vendor + ].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" "); + return hay.includes(q); +} +async function listBizMonitors(client, args, signal) { + const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all"; + const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw; + if (kind !== "all" && kind !== "projects" && kind !== "tasks") { + return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" }; + } + const purpose = str2(args, "purpose").trim(); + const status = str2(args, "status").trim().toLowerCase(); + const q = str2(args, "q").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); + const wantProjects = kind === "all" || kind === "projects"; + const wantTasks = kind === "all" || kind === "tasks"; + const fetches = []; + if (wantProjects) + fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); + else + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + if (wantTasks) { + const params = {}; + if (purpose) + params.purpose = purpose; + fetches.push(client.get("/v1/biz-state/tasks", params, signal)); + } else { + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + } + const [projRes, taskRes] = await Promise.all(fetches); + if (wantProjects && projRes.ok === false) + return projRes; + if (wantTasks && taskRes.ok === false) + return taskRes; + const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : []; + const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; + let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + let tasks = taskItems.map((row) => asRecord(row)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + const projectsTotal = projects.length; + const tasksTotal = tasks.length; + projects = projects.slice(0, limit); + tasks = tasks.slice(0, limit); + return { + ok: true, + data: { + kind, + projects: wantProjects ? projects : undefined, + tasks: wantTasks ? tasks : undefined, + counts: { + projects: wantProjects ? projectsTotal : 0, + tasks: wantTasks ? tasksTotal : 0, + projects_returned: wantProjects ? projects.length : 0, + tasks_returned: wantTasks ? tasks.length : 0 + }, + next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + kind: str4("all | projects | tasks (default all)"), + purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), + status: str4("Filter by status (e.g. active / paused)"), + q: str4("Keyword on name / ne_name / note / id"), + limit: num4("Max rows per list (default 100, max 500)") + }, listBizMonitors, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") }, getBizMonitorContext, getClient, t), diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 87e839a..e2db01e 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +function taskBriefNe(brief) { + const rec = asRecord(brief); + const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : ""; + const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : ""; + if (name && ip) + return `${name} (${ip})`; + return name || ip; +} +function slimMigrationProject(row) { + const mt = asRecord(row.monitor_template); + return { + id: row.id, + name: row.name, + status: row.status, + note: row.note, + monitor_template_id: row.monitor_template_id, + monitor_template_name: typeof mt.name === "string" ? mt.name : "", + old_task_id: row.old_task_id, + new_task_id: row.new_task_id, + old_hf_task_id: row.old_hf_task_id, + new_hf_task_id: row.new_hf_task_id, + old_ne: taskBriefNe(row.old_task), + new_ne: taskBriefNe(row.new_task), + hf_interval_sec: row.hf_interval_sec, + hf_start_at: row.hf_start_at, + hf_end_at: row.hf_end_at, + updated_at: row.updated_at + }; +} +function matchesBizMonitorQ(row, q) { + if (!q) + return true; + const hay = [ + row.id, + row.name, + row.note, + row.status, + row.ne_name, + row.ne_ip, + row.ne_id, + row.old_ne, + row.new_ne, + row.monitor_template_name, + row.purpose, + row.vendor + ].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" "); + return hay.includes(q); +} +async function listBizMonitors(client, args, signal) { + const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all"; + const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw; + if (kind !== "all" && kind !== "projects" && kind !== "tasks") { + return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" }; + } + const purpose = str2(args, "purpose").trim(); + const status = str2(args, "status").trim().toLowerCase(); + const q = str2(args, "q").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); + const wantProjects = kind === "all" || kind === "projects"; + const wantTasks = kind === "all" || kind === "tasks"; + const fetches = []; + if (wantProjects) + fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); + else + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + if (wantTasks) { + const params = {}; + if (purpose) + params.purpose = purpose; + fetches.push(client.get("/v1/biz-state/tasks", params, signal)); + } else { + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + } + const [projRes, taskRes] = await Promise.all(fetches); + if (wantProjects && projRes.ok === false) + return projRes; + if (wantTasks && taskRes.ok === false) + return taskRes; + const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : []; + const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; + let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + let tasks = taskItems.map((row) => asRecord(row)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + const projectsTotal = projects.length; + const tasksTotal = tasks.length; + projects = projects.slice(0, limit); + tasks = tasks.slice(0, limit); + return { + ok: true, + data: { + kind, + projects: wantProjects ? projects : undefined, + tasks: wantTasks ? tasks : undefined, + counts: { + projects: wantProjects ? projectsTotal : 0, + tasks: wantTasks ? tasksTotal : 0, + projects_returned: wantProjects ? projects.length : 0, + tasks_returned: wantTasks ? tasks.length : 0 + }, + next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + kind: str4("all | projects | tasks (default all)"), + purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), + status: str4("Filter by status (e.g. active / paused)"), + q: str4("Keyword on name / ne_name / note / id"), + limit: num4("Max rows per list (default 100, max 500)") + }, listBizMonitors, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") }, getBizMonitorContext, getClient, t), diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 25dc70f..e026c33 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +function taskBriefNe(brief) { + const rec = asRecord(brief); + const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : ""; + const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : ""; + if (name && ip) + return `${name} (${ip})`; + return name || ip; +} +function slimMigrationProject(row) { + const mt = asRecord(row.monitor_template); + return { + id: row.id, + name: row.name, + status: row.status, + note: row.note, + monitor_template_id: row.monitor_template_id, + monitor_template_name: typeof mt.name === "string" ? mt.name : "", + old_task_id: row.old_task_id, + new_task_id: row.new_task_id, + old_hf_task_id: row.old_hf_task_id, + new_hf_task_id: row.new_hf_task_id, + old_ne: taskBriefNe(row.old_task), + new_ne: taskBriefNe(row.new_task), + hf_interval_sec: row.hf_interval_sec, + hf_start_at: row.hf_start_at, + hf_end_at: row.hf_end_at, + updated_at: row.updated_at + }; +} +function matchesBizMonitorQ(row, q) { + if (!q) + return true; + const hay = [ + row.id, + row.name, + row.note, + row.status, + row.ne_name, + row.ne_ip, + row.ne_id, + row.old_ne, + row.new_ne, + row.monitor_template_name, + row.purpose, + row.vendor + ].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" "); + return hay.includes(q); +} +async function listBizMonitors(client, args, signal) { + const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all"; + const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw; + if (kind !== "all" && kind !== "projects" && kind !== "tasks") { + return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" }; + } + const purpose = str2(args, "purpose").trim(); + const status = str2(args, "status").trim().toLowerCase(); + const q = str2(args, "q").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); + const wantProjects = kind === "all" || kind === "projects"; + const wantTasks = kind === "all" || kind === "tasks"; + const fetches = []; + if (wantProjects) + fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); + else + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + if (wantTasks) { + const params = {}; + if (purpose) + params.purpose = purpose; + fetches.push(client.get("/v1/biz-state/tasks", params, signal)); + } else { + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + } + const [projRes, taskRes] = await Promise.all(fetches); + if (wantProjects && projRes.ok === false) + return projRes; + if (wantTasks && taskRes.ok === false) + return taskRes; + const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : []; + const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; + let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + let tasks = taskItems.map((row) => asRecord(row)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + const projectsTotal = projects.length; + const tasksTotal = tasks.length; + projects = projects.slice(0, limit); + tasks = tasks.slice(0, limit); + return { + ok: true, + data: { + kind, + projects: wantProjects ? projects : undefined, + tasks: wantTasks ? tasks : undefined, + counts: { + projects: wantProjects ? projectsTotal : 0, + tasks: wantTasks ? tasksTotal : 0, + projects_returned: wantProjects ? projects.length : 0, + tasks_returned: wantTasks ? tasks.length : 0 + }, + next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + kind: str4("all | projects | tasks (default all)"), + purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), + status: str4("Filter by status (e.g. active / paused)"), + q: str4("Keyword on name / ne_name / note / id"), + limit: num4("Max rows per list (default 100, max 500)") + }, listBizMonitors, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") }, getBizMonitorContext, getClient, t), diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 9da224f..8d6213d 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +function taskBriefNe(brief) { + const rec = asRecord(brief); + const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : ""; + const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : ""; + if (name && ip) + return `${name} (${ip})`; + return name || ip; +} +function slimMigrationProject(row) { + const mt = asRecord(row.monitor_template); + return { + id: row.id, + name: row.name, + status: row.status, + note: row.note, + monitor_template_id: row.monitor_template_id, + monitor_template_name: typeof mt.name === "string" ? mt.name : "", + old_task_id: row.old_task_id, + new_task_id: row.new_task_id, + old_hf_task_id: row.old_hf_task_id, + new_hf_task_id: row.new_hf_task_id, + old_ne: taskBriefNe(row.old_task), + new_ne: taskBriefNe(row.new_task), + hf_interval_sec: row.hf_interval_sec, + hf_start_at: row.hf_start_at, + hf_end_at: row.hf_end_at, + updated_at: row.updated_at + }; +} +function matchesBizMonitorQ(row, q) { + if (!q) + return true; + const hay = [ + row.id, + row.name, + row.note, + row.status, + row.ne_name, + row.ne_ip, + row.ne_id, + row.old_ne, + row.new_ne, + row.monitor_template_name, + row.purpose, + row.vendor + ].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" "); + return hay.includes(q); +} +async function listBizMonitors(client, args, signal) { + const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all"; + const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw; + if (kind !== "all" && kind !== "projects" && kind !== "tasks") { + return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" }; + } + const purpose = str2(args, "purpose").trim(); + const status = str2(args, "status").trim().toLowerCase(); + const q = str2(args, "q").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); + const wantProjects = kind === "all" || kind === "projects"; + const wantTasks = kind === "all" || kind === "tasks"; + const fetches = []; + if (wantProjects) + fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); + else + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + if (wantTasks) { + const params = {}; + if (purpose) + params.purpose = purpose; + fetches.push(client.get("/v1/biz-state/tasks", params, signal)); + } else { + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + } + const [projRes, taskRes] = await Promise.all(fetches); + if (wantProjects && projRes.ok === false) + return projRes; + if (wantTasks && taskRes.ok === false) + return taskRes; + const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : []; + const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; + let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + let tasks = taskItems.map((row) => asRecord(row)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + const projectsTotal = projects.length; + const tasksTotal = tasks.length; + projects = projects.slice(0, limit); + tasks = tasks.slice(0, limit); + return { + ok: true, + data: { + kind, + projects: wantProjects ? projects : undefined, + tasks: wantTasks ? tasks : undefined, + counts: { + projects: wantProjects ? projectsTotal : 0, + tasks: wantTasks ? tasksTotal : 0, + projects_returned: wantProjects ? projects.length : 0, + tasks_returned: wantTasks ? tasks.length : 0 + }, + next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + kind: str4("all | projects | tasks (default all)"), + purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), + status: str4("Filter by status (e.g. active / paused)"), + q: str4("Keyword on name / ne_name / note / id"), + limit: num4("Max rows per list (default 100, max 500)") + }, listBizMonitors, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") }, getBizMonitorContext, getClient, t), diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 295479e..88d6662 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +function taskBriefNe(brief) { + const rec = asRecord(brief); + const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : ""; + const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : ""; + if (name && ip) + return `${name} (${ip})`; + return name || ip; +} +function slimMigrationProject(row) { + const mt = asRecord(row.monitor_template); + return { + id: row.id, + name: row.name, + status: row.status, + note: row.note, + monitor_template_id: row.monitor_template_id, + monitor_template_name: typeof mt.name === "string" ? mt.name : "", + old_task_id: row.old_task_id, + new_task_id: row.new_task_id, + old_hf_task_id: row.old_hf_task_id, + new_hf_task_id: row.new_hf_task_id, + old_ne: taskBriefNe(row.old_task), + new_ne: taskBriefNe(row.new_task), + hf_interval_sec: row.hf_interval_sec, + hf_start_at: row.hf_start_at, + hf_end_at: row.hf_end_at, + updated_at: row.updated_at + }; +} +function matchesBizMonitorQ(row, q) { + if (!q) + return true; + const hay = [ + row.id, + row.name, + row.note, + row.status, + row.ne_name, + row.ne_ip, + row.ne_id, + row.old_ne, + row.new_ne, + row.monitor_template_name, + row.purpose, + row.vendor + ].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" "); + return hay.includes(q); +} +async function listBizMonitors(client, args, signal) { + const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all"; + const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw; + if (kind !== "all" && kind !== "projects" && kind !== "tasks") { + return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" }; + } + const purpose = str2(args, "purpose").trim(); + const status = str2(args, "status").trim().toLowerCase(); + const q = str2(args, "q").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); + const wantProjects = kind === "all" || kind === "projects"; + const wantTasks = kind === "all" || kind === "tasks"; + const fetches = []; + if (wantProjects) + fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); + else + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + if (wantTasks) { + const params = {}; + if (purpose) + params.purpose = purpose; + fetches.push(client.get("/v1/biz-state/tasks", params, signal)); + } else { + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + } + const [projRes, taskRes] = await Promise.all(fetches); + if (wantProjects && projRes.ok === false) + return projRes; + if (wantTasks && taskRes.ok === false) + return taskRes; + const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : []; + const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; + let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + let tasks = taskItems.map((row) => asRecord(row)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + const projectsTotal = projects.length; + const tasksTotal = tasks.length; + projects = projects.slice(0, limit); + tasks = tasks.slice(0, limit); + return { + ok: true, + data: { + kind, + projects: wantProjects ? projects : undefined, + tasks: wantTasks ? tasks : undefined, + counts: { + projects: wantProjects ? projectsTotal : 0, + tasks: wantTasks ? tasksTotal : 0, + projects_returned: wantProjects ? projects.length : 0, + tasks_returned: wantTasks ? tasks.length : 0 + }, + next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + kind: str4("all | projects | tasks (default all)"), + purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), + status: str4("Filter by status (e.g. active / paused)"), + q: str4("Keyword on name / ne_name / note / id"), + limit: num4("Max rows per list (default 100, max 500)") + }, listBizMonitors, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") }, getBizMonitorContext, getClient, t), diff --git a/lib/agent-tools.js b/lib/agent-tools.js index ba073e2..3b49e7e 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -40,6 +40,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1469,6 +1470,116 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +function taskBriefNe(brief) { + const rec = asRecord(brief); + const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : ""; + const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : ""; + if (name && ip) + return `${name} (${ip})`; + return name || ip; +} +function slimMigrationProject(row) { + const mt = asRecord(row.monitor_template); + return { + id: row.id, + name: row.name, + status: row.status, + note: row.note, + monitor_template_id: row.monitor_template_id, + monitor_template_name: typeof mt.name === "string" ? mt.name : "", + old_task_id: row.old_task_id, + new_task_id: row.new_task_id, + old_hf_task_id: row.old_hf_task_id, + new_hf_task_id: row.new_hf_task_id, + old_ne: taskBriefNe(row.old_task), + new_ne: taskBriefNe(row.new_task), + hf_interval_sec: row.hf_interval_sec, + hf_start_at: row.hf_start_at, + hf_end_at: row.hf_end_at, + updated_at: row.updated_at + }; +} +function matchesBizMonitorQ(row, q) { + if (!q) + return true; + const hay = [ + row.id, + row.name, + row.note, + row.status, + row.ne_name, + row.ne_ip, + row.ne_id, + row.old_ne, + row.new_ne, + row.monitor_template_name, + row.purpose, + row.vendor + ].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" "); + return hay.includes(q); +} +async function listBizMonitors(client, args, signal) { + const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all"; + const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw; + if (kind !== "all" && kind !== "projects" && kind !== "tasks") { + return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" }; + } + const purpose = str2(args, "purpose").trim(); + const status = str2(args, "status").trim().toLowerCase(); + const q = str2(args, "q").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); + const wantProjects = kind === "all" || kind === "projects"; + const wantTasks = kind === "all" || kind === "tasks"; + const fetches = []; + if (wantProjects) + fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); + else + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + if (wantTasks) { + const params = {}; + if (purpose) + params.purpose = purpose; + fetches.push(client.get("/v1/biz-state/tasks", params, signal)); + } else { + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + } + const [projRes, taskRes] = await Promise.all(fetches); + if (wantProjects && projRes.ok === false) + return projRes; + if (wantTasks && taskRes.ok === false) + return taskRes; + const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : []; + const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; + let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + let tasks = taskItems.map((row) => asRecord(row)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + const projectsTotal = projects.length; + const tasksTotal = tasks.length; + projects = projects.slice(0, limit); + tasks = tasks.slice(0, limit); + return { + ok: true, + data: { + kind, + projects: wantProjects ? projects : undefined, + tasks: wantTasks ? tasks : undefined, + counts: { + projects: wantProjects ? projectsTotal : 0, + tasks: wantTasks ? tasksTotal : 0, + projects_returned: wantProjects ? projects.length : 0, + tasks_returned: wantTasks ? tasks.length : 0 + }, + next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -2355,7 +2466,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + kind: str4("all | projects | tasks (default all)"), + purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), + status: str4("Filter by status (e.g. active / paused)"), + q: str4("Keyword on name / ne_name / note / id"), + limit: num4("Max rows per list (default 100, max 500)") + }, listBizMonitors, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") }, getBizMonitorContext, getClient, t), diff --git a/lib/index.js b/lib/index.js index 6415f41..3a5ac0c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -611,6 +611,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ + "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -3728,6 +3729,116 @@ async function findTopologyPaths(client, args, signal) { body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +function taskBriefNe(brief) { + const rec = asRecord(brief); + const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : ""; + const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : ""; + if (name && ip) + return `${name} (${ip})`; + return name || ip; +} +function slimMigrationProject(row) { + const mt = asRecord(row.monitor_template); + return { + id: row.id, + name: row.name, + status: row.status, + note: row.note, + monitor_template_id: row.monitor_template_id, + monitor_template_name: typeof mt.name === "string" ? mt.name : "", + old_task_id: row.old_task_id, + new_task_id: row.new_task_id, + old_hf_task_id: row.old_hf_task_id, + new_hf_task_id: row.new_hf_task_id, + old_ne: taskBriefNe(row.old_task), + new_ne: taskBriefNe(row.new_task), + hf_interval_sec: row.hf_interval_sec, + hf_start_at: row.hf_start_at, + hf_end_at: row.hf_end_at, + updated_at: row.updated_at + }; +} +function matchesBizMonitorQ(row, q) { + if (!q) + return true; + const hay = [ + row.id, + row.name, + row.note, + row.status, + row.ne_name, + row.ne_ip, + row.ne_id, + row.old_ne, + row.new_ne, + row.monitor_template_name, + row.purpose, + row.vendor + ].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" "); + return hay.includes(q); +} +async function listBizMonitors(client, args, signal) { + const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all"; + const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw; + if (kind !== "all" && kind !== "projects" && kind !== "tasks") { + return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" }; + } + const purpose = str2(args, "purpose").trim(); + const status = str2(args, "status").trim().toLowerCase(); + const q = str2(args, "q").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); + const wantProjects = kind === "all" || kind === "projects"; + const wantTasks = kind === "all" || kind === "tasks"; + const fetches = []; + if (wantProjects) + fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); + else + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + if (wantTasks) { + const params = {}; + if (purpose) + params.purpose = purpose; + fetches.push(client.get("/v1/biz-state/tasks", params, signal)); + } else { + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); + } + const [projRes, taskRes] = await Promise.all(fetches); + if (wantProjects && projRes.ok === false) + return projRes; + if (wantTasks && taskRes.ok === false) + return taskRes; + const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : []; + const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; + let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + let tasks = taskItems.map((row) => asRecord(row)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return matchesBizMonitorQ(row, q); + }); + const projectsTotal = projects.length; + const tasksTotal = tasks.length; + projects = projects.slice(0, limit); + tasks = tasks.slice(0, limit); + return { + ok: true, + data: { + kind, + projects: wantProjects ? projects : undefined, + tasks: wantTasks ? tasks : undefined, + counts: { + projects: wantProjects ? projectsTotal : 0, + tasks: wantTasks ? tasksTotal : 0, + projects_returned: wantProjects ? projects.length : 0, + tasks_returned: wantTasks ? tasks.length : 0 + }, + next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + } + }; +} async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); @@ -4614,7 +4725,14 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + kind: str4("all | projects | tasks (default all)"), + purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), + status: str4("Filter by status (e.g. active / paused)"), + q: str4("Keyword on name / ne_name / note / id"), + limit: num4("Max rows per list (default 100, max 500)") + }, listBizMonitors, getClient, t), + tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") }, getBizMonitorContext, getClient, t), diff --git a/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md b/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md index 76dd1fd..e3fa593 100644 --- a/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md +++ b/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md @@ -2,20 +2,22 @@ name: netx-biz-monitor description: >- Overnight cutover / biz_state monitor analysis via netxops host tools - (netx__getBizMonitor*). Given a project_id or task_id, read definitions, - board, reds/diffs with evidence (device-raw A/B, show commands), and judge - tool false-positive vs real business fault. Does not create templates/tasks. + (netx__listBizMonitors / netx__getBizMonitor*). List projects/tasks first + when no id is given; then read definitions, board, reds/diffs with evidence + (device-raw A/B, show commands), and judge tool false-positive vs real + business fault. Does not create templates/tasks. --- # netx-biz-monitor(割接 / 业务监控只读分析) -人配好监控与任务;AI 当晚用 **project_id**(或 task_id)做归因。 +人配好监控与任务;AI 当晚做归因。 工具在 **netxops**(`netx__*`),直连 netx REST,**不是** netx-mcp。 ## 工具(能力组 `bizMonitor`) | 工具 | 用途 | |------|------| +| `netx__listBizMonitors` | **入口目录**:列出割接/监控对比项目 + 业务监控任务(**无需** project_id) | | `netx__getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 | | `netx__getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 | | `netx__listBizMonitorReds` | 红单 + **evidence** | @@ -25,7 +27,7 @@ description: >- 交叉验证真障时再用 ops:`netx__execManagedNe` / 拓扑路径。 -## 关键约定(必守) +## 字段约定(必守) - **`old_key` / `new_key`**:设备采集原文 **A/B**(大屏与结论用这个) - **`match_*` / evidence.iface.normalized|map_***:内部配对,**不要**把映射后的 BB 说成两侧端口 @@ -34,12 +36,13 @@ description: >- ## 推荐顺序 -1. `getBizMonitorContext(project_id)` — 弄清盯什么 -2. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped -3. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)` -4. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤? -5. 需要原文 → `getBizCollectCommandRaw` -6. 像真障 → `execManagedNe` 现场核对 +1. 无 id → `listBizMonitors`(`kind=projects|tasks|all`,可加 `q` / `purpose`) +2. `getBizMonitorContext(project_id)` — 弄清盯什么 +3. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped +4. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)` +5. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤? +6. 需要原文 → `getBizCollectCommandRaw` +7. 像真障 → `execManagedNe` 现场核对 ## Verdict 速查(skill 解释,工具不硬编码) diff --git a/src/netx/capability-groups.ts b/src/netx/capability-groups.ts index faa15f6..0312125 100644 --- a/src/netx/capability-groups.ts +++ b/src/netx/capability-groups.ts @@ -81,6 +81,7 @@ export const TOOLS_BY_GROUP: Readonly (typeof v === 'string' || typeof v === 'number' ? String(v).toLowerCase() : '')) + .join(' ') + return hay.includes(q) +} + +/** + * Catalog entry: list cutover projects and/or biz_state tasks (no project_id required). + * Use this first when the user did not give an id; then call getBizMonitorContext. + */ +export async function listBizMonitors( + client: NetxClient, + args: NetxJson, + signal?: AbortSignal, +): Promise { + const kindRaw = str(args, 'kind', 'all').trim().toLowerCase() || 'all' + const kind = kindRaw === 'project' ? 'projects' + : kindRaw === 'task' ? 'tasks' + : kindRaw + if (kind !== 'all' && kind !== 'projects' && kind !== 'tasks') { + return { ok: false, error: 'kind_invalid', detail: 'kind must be all|projects|tasks' } + } + const purpose = str(args, 'purpose').trim() + const status = str(args, 'status').trim().toLowerCase() + const q = str(args, 'q').trim().toLowerCase() + const limit = clampInt(num(args, 'limit') ?? 100, 100, 1, 500) + + const wantProjects = kind === 'all' || kind === 'projects' + const wantTasks = kind === 'all' || kind === 'tasks' + + const fetches: Array> = [] + if (wantProjects) fetches.push(client.get('/v1/biz-migration/projects', {}, signal)) + else fetches.push(Promise.resolve({ ok: true, data: { items: [] } })) + if (wantTasks) { + const params: Record = {} + if (purpose) params.purpose = purpose + fetches.push(client.get('/v1/biz-state/tasks', params, signal)) + } else { + fetches.push(Promise.resolve({ ok: true, data: { items: [] } })) + } + + const [projRes, taskRes] = await Promise.all(fetches) + if (wantProjects && projRes.ok === false) return projRes + if (wantTasks && taskRes.ok === false) return taskRes + + const projItems = Array.isArray(asRecord(projRes.data).items) + ? (asRecord(projRes.data).items as unknown[]) + : [] + const taskItems = Array.isArray(asRecord(taskRes.data).items) + ? (asRecord(taskRes.data).items as unknown[]) + : [] + + let projects = projItems + .map((row) => slimMigrationProject(asRecord(row))) + .filter((row) => { + if (status && String(row.status || '').toLowerCase() !== status) return false + return matchesBizMonitorQ(row, q) + }) + let tasks = taskItems + .map((row) => asRecord(row)) + .filter((row) => { + if (status && String(row.status || '').toLowerCase() !== status) return false + return matchesBizMonitorQ(row, q) + }) + + const projectsTotal = projects.length + const tasksTotal = tasks.length + projects = projects.slice(0, limit) + tasks = tasks.slice(0, limit) + + return { + ok: true, + data: { + kind, + projects: wantProjects ? projects : undefined, + tasks: wantTasks ? tasks : undefined, + counts: { + projects: wantProjects ? projectsTotal : 0, + tasks: wantTasks ? tasksTotal : 0, + projects_returned: wantProjects ? projects.length : 0, + tasks_returned: wantTasks ? tasks.length : 0, + }, + next: 'Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle.', + }, + } +} + /** Fat cutover / biz_state definition bundle (templates, mapping, tasks). */ export async function getBizMonitorContext( client: NetxClient, diff --git a/src/netx/tools.ts b/src/netx/tools.ts index d4e4f7e..baaf53b 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -317,9 +317,21 @@ export function registerNetxTools( H.findTopologyPaths, getClient, t, ), // ── bizMonitor group (cutover / biz_state read via netx REST) ───────── + tool( + 'netx__listBizMonitors', + 'Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.', + { + kind: str('all | projects | tasks (default all)'), + purpose: str('Filter tasks: portrait | cutover_hf | empty=all'), + status: str('Filter by status (e.g. active / paused)'), + q: str('Keyword on name / ne_name / note / id'), + limit: num('Max rows per list (default 100, max 500)'), + }, + H.listBizMonitors, getClient, t, + ), tool( 'netx__getBizMonitorContext', - 'Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. Display keys are device-raw A/B.', + 'Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.', { project_id: str('Cutover / biz-migration project id'), task_id: str('biz_state task id (optional if project_id set)'), From b235957c34d1d255ec9b436baebf3410c428fae1 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 20 Sep 2026 15:26:46 +0800 Subject: [PATCH 09/10] Add listBizMonitorBatches so agents can discover cutover/collect batch and run ids. Co-authored-by: Cursor --- docs/TOOL_MAP.md | 1 + lib/agent-tools-biz-monitor.js | 184 ++++++++++++++- lib/agent-tools-common.js | 184 ++++++++++++++- lib/agent-tools-nms.js | 184 ++++++++++++++- lib/agent-tools-ops.js | 184 ++++++++++++++- lib/agent-tools-topology.js | 184 ++++++++++++++- lib/agent-tools.js | 184 ++++++++++++++- lib/index.js | 184 ++++++++++++++- .../biz-monitor/netx-biz-monitor/SKILL.md | 22 +- src/netx/capability-groups.ts | 1 + src/netx/handlers.ts | 215 +++++++++++++++++- src/netx/tools.ts | 17 +- 12 files changed, 1518 insertions(+), 26 deletions(-) diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 5df6038..0bcaa34 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -28,6 +28,7 @@ Host tools call netx REST directly (**not** netx-mcp). Token needs `biz-monitor: | Tool | Role | |------|------| | `listBizMonitors` | Catalog: cutover projects + biz_state tasks (no id required) | +| `listBizMonitorBatches` | Cutover / collect batch ids (+ optional evaluate runs) | | `getBizMonitorContext` | Project + templates + normalize + port map + tasks | | `getBizMonitorBoard` | Batch / evaluate progress | | `listBizMonitorReds` / `getBizMonitorDiffs` | Reds & diffs with evidence (raw A/B + show cmd) | diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index f8a36d9..b31986f 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { projects_returned: wantProjects ? projects.length : 0, tasks_returned: wantTasks ? tasks.length : 0 }, - next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." + } + }; +} +function slimCutoverBatch(row) { + return { + id: row.id, + project_id: row.project_id, + batch_label: row.batch_label, + status: row.status, + accept_status: row.accept_status, + accept_run_id: row.accept_run_id, + started_at: row.started_at, + ended_at: row.ended_at, + note: row.note, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +function slimCollectBatch(row, taskId = "") { + return { + id: row.id, + task_id: row.task_id || taskId, + status: row.status, + ne_name: row.ne_name, + command_count: row.command_count, + row_count: row.row_count, + is_baseline: row.is_baseline, + protected: row.protected, + message: row.message, + started_at: row.started_at, + ended_at: row.ended_at + }; +} +function slimEvalRun(row) { + const summary = asRecord(row.summary); + const progress = asRecord(summary.progress); + return { + id: row.id, + batch_id: row.batch_id, + purpose: row.purpose, + old_batch_id: row.old_batch_id, + new_batch_id: row.new_batch_id, + created_at: row.created_at, + anomaly: summary.anomaly, + progress_ok: progress.ok ?? summary.progress_ok, + progress_total: progress.total ?? summary.progress_total + }; +} +async function listBizMonitorBatches(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + const batchId = str2(args, "batch_id").trim(); + const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; + const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; + const status = str2(args, "status").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); + const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50); + let kind = kindRaw; + if (kind === "auto") { + if (batchId && !projectId && !taskId) + kind = "runs"; + else if (projectId && !taskId) + kind = "cutover"; + else if (taskId && !projectId) + kind = "collect"; + else if (projectId && taskId) + kind = "all"; + else if (batchId) + kind = "runs"; + else { + return { + ok: false, + error: "project_id_or_task_id_or_batch_id_required", + detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)." + }; + } + } + if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) { + return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" }; + } + const wantCutover = kind === "all" || kind === "cutover"; + const wantCollect = kind === "all" || kind === "collect"; + const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId)); + if (wantCutover && !projectId) { + return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" }; + } + if (wantCollect && !taskId) { + return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" }; + } + if (kind === "runs" && !batchId) { + return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" }; + } + let cutoverBatches = []; + let collectBatches = []; + let runs = []; + if (wantCutover) { + const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => { + if (batchId && String(row.id || "") !== batchId) + return false; + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantCollect) { + const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantRuns) { + const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0); + if (kind === "runs" && batchId) { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit); + } else if (includeRuns && runBatchIds.length > 0) { + const settled = await Promise.all(runBatchIds.map(async (id) => { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return { batch_id: id, error: res, items: [] }; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + return { + batch_id: id, + items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit) + }; + })); + for (const block of settled) { + if ("error" in block && block.error) { + const failed = cutoverBatches.find((b) => b.id === block.batch_id); + if (failed) + failed.runs_error = asRecord(block.error).error || "runs_fetch_failed"; + continue; + } + const target = cutoverBatches.find((b) => b.id === block.batch_id); + if (target) + target.runs = block.items; + for (const r of block.items) + runs.push(r); + } + } + } + return { + ok: true, + data: { + kind, + project_id: projectId || undefined, + task_id: taskId || undefined, + batch_id: batchId || undefined, + cutover_batches: wantCutover ? cutoverBatches : undefined, + collect_batches: wantCollect ? collectBatches : undefined, + runs: kind === "runs" ? runs : undefined, + counts: { + cutover_batches: cutoverBatches.length, + collect_batches: collectBatches.length, + runs: runs.length + }, + next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", { kind: str4("all | projects | tasks (default all)"), purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), status: str4("Filter by status (e.g. active / paused)"), q: str4("Keyword on name / ne_name / note / id"), limit: num4("Max rows per list (default 100, max 500)") }, listBizMonitors, getClient, t), + tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", { + kind: str4("auto | all | cutover | collect | runs (default auto)"), + project_id: str4("Cutover project id → cutover_batches"), + task_id: str4("biz_state task id → collect_batches"), + batch_id: str4("Cutover batch id (filter or kind=runs)"), + include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), + status: str4("Filter batch status"), + limit: num4("Max batches (default 50)"), + runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") + }, listBizMonitorBatches, getClient, t), tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index e2db01e..f711889 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { projects_returned: wantProjects ? projects.length : 0, tasks_returned: wantTasks ? tasks.length : 0 }, - next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." + } + }; +} +function slimCutoverBatch(row) { + return { + id: row.id, + project_id: row.project_id, + batch_label: row.batch_label, + status: row.status, + accept_status: row.accept_status, + accept_run_id: row.accept_run_id, + started_at: row.started_at, + ended_at: row.ended_at, + note: row.note, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +function slimCollectBatch(row, taskId = "") { + return { + id: row.id, + task_id: row.task_id || taskId, + status: row.status, + ne_name: row.ne_name, + command_count: row.command_count, + row_count: row.row_count, + is_baseline: row.is_baseline, + protected: row.protected, + message: row.message, + started_at: row.started_at, + ended_at: row.ended_at + }; +} +function slimEvalRun(row) { + const summary = asRecord(row.summary); + const progress = asRecord(summary.progress); + return { + id: row.id, + batch_id: row.batch_id, + purpose: row.purpose, + old_batch_id: row.old_batch_id, + new_batch_id: row.new_batch_id, + created_at: row.created_at, + anomaly: summary.anomaly, + progress_ok: progress.ok ?? summary.progress_ok, + progress_total: progress.total ?? summary.progress_total + }; +} +async function listBizMonitorBatches(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + const batchId = str2(args, "batch_id").trim(); + const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; + const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; + const status = str2(args, "status").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); + const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50); + let kind = kindRaw; + if (kind === "auto") { + if (batchId && !projectId && !taskId) + kind = "runs"; + else if (projectId && !taskId) + kind = "cutover"; + else if (taskId && !projectId) + kind = "collect"; + else if (projectId && taskId) + kind = "all"; + else if (batchId) + kind = "runs"; + else { + return { + ok: false, + error: "project_id_or_task_id_or_batch_id_required", + detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)." + }; + } + } + if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) { + return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" }; + } + const wantCutover = kind === "all" || kind === "cutover"; + const wantCollect = kind === "all" || kind === "collect"; + const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId)); + if (wantCutover && !projectId) { + return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" }; + } + if (wantCollect && !taskId) { + return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" }; + } + if (kind === "runs" && !batchId) { + return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" }; + } + let cutoverBatches = []; + let collectBatches = []; + let runs = []; + if (wantCutover) { + const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => { + if (batchId && String(row.id || "") !== batchId) + return false; + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantCollect) { + const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantRuns) { + const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0); + if (kind === "runs" && batchId) { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit); + } else if (includeRuns && runBatchIds.length > 0) { + const settled = await Promise.all(runBatchIds.map(async (id) => { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return { batch_id: id, error: res, items: [] }; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + return { + batch_id: id, + items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit) + }; + })); + for (const block of settled) { + if ("error" in block && block.error) { + const failed = cutoverBatches.find((b) => b.id === block.batch_id); + if (failed) + failed.runs_error = asRecord(block.error).error || "runs_fetch_failed"; + continue; + } + const target = cutoverBatches.find((b) => b.id === block.batch_id); + if (target) + target.runs = block.items; + for (const r of block.items) + runs.push(r); + } + } + } + return { + ok: true, + data: { + kind, + project_id: projectId || undefined, + task_id: taskId || undefined, + batch_id: batchId || undefined, + cutover_batches: wantCutover ? cutoverBatches : undefined, + collect_batches: wantCollect ? collectBatches : undefined, + runs: kind === "runs" ? runs : undefined, + counts: { + cutover_batches: cutoverBatches.length, + collect_batches: collectBatches.length, + runs: runs.length + }, + next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", { kind: str4("all | projects | tasks (default all)"), purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), status: str4("Filter by status (e.g. active / paused)"), q: str4("Keyword on name / ne_name / note / id"), limit: num4("Max rows per list (default 100, max 500)") }, listBizMonitors, getClient, t), + tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", { + kind: str4("auto | all | cutover | collect | runs (default auto)"), + project_id: str4("Cutover project id → cutover_batches"), + task_id: str4("biz_state task id → collect_batches"), + batch_id: str4("Cutover batch id (filter or kind=runs)"), + include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), + status: str4("Filter batch status"), + limit: num4("Max batches (default 50)"), + runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") + }, listBizMonitorBatches, getClient, t), tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index e026c33..5d3c406 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { projects_returned: wantProjects ? projects.length : 0, tasks_returned: wantTasks ? tasks.length : 0 }, - next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." + } + }; +} +function slimCutoverBatch(row) { + return { + id: row.id, + project_id: row.project_id, + batch_label: row.batch_label, + status: row.status, + accept_status: row.accept_status, + accept_run_id: row.accept_run_id, + started_at: row.started_at, + ended_at: row.ended_at, + note: row.note, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +function slimCollectBatch(row, taskId = "") { + return { + id: row.id, + task_id: row.task_id || taskId, + status: row.status, + ne_name: row.ne_name, + command_count: row.command_count, + row_count: row.row_count, + is_baseline: row.is_baseline, + protected: row.protected, + message: row.message, + started_at: row.started_at, + ended_at: row.ended_at + }; +} +function slimEvalRun(row) { + const summary = asRecord(row.summary); + const progress = asRecord(summary.progress); + return { + id: row.id, + batch_id: row.batch_id, + purpose: row.purpose, + old_batch_id: row.old_batch_id, + new_batch_id: row.new_batch_id, + created_at: row.created_at, + anomaly: summary.anomaly, + progress_ok: progress.ok ?? summary.progress_ok, + progress_total: progress.total ?? summary.progress_total + }; +} +async function listBizMonitorBatches(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + const batchId = str2(args, "batch_id").trim(); + const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; + const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; + const status = str2(args, "status").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); + const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50); + let kind = kindRaw; + if (kind === "auto") { + if (batchId && !projectId && !taskId) + kind = "runs"; + else if (projectId && !taskId) + kind = "cutover"; + else if (taskId && !projectId) + kind = "collect"; + else if (projectId && taskId) + kind = "all"; + else if (batchId) + kind = "runs"; + else { + return { + ok: false, + error: "project_id_or_task_id_or_batch_id_required", + detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)." + }; + } + } + if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) { + return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" }; + } + const wantCutover = kind === "all" || kind === "cutover"; + const wantCollect = kind === "all" || kind === "collect"; + const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId)); + if (wantCutover && !projectId) { + return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" }; + } + if (wantCollect && !taskId) { + return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" }; + } + if (kind === "runs" && !batchId) { + return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" }; + } + let cutoverBatches = []; + let collectBatches = []; + let runs = []; + if (wantCutover) { + const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => { + if (batchId && String(row.id || "") !== batchId) + return false; + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantCollect) { + const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantRuns) { + const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0); + if (kind === "runs" && batchId) { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit); + } else if (includeRuns && runBatchIds.length > 0) { + const settled = await Promise.all(runBatchIds.map(async (id) => { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return { batch_id: id, error: res, items: [] }; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + return { + batch_id: id, + items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit) + }; + })); + for (const block of settled) { + if ("error" in block && block.error) { + const failed = cutoverBatches.find((b) => b.id === block.batch_id); + if (failed) + failed.runs_error = asRecord(block.error).error || "runs_fetch_failed"; + continue; + } + const target = cutoverBatches.find((b) => b.id === block.batch_id); + if (target) + target.runs = block.items; + for (const r of block.items) + runs.push(r); + } + } + } + return { + ok: true, + data: { + kind, + project_id: projectId || undefined, + task_id: taskId || undefined, + batch_id: batchId || undefined, + cutover_batches: wantCutover ? cutoverBatches : undefined, + collect_batches: wantCollect ? collectBatches : undefined, + runs: kind === "runs" ? runs : undefined, + counts: { + cutover_batches: cutoverBatches.length, + collect_batches: collectBatches.length, + runs: runs.length + }, + next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", { kind: str4("all | projects | tasks (default all)"), purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), status: str4("Filter by status (e.g. active / paused)"), q: str4("Keyword on name / ne_name / note / id"), limit: num4("Max rows per list (default 100, max 500)") }, listBizMonitors, getClient, t), + tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", { + kind: str4("auto | all | cutover | collect | runs (default auto)"), + project_id: str4("Cutover project id → cutover_batches"), + task_id: str4("biz_state task id → collect_batches"), + batch_id: str4("Cutover batch id (filter or kind=runs)"), + include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), + status: str4("Filter batch status"), + limit: num4("Max batches (default 50)"), + runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") + }, listBizMonitorBatches, getClient, t), tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 8d6213d..3687d5a 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { projects_returned: wantProjects ? projects.length : 0, tasks_returned: wantTasks ? tasks.length : 0 }, - next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." + } + }; +} +function slimCutoverBatch(row) { + return { + id: row.id, + project_id: row.project_id, + batch_label: row.batch_label, + status: row.status, + accept_status: row.accept_status, + accept_run_id: row.accept_run_id, + started_at: row.started_at, + ended_at: row.ended_at, + note: row.note, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +function slimCollectBatch(row, taskId = "") { + return { + id: row.id, + task_id: row.task_id || taskId, + status: row.status, + ne_name: row.ne_name, + command_count: row.command_count, + row_count: row.row_count, + is_baseline: row.is_baseline, + protected: row.protected, + message: row.message, + started_at: row.started_at, + ended_at: row.ended_at + }; +} +function slimEvalRun(row) { + const summary = asRecord(row.summary); + const progress = asRecord(summary.progress); + return { + id: row.id, + batch_id: row.batch_id, + purpose: row.purpose, + old_batch_id: row.old_batch_id, + new_batch_id: row.new_batch_id, + created_at: row.created_at, + anomaly: summary.anomaly, + progress_ok: progress.ok ?? summary.progress_ok, + progress_total: progress.total ?? summary.progress_total + }; +} +async function listBizMonitorBatches(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + const batchId = str2(args, "batch_id").trim(); + const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; + const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; + const status = str2(args, "status").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); + const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50); + let kind = kindRaw; + if (kind === "auto") { + if (batchId && !projectId && !taskId) + kind = "runs"; + else if (projectId && !taskId) + kind = "cutover"; + else if (taskId && !projectId) + kind = "collect"; + else if (projectId && taskId) + kind = "all"; + else if (batchId) + kind = "runs"; + else { + return { + ok: false, + error: "project_id_or_task_id_or_batch_id_required", + detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)." + }; + } + } + if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) { + return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" }; + } + const wantCutover = kind === "all" || kind === "cutover"; + const wantCollect = kind === "all" || kind === "collect"; + const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId)); + if (wantCutover && !projectId) { + return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" }; + } + if (wantCollect && !taskId) { + return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" }; + } + if (kind === "runs" && !batchId) { + return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" }; + } + let cutoverBatches = []; + let collectBatches = []; + let runs = []; + if (wantCutover) { + const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => { + if (batchId && String(row.id || "") !== batchId) + return false; + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantCollect) { + const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantRuns) { + const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0); + if (kind === "runs" && batchId) { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit); + } else if (includeRuns && runBatchIds.length > 0) { + const settled = await Promise.all(runBatchIds.map(async (id) => { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return { batch_id: id, error: res, items: [] }; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + return { + batch_id: id, + items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit) + }; + })); + for (const block of settled) { + if ("error" in block && block.error) { + const failed = cutoverBatches.find((b) => b.id === block.batch_id); + if (failed) + failed.runs_error = asRecord(block.error).error || "runs_fetch_failed"; + continue; + } + const target = cutoverBatches.find((b) => b.id === block.batch_id); + if (target) + target.runs = block.items; + for (const r of block.items) + runs.push(r); + } + } + } + return { + ok: true, + data: { + kind, + project_id: projectId || undefined, + task_id: taskId || undefined, + batch_id: batchId || undefined, + cutover_batches: wantCutover ? cutoverBatches : undefined, + collect_batches: wantCollect ? collectBatches : undefined, + runs: kind === "runs" ? runs : undefined, + counts: { + cutover_batches: cutoverBatches.length, + collect_batches: collectBatches.length, + runs: runs.length + }, + next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", { kind: str4("all | projects | tasks (default all)"), purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), status: str4("Filter by status (e.g. active / paused)"), q: str4("Keyword on name / ne_name / note / id"), limit: num4("Max rows per list (default 100, max 500)") }, listBizMonitors, getClient, t), + tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", { + kind: str4("auto | all | cutover | collect | runs (default auto)"), + project_id: str4("Cutover project id → cutover_batches"), + task_id: str4("biz_state task id → collect_batches"), + batch_id: str4("Cutover batch id (filter or kind=runs)"), + include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), + status: str4("Filter batch status"), + limit: num4("Max batches (default 50)"), + runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") + }, listBizMonitorBatches, getClient, t), tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 88d6662..5c01f5c 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { projects_returned: wantProjects ? projects.length : 0, tasks_returned: wantTasks ? tasks.length : 0 }, - next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." + } + }; +} +function slimCutoverBatch(row) { + return { + id: row.id, + project_id: row.project_id, + batch_label: row.batch_label, + status: row.status, + accept_status: row.accept_status, + accept_run_id: row.accept_run_id, + started_at: row.started_at, + ended_at: row.ended_at, + note: row.note, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +function slimCollectBatch(row, taskId = "") { + return { + id: row.id, + task_id: row.task_id || taskId, + status: row.status, + ne_name: row.ne_name, + command_count: row.command_count, + row_count: row.row_count, + is_baseline: row.is_baseline, + protected: row.protected, + message: row.message, + started_at: row.started_at, + ended_at: row.ended_at + }; +} +function slimEvalRun(row) { + const summary = asRecord(row.summary); + const progress = asRecord(summary.progress); + return { + id: row.id, + batch_id: row.batch_id, + purpose: row.purpose, + old_batch_id: row.old_batch_id, + new_batch_id: row.new_batch_id, + created_at: row.created_at, + anomaly: summary.anomaly, + progress_ok: progress.ok ?? summary.progress_ok, + progress_total: progress.total ?? summary.progress_total + }; +} +async function listBizMonitorBatches(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + const batchId = str2(args, "batch_id").trim(); + const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; + const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; + const status = str2(args, "status").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); + const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50); + let kind = kindRaw; + if (kind === "auto") { + if (batchId && !projectId && !taskId) + kind = "runs"; + else if (projectId && !taskId) + kind = "cutover"; + else if (taskId && !projectId) + kind = "collect"; + else if (projectId && taskId) + kind = "all"; + else if (batchId) + kind = "runs"; + else { + return { + ok: false, + error: "project_id_or_task_id_or_batch_id_required", + detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)." + }; + } + } + if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) { + return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" }; + } + const wantCutover = kind === "all" || kind === "cutover"; + const wantCollect = kind === "all" || kind === "collect"; + const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId)); + if (wantCutover && !projectId) { + return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" }; + } + if (wantCollect && !taskId) { + return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" }; + } + if (kind === "runs" && !batchId) { + return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" }; + } + let cutoverBatches = []; + let collectBatches = []; + let runs = []; + if (wantCutover) { + const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => { + if (batchId && String(row.id || "") !== batchId) + return false; + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantCollect) { + const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantRuns) { + const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0); + if (kind === "runs" && batchId) { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit); + } else if (includeRuns && runBatchIds.length > 0) { + const settled = await Promise.all(runBatchIds.map(async (id) => { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return { batch_id: id, error: res, items: [] }; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + return { + batch_id: id, + items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit) + }; + })); + for (const block of settled) { + if ("error" in block && block.error) { + const failed = cutoverBatches.find((b) => b.id === block.batch_id); + if (failed) + failed.runs_error = asRecord(block.error).error || "runs_fetch_failed"; + continue; + } + const target = cutoverBatches.find((b) => b.id === block.batch_id); + if (target) + target.runs = block.items; + for (const r of block.items) + runs.push(r); + } + } + } + return { + ok: true, + data: { + kind, + project_id: projectId || undefined, + task_id: taskId || undefined, + batch_id: batchId || undefined, + cutover_batches: wantCutover ? cutoverBatches : undefined, + collect_batches: wantCollect ? collectBatches : undefined, + runs: kind === "runs" ? runs : undefined, + counts: { + cutover_batches: cutoverBatches.length, + collect_batches: collectBatches.length, + runs: runs.length + }, + next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", { kind: str4("all | projects | tasks (default all)"), purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), status: str4("Filter by status (e.g. active / paused)"), q: str4("Keyword on name / ne_name / note / id"), limit: num4("Max rows per list (default 100, max 500)") }, listBizMonitors, getClient, t), + tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", { + kind: str4("auto | all | cutover | collect | runs (default auto)"), + project_id: str4("Cutover project id → cutover_batches"), + task_id: str4("biz_state task id → collect_batches"), + batch_id: str4("Cutover batch id (filter or kind=runs)"), + include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), + status: str4("Filter batch status"), + limit: num4("Max batches (default 50)"), + runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") + }, listBizMonitorBatches, getClient, t), tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 3b49e7e..2ca382a 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -41,6 +41,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -1576,7 +1577,176 @@ async function listBizMonitors(client, args, signal) { projects_returned: wantProjects ? projects.length : 0, tasks_returned: wantTasks ? tasks.length : 0 }, - next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." + } + }; +} +function slimCutoverBatch(row) { + return { + id: row.id, + project_id: row.project_id, + batch_label: row.batch_label, + status: row.status, + accept_status: row.accept_status, + accept_run_id: row.accept_run_id, + started_at: row.started_at, + ended_at: row.ended_at, + note: row.note, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +function slimCollectBatch(row, taskId = "") { + return { + id: row.id, + task_id: row.task_id || taskId, + status: row.status, + ne_name: row.ne_name, + command_count: row.command_count, + row_count: row.row_count, + is_baseline: row.is_baseline, + protected: row.protected, + message: row.message, + started_at: row.started_at, + ended_at: row.ended_at + }; +} +function slimEvalRun(row) { + const summary = asRecord(row.summary); + const progress = asRecord(summary.progress); + return { + id: row.id, + batch_id: row.batch_id, + purpose: row.purpose, + old_batch_id: row.old_batch_id, + new_batch_id: row.new_batch_id, + created_at: row.created_at, + anomaly: summary.anomaly, + progress_ok: progress.ok ?? summary.progress_ok, + progress_total: progress.total ?? summary.progress_total + }; +} +async function listBizMonitorBatches(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + const batchId = str2(args, "batch_id").trim(); + const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; + const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; + const status = str2(args, "status").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); + const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50); + let kind = kindRaw; + if (kind === "auto") { + if (batchId && !projectId && !taskId) + kind = "runs"; + else if (projectId && !taskId) + kind = "cutover"; + else if (taskId && !projectId) + kind = "collect"; + else if (projectId && taskId) + kind = "all"; + else if (batchId) + kind = "runs"; + else { + return { + ok: false, + error: "project_id_or_task_id_or_batch_id_required", + detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)." + }; + } + } + if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) { + return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" }; + } + const wantCutover = kind === "all" || kind === "cutover"; + const wantCollect = kind === "all" || kind === "collect"; + const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId)); + if (wantCutover && !projectId) { + return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" }; + } + if (wantCollect && !taskId) { + return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" }; + } + if (kind === "runs" && !batchId) { + return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" }; + } + let cutoverBatches = []; + let collectBatches = []; + let runs = []; + if (wantCutover) { + const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => { + if (batchId && String(row.id || "") !== batchId) + return false; + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantCollect) { + const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantRuns) { + const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0); + if (kind === "runs" && batchId) { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit); + } else if (includeRuns && runBatchIds.length > 0) { + const settled = await Promise.all(runBatchIds.map(async (id) => { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return { batch_id: id, error: res, items: [] }; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + return { + batch_id: id, + items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit) + }; + })); + for (const block of settled) { + if ("error" in block && block.error) { + const failed = cutoverBatches.find((b) => b.id === block.batch_id); + if (failed) + failed.runs_error = asRecord(block.error).error || "runs_fetch_failed"; + continue; + } + const target = cutoverBatches.find((b) => b.id === block.batch_id); + if (target) + target.runs = block.items; + for (const r of block.items) + runs.push(r); + } + } + } + return { + ok: true, + data: { + kind, + project_id: projectId || undefined, + task_id: taskId || undefined, + batch_id: batchId || undefined, + cutover_batches: wantCutover ? cutoverBatches : undefined, + collect_batches: wantCollect ? collectBatches : undefined, + runs: kind === "runs" ? runs : undefined, + counts: { + cutover_batches: cutoverBatches.length, + collect_batches: collectBatches.length, + runs: runs.length + }, + next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." } }; } @@ -2466,13 +2636,23 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", { kind: str4("all | projects | tasks (default all)"), purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), status: str4("Filter by status (e.g. active / paused)"), q: str4("Keyword on name / ne_name / note / id"), limit: num4("Max rows per list (default 100, max 500)") }, listBizMonitors, getClient, t), + tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", { + kind: str4("auto | all | cutover | collect | runs (default auto)"), + project_id: str4("Cutover project id → cutover_batches"), + task_id: str4("biz_state task id → collect_batches"), + batch_id: str4("Cutover batch id (filter or kind=runs)"), + include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), + status: str4("Filter batch status"), + limit: num4("Max batches (default 50)"), + runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") + }, listBizMonitorBatches, getClient, t), tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") diff --git a/lib/index.js b/lib/index.js index 3a5ac0c..d11f6e7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -612,6 +612,7 @@ var TOOLS_BY_GROUP = Object.freeze({ ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", + "netx__listBizMonitorBatches", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", @@ -3835,7 +3836,176 @@ async function listBizMonitors(client, args, signal) { projects_returned: wantProjects ? projects.length : 0, tasks_returned: wantTasks ? tasks.length : 0 }, - next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." + next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." + } + }; +} +function slimCutoverBatch(row) { + return { + id: row.id, + project_id: row.project_id, + batch_label: row.batch_label, + status: row.status, + accept_status: row.accept_status, + accept_run_id: row.accept_run_id, + started_at: row.started_at, + ended_at: row.ended_at, + note: row.note, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +function slimCollectBatch(row, taskId = "") { + return { + id: row.id, + task_id: row.task_id || taskId, + status: row.status, + ne_name: row.ne_name, + command_count: row.command_count, + row_count: row.row_count, + is_baseline: row.is_baseline, + protected: row.protected, + message: row.message, + started_at: row.started_at, + ended_at: row.ended_at + }; +} +function slimEvalRun(row) { + const summary = asRecord(row.summary); + const progress = asRecord(summary.progress); + return { + id: row.id, + batch_id: row.batch_id, + purpose: row.purpose, + old_batch_id: row.old_batch_id, + new_batch_id: row.new_batch_id, + created_at: row.created_at, + anomaly: summary.anomaly, + progress_ok: progress.ok ?? summary.progress_ok, + progress_total: progress.total ?? summary.progress_total + }; +} +async function listBizMonitorBatches(client, args, signal) { + const projectId = str2(args, "project_id").trim(); + const taskId = str2(args, "task_id").trim(); + const batchId = str2(args, "batch_id").trim(); + const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; + const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; + const status = str2(args, "status").trim().toLowerCase(); + const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); + const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50); + let kind = kindRaw; + if (kind === "auto") { + if (batchId && !projectId && !taskId) + kind = "runs"; + else if (projectId && !taskId) + kind = "cutover"; + else if (taskId && !projectId) + kind = "collect"; + else if (projectId && taskId) + kind = "all"; + else if (batchId) + kind = "runs"; + else { + return { + ok: false, + error: "project_id_or_task_id_or_batch_id_required", + detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)." + }; + } + } + if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) { + return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" }; + } + const wantCutover = kind === "all" || kind === "cutover"; + const wantCollect = kind === "all" || kind === "collect"; + const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId)); + if (wantCutover && !projectId) { + return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" }; + } + if (wantCollect && !taskId) { + return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" }; + } + if (kind === "runs" && !batchId) { + return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" }; + } + let cutoverBatches = []; + let collectBatches = []; + let runs = []; + if (wantCutover) { + const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => { + if (batchId && String(row.id || "") !== batchId) + return false; + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantCollect) { + const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => { + if (status && String(row.status || "").toLowerCase() !== status) + return false; + return true; + }).slice(0, limit); + } + if (wantRuns) { + const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0); + if (kind === "runs" && batchId) { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return res; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit); + } else if (includeRuns && runBatchIds.length > 0) { + const settled = await Promise.all(runBatchIds.map(async (id) => { + const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal); + if (res.ok === false) + return { batch_id: id, error: res, items: [] }; + const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; + return { + batch_id: id, + items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit) + }; + })); + for (const block of settled) { + if ("error" in block && block.error) { + const failed = cutoverBatches.find((b) => b.id === block.batch_id); + if (failed) + failed.runs_error = asRecord(block.error).error || "runs_fetch_failed"; + continue; + } + const target = cutoverBatches.find((b) => b.id === block.batch_id); + if (target) + target.runs = block.items; + for (const r of block.items) + runs.push(r); + } + } + } + return { + ok: true, + data: { + kind, + project_id: projectId || undefined, + task_id: taskId || undefined, + batch_id: batchId || undefined, + cutover_batches: wantCutover ? cutoverBatches : undefined, + collect_batches: wantCollect ? collectBatches : undefined, + runs: kind === "runs" ? runs : undefined, + counts: { + cutover_batches: cutoverBatches.length, + collect_batches: collectBatches.length, + runs: runs.length + }, + next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." } }; } @@ -4725,13 +4895,23 @@ function registerNetxTools(ctx, connection, options) { layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), - tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { + tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", { kind: str4("all | projects | tasks (default all)"), purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), status: str4("Filter by status (e.g. active / paused)"), q: str4("Keyword on name / ne_name / note / id"), limit: num4("Max rows per list (default 100, max 500)") }, listBizMonitors, getClient, t), + tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", { + kind: str4("auto | all | cutover | collect | runs (default auto)"), + project_id: str4("Cutover project id → cutover_batches"), + task_id: str4("biz_state task id → collect_batches"), + batch_id: str4("Cutover batch id (filter or kind=runs)"), + include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), + status: str4("Filter batch status"), + limit: num4("Max batches (default 50)"), + runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") + }, listBizMonitorBatches, getClient, t), tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") diff --git a/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md b/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md index e3fa593..ebd44a9 100644 --- a/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md +++ b/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md @@ -2,10 +2,10 @@ name: netx-biz-monitor description: >- Overnight cutover / biz_state monitor analysis via netxops host tools - (netx__listBizMonitors / netx__getBizMonitor*). List projects/tasks first - when no id is given; then read definitions, board, reds/diffs with evidence - (device-raw A/B, show commands), and judge tool false-positive vs real - business fault. Does not create templates/tasks. + (netx__listBizMonitors / listBizMonitorBatches / getBizMonitor*). List + projects/tasks then batches when no id is given; read definitions, board, + reds/diffs with evidence (device-raw A/B, show commands), and judge tool + false-positive vs real business fault. Does not create templates/tasks. --- # netx-biz-monitor(割接 / 业务监控只读分析) @@ -18,6 +18,7 @@ description: >- | 工具 | 用途 | |------|------| | `netx__listBizMonitors` | **入口目录**:列出割接/监控对比项目 + 业务监控任务(**无需** project_id) | +| `netx__listBizMonitorBatches` | **批次目录**:`project_id`→割接批次;`task_id`→采集批次;`kind=runs`→evaluate run | | `netx__getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 | | `netx__getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 | | `netx__listBizMonitorReds` | 红单 + **evidence** | @@ -37,12 +38,13 @@ description: >- ## 推荐顺序 1. 无 id → `listBizMonitors`(`kind=projects|tasks|all`,可加 `q` / `purpose`) -2. `getBizMonitorContext(project_id)` — 弄清盯什么 -3. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped -4. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)` -5. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤? -6. 需要原文 → `getBizCollectCommandRaw` -7. 像真障 → `execManagedNe` 现场核对 +2. `listBizMonitorBatches(project_id)` 或 `(task_id)` — 拿到 **完整 batch_id**;需要 run 时再 `kind=runs` / `include_runs` +3. `getBizMonitorContext(project_id)` — 弄清盯什么 +4. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped +5. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)` +6. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤? +7. 需要原文 → `getBizCollectCommandRaw` +8. 像真障 → `execManagedNe` 现场核对 ## Verdict 速查(skill 解释,工具不硬编码) diff --git a/src/netx/capability-groups.ts b/src/netx/capability-groups.ts index 0312125..a9e0ab4 100644 --- a/src/netx/capability-groups.ts +++ b/src/netx/capability-groups.ts @@ -82,6 +82,7 @@ export const TOOLS_BY_GROUP: Readonly { + const projectId = str(args, 'project_id').trim() + const taskId = str(args, 'task_id').trim() + const batchId = str(args, 'batch_id').trim() + const kindRaw = str(args, 'kind', 'auto').trim().toLowerCase() || 'auto' + const includeRuns = args.include_runs === true || args.include_runs === 'true' || args.include_runs === 1 + const status = str(args, 'status').trim().toLowerCase() + const limit = clampInt(num(args, 'limit') ?? 50, 50, 1, 200) + const runsLimit = clampInt(num(args, 'runs_limit') ?? 10, 10, 1, 50) + + let kind = kindRaw + if (kind === 'auto') { + if (batchId && !projectId && !taskId) kind = 'runs' + else if (projectId && !taskId) kind = 'cutover' + else if (taskId && !projectId) kind = 'collect' + else if (projectId && taskId) kind = 'all' + else if (batchId) kind = 'runs' + else { + return { + ok: false, + error: 'project_id_or_task_id_or_batch_id_required', + detail: 'Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs).', + } + } + } + if (!['auto', 'all', 'cutover', 'collect', 'runs'].includes(kind)) { + return { ok: false, error: 'kind_invalid', detail: 'kind must be auto|all|cutover|collect|runs' } + } + + const wantCutover = kind === 'all' || kind === 'cutover' + const wantCollect = kind === 'all' || kind === 'collect' + const wantRuns = kind === 'runs' || (includeRuns && (wantCutover || Boolean(batchId))) + + if (wantCutover && !projectId) { + return { ok: false, error: 'project_id_required', detail: 'kind=cutover|all needs project_id' } + } + if (wantCollect && !taskId) { + return { ok: false, error: 'task_id_required', detail: 'kind=collect|all needs task_id' } + } + if (kind === 'runs' && !batchId) { + return { ok: false, error: 'batch_id_required', detail: 'kind=runs needs cutover batch_id' } + } + + let cutoverBatches: NetxJson[] = [] + let collectBatches: NetxJson[] = [] + let runs: NetxJson[] = [] + + if (wantCutover) { + const res = await client.get( + `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, + {}, + signal, + ) + if (res.ok === false) return res + const items = Array.isArray(asRecord(res.data).items) + ? (asRecord(res.data).items as unknown[]) + : [] + cutoverBatches = items + .map((row) => slimCutoverBatch(asRecord(row))) + .filter((row) => { + if (batchId && String(row.id || '') !== batchId) return false + if (status && String(row.status || '').toLowerCase() !== status) return false + return true + }) + .slice(0, limit) + } + + if (wantCollect) { + const res = await client.get( + `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, + { limit }, + signal, + ) + if (res.ok === false) return res + const items = Array.isArray(asRecord(res.data).items) + ? (asRecord(res.data).items as unknown[]) + : [] + collectBatches = items + .map((row) => slimCollectBatch(asRecord(row), taskId)) + .filter((row) => { + if (status && String(row.status || '').toLowerCase() !== status) return false + return true + }) + .slice(0, limit) + } + + if (wantRuns) { + const runBatchIds = kind === 'runs' + ? [batchId] + : cutoverBatches.map((b) => String(b.id || '').trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0) + if (kind === 'runs' && batchId) { + const res = await client.get( + `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, + { limit: runsLimit }, + signal, + ) + if (res.ok === false) return res + const items = Array.isArray(asRecord(res.data).items) + ? (asRecord(res.data).items as unknown[]) + : [] + runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit) + } else if (includeRuns && runBatchIds.length > 0) { + const settled = await Promise.all( + runBatchIds.map(async (id) => { + const res = await client.get( + `/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, + { limit: runsLimit }, + signal, + ) + if (res.ok === false) return { batch_id: id, error: res, items: [] as NetxJson[] } + const items = Array.isArray(asRecord(res.data).items) + ? (asRecord(res.data).items as unknown[]) + : [] + return { + batch_id: id, + items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit), + } + }), + ) + for (const block of settled) { + if ('error' in block && block.error) { + // Keep listing batches even if one runs fetch fails; attach error note. + const failed = cutoverBatches.find((b) => b.id === block.batch_id) + if (failed) failed.runs_error = asRecord(block.error).error || 'runs_fetch_failed' + continue + } + const target = cutoverBatches.find((b) => b.id === block.batch_id) + if (target) target.runs = block.items + for (const r of block.items) runs.push(r) + } + } + } + + return { + ok: true, + data: { + kind, + project_id: projectId || undefined, + task_id: taskId || undefined, + batch_id: batchId || undefined, + cutover_batches: wantCutover ? cutoverBatches : undefined, + collect_batches: wantCollect ? collectBatches : undefined, + runs: kind === 'runs' ? runs : undefined, + counts: { + cutover_batches: cutoverBatches.length, + collect_batches: collectBatches.length, + runs: runs.length, + }, + next: wantCutover + ? 'Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch.' + : wantCollect + ? 'Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw.' + : 'Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id).', }, } } diff --git a/src/netx/tools.ts b/src/netx/tools.ts index baaf53b..70a4857 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -319,7 +319,7 @@ export function registerNetxTools( // ── bizMonitor group (cutover / biz_state read via netx REST) ───────── tool( 'netx__listBizMonitors', - 'Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.', + 'Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.', { kind: str('all | projects | tasks (default all)'), purpose: str('Filter tasks: portrait | cutover_hf | empty=all'), @@ -329,6 +329,21 @@ export function registerNetxTools( }, H.listBizMonitors, getClient, t, ), + tool( + 'netx__listBizMonitorBatches', + 'List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.', + { + kind: str('auto | all | cutover | collect | runs (default auto)'), + project_id: str('Cutover project id → cutover_batches'), + task_id: str('biz_state task id → collect_batches'), + batch_id: str('Cutover batch id (filter or kind=runs)'), + include_runs: bool('Attach recent evaluate runs onto listed cutover batches'), + status: str('Filter batch status'), + limit: num('Max batches (default 50)'), + runs_limit: num('Max runs per batch when include_runs / kind=runs (default 10)'), + }, + H.listBizMonitorBatches, getClient, t, + ), tool( 'netx__getBizMonitorContext', 'Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.', From 4dd628b7ccebb5c0c542dadb0b2813f60c30d3d3 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 20 Sep 2026 15:35:08 +0800 Subject: [PATCH 10/10] Fix bizMonitor tool results failing DSH lossless JSON (strip undefined holes). Co-authored-by: Cursor --- lib/agent-tools-biz-monitor.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools-common.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools-nms.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools-ops.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools-topology.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools.js | 61 ++++++++++++++++++++++++--------- lib/index.js | 61 ++++++++++++++++++++++++--------- src/netx/handlers.ts | 27 ++++++++------- src/netx/json-safe.ts | 41 ++++++++++++++++++++++ src/netx/kb-local-tools.ts | 3 +- src/netx/tools.ts | 10 ++++-- test/capability-groups.test.mjs | 2 ++ test/json-safe.test.mjs | 40 +++++++++++++++++++++ 13 files changed, 414 insertions(+), 136 deletions(-) create mode 100644 src/netx/json-safe.ts create mode 100644 test/json-safe.test.mjs diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index b31986f..97b3358 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -737,6 +737,32 @@ function watchKbContext(listener) { }; } +// src/netx/json-safe.ts +function toLosslessJson(value) { + try { + const text = JSON.stringify(value); + if (text === undefined) { + return { ok: false, error: "tool_result_not_json", detail: "undefined_root" }; + } + const parsed = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed; + } + return { ok: true, data: { value: parsed } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; + } +} +function omitUndefined(record) { + const out = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) + out[key] = value; + } + return out; +} + // src/netx/kb-local-tools.ts var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); @@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return await execute(args); + return toLosslessJson(await execute(args)); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -1481,7 +1507,7 @@ function taskBriefNe(brief) { } function slimMigrationProject(row) { const mt = asRecord(row.monitor_template); - return { + return omitUndefined({ id: row.id, name: row.name, status: row.status, @@ -1498,7 +1524,7 @@ function slimMigrationProject(row) { hf_start_at: row.hf_start_at, hf_end_at: row.hf_end_at, updated_at: row.updated_at - }; + }); } function matchesBizMonitorQ(row, q) { if (!q) @@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) { tasks = tasks.slice(0, limit); return { ok: true, - data: { + data: omitUndefined({ kind, projects: wantProjects ? projects : undefined, tasks: wantTasks ? tasks : undefined, @@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) { tasks_returned: wantTasks ? tasks.length : 0 }, next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." - } + }) }; } function slimCutoverBatch(row) { - return { + return omitUndefined({ id: row.id, project_id: row.project_id, batch_label: row.batch_label, @@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) { note: row.note, created_at: row.created_at, updated_at: row.updated_at - }; + }); } function slimCollectBatch(row, taskId = "") { - return { + return omitUndefined({ id: row.id, - task_id: row.task_id || taskId, + task_id: row.task_id || taskId || undefined, status: row.status, ne_name: row.ne_name, command_count: row.command_count, @@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") { message: row.message, started_at: row.started_at, ended_at: row.ended_at - }; + }); } function slimEvalRun(row) { const summary = asRecord(row.summary); const progress = asRecord(summary.progress); - return { + return omitUndefined({ id: row.id, batch_id: row.batch_id, purpose: row.purpose, @@ -1624,7 +1650,7 @@ function slimEvalRun(row) { anomaly: summary.anomaly, progress_ok: progress.ok ?? summary.progress_ok, progress_total: progress.total ?? summary.progress_total - }; + }); } async function listBizMonitorBatches(client, args, signal) { const projectId = str2(args, "project_id").trim(); @@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) { } return { ok: true, - data: { + data: omitUndefined({ kind, project_id: projectId || undefined, task_id: taskId || undefined, @@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) { runs: runs.length }, next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." - } + }) }; } async function getBizMonitorContext(client, args, signal) { @@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - if (result.ok === false) { - throw new Error(JSON.stringify(result)); + const safe = toLosslessJson(result); + if (result.ok === false || safe.ok === false) { + throw new Error(JSON.stringify(safe)); } - return result; + return safe; } }); } diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index f711889..8af8c8f 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -737,6 +737,32 @@ function watchKbContext(listener) { }; } +// src/netx/json-safe.ts +function toLosslessJson(value) { + try { + const text = JSON.stringify(value); + if (text === undefined) { + return { ok: false, error: "tool_result_not_json", detail: "undefined_root" }; + } + const parsed = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed; + } + return { ok: true, data: { value: parsed } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; + } +} +function omitUndefined(record) { + const out = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) + out[key] = value; + } + return out; +} + // src/netx/kb-local-tools.ts var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); @@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return await execute(args); + return toLosslessJson(await execute(args)); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -1481,7 +1507,7 @@ function taskBriefNe(brief) { } function slimMigrationProject(row) { const mt = asRecord(row.monitor_template); - return { + return omitUndefined({ id: row.id, name: row.name, status: row.status, @@ -1498,7 +1524,7 @@ function slimMigrationProject(row) { hf_start_at: row.hf_start_at, hf_end_at: row.hf_end_at, updated_at: row.updated_at - }; + }); } function matchesBizMonitorQ(row, q) { if (!q) @@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) { tasks = tasks.slice(0, limit); return { ok: true, - data: { + data: omitUndefined({ kind, projects: wantProjects ? projects : undefined, tasks: wantTasks ? tasks : undefined, @@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) { tasks_returned: wantTasks ? tasks.length : 0 }, next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." - } + }) }; } function slimCutoverBatch(row) { - return { + return omitUndefined({ id: row.id, project_id: row.project_id, batch_label: row.batch_label, @@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) { note: row.note, created_at: row.created_at, updated_at: row.updated_at - }; + }); } function slimCollectBatch(row, taskId = "") { - return { + return omitUndefined({ id: row.id, - task_id: row.task_id || taskId, + task_id: row.task_id || taskId || undefined, status: row.status, ne_name: row.ne_name, command_count: row.command_count, @@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") { message: row.message, started_at: row.started_at, ended_at: row.ended_at - }; + }); } function slimEvalRun(row) { const summary = asRecord(row.summary); const progress = asRecord(summary.progress); - return { + return omitUndefined({ id: row.id, batch_id: row.batch_id, purpose: row.purpose, @@ -1624,7 +1650,7 @@ function slimEvalRun(row) { anomaly: summary.anomaly, progress_ok: progress.ok ?? summary.progress_ok, progress_total: progress.total ?? summary.progress_total - }; + }); } async function listBizMonitorBatches(client, args, signal) { const projectId = str2(args, "project_id").trim(); @@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) { } return { ok: true, - data: { + data: omitUndefined({ kind, project_id: projectId || undefined, task_id: taskId || undefined, @@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) { runs: runs.length }, next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." - } + }) }; } async function getBizMonitorContext(client, args, signal) { @@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - if (result.ok === false) { - throw new Error(JSON.stringify(result)); + const safe = toLosslessJson(result); + if (result.ok === false || safe.ok === false) { + throw new Error(JSON.stringify(safe)); } - return result; + return safe; } }); } diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 5d3c406..1f4a3b7 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -737,6 +737,32 @@ function watchKbContext(listener) { }; } +// src/netx/json-safe.ts +function toLosslessJson(value) { + try { + const text = JSON.stringify(value); + if (text === undefined) { + return { ok: false, error: "tool_result_not_json", detail: "undefined_root" }; + } + const parsed = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed; + } + return { ok: true, data: { value: parsed } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; + } +} +function omitUndefined(record) { + const out = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) + out[key] = value; + } + return out; +} + // src/netx/kb-local-tools.ts var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); @@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return await execute(args); + return toLosslessJson(await execute(args)); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -1481,7 +1507,7 @@ function taskBriefNe(brief) { } function slimMigrationProject(row) { const mt = asRecord(row.monitor_template); - return { + return omitUndefined({ id: row.id, name: row.name, status: row.status, @@ -1498,7 +1524,7 @@ function slimMigrationProject(row) { hf_start_at: row.hf_start_at, hf_end_at: row.hf_end_at, updated_at: row.updated_at - }; + }); } function matchesBizMonitorQ(row, q) { if (!q) @@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) { tasks = tasks.slice(0, limit); return { ok: true, - data: { + data: omitUndefined({ kind, projects: wantProjects ? projects : undefined, tasks: wantTasks ? tasks : undefined, @@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) { tasks_returned: wantTasks ? tasks.length : 0 }, next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." - } + }) }; } function slimCutoverBatch(row) { - return { + return omitUndefined({ id: row.id, project_id: row.project_id, batch_label: row.batch_label, @@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) { note: row.note, created_at: row.created_at, updated_at: row.updated_at - }; + }); } function slimCollectBatch(row, taskId = "") { - return { + return omitUndefined({ id: row.id, - task_id: row.task_id || taskId, + task_id: row.task_id || taskId || undefined, status: row.status, ne_name: row.ne_name, command_count: row.command_count, @@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") { message: row.message, started_at: row.started_at, ended_at: row.ended_at - }; + }); } function slimEvalRun(row) { const summary = asRecord(row.summary); const progress = asRecord(summary.progress); - return { + return omitUndefined({ id: row.id, batch_id: row.batch_id, purpose: row.purpose, @@ -1624,7 +1650,7 @@ function slimEvalRun(row) { anomaly: summary.anomaly, progress_ok: progress.ok ?? summary.progress_ok, progress_total: progress.total ?? summary.progress_total - }; + }); } async function listBizMonitorBatches(client, args, signal) { const projectId = str2(args, "project_id").trim(); @@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) { } return { ok: true, - data: { + data: omitUndefined({ kind, project_id: projectId || undefined, task_id: taskId || undefined, @@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) { runs: runs.length }, next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." - } + }) }; } async function getBizMonitorContext(client, args, signal) { @@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - if (result.ok === false) { - throw new Error(JSON.stringify(result)); + const safe = toLosslessJson(result); + if (result.ok === false || safe.ok === false) { + throw new Error(JSON.stringify(safe)); } - return result; + return safe; } }); } diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 3687d5a..90e9342 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -737,6 +737,32 @@ function watchKbContext(listener) { }; } +// src/netx/json-safe.ts +function toLosslessJson(value) { + try { + const text = JSON.stringify(value); + if (text === undefined) { + return { ok: false, error: "tool_result_not_json", detail: "undefined_root" }; + } + const parsed = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed; + } + return { ok: true, data: { value: parsed } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; + } +} +function omitUndefined(record) { + const out = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) + out[key] = value; + } + return out; +} + // src/netx/kb-local-tools.ts var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); @@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return await execute(args); + return toLosslessJson(await execute(args)); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -1481,7 +1507,7 @@ function taskBriefNe(brief) { } function slimMigrationProject(row) { const mt = asRecord(row.monitor_template); - return { + return omitUndefined({ id: row.id, name: row.name, status: row.status, @@ -1498,7 +1524,7 @@ function slimMigrationProject(row) { hf_start_at: row.hf_start_at, hf_end_at: row.hf_end_at, updated_at: row.updated_at - }; + }); } function matchesBizMonitorQ(row, q) { if (!q) @@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) { tasks = tasks.slice(0, limit); return { ok: true, - data: { + data: omitUndefined({ kind, projects: wantProjects ? projects : undefined, tasks: wantTasks ? tasks : undefined, @@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) { tasks_returned: wantTasks ? tasks.length : 0 }, next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." - } + }) }; } function slimCutoverBatch(row) { - return { + return omitUndefined({ id: row.id, project_id: row.project_id, batch_label: row.batch_label, @@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) { note: row.note, created_at: row.created_at, updated_at: row.updated_at - }; + }); } function slimCollectBatch(row, taskId = "") { - return { + return omitUndefined({ id: row.id, - task_id: row.task_id || taskId, + task_id: row.task_id || taskId || undefined, status: row.status, ne_name: row.ne_name, command_count: row.command_count, @@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") { message: row.message, started_at: row.started_at, ended_at: row.ended_at - }; + }); } function slimEvalRun(row) { const summary = asRecord(row.summary); const progress = asRecord(summary.progress); - return { + return omitUndefined({ id: row.id, batch_id: row.batch_id, purpose: row.purpose, @@ -1624,7 +1650,7 @@ function slimEvalRun(row) { anomaly: summary.anomaly, progress_ok: progress.ok ?? summary.progress_ok, progress_total: progress.total ?? summary.progress_total - }; + }); } async function listBizMonitorBatches(client, args, signal) { const projectId = str2(args, "project_id").trim(); @@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) { } return { ok: true, - data: { + data: omitUndefined({ kind, project_id: projectId || undefined, task_id: taskId || undefined, @@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) { runs: runs.length }, next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." - } + }) }; } async function getBizMonitorContext(client, args, signal) { @@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - if (result.ok === false) { - throw new Error(JSON.stringify(result)); + const safe = toLosslessJson(result); + if (result.ok === false || safe.ok === false) { + throw new Error(JSON.stringify(safe)); } - return result; + return safe; } }); } diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 5c01f5c..3baa967 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -737,6 +737,32 @@ function watchKbContext(listener) { }; } +// src/netx/json-safe.ts +function toLosslessJson(value) { + try { + const text = JSON.stringify(value); + if (text === undefined) { + return { ok: false, error: "tool_result_not_json", detail: "undefined_root" }; + } + const parsed = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed; + } + return { ok: true, data: { value: parsed } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; + } +} +function omitUndefined(record) { + const out = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) + out[key] = value; + } + return out; +} + // src/netx/kb-local-tools.ts var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); @@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return await execute(args); + return toLosslessJson(await execute(args)); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -1481,7 +1507,7 @@ function taskBriefNe(brief) { } function slimMigrationProject(row) { const mt = asRecord(row.monitor_template); - return { + return omitUndefined({ id: row.id, name: row.name, status: row.status, @@ -1498,7 +1524,7 @@ function slimMigrationProject(row) { hf_start_at: row.hf_start_at, hf_end_at: row.hf_end_at, updated_at: row.updated_at - }; + }); } function matchesBizMonitorQ(row, q) { if (!q) @@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) { tasks = tasks.slice(0, limit); return { ok: true, - data: { + data: omitUndefined({ kind, projects: wantProjects ? projects : undefined, tasks: wantTasks ? tasks : undefined, @@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) { tasks_returned: wantTasks ? tasks.length : 0 }, next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." - } + }) }; } function slimCutoverBatch(row) { - return { + return omitUndefined({ id: row.id, project_id: row.project_id, batch_label: row.batch_label, @@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) { note: row.note, created_at: row.created_at, updated_at: row.updated_at - }; + }); } function slimCollectBatch(row, taskId = "") { - return { + return omitUndefined({ id: row.id, - task_id: row.task_id || taskId, + task_id: row.task_id || taskId || undefined, status: row.status, ne_name: row.ne_name, command_count: row.command_count, @@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") { message: row.message, started_at: row.started_at, ended_at: row.ended_at - }; + }); } function slimEvalRun(row) { const summary = asRecord(row.summary); const progress = asRecord(summary.progress); - return { + return omitUndefined({ id: row.id, batch_id: row.batch_id, purpose: row.purpose, @@ -1624,7 +1650,7 @@ function slimEvalRun(row) { anomaly: summary.anomaly, progress_ok: progress.ok ?? summary.progress_ok, progress_total: progress.total ?? summary.progress_total - }; + }); } async function listBizMonitorBatches(client, args, signal) { const projectId = str2(args, "project_id").trim(); @@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) { } return { ok: true, - data: { + data: omitUndefined({ kind, project_id: projectId || undefined, task_id: taskId || undefined, @@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) { runs: runs.length }, next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." - } + }) }; } async function getBizMonitorContext(client, args, signal) { @@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - if (result.ok === false) { - throw new Error(JSON.stringify(result)); + const safe = toLosslessJson(result); + if (result.ok === false || safe.ok === false) { + throw new Error(JSON.stringify(safe)); } - return result; + return safe; } }); } diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 2ca382a..dca3139 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -737,6 +737,32 @@ function watchKbContext(listener) { }; } +// src/netx/json-safe.ts +function toLosslessJson(value) { + try { + const text = JSON.stringify(value); + if (text === undefined) { + return { ok: false, error: "tool_result_not_json", detail: "undefined_root" }; + } + const parsed = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed; + } + return { ok: true, data: { value: parsed } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; + } +} +function omitUndefined(record) { + const out = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) + out[key] = value; + } + return out; +} + // src/netx/kb-local-tools.ts var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); @@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return await execute(args); + return toLosslessJson(await execute(args)); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -1481,7 +1507,7 @@ function taskBriefNe(brief) { } function slimMigrationProject(row) { const mt = asRecord(row.monitor_template); - return { + return omitUndefined({ id: row.id, name: row.name, status: row.status, @@ -1498,7 +1524,7 @@ function slimMigrationProject(row) { hf_start_at: row.hf_start_at, hf_end_at: row.hf_end_at, updated_at: row.updated_at - }; + }); } function matchesBizMonitorQ(row, q) { if (!q) @@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) { tasks = tasks.slice(0, limit); return { ok: true, - data: { + data: omitUndefined({ kind, projects: wantProjects ? projects : undefined, tasks: wantTasks ? tasks : undefined, @@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) { tasks_returned: wantTasks ? tasks.length : 0 }, next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." - } + }) }; } function slimCutoverBatch(row) { - return { + return omitUndefined({ id: row.id, project_id: row.project_id, batch_label: row.batch_label, @@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) { note: row.note, created_at: row.created_at, updated_at: row.updated_at - }; + }); } function slimCollectBatch(row, taskId = "") { - return { + return omitUndefined({ id: row.id, - task_id: row.task_id || taskId, + task_id: row.task_id || taskId || undefined, status: row.status, ne_name: row.ne_name, command_count: row.command_count, @@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") { message: row.message, started_at: row.started_at, ended_at: row.ended_at - }; + }); } function slimEvalRun(row) { const summary = asRecord(row.summary); const progress = asRecord(summary.progress); - return { + return omitUndefined({ id: row.id, batch_id: row.batch_id, purpose: row.purpose, @@ -1624,7 +1650,7 @@ function slimEvalRun(row) { anomaly: summary.anomaly, progress_ok: progress.ok ?? summary.progress_ok, progress_total: progress.total ?? summary.progress_total - }; + }); } async function listBizMonitorBatches(client, args, signal) { const projectId = str2(args, "project_id").trim(); @@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) { } return { ok: true, - data: { + data: omitUndefined({ kind, project_id: projectId || undefined, task_id: taskId || undefined, @@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) { runs: runs.length }, next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." - } + }) }; } async function getBizMonitorContext(client, args, signal) { @@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - if (result.ok === false) { - throw new Error(JSON.stringify(result)); + const safe = toLosslessJson(result); + if (result.ok === false || safe.ok === false) { + throw new Error(JSON.stringify(safe)); } - return result; + return safe; } }); } diff --git a/lib/index.js b/lib/index.js index d11f6e7..e5ec056 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1599,6 +1599,32 @@ function resetKbContext() { applyKbEnv(unconfiguredKbSnapshot()); } +// src/netx/json-safe.ts +function toLosslessJson(value) { + try { + const text = JSON.stringify(value); + if (text === undefined) { + return { ok: false, error: "tool_result_not_json", detail: "undefined_root" }; + } + const parsed = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed; + } + return { ok: true, data: { value: parsed } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; + } +} +function omitUndefined(record) { + const out = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) + out[key] = value; + } + return out; +} + // src/netx/kb-local-tools.ts var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); @@ -1628,7 +1654,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return await execute(args); + return toLosslessJson(await execute(args)); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -3740,7 +3766,7 @@ function taskBriefNe(brief) { } function slimMigrationProject(row) { const mt = asRecord(row.monitor_template); - return { + return omitUndefined({ id: row.id, name: row.name, status: row.status, @@ -3757,7 +3783,7 @@ function slimMigrationProject(row) { hf_start_at: row.hf_start_at, hf_end_at: row.hf_end_at, updated_at: row.updated_at - }; + }); } function matchesBizMonitorQ(row, q) { if (!q) @@ -3826,7 +3852,7 @@ async function listBizMonitors(client, args, signal) { tasks = tasks.slice(0, limit); return { ok: true, - data: { + data: omitUndefined({ kind, projects: wantProjects ? projects : undefined, tasks: wantTasks ? tasks : undefined, @@ -3837,11 +3863,11 @@ async function listBizMonitors(client, args, signal) { tasks_returned: wantTasks ? tasks.length : 0 }, next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id." - } + }) }; } function slimCutoverBatch(row) { - return { + return omitUndefined({ id: row.id, project_id: row.project_id, batch_label: row.batch_label, @@ -3853,12 +3879,12 @@ function slimCutoverBatch(row) { note: row.note, created_at: row.created_at, updated_at: row.updated_at - }; + }); } function slimCollectBatch(row, taskId = "") { - return { + return omitUndefined({ id: row.id, - task_id: row.task_id || taskId, + task_id: row.task_id || taskId || undefined, status: row.status, ne_name: row.ne_name, command_count: row.command_count, @@ -3868,12 +3894,12 @@ function slimCollectBatch(row, taskId = "") { message: row.message, started_at: row.started_at, ended_at: row.ended_at - }; + }); } function slimEvalRun(row) { const summary = asRecord(row.summary); const progress = asRecord(summary.progress); - return { + return omitUndefined({ id: row.id, batch_id: row.batch_id, purpose: row.purpose, @@ -3883,7 +3909,7 @@ function slimEvalRun(row) { anomaly: summary.anomaly, progress_ok: progress.ok ?? summary.progress_ok, progress_total: progress.total ?? summary.progress_total - }; + }); } async function listBizMonitorBatches(client, args, signal) { const projectId = str2(args, "project_id").trim(); @@ -3992,7 +4018,7 @@ async function listBizMonitorBatches(client, args, signal) { } return { ok: true, - data: { + data: omitUndefined({ kind, project_id: projectId || undefined, task_id: taskId || undefined, @@ -4006,7 +4032,7 @@ async function listBizMonitorBatches(client, args, signal) { runs: runs.length }, next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)." - } + }) }; } async function getBizMonitorContext(client, args, signal) { @@ -4750,10 +4776,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - if (result.ok === false) { - throw new Error(JSON.stringify(result)); + const safe = toLosslessJson(result); + if (result.ok === false || safe.ok === false) { + throw new Error(JSON.stringify(safe)); } - return result; + return safe; } }); } diff --git a/src/netx/handlers.ts b/src/netx/handlers.ts index 305e6cc..faa68df 100644 --- a/src/netx/handlers.ts +++ b/src/netx/handlers.ts @@ -3,6 +3,7 @@ */ import { type NetxClient, quoteNeId, type NetxJson } from './http.ts' +import { omitUndefined } from './json-safe.ts' const EXEC_MAX_COMMANDS = 5 @@ -343,7 +344,7 @@ function taskBriefNe(brief: unknown): string { function slimMigrationProject(row: NetxJson): NetxJson { const mt = asRecord(row.monitor_template) - return { + return omitUndefined({ id: row.id, name: row.name, status: row.status, @@ -360,7 +361,7 @@ function slimMigrationProject(row: NetxJson): NetxJson { hf_start_at: row.hf_start_at, hf_end_at: row.hf_end_at, updated_at: row.updated_at, - } + }) } function matchesBizMonitorQ(row: NetxJson, q: string): boolean { @@ -440,7 +441,7 @@ export async function listBizMonitors( return { ok: true, - data: { + data: omitUndefined({ kind, projects: wantProjects ? projects : undefined, tasks: wantTasks ? tasks : undefined, @@ -451,12 +452,12 @@ export async function listBizMonitors( tasks_returned: wantTasks ? tasks.length : 0, }, next: 'Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id.', - }, + }), } } function slimCutoverBatch(row: NetxJson): NetxJson { - return { + return omitUndefined({ id: row.id, project_id: row.project_id, batch_label: row.batch_label, @@ -468,13 +469,13 @@ function slimCutoverBatch(row: NetxJson): NetxJson { note: row.note, created_at: row.created_at, updated_at: row.updated_at, - } + }) } function slimCollectBatch(row: NetxJson, taskId = ''): NetxJson { - return { + return omitUndefined({ id: row.id, - task_id: row.task_id || taskId, + task_id: row.task_id || taskId || undefined, status: row.status, ne_name: row.ne_name, command_count: row.command_count, @@ -484,13 +485,13 @@ function slimCollectBatch(row: NetxJson, taskId = ''): NetxJson { message: row.message, started_at: row.started_at, ended_at: row.ended_at, - } + }) } function slimEvalRun(row: NetxJson): NetxJson { const summary = asRecord(row.summary) const progress = asRecord(summary.progress) - return { + return omitUndefined({ id: row.id, batch_id: row.batch_id, purpose: row.purpose, @@ -500,7 +501,7 @@ function slimEvalRun(row: NetxJson): NetxJson { anomaly: summary.anomaly, progress_ok: progress.ok ?? summary.progress_ok, progress_total: progress.total ?? summary.progress_total, - } + }) } /** @@ -646,7 +647,7 @@ export async function listBizMonitorBatches( return { ok: true, - data: { + data: omitUndefined({ kind, project_id: projectId || undefined, task_id: taskId || undefined, @@ -664,7 +665,7 @@ export async function listBizMonitorBatches( : wantCollect ? 'Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw.' : 'Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id).', - }, + }), } } diff --git a/src/netx/json-safe.ts b/src/netx/json-safe.ts new file mode 100644 index 0000000..86d6476 --- /dev/null +++ b/src/netx/json-safe.ts @@ -0,0 +1,41 @@ +/** + * DSH tool outputs must be lossless JSON: no `undefined` property values, + * no NaN/Infinity, no class instances. Build plain objects carefully, or + * round-trip through JSON before returning from a tool. + */ + +import type { NetxJson } from './http.ts' + +/** + * Detach a plain JSON-compatible value for DSH tool results. + * Drops object keys whose value is `undefined` (via JSON.stringify). + * @param value - handler result (may contain undefined holes). + * @returns parsed plain JSON object, or a safe error envelope. + */ +export function toLosslessJson(value: unknown): NetxJson { + try { + const text = JSON.stringify(value) + if (text === undefined) { + return { ok: false, error: 'tool_result_not_json', detail: 'undefined_root' } + } + const parsed: unknown = JSON.parse(text) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as NetxJson + } + return { ok: true, data: { value: parsed } } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + return { ok: false, error: 'tool_result_not_json', detail: detail.slice(0, 400) } + } +} + +/** + * Build a record omitting keys whose value is `undefined` (keeps null). + */ +export function omitUndefined(record: Record): NetxJson { + const out: NetxJson = {} + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) out[key] = value as NetxJson[string] + } + return out +} diff --git a/src/netx/kb-local-tools.ts b/src/netx/kb-local-tools.ts index 3dc08f1..d79d8d4 100644 --- a/src/netx/kb-local-tools.ts +++ b/src/netx/kb-local-tools.ts @@ -17,6 +17,7 @@ import { } from './kb-local-ops.ts' import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts' import { getKbContext } from './kb-runtime.ts' +import { toLosslessJson } from './json-safe.ts' const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) }) const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) }) @@ -56,7 +57,7 @@ function tool( isConcurrencySafe: () => false, async execute(args) { try { - return await execute(args as Record) + return toLosslessJson(await execute(args as Record)) } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)) } diff --git a/src/netx/tools.ts b/src/netx/tools.ts index 70a4857..81378ce 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -11,6 +11,7 @@ import { type NetxCapabilityGroups, } from './capability-groups.ts' import { createNetxClient, type NetxClient, type NetxJson } from './http.ts' +import { toLosslessJson } from './json-safe.ts' import { getNetxConnection } from './runtime.ts' import * as H from './handlers.ts' import * as T from './topology-handlers.ts' @@ -74,10 +75,13 @@ function tool( isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args as NetxJson, exec.signal) - if (result.ok === false) { - throw new Error(JSON.stringify(result)) + // DSH rejects tool results that contain `undefined` property holes + // ("value is not lossless JSON"). Round-trip strips them. + const safe = toLosslessJson(result) + if (result.ok === false || safe.ok === false) { + throw new Error(JSON.stringify(safe)) } - return result + return safe }, }) } diff --git a/test/capability-groups.test.mjs b/test/capability-groups.test.mjs index 8cf37c3..20c204c 100644 --- a/test/capability-groups.test.mjs +++ b/test/capability-groups.test.mjs @@ -39,6 +39,8 @@ test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs')) assert.ok(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits')) assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext')) + assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitors')) + assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches')) assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizCollectCommandRaw')) }) diff --git a/test/json-safe.test.mjs b/test/json-safe.test.mjs new file mode 100644 index 0000000..286bd7f --- /dev/null +++ b/test/json-safe.test.mjs @@ -0,0 +1,40 @@ +/** + * Lossless JSON helpers for DSH tool results. + */ + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { omitUndefined, toLosslessJson } from '../src/netx/json-safe.ts' + +test('omitUndefined drops undefined keys but keeps null/false/0', () => { + assert.deepEqual( + omitUndefined({ a: 1, b: undefined, c: null, d: false, e: 0 }), + { a: 1, c: null, d: false, e: 0 }, + ) +}) + +test('toLosslessJson strips undefined holes (listBizMonitors-style payload)', () => { + const raw = { + ok: true, + data: { + kind: 'projects', + projects: [{ id: 'p1', name: 'demo' }], + tasks: undefined, + next: 'go', + }, + } + const safe = toLosslessJson(raw) + assert.equal(safe.ok, true) + const data = safe.data + assert.ok(data && typeof data === 'object' && !Array.isArray(data)) + assert.equal(Object.hasOwn(data, 'tasks'), false) + assert.deepEqual(data.projects, [{ id: 'p1', name: 'demo' }]) +}) + +test('toLosslessJson keeps failure envelopes serializable', () => { + const safe = toLosslessJson({ ok: false, error: 'x', detail: undefined }) + assert.equal(safe.ok, false) + assert.equal(safe.error, 'x') + assert.equal(Object.hasOwn(safe, 'detail'), false) +})