diff --git a/.gitignore b/.gitignore index e635103..0d4b35e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,9 @@ 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/docs/INSTALL.md b/docs/INSTALL.md index 53bde4c..41029e0 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`, `biz-monitor:read`, `sql:query`, …). +2. **API token** with scopes matching the tools you use (`alarms:read`, `ne:read`, `ne:exec`, `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 for NMS/CLI — **not** for biz-monitor; that is netxops host tools only.) +No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MCP hosts can still use `python -m netx_mcp` separately.) ## 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`). Cutover analysis: `netx__getBizMonitorContext` … (**bizMonitor** → `netx-biz-monitor`). Canvas / dual_unit need **topology** (`netx-topology`). +4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). 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/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index b15f53b..4cdd832 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` | MANIFEST annotation (`KB_*`) + dual-plane write rules | +| `kb-context` | Identity annotation from MANIFEST (`KB_*`) | | `_skills/kb-*` | Shared operator KB triage / ingest / retrieve / export / package | | `paths.localSkills` | Operator-local skills (alongside `_skills/`; same toggles) | @@ -38,11 +38,11 @@ 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 MANIFEST fields + `kbLocal` + dual-plane write rules (refs / memories / drafts / suggestions) | +| Skill `kb-context` | Same identity + `kbLocal` + local create/update/delete tool names | | 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. | +| 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) | -RPC (channel `/netxops`): `kb.status` (published snapshot), `kb.reload` (re-resolve live `kbRoot` + republish), `kb.resolve` with `{ path }` (preview unsaved paths). +RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths). ## Pack skill registration diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 0bcaa34..e84243b 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -6,10 +6,9 @@ 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` | `tools-biz-monitor` (legacy aliases: `tools-nms` / `tools-common` → ops). +Forced mounts: `dsh-netxops/tools-ops` | `tools-topology` (legacy aliases: `tools-nms` / `tools-common` → ops). Model names: `netx__`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` still hits `/v1/ume/*`. @@ -21,19 +20,6 @@ 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 | -|------|------| -| `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) | -| `getBizCollectBatch` / `getBizCollectCommandRaw` | Collect audit + full CLI raw | - ## topology → `netx-topology` | Tool | Role | @@ -49,14 +35,13 @@ 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}/refs|memories|drafts|suggestions`. +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/`. | Tool | Role | |------|------| -| `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. | +| `kbWriteMemory` | New diary entry under `memories/{日常笔记\|排障复盘\|AI思维链}/` (`overwrite` optional) | | `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 (`root` may be `refs`) | +| `kbListLocal` | List recent `.md` under writable trees | diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js deleted file mode 100644 index 97b3358..0000000 --- a/lib/agent-tools-biz-monitor.js +++ /dev/null @@ -1,3029 +0,0 @@ -// 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__listBizMonitors", - "netx__listBizMonitorBatches", - "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/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 } : {} }); -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 toLosslessJson(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); -} -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 omitUndefined({ - 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: omitUndefined({ - 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; then listBizMonitorBatches for batch_id / run_id." - }) - }; -} -function slimCutoverBatch(row) { - return omitUndefined({ - 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 omitUndefined({ - id: row.id, - task_id: row.task_id || taskId || undefined, - 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 omitUndefined({ - 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: omitUndefined({ - 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)." - }) - }; -} -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); - const safe = toLosslessJson(result); - if (result.ok === false || safe.ok === false) { - throw new Error(JSON.stringify(safe)); - } - return safe; - } - }); -} -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__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)") - }, 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 -}; diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 8af8c8f..681b622 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -1,8 +1,7 @@ // 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 }) + topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" - ]), - bizMonitor: Object.freeze([ - "netx__listBizMonitors", - "netx__listBizMonitorBatches", - "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" + topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology", - "bizMonitor" + "topology" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -73,12 +60,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const out = new Set; + const names = new Set; for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); } - return out; + return names; } // src/netx/group-skills.ts @@ -234,16 +221,7 @@ 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" + "suggestions" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -303,10 +281,6 @@ 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"); @@ -380,19 +354,6 @@ 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"; @@ -403,29 +364,18 @@ function skillBody(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`." + "**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." ] : [ "", "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).", + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ "## Knowledge base (configured)", "", @@ -593,25 +543,6 @@ 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); @@ -737,41 +668,10 @@ 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 } : {} }); 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) }]; } @@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return toLosslessJson(await execute(args)); + return await execute(args); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -808,16 +708,20 @@ 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.", { - 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") + 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") + } }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -825,12 +729,17 @@ 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 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") + 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") + } }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -838,17 +747,21 @@ 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 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") + 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") + } }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { 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") + 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") + } }, (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") + 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") + } }, (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)") + 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)") + } }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined @@ -1497,347 +1406,6 @@ 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 omitUndefined({ - 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: omitUndefined({ - 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; then listBizMonitorBatches for batch_id / run_id." - }) - }; -} -function slimCutoverBatch(row) { - return omitUndefined({ - 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 omitUndefined({ - id: row.id, - task_id: row.task_id || taskId || undefined, - 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 omitUndefined({ - 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: omitUndefined({ - 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)." - }) - }; -} -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) { @@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - const safe = toLosslessJson(result); - if (result.ok === false || safe.ok === false) { - throw new Error(JSON.stringify(safe)); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); } - return safe; + return result; } }); } @@ -2663,53 +2230,6 @@ 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 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)") - }, 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 1f4a3b7..ec63a46 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -1,8 +1,7 @@ // 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 }) + topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" - ]), - bizMonitor: Object.freeze([ - "netx__listBizMonitors", - "netx__listBizMonitorBatches", - "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" + topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology", - "bizMonitor" + "topology" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -73,12 +60,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const out = new Set; + const names = new Set; for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); } - return out; + return names; } // src/netx/group-skills.ts @@ -234,16 +221,7 @@ 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" + "suggestions" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -303,10 +281,6 @@ 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"); @@ -380,19 +354,6 @@ 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"; @@ -403,29 +364,18 @@ function skillBody(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`." + "**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." ] : [ "", "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).", + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ "## Knowledge base (configured)", "", @@ -593,25 +543,6 @@ 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); @@ -737,41 +668,10 @@ 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 } : {} }); 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) }]; } @@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return toLosslessJson(await execute(args)); + return await execute(args); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -808,16 +708,20 @@ 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.", { - 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") + 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") + } }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -825,12 +729,17 @@ 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 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") + 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") + } }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -838,17 +747,21 @@ 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 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") + 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") + } }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { 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") + 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") + } }, (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") + 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") + } }, (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)") + 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)") + } }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined @@ -1497,347 +1406,6 @@ 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 omitUndefined({ - 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: omitUndefined({ - 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; then listBizMonitorBatches for batch_id / run_id." - }) - }; -} -function slimCutoverBatch(row) { - return omitUndefined({ - 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 omitUndefined({ - id: row.id, - task_id: row.task_id || taskId || undefined, - 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 omitUndefined({ - 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: omitUndefined({ - 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)." - }) - }; -} -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) { @@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - const safe = toLosslessJson(result); - if (result.ok === false || safe.ok === false) { - throw new Error(JSON.stringify(safe)); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); } - return safe; + return result; } }); } @@ -2663,53 +2230,6 @@ 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 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)") - }, 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 90e9342..0050eab 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -1,8 +1,7 @@ // 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 }) + topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" - ]), - bizMonitor: Object.freeze([ - "netx__listBizMonitors", - "netx__listBizMonitorBatches", - "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" + topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology", - "bizMonitor" + "topology" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -73,12 +60,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const out = new Set; + const names = new Set; for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); } - return out; + return names; } // src/netx/group-skills.ts @@ -234,16 +221,7 @@ 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" + "suggestions" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -303,10 +281,6 @@ 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"); @@ -380,19 +354,6 @@ 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"; @@ -403,29 +364,18 @@ function skillBody(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`." + "**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." ] : [ "", "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).", + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ "## Knowledge base (configured)", "", @@ -593,25 +543,6 @@ 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); @@ -737,41 +668,10 @@ 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 } : {} }); 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) }]; } @@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return toLosslessJson(await execute(args)); + return await execute(args); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -808,16 +708,20 @@ 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.", { - 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") + 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") + } }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -825,12 +729,17 @@ 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 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") + 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") + } }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -838,17 +747,21 @@ 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 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") + 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") + } }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { 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") + 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") + } }, (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") + 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") + } }, (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)") + 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)") + } }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined @@ -1497,347 +1406,6 @@ 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 omitUndefined({ - 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: omitUndefined({ - 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; then listBizMonitorBatches for batch_id / run_id." - }) - }; -} -function slimCutoverBatch(row) { - return omitUndefined({ - 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 omitUndefined({ - id: row.id, - task_id: row.task_id || taskId || undefined, - 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 omitUndefined({ - 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: omitUndefined({ - 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)." - }) - }; -} -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) { @@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - const safe = toLosslessJson(result); - if (result.ok === false || safe.ok === false) { - throw new Error(JSON.stringify(safe)); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); } - return safe; + return result; } }); } @@ -2663,53 +2230,6 @@ 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 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)") - }, 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 3baa967..f709df5 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -1,8 +1,7 @@ // 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 }) + topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" - ]), - bizMonitor: Object.freeze([ - "netx__listBizMonitors", - "netx__listBizMonitorBatches", - "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" + topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology", - "bizMonitor" + "topology" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -73,12 +60,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const out = new Set; + const names = new Set; for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); } - return out; + return names; } // src/netx/group-skills.ts @@ -234,16 +221,7 @@ 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" + "suggestions" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -303,10 +281,6 @@ 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"); @@ -380,19 +354,6 @@ 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"; @@ -403,29 +364,18 @@ function skillBody(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`." + "**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." ] : [ "", "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).", + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ "## Knowledge base (configured)", "", @@ -593,25 +543,6 @@ 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); @@ -737,41 +668,10 @@ 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 } : {} }); 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) }]; } @@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return toLosslessJson(await execute(args)); + return await execute(args); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -808,16 +708,20 @@ 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.", { - 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") + 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") + } }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -825,12 +729,17 @@ 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 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") + 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") + } }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -838,17 +747,21 @@ 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 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") + 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") + } }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { 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") + 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") + } }, (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") + 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") + } }, (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)") + 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)") + } }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined @@ -1497,347 +1406,6 @@ 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 omitUndefined({ - 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: omitUndefined({ - 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; then listBizMonitorBatches for batch_id / run_id." - }) - }; -} -function slimCutoverBatch(row) { - return omitUndefined({ - 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 omitUndefined({ - id: row.id, - task_id: row.task_id || taskId || undefined, - 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 omitUndefined({ - 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: omitUndefined({ - 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)." - }) - }; -} -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) { @@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - const safe = toLosslessJson(result); - if (result.ok === false || safe.ok === false) { - throw new Error(JSON.stringify(safe)); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); } - return safe; + return result; } }); } @@ -2663,53 +2230,6 @@ 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 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)") - }, 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 dca3139..ff64468 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -1,8 +1,7 @@ // 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 }) + topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" - ]), - bizMonitor: Object.freeze([ - "netx__listBizMonitors", - "netx__listBizMonitorBatches", - "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" + topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology", - "bizMonitor" + "topology" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; @@ -73,12 +60,12 @@ function groupsForced(only) { return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { - const out = new Set; + const names = new Set; for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); } - return out; + return names; } // src/netx/group-skills.ts @@ -234,16 +221,7 @@ 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" + "suggestions" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -303,10 +281,6 @@ 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"); @@ -380,19 +354,6 @@ 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"; @@ -403,29 +364,18 @@ function skillBody(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`." + "**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." ] : [ "", "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).", + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ "## Knowledge base (configured)", "", @@ -593,25 +543,6 @@ 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); @@ -737,41 +668,10 @@ 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 } : {} }); 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) }]; } @@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return toLosslessJson(await execute(args)); + return await execute(args); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -808,16 +708,20 @@ 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.", { - 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") + 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") + } }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -825,12 +729,17 @@ 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 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") + 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") + } }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -838,17 +747,21 @@ 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 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") + 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") + } }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { 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") + 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") + } }, (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") + 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") + } }, (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)") + 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)") + } }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined @@ -1497,347 +1406,6 @@ 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 omitUndefined({ - 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: omitUndefined({ - 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; then listBizMonitorBatches for batch_id / run_id." - }) - }; -} -function slimCutoverBatch(row) { - return omitUndefined({ - 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 omitUndefined({ - id: row.id, - task_id: row.task_id || taskId || undefined, - 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 omitUndefined({ - 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: omitUndefined({ - 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)." - }) - }; -} -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) { @@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - const safe = toLosslessJson(result); - if (result.ok === false || safe.ok === false) { - throw new Error(JSON.stringify(safe)); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); } - return safe; + return result; } }); } @@ -2663,53 +2230,6 @@ 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 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)") - }, 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 346f59a..44e2215 100644 --- a/lib/client.js +++ b/lib/client.js @@ -591,7 +591,6 @@ 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(); @@ -625,44 +624,22 @@ 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 (asRpcOk(result)) { + if (result !== null && typeof result === "object" && result.ok === true) { return asKbSnapshot(result.value); } - 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); + return unconfiguredKbSnapshot(); } async function resolveKbPath(call, path, signal) { let result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal); - if (!asRpcOk(result)) { + if (!(result !== null && typeof result === "object" && result.ok === true)) { result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { args: { path } }, signal); } - if (asRpcOk(result)) { + if (result !== null && typeof result === "object" && result.ok === true) { return asKbSnapshot(result.value); } - return { - ...unconfiguredKbSnapshot(), - status: "error", - errorMessage: "kb.resolve unavailable" - }; + return unconfiguredKbSnapshot(); } function kbStatusTone(status) { if (status === "configured") @@ -1451,20 +1428,6 @@ 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"); - } }) ] }), @@ -1923,7 +1886,7 @@ class CardForm { } async store(field, value) { await this.scope.set(field, value); - return this.sectionValue(field) === value || this.userLayer()?.[field] === value; + return this.userLayer()?.[field] === value; } stage(field, edit) { this.staged.set(field, edit); @@ -1982,9 +1945,7 @@ class NetxopsCardController { kbDirectoryPicker; kbBrowseInFlight = false; kbStatusInFlight = false; - kbReloadInFlight = false; kbUiError = null; - lastSyncedKbRoot = undefined; imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }; sessionsExportStatus = null; sessionsExportBusy = false; @@ -2009,8 +1970,6 @@ class NetxopsCardController { booleanField("groupOpsPublic"), booleanField("groupTopologyInPreset"), booleanField("groupTopologyPublic"), - booleanFieldPersistFalse("groupBizMonitorInPreset"), - booleanField("groupBizMonitorPublic"), booleanFieldPersistFalse("groupKbInPreset"), booleanField("groupKbPublic"), booleanField("alarmPushEnabled"), @@ -2024,7 +1983,6 @@ class NetxopsCardController { scope.subscribe(() => { this.readCredential(); this.refreshKbPreview(); - this.syncKbRootFromSettings(); }); this.readCredential(); } @@ -2068,7 +2026,7 @@ class NetxopsCardController { } this.startStatusPoll(); this.refreshAlarmPushStatus(); - this.reloadKbFromHost(); + this.refreshKbStatus(); this.refreshKbPreview(); this.refreshImDeliveryCatalog(); this.refreshSessionsExportStatus(); @@ -2156,42 +2114,15 @@ class NetxopsCardController { this.kbStatusInFlight = true; try { const next = await fetchKbStatus(call); - this.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()); } 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) @@ -2214,30 +2145,6 @@ 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; @@ -2246,7 +2153,11 @@ class NetxopsCardController { this.store.set(this.projection()); const applyPath = (path) => { this.form.actions().edit("kbRoot", path); - this.commitKbRoot(path); + this.refreshKbPreview(); + this.form.save().then(() => { + this.refreshKbStatus(); + this.refreshKbPreview(); + }); }; const fail = (error) => { this.kbUiError = error instanceof Error ? error.message : String(error); @@ -2328,8 +2239,6 @@ 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"), @@ -2489,7 +2398,6 @@ var en = { nmsProviderHint: "zte-ume", groupOps: "ops", groupTopology: "topology", - groupBizMonitor: "bizMonitor", groupKb: "Knowledge skills", groupInPreset: "In Netx Ops preset", groupPublic: "Publish to other presets", @@ -2498,7 +2406,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, then reloads Host KB status. Manual paste still needs Save.", + kbSaveHint: "Browse writes and saves automatically. Manual paste still needs Save.", kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}", kbStatusUnconfigured: "Knowledge base: not configured (pure netx)", kbStatusError: "Knowledge base: error — {detail}", @@ -2567,7 +2475,6 @@ var zh = { nmsProviderHint: "zte-ume", groupOps: "ops", groupTopology: "topology", - groupBizMonitor: "bizMonitor", groupKb: "知识库技能", groupInPreset: "在 Netx Ops 预设中启用", groupPublic: "对其他预设公开", @@ -2576,7 +2483,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 e5ec056..9047449 100644 --- a/lib/index.js +++ b/lib/index.js @@ -546,34 +546,10 @@ 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 }), - topology: Object.freeze({ inPreset: false, public: false }), - bizMonitor: Object.freeze({ inPreset: true, public: false }) + topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ @@ -609,27 +585,15 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" - ]), - bizMonitor: Object.freeze([ - "netx__listBizMonitors", - "netx__listBizMonitorBatches", - "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" + topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", - "topology", - "bizMonitor" + "topology" ]); function capabilityGroupsFromSettings(fields) { const src = fields ?? {}; @@ -646,10 +610,6 @@ function capabilityGroupsFromSettings(fields) { opsInPreset = legacy.some((v) => v === true); } } - let bizMonitorInPreset = true; - if (src.groupBizMonitorInPreset !== undefined) { - bizMonitorInPreset = src.groupBizMonitorInPreset !== false; - } return { ops: { inPreset: opsInPreset, @@ -658,10 +618,6 @@ 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 } }; } @@ -674,12 +630,12 @@ function groupsForPlane(groups, plane, only) { return enabled.filter((id) => allow.has(id)); } function toolNamesForGroups(groupIds) { - const out = new Set; + const names = new Set; for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); } - return out; + return names; } // src/netx/group-skills.ts @@ -835,16 +791,7 @@ import { join as join3, 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" + "suggestions" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -904,10 +851,6 @@ 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"); @@ -981,19 +924,6 @@ 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"; @@ -1004,29 +934,18 @@ function skillBody(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`." + "**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." ] : [ "", "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).", + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ "## Knowledge base (configured)", "", @@ -1437,25 +1356,6 @@ 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); @@ -1599,41 +1499,10 @@ 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 } : {} }); 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) }]; } @@ -1654,7 +1523,7 @@ function tool(name, description, parameters, execute) { isConcurrencySafe: () => false, async execute(args) { try { - return toLosslessJson(await execute(args)); + return await execute(args); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } @@ -1670,16 +1539,20 @@ 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.", { - 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") + 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") + } }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), @@ -1687,12 +1560,17 @@ 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 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") + 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") + } }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), @@ -1700,17 +1578,21 @@ 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 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") + 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") + } }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), @@ -1718,43 +1600,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { 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") + 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") + } }, (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") + 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") + } }, (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)") + 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)") + } }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined @@ -3090,196 +2968,6 @@ 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"; @@ -3756,347 +3444,6 @@ 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 omitUndefined({ - 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: omitUndefined({ - 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; then listBizMonitorBatches for batch_id / run_id." - }) - }; -} -function slimCutoverBatch(row) { - return omitUndefined({ - 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 omitUndefined({ - id: row.id, - task_id: row.task_id || taskId || undefined, - 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 omitUndefined({ - 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: omitUndefined({ - 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)." - }) - }; -} -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) { @@ -4776,11 +4123,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) { isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); - const safe = toLosslessJson(result); - if (result.ok === false || safe.ok === false) { - throw new Error(JSON.stringify(safe)); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); } - return safe; + return result; } }); } @@ -4922,53 +4268,6 @@ 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 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)") - }, 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 }, @@ -5170,8 +4469,6 @@ 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) @@ -5261,10 +4558,12 @@ function apply(ctx, config = Config({})) { token, logger: ctx.logger, onAlarm: async (payload) => { - await dispatchAlarmToSinks(ctx, payload, { - deliverDsh, - deliverIm, - imTargets, + if (deliverDsh) { + await deliverAlarmToSession(ctx, payload, lang); + } + await deliverAlarmToIm(ctx, payload, { + enabled: deliverIm, + targets: imTargets, lang }); } @@ -5492,92 +4791,69 @@ 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.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 7bbc103..2b2c34a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.40", + "version": "0.1.33", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", @@ -13,7 +13,6 @@ "./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" }, @@ -25,7 +24,6 @@ "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/PERSONA.md b/presets/netxops/PERSONA.md index 5e99b20..f60ba14 100644 --- a/presets/netxops/PERSONA.md +++ b/presets/netxops/PERSONA.md @@ -33,25 +33,7 @@ ## 工具 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 -- 工作区:与标准模式相同(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`。 +- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。 ## 委派(默认 subagent,活多再拆) diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index 224da38..36fb4a2 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -46,25 +46,7 @@ ## 工具 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 - - 工作区:与标准模式相同(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`。 + - 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。 ## 委派(默认 subagent,活多再拆) 你本人逻辑上全能。默认自己干;只有活明显偏多、且可并行时,才用 `subagent`(必要时 `subagent_fork`)拆开干。 diff --git a/presets/netxops/skills/README.md b/presets/netxops/skills/README.md index 8e52795..83d5b5e 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 deleted file mode 100644 index ebd44a9..0000000 --- a/presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: netx-biz-monitor -description: >- - Overnight cutover / biz_state monitor analysis via netxops host tools - (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(割接 / 业务监控只读分析) - -人配好监控与任务;AI 当晚做归因。 -工具在 **netxops**(`netx__*`),直连 netx REST,**不是** netx-mcp。 - -## 工具(能力组 `bizMonitor`) - -| 工具 | 用途 | -|------|------| -| `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** | -| `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. 无 id → `listBizMonitors`(`kind=projects|tasks|all`,可加 `q` / `purpose`) -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 解释,工具不硬编码) - -| 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 bd30554..c4ab112 100644 --- a/scripts/build-host.mjs +++ b/scripts/build-host.mjs @@ -34,7 +34,6 @@ 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 f9d0142..c979546 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", "biz-monitor")) { +foreach ($group in @("ops", "topology")) { $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 deleted file mode 100644 index 3040b19..0000000 --- a/src/agent-tools-biz-monitor.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 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 6201df3..7943898 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -564,20 +564,6 @@ 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/card-form.ts b/src/client/card-form.ts index 8da547a..8cff4cb 100644 --- a/src/client/card-form.ts +++ b/src/client/card-form.ts @@ -218,9 +218,7 @@ export class CardForm { private async store(field: string, value: unknown): Promise { await this.scope.set(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 + return this.userLayer()?.[field] === value } private stage(field: string, edit: StagedEdit): void { diff --git a/src/client/controller.ts b/src/client/controller.ts index da93e4b..813fc9f 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -17,7 +17,6 @@ import { } from './alarm-push-status-view.ts' import { fetchKbStatus, - reloadKbStatus, resolveKbPath, type KbSnapshot, } from './kb-status-view.ts' @@ -57,8 +56,6 @@ 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 @@ -84,8 +81,6 @@ export interface NetxopsCardState extends CardShell { groupOpsPublic: CardFieldState groupTopologyInPreset: CardFieldState groupTopologyPublic: CardFieldState - groupBizMonitorInPreset: CardFieldState - groupBizMonitorPublic: CardFieldState nmsProvider: CardFieldState kbRoot: CardFieldState groupKbInPreset: CardFieldState @@ -160,9 +155,7 @@ 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 @@ -191,8 +184,6 @@ export class NetxopsCardController { booleanField('groupOpsPublic'), booleanField('groupTopologyInPreset'), booleanField('groupTopologyPublic'), - booleanFieldPersistFalse('groupBizMonitorInPreset'), - booleanField('groupBizMonitorPublic'), booleanFieldPersistFalse('groupKbInPreset'), booleanField('groupKbPublic'), booleanField('alarmPushEnabled'), @@ -208,7 +199,6 @@ export class NetxopsCardController { scope.subscribe(() => { void this.readCredential() void this.refreshKbPreview() - void this.syncKbRootFromSettings() }) void this.readCredential() } @@ -256,7 +246,7 @@ export class NetxopsCardController { } this.startStatusPoll() void this.refreshAlarmPushStatus() - void this.reloadKbFromHost() + void this.refreshKbStatus() void this.refreshKbPreview() void this.refreshImDeliveryCatalog() void this.refreshSessionsExportStatus() @@ -374,7 +364,18 @@ export class NetxopsCardController { this.kbStatusInFlight = true try { const next = await fetchKbStatus(call) - this.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()) } catch { // Keep last good snapshot; next poll retries. } finally { @@ -382,50 +383,6 @@ 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 @@ -459,38 +416,6 @@ 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. @@ -503,7 +428,12 @@ export class NetxopsCardController { const applyPath = (path: string): void => { this.form.actions().edit('kbRoot', path) - void this.commitKbRoot(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() + }) } const fail = (error: unknown): void => { @@ -605,8 +535,6 @@ 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/kb-status-view.ts b/src/client/kb-status-view.ts index 205990e..68a9c29 100644 --- a/src/client/kb-status-view.ts +++ b/src/client/kb-status-view.ts @@ -10,7 +10,6 @@ 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 } @@ -51,10 +50,6 @@ 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. */ @@ -63,37 +58,10 @@ export async function fetchKbStatus( signal?: AbortSignal, ): Promise { const result = await call(NETXOPS_RPC_CHANNEL, KB_STATUS_ENDPOINT, {}, signal) - if (asRpcOk(result)) { - return asKbSnapshot(result.value) + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { + return asKbSnapshot((result as { value?: unknown }).value) } - // 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) + return unconfiguredKbSnapshot() } /** @@ -106,17 +74,13 @@ 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 (!asRpcOk(result)) { + if (!(result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true)) { result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { args: { path } }, signal) } - if (asRpcOk(result)) { - return asKbSnapshot(result.value) - } - return { - ...unconfiguredKbSnapshot(), - status: 'error', - errorMessage: 'kb.resolve unavailable', + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { + return asKbSnapshot((result as { value?: unknown }).value) } + return unconfiguredKbSnapshot() } /** Badge tone for the settings card. */ diff --git a/src/client/locales.ts b/src/client/locales.ts index 154daf1..9bafc3d 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -21,7 +21,6 @@ export type NetxopsLocaleKey = | 'nmsProviderHint' | 'groupOps' | 'groupTopology' - | 'groupBizMonitor' | 'groupKb' | 'groupInPreset' | 'groupPublic' @@ -99,7 +98,6 @@ 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', @@ -108,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, then reloads Host KB status. Manual paste still needs Save.', + kbSaveHint: 'Browse writes and saves automatically. Manual paste still needs Save.', kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}', kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)', kbStatusError: 'Knowledge base: error — {detail}', @@ -178,7 +176,6 @@ export const zh: Record = { nmsProviderHint: 'zte-ume', groupOps: 'ops', groupTopology: 'topology', - groupBizMonitor: 'bizMonitor', groupKb: '知识库技能', groupInPreset: '在 Netx Ops 预设中启用', groupPublic: '对其他预设公开', @@ -187,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 475e92f..5d18000 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,8 +35,8 @@ import { publishAlarmPushStatus, resetAlarmPushStatus, } from './netx/alarm-push-status.ts' -import { dispatchAlarmToSinks } from './netx/alarm-dispatch.ts' -import { resetAlarmSession } from './netx/alarm-session.ts' +import { deliverAlarmToIm } from './netx/alarm-im.ts' +import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts' import { capabilityGroupsFromSettings, groupsForPlane, @@ -72,7 +72,6 @@ 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. */ @@ -152,10 +151,6 @@ 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 @@ -187,8 +182,6 @@ 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), @@ -314,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) => { - // Parallel sinks: a stuck/failing DSH sticky session must not block WhatsApp/IM. - await dispatchAlarmToSinks(ctx, payload, { - deliverDsh, - deliverIm, - imTargets, + if (deliverDsh) { + await deliverAlarmToSession(ctx, payload, lang) + } + await deliverAlarmToIm(ctx, payload, { + enabled: deliverIm, + targets: imTargets, lang, }) }, @@ -602,112 +595,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, 'netxops: dispose public skills') }) - 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. + // Browser card: alarm-push status + IM catalog (RPC) and all-sessions ZIP (Fetch). ctx.inject(['connection'], (connCtx) => { const connection = connCtx.connection as { rpc?: { handle?: ( @@ -722,6 +610,72 @@ 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.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/alarm-dispatch.ts b/src/netx/alarm-dispatch.ts deleted file mode 100644 index 888254d..0000000 --- a/src/netx/alarm-dispatch.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 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/src/netx/capability-groups.ts b/src/netx/capability-groups.ts index a9e0ab4..2c85ed2 100644 --- a/src/netx/capability-groups.ts +++ b/src/netx/capability-groups.ts @@ -5,11 +5,10 @@ * * - `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' | 'bizMonitor' +export type NetxCapabilityGroupId = 'ops' | 'topology' /** Per-group exposure knobs. */ export interface NetxGroupExposure { @@ -28,17 +27,14 @@ export interface NetxCapabilityGroupSettingsFields { groupOpsPublic: boolean groupTopologyInPreset: boolean groupTopologyPublic: boolean - groupBizMonitorInPreset: boolean - groupBizMonitorPublic: boolean } /** - * Default: ops in Ops preset; topology / bizMonitor and all public off. + * Default: ops in Ops preset; topology 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 }), }) /** @@ -80,29 +76,17 @@ 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', ]) /** @@ -142,11 +126,6 @@ export function capabilityGroupsFromSettings( } } - let bizMonitorInPreset = true - if (src.groupBizMonitorInPreset !== undefined) { - bizMonitorInPreset = src.groupBizMonitorInPreset !== false - } - return { ops: { inPreset: opsInPreset, @@ -161,10 +140,6 @@ export function capabilityGroupsFromSettings( public: src.groupTopologyPublic === true || src.groupTopologyLayoutPublic === true, }, - bizMonitor: { - inPreset: bizMonitorInPreset, - public: src.groupBizMonitorPublic === true, - }, } } @@ -202,9 +177,9 @@ export function groupsForced( * Tool name allow-list for the given groups. */ export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set { - const out = new Set() + const names = new Set() for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) out.add(name) + for (const tool of TOOLS_BY_GROUP[id]) names.add(tool) } - return out + return names } diff --git a/src/netx/handlers.ts b/src/netx/handlers.ts index faa68df..c8f4108 100644 --- a/src/netx/handlers.ts +++ b/src/netx/handlers.ts @@ -3,7 +3,6 @@ */ import { type NetxClient, quoteNeId, type NetxJson } from './http.ts' -import { omitUndefined } from './json-safe.ts' const EXEC_MAX_COMMANDS = 5 @@ -331,436 +330,3 @@ 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) ───────────────────────────── - -function taskBriefNe(brief: unknown): string { - 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: NetxJson): NetxJson { - const mt = asRecord(row.monitor_template) - return omitUndefined({ - 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: NetxJson, q: string): boolean { - 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) -} - -/** - * 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: omitUndefined({ - 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; then listBizMonitorBatches for batch_id / run_id.', - }), - } -} - -function slimCutoverBatch(row: NetxJson): NetxJson { - return omitUndefined({ - 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: NetxJson, taskId = ''): NetxJson { - return omitUndefined({ - id: row.id, - task_id: row.task_id || taskId || undefined, - 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: NetxJson): NetxJson { - const summary = asRecord(row.summary) - const progress = asRecord(summary.progress) - return omitUndefined({ - 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, - }) -} - -/** - * List cutover batches, biz_state collect batches, and/or evaluate runs. - * Needs project_id and/or task_id and/or cutover batch_id (for runs). - */ -export async function listBizMonitorBatches( - client: NetxClient, - args: NetxJson, - signal?: AbortSignal, -): Promise { - 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: omitUndefined({ - 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).', - }), - } -} - -/** 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/json-safe.ts b/src/netx/json-safe.ts deleted file mode 100644 index 86d6476..0000000 --- a/src/netx/json-safe.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * 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-context-skill.ts b/src/netx/kb-context-skill.ts index 1ae7c2c..0267996 100644 --- a/src/netx/kb-context-skill.ts +++ b/src/netx/kb-context-skill.ts @@ -22,23 +22,12 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string 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`.', + '**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.', ] : [ '', @@ -46,7 +35,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string ] return { description: - 'Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).', + 'Operator knowledge-base context for this Host (paths + identity from MANIFEST).', content: [ '## Knowledge base (configured)', '', diff --git a/src/netx/kb-local-ops.ts b/src/netx/kb-local-ops.ts index 9c5af13..94f1cfb 100644 --- a/src/netx/kb-local-ops.ts +++ b/src/netx/kb-local-ops.ts @@ -18,15 +18,12 @@ import { draftFileName, memoryDir, memoryFileName, - refDir, - refFileName, resolveExistingWritableFile, resolveKbLocalRoot, resolveWritableLocalPath, suggestionDir, suggestionFileName, type MemoryBucket, - type RefArea, type SuggestionKind, KB_LOCAL_WRITABLE_ROOTS, type KbLocalWritableRoot, @@ -171,49 +168,7 @@ export function createSuggestion( } /** - * 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. + * Replace body of an existing file under memories|drafts|suggestions. * 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 97fb2bb..b86fac3 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: memories / drafts / suggestions / refs (not local_skills). + * Writable trees: memories / drafts / suggestions only (not identity / local_skills). */ import { existsSync, realpathSync, statSync } from 'node:fs' @@ -12,7 +12,6 @@ export const KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ 'memories', 'drafts', 'suggestions', - 'refs', ] as const) export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number] @@ -20,18 +19,6 @@ 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: '排障复盘', @@ -123,11 +110,6 @@ 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. @@ -225,20 +207,3 @@ 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 d79d8d4..57ce322 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 / refs). Bypasses workspace sandbox. + * (memories / drafts / suggestions). Bypasses workspace sandbox. */ import type { Context } from '@deepseek-ai/cordis' @@ -9,24 +9,17 @@ import type { KbSnapshot } from './kb-manifest.ts' import { createDraft, createMemory, - createRef, createSuggestion, deleteLocalFile, listLocalFiles, updateLocalFile, } from './kb-local-ops.ts' -import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts' +import { kbLocalToolsEnabled, resolveKbLocalRoot } 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 } : {}) }) 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) }] @@ -44,7 +37,6 @@ function liveSnapshot(): KbSnapshot { function tool( name: string, description: string, - /** DSH `defineTool` expects a flat parameter map — not a full JSON Schema object. */ parameters: Record, execute: (args: Record) => Promise | unknown, ) { @@ -57,7 +49,7 @@ function tool( isConcurrencySafe: () => false, async execute(args) { try { - return toLosslessJson(await execute(args as Record)) + return await execute(args as Record) } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)) } @@ -87,16 +79,20 @@ export function registerKbLocalTools( + 'Default create-only; set overwrite=true to replace same filename. ' + 'Do not use workspace Write — KB is outside the sandbox.', { - bucket: { - type: 'string' as const, - required: true as const, - enum: ['note', 'rca_review', 'ai_trace'], - description: 'Memory subdirectory', + 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'), }, - 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', @@ -110,13 +106,18 @@ 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 formal RCA.', + + 'Set overwrite=true to replace. Not for identity/ or 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'), + 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'), + }, }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ''), @@ -129,18 +130,22 @@ export function registerKbLocalTools( tool( 'netx__kbWriteSuggestion', `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` - + 'Use for HQ pack/theory corrections or tool/process improvements.', + + 'Use for theory corrections or tool/process improvements — never rewrite identity/.', { - kind: { - type: 'string' as const, - required: true as const, - enum: ['theory', 'improvement'], - description: 'theory = knowledge fix; improvement = tools/skills/process', + 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'), }, - 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', @@ -150,40 +155,19 @@ 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|refs ` + `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` - + 'Drafts keep status: draft.', + + 'Cannot touch identity/ or outside paths.local. Drafts keep status: draft.', { - path: reqStr('Absolute path or path relative to KB local root'), - body: reqStr('New full markdown body'), + 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'), + }, }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ''), @@ -192,10 +176,15 @@ export function registerKbLocalTools( ), tool( 'netx__kbDeleteLocal', - `Delete one existing markdown under memories|drafts|suggestions|refs ` - + `(absolute or relative to ${localRoot}).`, + `Delete one existing markdown under memories|drafts|suggestions ` + + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { - path: reqStr('Absolute path or path relative to KB local root'), + type: 'object', + additionalProperties: false, + required: ['path'], + properties: { + path: str('Absolute path or path relative to KB local root'), + }, }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? ''), @@ -203,18 +192,22 @@ export function registerKbLocalTools( ), tool( 'netx__kbListLocal', - `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` - + 'Read contents via absolute paths from the listing.', + `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + + 'Does not include identity/. Read contents via absolute paths from the listing.', { - root: { - type: 'string' as const, - enum: ['memories', 'drafts', 'suggestions', 'refs', 'all'], - description: 'Subtree to list (default all writable)', + 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)'), }, - limit: num('Max entries 1–200 (default 50)'), }, (args) => listLocalFiles(liveSnapshot(), { - root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'refs' | 'all' | undefined) ?? 'all', + root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'all' | undefined) ?? 'all', limit: typeof args.limit === 'number' ? args.limit : undefined, }), ), diff --git a/src/netx/netxops-web-rpc.ts b/src/netx/netxops-web-rpc.ts deleted file mode 100644 index b478607..0000000 --- a/src/netx/netxops-web-rpc.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * 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 () => {} -} diff --git a/src/netx/tools.ts b/src/netx/tools.ts index 81378ce..fb255be 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -11,7 +11,6 @@ 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' @@ -75,13 +74,10 @@ function tool( isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args as NetxJson, exec.signal) - // 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)) + if (result.ok === false) { + throw new Error(JSON.stringify(result)) } - return safe + return result }, }) } @@ -320,94 +316,6 @@ 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 listBizMonitorBatches / 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__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.', - { - 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/alarm-dispatch.test.mjs b/test/alarm-dispatch.test.mjs deleted file mode 100644 index 155b802..0000000 --- a/test/alarm-dispatch.test.mjs +++ /dev/null @@ -1,67 +0,0 @@ -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') -}) diff --git a/test/capability-groups.test.mjs b/test/capability-groups.test.mjs index 20c204c..f2954cf 100644 --- a/test/capability-groups.test.mjs +++ b/test/capability-groups.test.mjs @@ -15,21 +15,19 @@ import { TOOLS_BY_GROUP, } from '../src/netx/capability-groups.ts' -test('defaults: ops + bizMonitor in preset; topology and public off', () => { +test('defaults: ops 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', 'bizMonitor']) + assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops']) assert.deepEqual(groupsForPlane(undefined, 'public'), []) }) -test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => { +test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas', () => { 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')) @@ -38,16 +36,11 @@ 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__listBizMonitors')) - assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches')) - 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, }) @@ -56,7 +49,6 @@ 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', () => { @@ -69,7 +61,6 @@ 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', () => { @@ -83,5 +74,4 @@ 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']) }) diff --git a/test/json-safe.test.mjs b/test/json-safe.test.mjs deleted file mode 100644 index 286bd7f..0000000 --- a/test/json-safe.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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) -}) diff --git a/test/kb-local.test.mjs b/test/kb-local.test.mjs index 35e1494..4f09e8f 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 } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' @@ -11,7 +11,6 @@ import test from 'node:test' import { createDraft, createMemory, - createRef, createSuggestion, deleteLocalFile, listLocalFiles, @@ -63,13 +62,13 @@ test('draftFileName forces DRAFT- prefix', () => { assert.match(name, /^DRAFT-20260915-jakarta-down\.md$/) }) -test('resolveWritableLocalPath rejects non-writable roots', () => { +test('resolveWritableLocalPath rejects identity', () => { withTemp((root) => { const local = join(root, '_local') mkdirSync(local, { recursive: true }) assert.throws( - () => resolveWritableLocalPath(local, 'local_skills', 'x.md'), - /allowed under/, + () => resolveWritableLocalPath(local, 'identity', '话术.md'), + /identity|allowed under/, ) }) }) @@ -130,50 +129,26 @@ test('draft injects status: draft frontmatter', () => { }) }) -test('createRef device PROFILE + list refs', () => { +test('suggestion + refuse identity path update', () => { withTemp((root) => { const s = snap(root) - 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, { + const idDir = join(root, '_local', 'identity') + mkdirSync(idDir, { recursive: true }) + writeFileSync(join(idDir, '话术与边界.md'), 'secret\n', 'utf8') + createSuggestion(s, { kind: 'improvement', slug: 'tool-x', body: '# sug\n', date: '2026-09-15', }) - assert.match(created.relativePath, /suggestions\/improvement\//) + assert.throws( + () => updateLocalFile(s, { + path: 'identity/话术与边界.md', + body: 'hacked\n', + }), + /identity|allowed under/, + ) + assert.equal(readFileSync(join(idDir, '话术与边界.md'), 'utf8'), 'secret\n') }) })