mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-15 20:35:52 +08:00
Compare commits
10 commits
3a731389f0
...
4dd628b7cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dd628b7cc | |||
| b235957c34 | |||
| c1d07f7509 | |||
| 5a2ab85d14 | |||
| 0934e10409 | |||
| f7801db296 | |||
| 1b4c402723 | |||
| 0f31a6648a | |||
| 62d3f565ce | |||
| da08c38949 |
40 changed files with 8510 additions and 829 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,9 +4,11 @@ dist/
|
|||
lib/*
|
||||
!lib/index.js
|
||||
!lib/agent-tools.js
|
||||
!lib/agent-tools-ops.js
|
||||
!lib/agent-tools-nms.js
|
||||
!lib/agent-tools-common.js
|
||||
!lib/agent-tools-topology.js
|
||||
!lib/agent-tools-biz-monitor.js
|
||||
!lib/client.js
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Install Netx Ops on DeepSeek Harness
|
||||
# Install Netx Ops on DeepSeek Harness
|
||||
|
||||
## What `dsh plugin add` gives you
|
||||
|
||||
|
|
@ -16,9 +16,9 @@ You do **not** run `link-preset.ps1` for normal use. That script is only a manua
|
|||
## Still required outside the npm package
|
||||
|
||||
1. **netx API** reachable (default `http://127.0.0.1:8890`) — the data plane.
|
||||
2. **API token** with scopes matching the tools you use (`alarms:read`, `ne:read`, `ne:exec`, `sql:query`, …).
|
||||
2. **API token** with scopes matching the tools you use (`alarms:read`, `ne:read`, `ne:exec`, `biz-monitor:read`, `sql:query`, …).
|
||||
|
||||
No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MCP hosts can still use `python -m netx_mcp` separately.)
|
||||
No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MCP hosts can still use `python -m netx_mcp` separately for NMS/CLI — **not** for biz-monitor; that is netxops host tools only.)
|
||||
|
||||
## Clone / `pnpm install`(仓库源码)
|
||||
|
||||
|
|
@ -67,7 +67,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net
|
|||
1. Plugins card **Netx Ops** visible.
|
||||
2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail).
|
||||
3. Agent presets → Custom → **Netx Ops**.
|
||||
4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Canvas / dual_unit need **topology** (`netx-topology`).
|
||||
4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Cutover analysis: `netx__getBizMonitorContext` … (**bizMonitor** → `netx-biz-monitor`). Canvas / dual_unit need **topology** (`netx-topology`).
|
||||
5. Export section shows a session count; **下载 ZIP** starts a browser download.
|
||||
6. Optional knowledge-base badge shows operator/country/version when `kbRoot` + MANIFEST are valid.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Operator-subset knowledge packages are wired through **netxops settings**, not i
|
|||
| Surface | Responsibility |
|
||||
|---------|----------------|
|
||||
| `netx-ops` | Live netx evidence (alarms / inventory / CLI) — **pure netx**, no KB playbooks |
|
||||
| `kb-context` | Identity annotation from MANIFEST (`KB_*`) |
|
||||
| `kb-context` | MANIFEST annotation (`KB_*`) + dual-plane write rules |
|
||||
| `_skills/kb-*` | Shared operator KB triage / ingest / retrieve / export / package |
|
||||
| `paths.localSkills` | Operator-local skills (alongside `_skills/`; same toggles) |
|
||||
|
||||
|
|
@ -38,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 identity + `kbLocal` + local create/update/delete tool names |
|
||||
| Skill `kb-context` | Same MANIFEST fields + `kbLocal` + dual-plane write rules (refs / memories / drafts / suggestions) |
|
||||
| Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane |
|
||||
| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `memories\|drafts\|suggestions` only (`identity/` host persona, agent read-only) |
|
||||
| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteRef` / `WriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `refs\|memories\|drafts\|suggestions`. HQ pack is read-only; evolve site product knowledge with `kbWriteRef` (per-`host_name` PROFILE) and capture experience with `kbWriteMemory` as it appears. |
|
||||
|
||||
RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths).
|
||||
RPC (channel `/netxops`): `kb.status` (published snapshot), `kb.reload` (re-resolve live `kbRoot` + republish), `kb.resolve` with `{ path }` (preview unsaved paths).
|
||||
|
||||
## Pack skill registration
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ Tools register by capability group. **One group ↔ one skill.**
|
|||
|-------|-------|------------------------|
|
||||
| **ops** | `netx-ops` | on |
|
||||
| **topology** | `netx-topology` | off |
|
||||
| **bizMonitor** | `netx-biz-monitor` | on |
|
||||
|
||||
Public flags mount the same group on the host for other presets.
|
||||
Forced mounts: `dsh-netxops/tools-ops` | `tools-topology` (legacy aliases: `tools-nms` / `tools-common` → ops).
|
||||
Forced mounts: `dsh-netxops/tools-ops` | `tools-topology` | `tools-biz-monitor` (legacy aliases: `tools-nms` / `tools-common` → ops).
|
||||
|
||||
Model names: `netx__<stem>`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` still hits `/v1/ume/*`.
|
||||
|
||||
|
|
@ -20,6 +21,19 @@ Model names: `netx__<stem>`. 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 |
|
||||
|
|
@ -35,13 +49,14 @@ Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`.
|
|||
|
||||
## knowledge base → `groupKb*` (not ops/topology)
|
||||
|
||||
Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/memories|drafts|suggestions` only — **not** `identity/`.
|
||||
Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/refs|memories|drafts|suggestions`.
|
||||
|
||||
| Tool | Role |
|
||||
|------|------|
|
||||
| `kbWriteMemory` | New diary entry under `memories/{日常笔记\|排障复盘\|AI思维链}/` (`overwrite` optional) |
|
||||
| `kbWriteRef` | Site product KB under `refs/{inventory\|devices\|topology\|business\|commands\|handbooks}/`. `devices` requires `device=host_name`; primary profile slug=`PROFILE`. |
|
||||
| `kbWriteMemory` | Diary under `memories/{日常笔记\|排障复盘\|AI思维链}/` — write as soon as reusable experience appears. |
|
||||
| `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) |
|
||||
| `kbWriteSuggestion` | New file under `suggestions/{theory\|improvement}/` |
|
||||
| `kbUpdateLocal` | Replace body of an existing jailed file |
|
||||
| `kbDeleteLocal` | Delete one jailed file |
|
||||
| `kbListLocal` | List recent `.md` under writable trees |
|
||||
| `kbListLocal` | List recent `.md` under writable trees (`root` may be `refs`) |
|
||||
|
|
|
|||
3029
lib/agent-tools-biz-monitor.js
Normal file
3029
lib/agent-tools-biz-monitor.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,8 @@
|
|||
// src/netx/capability-groups.ts
|
||||
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
|
||||
ops: Object.freeze({ inPreset: true, public: false }),
|
||||
topology: Object.freeze({ inPreset: false, public: false })
|
||||
topology: Object.freeze({ inPreset: false, public: false }),
|
||||
bizMonitor: Object.freeze({ inPreset: true, public: false })
|
||||
});
|
||||
var TOOLS_BY_GROUP = Object.freeze({
|
||||
ops: Object.freeze([
|
||||
|
|
@ -37,15 +38,27 @@ 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"
|
||||
topology: "topology",
|
||||
bizMonitor: "biz-monitor"
|
||||
});
|
||||
var CAPABILITY_GROUP_IDS = Object.freeze([
|
||||
"ops",
|
||||
"topology"
|
||||
"topology",
|
||||
"bizMonitor"
|
||||
]);
|
||||
function groupsForPlane(groups, plane, only) {
|
||||
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
|
||||
|
|
@ -60,12 +73,12 @@ function groupsForced(only) {
|
|||
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
|
||||
}
|
||||
function toolNamesForGroups(groupIds) {
|
||||
const names = new Set;
|
||||
const out = new Set;
|
||||
for (const id of groupIds) {
|
||||
for (const tool of TOOLS_BY_GROUP[id])
|
||||
names.add(tool);
|
||||
for (const name of TOOLS_BY_GROUP[id] ?? [])
|
||||
out.add(name);
|
||||
}
|
||||
return names;
|
||||
return out;
|
||||
}
|
||||
|
||||
// src/netx/group-skills.ts
|
||||
|
|
@ -221,7 +234,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
|
|||
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
|
||||
"memories",
|
||||
"drafts",
|
||||
"suggestions"
|
||||
"suggestions",
|
||||
"refs"
|
||||
]);
|
||||
var REF_AREAS = Object.freeze([
|
||||
"inventory",
|
||||
"devices",
|
||||
"topology",
|
||||
"business",
|
||||
"commands",
|
||||
"handbooks"
|
||||
]);
|
||||
var MEMORY_BUCKET_DIR = Object.freeze({
|
||||
note: "日常笔记",
|
||||
|
|
@ -281,6 +303,10 @@ function suggestionFileName(opts) {
|
|||
const date = normalizeIsoDate(opts.date, opts.now);
|
||||
return `${date}-${sanitizeSlug(opts.slug)}.md`;
|
||||
}
|
||||
function refFileName(slug) {
|
||||
const base = sanitizeSlug(slug, "note");
|
||||
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
|
||||
}
|
||||
function resolveWritableLocalPath(localRoot, ...segments) {
|
||||
if (segments.some((s) => s.includes("\x00"))) {
|
||||
throw new Error("path segment contains NUL");
|
||||
|
|
@ -354,6 +380,19 @@ function draftDir(domain) {
|
|||
return "drafts";
|
||||
return join2("drafts", sanitizeSlug(d, "misc"));
|
||||
}
|
||||
function refDir(area, device) {
|
||||
if (!REF_AREAS.includes(area)) {
|
||||
throw new Error(`invalid refs area: ${area}`);
|
||||
}
|
||||
if (area === "devices") {
|
||||
const host = sanitizeSlug(device?.trim() || "", "");
|
||||
if (!host) {
|
||||
throw new Error("refs/devices requires device (host_name)");
|
||||
}
|
||||
return join2("refs", "devices", host);
|
||||
}
|
||||
return join2("refs", area);
|
||||
}
|
||||
|
||||
// src/netx/kb-context-skill.ts
|
||||
var SKILL_NAME = "kb-context";
|
||||
|
|
@ -364,18 +403,29 @@ function skillBody(snapshot) {
|
|||
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
|
||||
const localGuide = localRoot ? [
|
||||
"",
|
||||
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
|
||||
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
|
||||
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
|
||||
"Do **not** use workspace Write/bash for KB paths (sandbox).",
|
||||
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
|
||||
"file boundary issues as `suggestions/` instead."
|
||||
"### Dual plane (mandatory)",
|
||||
"",
|
||||
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
|
||||
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
|
||||
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
|
||||
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
|
||||
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
|
||||
"",
|
||||
"### refs — site product knowledge (evolve every task)",
|
||||
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
|
||||
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
|
||||
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
|
||||
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
|
||||
"",
|
||||
"### memories — write immediately when valuable",
|
||||
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
|
||||
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
|
||||
] : [
|
||||
"",
|
||||
"No local write tools until `paths.local` is present in MANIFEST."
|
||||
];
|
||||
return {
|
||||
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||
description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).",
|
||||
content: [
|
||||
"## Knowledge base (configured)",
|
||||
"",
|
||||
|
|
@ -543,6 +593,25 @@ function createSuggestion(snapshot, args) {
|
|||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function createRef(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const name = refFileName(args.slug);
|
||||
const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name);
|
||||
if (existsSync3(target.absolutePath) && !args.overwrite) {
|
||||
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
|
||||
}
|
||||
mkdirSync(dirname2(target.absolutePath), { recursive: true });
|
||||
const existed = existsSync3(target.absolutePath);
|
||||
writeFileSync(target.absolutePath, args.body.endsWith(`
|
||||
`) ? args.body : `${args.body}
|
||||
`, "utf8");
|
||||
return {
|
||||
ok: true,
|
||||
action: existed ? "updated" : "created",
|
||||
absolutePath: target.absolutePath,
|
||||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function updateLocalFile(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const target = resolveExistingWritableFile(localRoot, args.path);
|
||||
|
|
@ -668,10 +737,41 @@ 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) }];
|
||||
}
|
||||
|
|
@ -692,7 +792,7 @@ function tool(name, description, parameters, execute) {
|
|||
isConcurrencySafe: () => false,
|
||||
async execute(args) {
|
||||
try {
|
||||
return await execute(args);
|
||||
return toLosslessJson(await execute(args));
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
|
@ -708,20 +808,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
return () => {};
|
||||
const entries = [
|
||||
tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["bucket", "slug", "body"],
|
||||
properties: {
|
||||
bucket: {
|
||||
type: "string",
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: str("Short filename stem (no path separators)"),
|
||||
body: str("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}
|
||||
bucket: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: reqStr("Short filename stem (no path separators)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createMemory(liveSnapshot(), {
|
||||
bucket: args.bucket,
|
||||
slug: String(args.slug ?? ""),
|
||||
|
|
@ -729,17 +825,12 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -747,21 +838,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -769,39 +856,43 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
|
||||
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")
|
||||
}
|
||||
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
|
||||
area: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
|
||||
description: "refs/ subtree"
|
||||
},
|
||||
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
device: str("Required when area=devices — host_name (never UUID)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createRef(liveSnapshot(), {
|
||||
area: args.area,
|
||||
slug: String(args.slug ?? ""),
|
||||
body: String(args.body ?? ""),
|
||||
device: args.device != null ? String(args.device) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
|
||||
path: reqStr("Absolute path or path relative to KB local root"),
|
||||
body: reqStr("New full markdown body")
|
||||
}, (args) => updateLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? ""),
|
||||
body: String(args.body ?? "")
|
||||
})),
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: str("Absolute path or path relative to KB local root")
|
||||
}
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
|
||||
path: reqStr("Absolute path or path relative to KB local root")
|
||||
}, (args) => deleteLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? "")
|
||||
})),
|
||||
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
|
||||
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)")
|
||||
}
|
||||
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
|
||||
|
|
@ -1406,6 +1497,347 @@ 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) {
|
||||
|
|
@ -2085,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
|
|||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const result = await handler(getClient(), args, exec.signal);
|
||||
if (result.ok === false) {
|
||||
throw new Error(JSON.stringify(result));
|
||||
const safe = toLosslessJson(result);
|
||||
if (result.ok === false || safe.ok === false) {
|
||||
throw new Error(JSON.stringify(safe));
|
||||
}
|
||||
return result;
|
||||
return safe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2230,6 +2663,53 @@ 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 },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// src/netx/capability-groups.ts
|
||||
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
|
||||
ops: Object.freeze({ inPreset: true, public: false }),
|
||||
topology: Object.freeze({ inPreset: false, public: false })
|
||||
topology: Object.freeze({ inPreset: false, public: false }),
|
||||
bizMonitor: Object.freeze({ inPreset: true, public: false })
|
||||
});
|
||||
var TOOLS_BY_GROUP = Object.freeze({
|
||||
ops: Object.freeze([
|
||||
|
|
@ -37,15 +38,27 @@ 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"
|
||||
topology: "topology",
|
||||
bizMonitor: "biz-monitor"
|
||||
});
|
||||
var CAPABILITY_GROUP_IDS = Object.freeze([
|
||||
"ops",
|
||||
"topology"
|
||||
"topology",
|
||||
"bizMonitor"
|
||||
]);
|
||||
function groupsForPlane(groups, plane, only) {
|
||||
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
|
||||
|
|
@ -60,12 +73,12 @@ function groupsForced(only) {
|
|||
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
|
||||
}
|
||||
function toolNamesForGroups(groupIds) {
|
||||
const names = new Set;
|
||||
const out = new Set;
|
||||
for (const id of groupIds) {
|
||||
for (const tool of TOOLS_BY_GROUP[id])
|
||||
names.add(tool);
|
||||
for (const name of TOOLS_BY_GROUP[id] ?? [])
|
||||
out.add(name);
|
||||
}
|
||||
return names;
|
||||
return out;
|
||||
}
|
||||
|
||||
// src/netx/group-skills.ts
|
||||
|
|
@ -221,7 +234,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
|
|||
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
|
||||
"memories",
|
||||
"drafts",
|
||||
"suggestions"
|
||||
"suggestions",
|
||||
"refs"
|
||||
]);
|
||||
var REF_AREAS = Object.freeze([
|
||||
"inventory",
|
||||
"devices",
|
||||
"topology",
|
||||
"business",
|
||||
"commands",
|
||||
"handbooks"
|
||||
]);
|
||||
var MEMORY_BUCKET_DIR = Object.freeze({
|
||||
note: "日常笔记",
|
||||
|
|
@ -281,6 +303,10 @@ function suggestionFileName(opts) {
|
|||
const date = normalizeIsoDate(opts.date, opts.now);
|
||||
return `${date}-${sanitizeSlug(opts.slug)}.md`;
|
||||
}
|
||||
function refFileName(slug) {
|
||||
const base = sanitizeSlug(slug, "note");
|
||||
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
|
||||
}
|
||||
function resolveWritableLocalPath(localRoot, ...segments) {
|
||||
if (segments.some((s) => s.includes("\x00"))) {
|
||||
throw new Error("path segment contains NUL");
|
||||
|
|
@ -354,6 +380,19 @@ function draftDir(domain) {
|
|||
return "drafts";
|
||||
return join2("drafts", sanitizeSlug(d, "misc"));
|
||||
}
|
||||
function refDir(area, device) {
|
||||
if (!REF_AREAS.includes(area)) {
|
||||
throw new Error(`invalid refs area: ${area}`);
|
||||
}
|
||||
if (area === "devices") {
|
||||
const host = sanitizeSlug(device?.trim() || "", "");
|
||||
if (!host) {
|
||||
throw new Error("refs/devices requires device (host_name)");
|
||||
}
|
||||
return join2("refs", "devices", host);
|
||||
}
|
||||
return join2("refs", area);
|
||||
}
|
||||
|
||||
// src/netx/kb-context-skill.ts
|
||||
var SKILL_NAME = "kb-context";
|
||||
|
|
@ -364,18 +403,29 @@ function skillBody(snapshot) {
|
|||
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
|
||||
const localGuide = localRoot ? [
|
||||
"",
|
||||
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
|
||||
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
|
||||
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
|
||||
"Do **not** use workspace Write/bash for KB paths (sandbox).",
|
||||
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
|
||||
"file boundary issues as `suggestions/` instead."
|
||||
"### Dual plane (mandatory)",
|
||||
"",
|
||||
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
|
||||
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
|
||||
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
|
||||
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
|
||||
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
|
||||
"",
|
||||
"### refs — site product knowledge (evolve every task)",
|
||||
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
|
||||
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
|
||||
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
|
||||
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
|
||||
"",
|
||||
"### memories — write immediately when valuable",
|
||||
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
|
||||
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
|
||||
] : [
|
||||
"",
|
||||
"No local write tools until `paths.local` is present in MANIFEST."
|
||||
];
|
||||
return {
|
||||
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||
description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).",
|
||||
content: [
|
||||
"## Knowledge base (configured)",
|
||||
"",
|
||||
|
|
@ -543,6 +593,25 @@ function createSuggestion(snapshot, args) {
|
|||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function createRef(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const name = refFileName(args.slug);
|
||||
const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name);
|
||||
if (existsSync3(target.absolutePath) && !args.overwrite) {
|
||||
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
|
||||
}
|
||||
mkdirSync(dirname2(target.absolutePath), { recursive: true });
|
||||
const existed = existsSync3(target.absolutePath);
|
||||
writeFileSync(target.absolutePath, args.body.endsWith(`
|
||||
`) ? args.body : `${args.body}
|
||||
`, "utf8");
|
||||
return {
|
||||
ok: true,
|
||||
action: existed ? "updated" : "created",
|
||||
absolutePath: target.absolutePath,
|
||||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function updateLocalFile(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const target = resolveExistingWritableFile(localRoot, args.path);
|
||||
|
|
@ -668,10 +737,41 @@ 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) }];
|
||||
}
|
||||
|
|
@ -692,7 +792,7 @@ function tool(name, description, parameters, execute) {
|
|||
isConcurrencySafe: () => false,
|
||||
async execute(args) {
|
||||
try {
|
||||
return await execute(args);
|
||||
return toLosslessJson(await execute(args));
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
|
@ -708,20 +808,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
return () => {};
|
||||
const entries = [
|
||||
tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["bucket", "slug", "body"],
|
||||
properties: {
|
||||
bucket: {
|
||||
type: "string",
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: str("Short filename stem (no path separators)"),
|
||||
body: str("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}
|
||||
bucket: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: reqStr("Short filename stem (no path separators)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createMemory(liveSnapshot(), {
|
||||
bucket: args.bucket,
|
||||
slug: String(args.slug ?? ""),
|
||||
|
|
@ -729,17 +825,12 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -747,21 +838,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -769,39 +856,43 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
|
||||
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")
|
||||
}
|
||||
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
|
||||
area: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
|
||||
description: "refs/ subtree"
|
||||
},
|
||||
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
device: str("Required when area=devices — host_name (never UUID)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createRef(liveSnapshot(), {
|
||||
area: args.area,
|
||||
slug: String(args.slug ?? ""),
|
||||
body: String(args.body ?? ""),
|
||||
device: args.device != null ? String(args.device) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
|
||||
path: reqStr("Absolute path or path relative to KB local root"),
|
||||
body: reqStr("New full markdown body")
|
||||
}, (args) => updateLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? ""),
|
||||
body: String(args.body ?? "")
|
||||
})),
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: str("Absolute path or path relative to KB local root")
|
||||
}
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
|
||||
path: reqStr("Absolute path or path relative to KB local root")
|
||||
}, (args) => deleteLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? "")
|
||||
})),
|
||||
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
|
||||
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)")
|
||||
}
|
||||
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
|
||||
|
|
@ -1406,6 +1497,347 @@ 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) {
|
||||
|
|
@ -2085,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
|
|||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const result = await handler(getClient(), args, exec.signal);
|
||||
if (result.ok === false) {
|
||||
throw new Error(JSON.stringify(result));
|
||||
const safe = toLosslessJson(result);
|
||||
if (result.ok === false || safe.ok === false) {
|
||||
throw new Error(JSON.stringify(safe));
|
||||
}
|
||||
return result;
|
||||
return safe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2230,6 +2663,53 @@ 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 },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// src/netx/capability-groups.ts
|
||||
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
|
||||
ops: Object.freeze({ inPreset: true, public: false }),
|
||||
topology: Object.freeze({ inPreset: false, public: false })
|
||||
topology: Object.freeze({ inPreset: false, public: false }),
|
||||
bizMonitor: Object.freeze({ inPreset: true, public: false })
|
||||
});
|
||||
var TOOLS_BY_GROUP = Object.freeze({
|
||||
ops: Object.freeze([
|
||||
|
|
@ -37,15 +38,27 @@ 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"
|
||||
topology: "topology",
|
||||
bizMonitor: "biz-monitor"
|
||||
});
|
||||
var CAPABILITY_GROUP_IDS = Object.freeze([
|
||||
"ops",
|
||||
"topology"
|
||||
"topology",
|
||||
"bizMonitor"
|
||||
]);
|
||||
function groupsForPlane(groups, plane, only) {
|
||||
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
|
||||
|
|
@ -60,12 +73,12 @@ function groupsForced(only) {
|
|||
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
|
||||
}
|
||||
function toolNamesForGroups(groupIds) {
|
||||
const names = new Set;
|
||||
const out = new Set;
|
||||
for (const id of groupIds) {
|
||||
for (const tool of TOOLS_BY_GROUP[id])
|
||||
names.add(tool);
|
||||
for (const name of TOOLS_BY_GROUP[id] ?? [])
|
||||
out.add(name);
|
||||
}
|
||||
return names;
|
||||
return out;
|
||||
}
|
||||
|
||||
// src/netx/group-skills.ts
|
||||
|
|
@ -221,7 +234,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
|
|||
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
|
||||
"memories",
|
||||
"drafts",
|
||||
"suggestions"
|
||||
"suggestions",
|
||||
"refs"
|
||||
]);
|
||||
var REF_AREAS = Object.freeze([
|
||||
"inventory",
|
||||
"devices",
|
||||
"topology",
|
||||
"business",
|
||||
"commands",
|
||||
"handbooks"
|
||||
]);
|
||||
var MEMORY_BUCKET_DIR = Object.freeze({
|
||||
note: "日常笔记",
|
||||
|
|
@ -281,6 +303,10 @@ function suggestionFileName(opts) {
|
|||
const date = normalizeIsoDate(opts.date, opts.now);
|
||||
return `${date}-${sanitizeSlug(opts.slug)}.md`;
|
||||
}
|
||||
function refFileName(slug) {
|
||||
const base = sanitizeSlug(slug, "note");
|
||||
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
|
||||
}
|
||||
function resolveWritableLocalPath(localRoot, ...segments) {
|
||||
if (segments.some((s) => s.includes("\x00"))) {
|
||||
throw new Error("path segment contains NUL");
|
||||
|
|
@ -354,6 +380,19 @@ function draftDir(domain) {
|
|||
return "drafts";
|
||||
return join2("drafts", sanitizeSlug(d, "misc"));
|
||||
}
|
||||
function refDir(area, device) {
|
||||
if (!REF_AREAS.includes(area)) {
|
||||
throw new Error(`invalid refs area: ${area}`);
|
||||
}
|
||||
if (area === "devices") {
|
||||
const host = sanitizeSlug(device?.trim() || "", "");
|
||||
if (!host) {
|
||||
throw new Error("refs/devices requires device (host_name)");
|
||||
}
|
||||
return join2("refs", "devices", host);
|
||||
}
|
||||
return join2("refs", area);
|
||||
}
|
||||
|
||||
// src/netx/kb-context-skill.ts
|
||||
var SKILL_NAME = "kb-context";
|
||||
|
|
@ -364,18 +403,29 @@ function skillBody(snapshot) {
|
|||
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
|
||||
const localGuide = localRoot ? [
|
||||
"",
|
||||
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
|
||||
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
|
||||
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
|
||||
"Do **not** use workspace Write/bash for KB paths (sandbox).",
|
||||
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
|
||||
"file boundary issues as `suggestions/` instead."
|
||||
"### Dual plane (mandatory)",
|
||||
"",
|
||||
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
|
||||
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
|
||||
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
|
||||
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
|
||||
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
|
||||
"",
|
||||
"### refs — site product knowledge (evolve every task)",
|
||||
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
|
||||
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
|
||||
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
|
||||
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
|
||||
"",
|
||||
"### memories — write immediately when valuable",
|
||||
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
|
||||
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
|
||||
] : [
|
||||
"",
|
||||
"No local write tools until `paths.local` is present in MANIFEST."
|
||||
];
|
||||
return {
|
||||
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||
description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).",
|
||||
content: [
|
||||
"## Knowledge base (configured)",
|
||||
"",
|
||||
|
|
@ -543,6 +593,25 @@ function createSuggestion(snapshot, args) {
|
|||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function createRef(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const name = refFileName(args.slug);
|
||||
const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name);
|
||||
if (existsSync3(target.absolutePath) && !args.overwrite) {
|
||||
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
|
||||
}
|
||||
mkdirSync(dirname2(target.absolutePath), { recursive: true });
|
||||
const existed = existsSync3(target.absolutePath);
|
||||
writeFileSync(target.absolutePath, args.body.endsWith(`
|
||||
`) ? args.body : `${args.body}
|
||||
`, "utf8");
|
||||
return {
|
||||
ok: true,
|
||||
action: existed ? "updated" : "created",
|
||||
absolutePath: target.absolutePath,
|
||||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function updateLocalFile(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const target = resolveExistingWritableFile(localRoot, args.path);
|
||||
|
|
@ -668,10 +737,41 @@ 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) }];
|
||||
}
|
||||
|
|
@ -692,7 +792,7 @@ function tool(name, description, parameters, execute) {
|
|||
isConcurrencySafe: () => false,
|
||||
async execute(args) {
|
||||
try {
|
||||
return await execute(args);
|
||||
return toLosslessJson(await execute(args));
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
|
@ -708,20 +808,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
return () => {};
|
||||
const entries = [
|
||||
tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["bucket", "slug", "body"],
|
||||
properties: {
|
||||
bucket: {
|
||||
type: "string",
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: str("Short filename stem (no path separators)"),
|
||||
body: str("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}
|
||||
bucket: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: reqStr("Short filename stem (no path separators)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createMemory(liveSnapshot(), {
|
||||
bucket: args.bucket,
|
||||
slug: String(args.slug ?? ""),
|
||||
|
|
@ -729,17 +825,12 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -747,21 +838,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -769,39 +856,43 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
|
||||
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")
|
||||
}
|
||||
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
|
||||
area: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
|
||||
description: "refs/ subtree"
|
||||
},
|
||||
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
device: str("Required when area=devices — host_name (never UUID)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createRef(liveSnapshot(), {
|
||||
area: args.area,
|
||||
slug: String(args.slug ?? ""),
|
||||
body: String(args.body ?? ""),
|
||||
device: args.device != null ? String(args.device) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
|
||||
path: reqStr("Absolute path or path relative to KB local root"),
|
||||
body: reqStr("New full markdown body")
|
||||
}, (args) => updateLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? ""),
|
||||
body: String(args.body ?? "")
|
||||
})),
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: str("Absolute path or path relative to KB local root")
|
||||
}
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
|
||||
path: reqStr("Absolute path or path relative to KB local root")
|
||||
}, (args) => deleteLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? "")
|
||||
})),
|
||||
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
|
||||
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)")
|
||||
}
|
||||
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
|
||||
|
|
@ -1406,6 +1497,347 @@ 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) {
|
||||
|
|
@ -2085,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
|
|||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const result = await handler(getClient(), args, exec.signal);
|
||||
if (result.ok === false) {
|
||||
throw new Error(JSON.stringify(result));
|
||||
const safe = toLosslessJson(result);
|
||||
if (result.ok === false || safe.ok === false) {
|
||||
throw new Error(JSON.stringify(safe));
|
||||
}
|
||||
return result;
|
||||
return safe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2230,6 +2663,53 @@ 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 },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// src/netx/capability-groups.ts
|
||||
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
|
||||
ops: Object.freeze({ inPreset: true, public: false }),
|
||||
topology: Object.freeze({ inPreset: false, public: false })
|
||||
topology: Object.freeze({ inPreset: false, public: false }),
|
||||
bizMonitor: Object.freeze({ inPreset: true, public: false })
|
||||
});
|
||||
var TOOLS_BY_GROUP = Object.freeze({
|
||||
ops: Object.freeze([
|
||||
|
|
@ -37,15 +38,27 @@ 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"
|
||||
topology: "topology",
|
||||
bizMonitor: "biz-monitor"
|
||||
});
|
||||
var CAPABILITY_GROUP_IDS = Object.freeze([
|
||||
"ops",
|
||||
"topology"
|
||||
"topology",
|
||||
"bizMonitor"
|
||||
]);
|
||||
function groupsForPlane(groups, plane, only) {
|
||||
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
|
||||
|
|
@ -60,12 +73,12 @@ function groupsForced(only) {
|
|||
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
|
||||
}
|
||||
function toolNamesForGroups(groupIds) {
|
||||
const names = new Set;
|
||||
const out = new Set;
|
||||
for (const id of groupIds) {
|
||||
for (const tool of TOOLS_BY_GROUP[id])
|
||||
names.add(tool);
|
||||
for (const name of TOOLS_BY_GROUP[id] ?? [])
|
||||
out.add(name);
|
||||
}
|
||||
return names;
|
||||
return out;
|
||||
}
|
||||
|
||||
// src/netx/group-skills.ts
|
||||
|
|
@ -221,7 +234,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
|
|||
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
|
||||
"memories",
|
||||
"drafts",
|
||||
"suggestions"
|
||||
"suggestions",
|
||||
"refs"
|
||||
]);
|
||||
var REF_AREAS = Object.freeze([
|
||||
"inventory",
|
||||
"devices",
|
||||
"topology",
|
||||
"business",
|
||||
"commands",
|
||||
"handbooks"
|
||||
]);
|
||||
var MEMORY_BUCKET_DIR = Object.freeze({
|
||||
note: "日常笔记",
|
||||
|
|
@ -281,6 +303,10 @@ function suggestionFileName(opts) {
|
|||
const date = normalizeIsoDate(opts.date, opts.now);
|
||||
return `${date}-${sanitizeSlug(opts.slug)}.md`;
|
||||
}
|
||||
function refFileName(slug) {
|
||||
const base = sanitizeSlug(slug, "note");
|
||||
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
|
||||
}
|
||||
function resolveWritableLocalPath(localRoot, ...segments) {
|
||||
if (segments.some((s) => s.includes("\x00"))) {
|
||||
throw new Error("path segment contains NUL");
|
||||
|
|
@ -354,6 +380,19 @@ function draftDir(domain) {
|
|||
return "drafts";
|
||||
return join2("drafts", sanitizeSlug(d, "misc"));
|
||||
}
|
||||
function refDir(area, device) {
|
||||
if (!REF_AREAS.includes(area)) {
|
||||
throw new Error(`invalid refs area: ${area}`);
|
||||
}
|
||||
if (area === "devices") {
|
||||
const host = sanitizeSlug(device?.trim() || "", "");
|
||||
if (!host) {
|
||||
throw new Error("refs/devices requires device (host_name)");
|
||||
}
|
||||
return join2("refs", "devices", host);
|
||||
}
|
||||
return join2("refs", area);
|
||||
}
|
||||
|
||||
// src/netx/kb-context-skill.ts
|
||||
var SKILL_NAME = "kb-context";
|
||||
|
|
@ -364,18 +403,29 @@ function skillBody(snapshot) {
|
|||
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
|
||||
const localGuide = localRoot ? [
|
||||
"",
|
||||
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
|
||||
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
|
||||
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
|
||||
"Do **not** use workspace Write/bash for KB paths (sandbox).",
|
||||
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
|
||||
"file boundary issues as `suggestions/` instead."
|
||||
"### Dual plane (mandatory)",
|
||||
"",
|
||||
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
|
||||
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
|
||||
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
|
||||
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
|
||||
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
|
||||
"",
|
||||
"### refs — site product knowledge (evolve every task)",
|
||||
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
|
||||
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
|
||||
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
|
||||
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
|
||||
"",
|
||||
"### memories — write immediately when valuable",
|
||||
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
|
||||
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
|
||||
] : [
|
||||
"",
|
||||
"No local write tools until `paths.local` is present in MANIFEST."
|
||||
];
|
||||
return {
|
||||
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||
description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).",
|
||||
content: [
|
||||
"## Knowledge base (configured)",
|
||||
"",
|
||||
|
|
@ -543,6 +593,25 @@ function createSuggestion(snapshot, args) {
|
|||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function createRef(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const name = refFileName(args.slug);
|
||||
const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name);
|
||||
if (existsSync3(target.absolutePath) && !args.overwrite) {
|
||||
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
|
||||
}
|
||||
mkdirSync(dirname2(target.absolutePath), { recursive: true });
|
||||
const existed = existsSync3(target.absolutePath);
|
||||
writeFileSync(target.absolutePath, args.body.endsWith(`
|
||||
`) ? args.body : `${args.body}
|
||||
`, "utf8");
|
||||
return {
|
||||
ok: true,
|
||||
action: existed ? "updated" : "created",
|
||||
absolutePath: target.absolutePath,
|
||||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function updateLocalFile(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const target = resolveExistingWritableFile(localRoot, args.path);
|
||||
|
|
@ -668,10 +737,41 @@ 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) }];
|
||||
}
|
||||
|
|
@ -692,7 +792,7 @@ function tool(name, description, parameters, execute) {
|
|||
isConcurrencySafe: () => false,
|
||||
async execute(args) {
|
||||
try {
|
||||
return await execute(args);
|
||||
return toLosslessJson(await execute(args));
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
|
@ -708,20 +808,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
return () => {};
|
||||
const entries = [
|
||||
tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["bucket", "slug", "body"],
|
||||
properties: {
|
||||
bucket: {
|
||||
type: "string",
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: str("Short filename stem (no path separators)"),
|
||||
body: str("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}
|
||||
bucket: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: reqStr("Short filename stem (no path separators)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createMemory(liveSnapshot(), {
|
||||
bucket: args.bucket,
|
||||
slug: String(args.slug ?? ""),
|
||||
|
|
@ -729,17 +825,12 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -747,21 +838,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -769,39 +856,43 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
|
||||
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")
|
||||
}
|
||||
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
|
||||
area: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
|
||||
description: "refs/ subtree"
|
||||
},
|
||||
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
device: str("Required when area=devices — host_name (never UUID)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createRef(liveSnapshot(), {
|
||||
area: args.area,
|
||||
slug: String(args.slug ?? ""),
|
||||
body: String(args.body ?? ""),
|
||||
device: args.device != null ? String(args.device) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
|
||||
path: reqStr("Absolute path or path relative to KB local root"),
|
||||
body: reqStr("New full markdown body")
|
||||
}, (args) => updateLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? ""),
|
||||
body: String(args.body ?? "")
|
||||
})),
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: str("Absolute path or path relative to KB local root")
|
||||
}
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
|
||||
path: reqStr("Absolute path or path relative to KB local root")
|
||||
}, (args) => deleteLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? "")
|
||||
})),
|
||||
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
|
||||
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)")
|
||||
}
|
||||
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
|
||||
|
|
@ -1406,6 +1497,347 @@ 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) {
|
||||
|
|
@ -2085,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
|
|||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const result = await handler(getClient(), args, exec.signal);
|
||||
if (result.ok === false) {
|
||||
throw new Error(JSON.stringify(result));
|
||||
const safe = toLosslessJson(result);
|
||||
if (result.ok === false || safe.ok === false) {
|
||||
throw new Error(JSON.stringify(safe));
|
||||
}
|
||||
return result;
|
||||
return safe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2230,6 +2663,53 @@ 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 },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// src/netx/capability-groups.ts
|
||||
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
|
||||
ops: Object.freeze({ inPreset: true, public: false }),
|
||||
topology: Object.freeze({ inPreset: false, public: false })
|
||||
topology: Object.freeze({ inPreset: false, public: false }),
|
||||
bizMonitor: Object.freeze({ inPreset: true, public: false })
|
||||
});
|
||||
var TOOLS_BY_GROUP = Object.freeze({
|
||||
ops: Object.freeze([
|
||||
|
|
@ -37,15 +38,27 @@ 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"
|
||||
topology: "topology",
|
||||
bizMonitor: "biz-monitor"
|
||||
});
|
||||
var CAPABILITY_GROUP_IDS = Object.freeze([
|
||||
"ops",
|
||||
"topology"
|
||||
"topology",
|
||||
"bizMonitor"
|
||||
]);
|
||||
function groupsForPlane(groups, plane, only) {
|
||||
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
|
||||
|
|
@ -60,12 +73,12 @@ function groupsForced(only) {
|
|||
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
|
||||
}
|
||||
function toolNamesForGroups(groupIds) {
|
||||
const names = new Set;
|
||||
const out = new Set;
|
||||
for (const id of groupIds) {
|
||||
for (const tool of TOOLS_BY_GROUP[id])
|
||||
names.add(tool);
|
||||
for (const name of TOOLS_BY_GROUP[id] ?? [])
|
||||
out.add(name);
|
||||
}
|
||||
return names;
|
||||
return out;
|
||||
}
|
||||
|
||||
// src/netx/group-skills.ts
|
||||
|
|
@ -221,7 +234,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
|
|||
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
|
||||
"memories",
|
||||
"drafts",
|
||||
"suggestions"
|
||||
"suggestions",
|
||||
"refs"
|
||||
]);
|
||||
var REF_AREAS = Object.freeze([
|
||||
"inventory",
|
||||
"devices",
|
||||
"topology",
|
||||
"business",
|
||||
"commands",
|
||||
"handbooks"
|
||||
]);
|
||||
var MEMORY_BUCKET_DIR = Object.freeze({
|
||||
note: "日常笔记",
|
||||
|
|
@ -281,6 +303,10 @@ function suggestionFileName(opts) {
|
|||
const date = normalizeIsoDate(opts.date, opts.now);
|
||||
return `${date}-${sanitizeSlug(opts.slug)}.md`;
|
||||
}
|
||||
function refFileName(slug) {
|
||||
const base = sanitizeSlug(slug, "note");
|
||||
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
|
||||
}
|
||||
function resolveWritableLocalPath(localRoot, ...segments) {
|
||||
if (segments.some((s) => s.includes("\x00"))) {
|
||||
throw new Error("path segment contains NUL");
|
||||
|
|
@ -354,6 +380,19 @@ function draftDir(domain) {
|
|||
return "drafts";
|
||||
return join2("drafts", sanitizeSlug(d, "misc"));
|
||||
}
|
||||
function refDir(area, device) {
|
||||
if (!REF_AREAS.includes(area)) {
|
||||
throw new Error(`invalid refs area: ${area}`);
|
||||
}
|
||||
if (area === "devices") {
|
||||
const host = sanitizeSlug(device?.trim() || "", "");
|
||||
if (!host) {
|
||||
throw new Error("refs/devices requires device (host_name)");
|
||||
}
|
||||
return join2("refs", "devices", host);
|
||||
}
|
||||
return join2("refs", area);
|
||||
}
|
||||
|
||||
// src/netx/kb-context-skill.ts
|
||||
var SKILL_NAME = "kb-context";
|
||||
|
|
@ -364,18 +403,29 @@ function skillBody(snapshot) {
|
|||
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
|
||||
const localGuide = localRoot ? [
|
||||
"",
|
||||
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
|
||||
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
|
||||
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
|
||||
"Do **not** use workspace Write/bash for KB paths (sandbox).",
|
||||
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
|
||||
"file boundary issues as `suggestions/` instead."
|
||||
"### Dual plane (mandatory)",
|
||||
"",
|
||||
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
|
||||
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
|
||||
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
|
||||
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
|
||||
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
|
||||
"",
|
||||
"### refs — site product knowledge (evolve every task)",
|
||||
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
|
||||
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
|
||||
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
|
||||
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
|
||||
"",
|
||||
"### memories — write immediately when valuable",
|
||||
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
|
||||
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
|
||||
] : [
|
||||
"",
|
||||
"No local write tools until `paths.local` is present in MANIFEST."
|
||||
];
|
||||
return {
|
||||
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||
description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).",
|
||||
content: [
|
||||
"## Knowledge base (configured)",
|
||||
"",
|
||||
|
|
@ -543,6 +593,25 @@ function createSuggestion(snapshot, args) {
|
|||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function createRef(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const name = refFileName(args.slug);
|
||||
const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name);
|
||||
if (existsSync3(target.absolutePath) && !args.overwrite) {
|
||||
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
|
||||
}
|
||||
mkdirSync(dirname2(target.absolutePath), { recursive: true });
|
||||
const existed = existsSync3(target.absolutePath);
|
||||
writeFileSync(target.absolutePath, args.body.endsWith(`
|
||||
`) ? args.body : `${args.body}
|
||||
`, "utf8");
|
||||
return {
|
||||
ok: true,
|
||||
action: existed ? "updated" : "created",
|
||||
absolutePath: target.absolutePath,
|
||||
relativePath: target.relativePath
|
||||
};
|
||||
}
|
||||
function updateLocalFile(snapshot, args) {
|
||||
const localRoot = requireLocalRoot(snapshot);
|
||||
const target = resolveExistingWritableFile(localRoot, args.path);
|
||||
|
|
@ -668,10 +737,41 @@ 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) }];
|
||||
}
|
||||
|
|
@ -692,7 +792,7 @@ function tool(name, description, parameters, execute) {
|
|||
isConcurrencySafe: () => false,
|
||||
async execute(args) {
|
||||
try {
|
||||
return await execute(args);
|
||||
return toLosslessJson(await execute(args));
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
|
@ -708,20 +808,16 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
return () => {};
|
||||
const entries = [
|
||||
tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["bucket", "slug", "body"],
|
||||
properties: {
|
||||
bucket: {
|
||||
type: "string",
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: str("Short filename stem (no path separators)"),
|
||||
body: str("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}
|
||||
bucket: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["note", "rca_review", "ai_trace"],
|
||||
description: "Memory subdirectory"
|
||||
},
|
||||
slug: reqStr("Short filename stem (no path separators)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
date: str("Optional YYYY-MM-DD (default today)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createMemory(liveSnapshot(), {
|
||||
bucket: args.bucket,
|
||||
slug: String(args.slug ?? ""),
|
||||
|
|
@ -729,17 +825,12 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -747,21 +838,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
|
||||
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")
|
||||
}
|
||||
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 ?? ""),
|
||||
|
|
@ -769,39 +856,43 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
|
|||
date: args.date != null ? String(args.date) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
|
||||
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")
|
||||
}
|
||||
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
|
||||
area: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
|
||||
description: "refs/ subtree"
|
||||
},
|
||||
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
|
||||
body: reqStr("Full markdown body"),
|
||||
device: str("Required when area=devices — host_name (never UUID)"),
|
||||
overwrite: bool("Replace if the target file already exists")
|
||||
}, (args) => createRef(liveSnapshot(), {
|
||||
area: args.area,
|
||||
slug: String(args.slug ?? ""),
|
||||
body: String(args.body ?? ""),
|
||||
device: args.device != null ? String(args.device) : undefined,
|
||||
overwrite: args.overwrite === true
|
||||
})),
|
||||
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
|
||||
path: reqStr("Absolute path or path relative to KB local root"),
|
||||
body: reqStr("New full markdown body")
|
||||
}, (args) => updateLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? ""),
|
||||
body: String(args.body ?? "")
|
||||
})),
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: str("Absolute path or path relative to KB local root")
|
||||
}
|
||||
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
|
||||
path: reqStr("Absolute path or path relative to KB local root")
|
||||
}, (args) => deleteLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? "")
|
||||
})),
|
||||
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
|
||||
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)")
|
||||
}
|
||||
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
|
||||
|
|
@ -1406,6 +1497,347 @@ 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) {
|
||||
|
|
@ -2085,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
|
|||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const result = await handler(getClient(), args, exec.signal);
|
||||
if (result.ok === false) {
|
||||
throw new Error(JSON.stringify(result));
|
||||
const safe = toLosslessJson(result);
|
||||
if (result.ok === false || safe.ok === false) {
|
||||
throw new Error(JSON.stringify(safe));
|
||||
}
|
||||
return result;
|
||||
return safe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2230,6 +2663,53 @@ 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 },
|
||||
|
|
|
|||
131
lib/client.js
131
lib/client.js
|
|
@ -591,6 +591,7 @@ function unconfiguredKbSnapshot() {
|
|||
// src/client/kb-status-view.ts
|
||||
var NETXOPS_RPC_CHANNEL2 = "/netxops";
|
||||
var KB_STATUS_ENDPOINT = "kb.status";
|
||||
var KB_RELOAD_ENDPOINT = "kb.reload";
|
||||
var KB_RESOLVE_ENDPOINT = "kb.resolve";
|
||||
function asKbSnapshot(value) {
|
||||
const empty = unconfiguredKbSnapshot();
|
||||
|
|
@ -624,22 +625,44 @@ function asKbSnapshot(value) {
|
|||
errorMessage: typeof row.errorMessage === "string" ? row.errorMessage : ""
|
||||
};
|
||||
}
|
||||
function asRpcOk(result) {
|
||||
return result !== null && typeof result === "object" && result.ok === true;
|
||||
}
|
||||
async function fetchKbStatus(call, signal) {
|
||||
const result = await call(NETXOPS_RPC_CHANNEL2, KB_STATUS_ENDPOINT, {}, signal);
|
||||
if (result !== null && typeof result === "object" && result.ok === true) {
|
||||
if (asRpcOk(result)) {
|
||||
return asKbSnapshot(result.value);
|
||||
}
|
||||
return unconfiguredKbSnapshot();
|
||||
if (result !== null && typeof result === "object" && result.ok === false) {
|
||||
const message = String(result.error?.message ?? "kb.status rpc failed");
|
||||
return { ...unconfiguredKbSnapshot(), status: "error", errorMessage: message };
|
||||
}
|
||||
return {
|
||||
...unconfiguredKbSnapshot(),
|
||||
status: "error",
|
||||
errorMessage: "kb.status unavailable"
|
||||
};
|
||||
}
|
||||
async function reloadKbStatus(call, signal) {
|
||||
const result = await call(NETXOPS_RPC_CHANNEL2, KB_RELOAD_ENDPOINT, {}, signal);
|
||||
if (asRpcOk(result)) {
|
||||
return asKbSnapshot(result.value);
|
||||
}
|
||||
return fetchKbStatus(call, signal);
|
||||
}
|
||||
async function resolveKbPath(call, path, signal) {
|
||||
let result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal);
|
||||
if (!(result !== null && typeof result === "object" && result.ok === true)) {
|
||||
if (!asRpcOk(result)) {
|
||||
result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { args: { path } }, signal);
|
||||
}
|
||||
if (result !== null && typeof result === "object" && result.ok === true) {
|
||||
if (asRpcOk(result)) {
|
||||
return asKbSnapshot(result.value);
|
||||
}
|
||||
return unconfiguredKbSnapshot();
|
||||
return {
|
||||
...unconfiguredKbSnapshot(),
|
||||
status: "error",
|
||||
errorMessage: "kb.resolve unavailable"
|
||||
};
|
||||
}
|
||||
function kbStatusTone(status) {
|
||||
if (status === "configured")
|
||||
|
|
@ -1428,6 +1451,20 @@ function NetxopsCard(props) {
|
|||
onEditPublic: (checked) => {
|
||||
props.edit("groupTopologyPublic", checked ? "true" : "false");
|
||||
}
|
||||
}),
|
||||
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
|
||||
title: t("groupBizMonitor"),
|
||||
inPresetLabel: t("groupInPreset"),
|
||||
publicLabel: t("groupPublic"),
|
||||
inPreset: state.groupBizMonitorInPreset,
|
||||
published: state.groupBizMonitorPublic,
|
||||
disabled,
|
||||
onEditInPreset: (checked) => {
|
||||
props.edit("groupBizMonitorInPreset", checked ? "true" : "false");
|
||||
},
|
||||
onEditPublic: (checked) => {
|
||||
props.edit("groupBizMonitorPublic", checked ? "true" : "false");
|
||||
}
|
||||
})
|
||||
]
|
||||
}),
|
||||
|
|
@ -1886,7 +1923,7 @@ class CardForm {
|
|||
}
|
||||
async store(field, value) {
|
||||
await this.scope.set(field, value);
|
||||
return this.userLayer()?.[field] === value;
|
||||
return this.sectionValue(field) === value || this.userLayer()?.[field] === value;
|
||||
}
|
||||
stage(field, edit) {
|
||||
this.staged.set(field, edit);
|
||||
|
|
@ -1945,7 +1982,9 @@ class NetxopsCardController {
|
|||
kbDirectoryPicker;
|
||||
kbBrowseInFlight = false;
|
||||
kbStatusInFlight = false;
|
||||
kbReloadInFlight = false;
|
||||
kbUiError = null;
|
||||
lastSyncedKbRoot = undefined;
|
||||
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
||||
sessionsExportStatus = null;
|
||||
sessionsExportBusy = false;
|
||||
|
|
@ -1970,6 +2009,8 @@ class NetxopsCardController {
|
|||
booleanField("groupOpsPublic"),
|
||||
booleanField("groupTopologyInPreset"),
|
||||
booleanField("groupTopologyPublic"),
|
||||
booleanFieldPersistFalse("groupBizMonitorInPreset"),
|
||||
booleanField("groupBizMonitorPublic"),
|
||||
booleanFieldPersistFalse("groupKbInPreset"),
|
||||
booleanField("groupKbPublic"),
|
||||
booleanField("alarmPushEnabled"),
|
||||
|
|
@ -1983,6 +2024,7 @@ class NetxopsCardController {
|
|||
scope.subscribe(() => {
|
||||
this.readCredential();
|
||||
this.refreshKbPreview();
|
||||
this.syncKbRootFromSettings();
|
||||
});
|
||||
this.readCredential();
|
||||
}
|
||||
|
|
@ -2026,7 +2068,7 @@ class NetxopsCardController {
|
|||
}
|
||||
this.startStatusPoll();
|
||||
this.refreshAlarmPushStatus();
|
||||
this.refreshKbStatus();
|
||||
this.reloadKbFromHost();
|
||||
this.refreshKbPreview();
|
||||
this.refreshImDeliveryCatalog();
|
||||
this.refreshSessionsExportStatus();
|
||||
|
|
@ -2114,15 +2156,42 @@ class NetxopsCardController {
|
|||
this.kbStatusInFlight = true;
|
||||
try {
|
||||
const next = await fetchKbStatus(call);
|
||||
const prev = this.kbStatus;
|
||||
if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage)
|
||||
return;
|
||||
this.kbStatus = next;
|
||||
this.store.set(this.projection());
|
||||
this.applyKbSnapshot(next);
|
||||
} catch {} finally {
|
||||
this.kbStatusInFlight = false;
|
||||
}
|
||||
}
|
||||
async reloadKbFromHost() {
|
||||
const call = this.rpcCall;
|
||||
if (call === undefined || this.kbReloadInFlight)
|
||||
return;
|
||||
this.kbReloadInFlight = true;
|
||||
try {
|
||||
const next = await reloadKbStatus(call);
|
||||
this.applyKbSnapshot(next);
|
||||
this.lastSyncedKbRoot = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
|
||||
} catch {
|
||||
this.refreshKbStatus();
|
||||
} finally {
|
||||
this.kbReloadInFlight = false;
|
||||
}
|
||||
}
|
||||
applyKbSnapshot(next) {
|
||||
const prev = this.kbStatus;
|
||||
if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage)
|
||||
return;
|
||||
this.kbStatus = next;
|
||||
this.store.set(this.projection());
|
||||
}
|
||||
async syncKbRootFromSettings() {
|
||||
if (this.rpcCall === undefined)
|
||||
return;
|
||||
const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
|
||||
if (saved === this.lastSyncedKbRoot)
|
||||
return;
|
||||
this.lastSyncedKbRoot = saved;
|
||||
await this.reloadKbFromHost();
|
||||
}
|
||||
async refreshKbPreview() {
|
||||
const call = this.rpcCall;
|
||||
if (call === undefined)
|
||||
|
|
@ -2145,6 +2214,30 @@ class NetxopsCardController {
|
|||
this.store.set(this.projection());
|
||||
} catch {}
|
||||
}
|
||||
async commitKbRoot(path) {
|
||||
const trimmed = path.trim();
|
||||
this.kbUiError = null;
|
||||
this.store.set(this.projection());
|
||||
await this.form.save();
|
||||
const shell = this.form.shell();
|
||||
const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
|
||||
if (shell.failed || saved !== trimmed) {
|
||||
this.kbUiError = saved === trimmed ? "kbRoot save failed" : `kbRoot save did not land (saved="${saved || "(empty)"}")`;
|
||||
this.store.set(this.projection());
|
||||
const call = this.rpcCall;
|
||||
if (call !== undefined && trimmed !== "") {
|
||||
try {
|
||||
this.kbPreview = await resolveKbPath(call, trimmed);
|
||||
this.store.set(this.projection());
|
||||
} catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.kbPreview = null;
|
||||
this.lastSyncedKbRoot = undefined;
|
||||
await this.reloadKbFromHost();
|
||||
this.refreshKbPreview();
|
||||
}
|
||||
browseKbRoot() {
|
||||
if (this.kbBrowseInFlight)
|
||||
return;
|
||||
|
|
@ -2153,11 +2246,7 @@ class NetxopsCardController {
|
|||
this.store.set(this.projection());
|
||||
const applyPath = (path) => {
|
||||
this.form.actions().edit("kbRoot", path);
|
||||
this.refreshKbPreview();
|
||||
this.form.save().then(() => {
|
||||
this.refreshKbStatus();
|
||||
this.refreshKbPreview();
|
||||
});
|
||||
this.commitKbRoot(path);
|
||||
};
|
||||
const fail = (error) => {
|
||||
this.kbUiError = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -2239,6 +2328,8 @@ class NetxopsCardController {
|
|||
groupOpsPublic: this.form.field("groupOpsPublic"),
|
||||
groupTopologyInPreset: this.form.field("groupTopologyInPreset"),
|
||||
groupTopologyPublic: this.form.field("groupTopologyPublic"),
|
||||
groupBizMonitorInPreset: this.form.field("groupBizMonitorInPreset"),
|
||||
groupBizMonitorPublic: this.form.field("groupBizMonitorPublic"),
|
||||
groupKbInPreset: this.form.field("groupKbInPreset"),
|
||||
groupKbPublic: this.form.field("groupKbPublic"),
|
||||
alarmPushEnabled: this.form.field("alarmPushEnabled"),
|
||||
|
|
@ -2398,6 +2489,7 @@ var en = {
|
|||
nmsProviderHint: "zte-ume",
|
||||
groupOps: "ops",
|
||||
groupTopology: "topology",
|
||||
groupBizMonitor: "bizMonitor",
|
||||
groupKb: "Knowledge skills",
|
||||
groupInPreset: "In Netx Ops preset",
|
||||
groupPublic: "Publish to other presets",
|
||||
|
|
@ -2406,7 +2498,7 @@ var en = {
|
|||
kbBrowse: "Browse…",
|
||||
kbBrowseUnavailable: "Directory picker unavailable — paste an absolute folder path, then Save.",
|
||||
kbBrowseFailed: "Browse failed: {detail}",
|
||||
kbSaveHint: "Browse writes and saves automatically. Manual paste still needs Save.",
|
||||
kbSaveHint: "Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.",
|
||||
kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}",
|
||||
kbStatusUnconfigured: "Knowledge base: not configured (pure netx)",
|
||||
kbStatusError: "Knowledge base: error — {detail}",
|
||||
|
|
@ -2475,6 +2567,7 @@ var zh = {
|
|||
nmsProviderHint: "zte-ume",
|
||||
groupOps: "ops",
|
||||
groupTopology: "topology",
|
||||
groupBizMonitor: "bizMonitor",
|
||||
groupKb: "知识库技能",
|
||||
groupInPreset: "在 Netx Ops 预设中启用",
|
||||
groupPublic: "对其他预设公开",
|
||||
|
|
@ -2483,7 +2576,7 @@ var zh = {
|
|||
kbBrowse: "浏览…",
|
||||
kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对目录路径后点保存。",
|
||||
kbBrowseFailed: "浏览失败:{detail}",
|
||||
kbSaveHint: "点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。",
|
||||
kbSaveHint: "点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。",
|
||||
kbStatusConfigured: "知识库: {operator}({country}) v{version}",
|
||||
kbStatusUnconfigured: "知识库: 未配置(纯 netx)",
|
||||
kbStatusError: "知识库: 错误 — {detail}",
|
||||
|
|
|
|||
1028
lib/index.js
1028
lib/index.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dsh-netxops",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.40",
|
||||
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"./tools-nms": "./lib/agent-tools-nms.js",
|
||||
"./tools-common": "./lib/agent-tools-common.js",
|
||||
"./tools-topology": "./lib/agent-tools-topology.js",
|
||||
"./tools-biz-monitor": "./lib/agent-tools-biz-monitor.js",
|
||||
"./client": "./lib/client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"lib/agent-tools-nms.js",
|
||||
"lib/agent-tools-common.js",
|
||||
"lib/agent-tools-topology.js",
|
||||
"lib/agent-tools-biz-monitor.js",
|
||||
"lib/client.js",
|
||||
"presets/",
|
||||
"cordis.patch.yml",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,25 @@
|
|||
## 工具
|
||||
- netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。
|
||||
- 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。
|
||||
- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。
|
||||
- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等)。
|
||||
|
||||
## 知识库双平面(KB 已配置时 — 铁律)
|
||||
- **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。
|
||||
- **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。
|
||||
- **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。
|
||||
|
||||
### refs — 本网产品知识(可进化核心)
|
||||
- 排障涉及某网元时:**先读** `refs/devices/<host_name>/`;没有则 netx 取证后立刻 `kbWriteRef` 建档/补全。
|
||||
- `area=devices` 必须带 `device=host_name`(禁 UUID);画像主文件 slug=`PROFILE`。
|
||||
- 台账/汇总 → `inventory`;跨机拓扑 → `topology`;业务域 → `business`;本网命令集 → `commands`;速查 → `handbooks`。
|
||||
- **设备事实写 refs,过程经验写 memories**,不要把台账塞进日记。
|
||||
|
||||
### memories — 随手记
|
||||
任务中一旦出现可复用价值(经验、踩坑、口诀、误判纠正),立刻 `kbWriteMemory`;宁短勿丢。
|
||||
- `note` / `rca_review` / `ai_trace`
|
||||
|
||||
### drafts / suggestions
|
||||
未闭环案例 → `kbWriteDraft`;总部通用层有误或工具/流程改进 → `kbWriteSuggestion`。
|
||||
|
||||
## 委派(默认 subagent,活多再拆)
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,25 @@
|
|||
## 工具
|
||||
- netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。
|
||||
- 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。
|
||||
- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。
|
||||
- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等)。
|
||||
|
||||
## 知识库双平面(KB 已配置时 — 铁律)
|
||||
- **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。
|
||||
- **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。
|
||||
- **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。
|
||||
|
||||
### refs — 本网产品知识(可进化核心)
|
||||
- 排障涉及某网元时:**先读** `refs/devices/<host_name>/`;没有则 netx 取证后立刻 `kbWriteRef` 建档/补全。
|
||||
- `area=devices` 必须带 `device=host_name`(禁 UUID);画像主文件 slug=`PROFILE`。
|
||||
- 台账/汇总 → `inventory`;跨机拓扑 → `topology`;业务域 → `business`;本网命令集 → `commands`;速查 → `handbooks`。
|
||||
- **设备事实写 refs,过程经验写 memories**,不要把台账塞进日记。
|
||||
|
||||
### memories — 随手记
|
||||
任务中一旦出现可复用价值(经验、踩坑、口诀、误判纠正),立刻 `kbWriteMemory`;宁短勿丢。
|
||||
- `note` / `rca_review` / `ai_trace`
|
||||
|
||||
### drafts / suggestions
|
||||
未闭环案例 → `kbWriteDraft`;总部通用层有误或工具/流程改进 → `kbWriteSuggestion`。
|
||||
|
||||
## 委派(默认 subagent,活多再拆)
|
||||
你本人逻辑上全能。默认自己干;只有活明显偏多、且可并行时,才用 `subagent`(必要时 `subagent_fork`)拆开干。
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
66
presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md
Normal file
66
presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
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 **只读分析**。
|
||||
|
|
@ -34,6 +34,7 @@ const entries = [
|
|||
{ entry: join(root, 'src/agent-tools-nms.ts'), out: join(outDir, 'agent-tools-nms.js') },
|
||||
{ entry: join(root, 'src/agent-tools-common.ts'), out: join(outDir, 'agent-tools-common.js') },
|
||||
{ entry: join(root, 'src/agent-tools-topology.ts'), out: join(outDir, 'agent-tools-topology.js') },
|
||||
{ entry: join(root, 'src/agent-tools-biz-monitor.ts'), out: join(outDir, 'agent-tools-biz-monitor.js') },
|
||||
]
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ $src = Join-Path $NetxRoot "skills"
|
|||
$dst = Join-Path $opsRoot "presets\netxops\skills"
|
||||
if (-not (Test-Path $src)) { throw "missing $src" }
|
||||
|
||||
foreach ($group in @("ops", "topology")) {
|
||||
foreach ($group in @("ops", "topology", "biz-monitor")) {
|
||||
$from = Join-Path $src $group
|
||||
$to = Join-Path $dst $group
|
||||
if (-not (Test-Path $from)) { throw "missing group $from" }
|
||||
|
|
|
|||
15
src/agent-tools-biz-monitor.ts
Normal file
15
src/agent-tools-biz-monitor.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Forced bizMonitor-group tools + skill (cutover / biz_state read analysis).
|
||||
*
|
||||
* @module dsh-netxops/tools-biz-monitor
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
|
||||
|
||||
export const name = 'netxops-tools-biz-monitor'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['bizMonitor'] })
|
||||
}
|
||||
|
|
@ -564,6 +564,20 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
props.edit('groupTopologyPublic', checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupBizMonitor')}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
inPreset={state.groupBizMonitorInPreset}
|
||||
published={state.groupBizMonitorPublic}
|
||||
disabled={disabled}
|
||||
onEditInPreset={(checked) => {
|
||||
props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false')
|
||||
}}
|
||||
onEditPublic={(checked) => {
|
||||
props.edit('groupBizMonitorPublic', checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dsh-nx-settings-card">
|
||||
|
|
|
|||
|
|
@ -218,7 +218,9 @@ export class CardForm<T> {
|
|||
|
||||
private async store(field: string, value: unknown): Promise<boolean> {
|
||||
await this.scope.set(field, value)
|
||||
return this.userLayer()?.[field] === value
|
||||
// `settings.mutate` can settle without throwing on a refused write; confirm
|
||||
// via the resolved section value (user layer alone can lag or omit keys).
|
||||
return this.sectionValue(field) === value || this.userLayer()?.[field] === value
|
||||
}
|
||||
|
||||
private stage(field: string, edit: StagedEdit): void {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from './alarm-push-status-view.ts'
|
||||
import {
|
||||
fetchKbStatus,
|
||||
reloadKbStatus,
|
||||
resolveKbPath,
|
||||
type KbSnapshot,
|
||||
} from './kb-status-view.ts'
|
||||
|
|
@ -56,6 +57,8 @@ export interface NetxopsSettings {
|
|||
groupOpsPublic?: boolean
|
||||
groupTopologyInPreset?: boolean
|
||||
groupTopologyPublic?: boolean
|
||||
groupBizMonitorInPreset?: boolean
|
||||
groupBizMonitorPublic?: boolean
|
||||
nmsProvider?: string
|
||||
/** Operator-subset knowledge package root (Host-local path). */
|
||||
kbRoot?: string
|
||||
|
|
@ -81,6 +84,8 @@ export interface NetxopsCardState extends CardShell {
|
|||
groupOpsPublic: CardFieldState
|
||||
groupTopologyInPreset: CardFieldState
|
||||
groupTopologyPublic: CardFieldState
|
||||
groupBizMonitorInPreset: CardFieldState
|
||||
groupBizMonitorPublic: CardFieldState
|
||||
nmsProvider: CardFieldState
|
||||
kbRoot: CardFieldState
|
||||
groupKbInPreset: CardFieldState
|
||||
|
|
@ -155,7 +160,9 @@ export class NetxopsCardController {
|
|||
private kbDirectoryPicker: DirectoryPickerRemote | undefined
|
||||
private kbBrowseInFlight = false
|
||||
private kbStatusInFlight = false
|
||||
private kbReloadInFlight = false
|
||||
private kbUiError: string | null = null
|
||||
private lastSyncedKbRoot: string | undefined = undefined
|
||||
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||
private sessionsExportStatus: SessionsExportStatus | null = null
|
||||
private sessionsExportBusy = false
|
||||
|
|
@ -184,6 +191,8 @@ export class NetxopsCardController {
|
|||
booleanField('groupOpsPublic'),
|
||||
booleanField('groupTopologyInPreset'),
|
||||
booleanField('groupTopologyPublic'),
|
||||
booleanFieldPersistFalse('groupBizMonitorInPreset'),
|
||||
booleanField('groupBizMonitorPublic'),
|
||||
booleanFieldPersistFalse('groupKbInPreset'),
|
||||
booleanField('groupKbPublic'),
|
||||
booleanField('alarmPushEnabled'),
|
||||
|
|
@ -199,6 +208,7 @@ export class NetxopsCardController {
|
|||
scope.subscribe(() => {
|
||||
void this.readCredential()
|
||||
void this.refreshKbPreview()
|
||||
void this.syncKbRootFromSettings()
|
||||
})
|
||||
void this.readCredential()
|
||||
}
|
||||
|
|
@ -246,7 +256,7 @@ export class NetxopsCardController {
|
|||
}
|
||||
this.startStatusPoll()
|
||||
void this.refreshAlarmPushStatus()
|
||||
void this.refreshKbStatus()
|
||||
void this.reloadKbFromHost()
|
||||
void this.refreshKbPreview()
|
||||
void this.refreshImDeliveryCatalog()
|
||||
void this.refreshSessionsExportStatus()
|
||||
|
|
@ -364,18 +374,7 @@ export class NetxopsCardController {
|
|||
this.kbStatusInFlight = true
|
||||
try {
|
||||
const next = await fetchKbStatus(call)
|
||||
const prev = this.kbStatus
|
||||
if (
|
||||
prev
|
||||
&& prev.status === next.status
|
||||
&& prev.realRoot === next.realRoot
|
||||
&& prev.operatorName === next.operatorName
|
||||
&& prev.country === next.country
|
||||
&& prev.version === next.version
|
||||
&& prev.errorMessage === next.errorMessage
|
||||
) return
|
||||
this.kbStatus = next
|
||||
this.store.set(this.projection())
|
||||
this.applyKbSnapshot(next)
|
||||
} catch {
|
||||
// Keep last good snapshot; next poll retries.
|
||||
} finally {
|
||||
|
|
@ -383,6 +382,50 @@ export class NetxopsCardController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Host to re-resolve kbRoot from live settings and republish.
|
||||
* Prefer this after browse/save so the badge cannot stick on a stale snapshot.
|
||||
*/
|
||||
private async reloadKbFromHost(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const call = this.rpcCall
|
||||
if (call === undefined) return
|
||||
|
|
@ -416,6 +459,38 @@ export class NetxopsCardController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist kbRoot then force Host reload so the badge matches the bound package.
|
||||
*/
|
||||
private async commitKbRoot(path: string): Promise<void> {
|
||||
const trimmed = path.trim()
|
||||
this.kbUiError = null
|
||||
this.store.set(this.projection())
|
||||
await this.form.save()
|
||||
const shell = this.form.shell()
|
||||
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
|
||||
if (shell.failed || saved !== trimmed) {
|
||||
this.kbUiError = saved === trimmed
|
||||
? 'kbRoot save failed'
|
||||
: `kbRoot save did not land (saved="${saved || '(empty)'}")`
|
||||
this.store.set(this.projection())
|
||||
const call = this.rpcCall
|
||||
if (call !== undefined && trimmed !== '') {
|
||||
try {
|
||||
this.kbPreview = await resolveKbPath(call, trimmed)
|
||||
this.store.set(this.projection())
|
||||
} catch {
|
||||
// keep error banner
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
this.kbPreview = null
|
||||
this.lastSyncedKbRoot = undefined
|
||||
await this.reloadKbFromHost()
|
||||
void this.refreshKbPreview()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Host OS directory chooser (or /api directoryPicker/pick fallback)
|
||||
* and write the path into kbRoot.
|
||||
|
|
@ -428,12 +503,7 @@ export class NetxopsCardController {
|
|||
|
||||
const applyPath = (path: string): void => {
|
||||
this.form.actions().edit('kbRoot', path)
|
||||
void this.refreshKbPreview()
|
||||
// Selecting a folder should land immediately — don't require a separate Save.
|
||||
void this.form.save().then(() => {
|
||||
void this.refreshKbStatus()
|
||||
void this.refreshKbPreview()
|
||||
})
|
||||
void this.commitKbRoot(path)
|
||||
}
|
||||
|
||||
const fail = (error: unknown): void => {
|
||||
|
|
@ -535,6 +605,8 @@ export class NetxopsCardController {
|
|||
groupOpsPublic: this.form.field('groupOpsPublic'),
|
||||
groupTopologyInPreset: this.form.field('groupTopologyInPreset'),
|
||||
groupTopologyPublic: this.form.field('groupTopologyPublic'),
|
||||
groupBizMonitorInPreset: this.form.field('groupBizMonitorInPreset'),
|
||||
groupBizMonitorPublic: this.form.field('groupBizMonitorPublic'),
|
||||
groupKbInPreset: this.form.field('groupKbInPreset'),
|
||||
groupKbPublic: this.form.field('groupKbPublic'),
|
||||
alarmPushEnabled: this.form.field('alarmPushEnabled'),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
|
|||
export const NETXOPS_RPC_CHANNEL = '/netxops'
|
||||
|
||||
export const KB_STATUS_ENDPOINT = 'kb.status'
|
||||
export const KB_RELOAD_ENDPOINT = 'kb.reload'
|
||||
export const KB_RESOLVE_ENDPOINT = 'kb.resolve'
|
||||
|
||||
export type { KbSnapshot, KbStatus }
|
||||
|
|
@ -50,6 +51,10 @@ export function asKbSnapshot(value: unknown): KbSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
function asRpcOk(result: unknown): result is { ok: true; value?: unknown } {
|
||||
return result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the published KB snapshot from the Host.
|
||||
*/
|
||||
|
|
@ -58,10 +63,37 @@ export async function fetchKbStatus(
|
|||
signal?: AbortSignal,
|
||||
): Promise<KbSnapshot> {
|
||||
const result = await call(NETXOPS_RPC_CHANNEL, KB_STATUS_ENDPOINT, {}, signal)
|
||||
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||
return asKbSnapshot((result as { value?: unknown }).value)
|
||||
if (asRpcOk(result)) {
|
||||
return asKbSnapshot(result.value)
|
||||
}
|
||||
return unconfiguredKbSnapshot()
|
||||
// Do not pretend "unconfigured" on transport/shape miss — keep empty identity
|
||||
// but mark error so the badge is not stuck on the soft "pure netx" copy.
|
||||
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) {
|
||||
const message = String(
|
||||
(result as { error?: { message?: string } }).error?.message ?? 'kb.status rpc failed',
|
||||
)
|
||||
return { ...unconfiguredKbSnapshot(), status: 'error', errorMessage: message }
|
||||
}
|
||||
return {
|
||||
...unconfiguredKbSnapshot(),
|
||||
status: 'error',
|
||||
errorMessage: 'kb.status unavailable',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force Host to re-resolve `kbRoot` from live settings and republish.
|
||||
*/
|
||||
export async function reloadKbStatus(
|
||||
call: AlarmPushRpcCall,
|
||||
signal?: AbortSignal,
|
||||
): Promise<KbSnapshot> {
|
||||
const result = await call(NETXOPS_RPC_CHANNEL, KB_RELOAD_ENDPOINT, {}, signal)
|
||||
if (asRpcOk(result)) {
|
||||
return asKbSnapshot(result.value)
|
||||
}
|
||||
// Older hosts without kb.reload — fall back to status / resolve.
|
||||
return fetchKbStatus(call, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,13 +106,17 @@ export async function resolveKbPath(
|
|||
): Promise<KbSnapshot> {
|
||||
// Prefer flat `{ path }` (netxops channel); also try `{ args: { path } }`.
|
||||
let result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal)
|
||||
if (!(result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true)) {
|
||||
if (!asRpcOk(result)) {
|
||||
result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { args: { path } }, signal)
|
||||
}
|
||||
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||
return asKbSnapshot((result as { value?: unknown }).value)
|
||||
if (asRpcOk(result)) {
|
||||
return asKbSnapshot(result.value)
|
||||
}
|
||||
return {
|
||||
...unconfiguredKbSnapshot(),
|
||||
status: 'error',
|
||||
errorMessage: 'kb.resolve unavailable',
|
||||
}
|
||||
return unconfiguredKbSnapshot()
|
||||
}
|
||||
|
||||
/** Badge tone for the settings card. */
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export type NetxopsLocaleKey =
|
|||
| 'nmsProviderHint'
|
||||
| 'groupOps'
|
||||
| 'groupTopology'
|
||||
| 'groupBizMonitor'
|
||||
| 'groupKb'
|
||||
| 'groupInPreset'
|
||||
| 'groupPublic'
|
||||
|
|
@ -98,6 +99,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
nmsProviderHint: 'zte-ume',
|
||||
groupOps: 'ops',
|
||||
groupTopology: 'topology',
|
||||
groupBizMonitor: 'bizMonitor',
|
||||
groupKb: 'Knowledge skills',
|
||||
groupInPreset: 'In Netx Ops preset',
|
||||
groupPublic: 'Publish to other presets',
|
||||
|
|
@ -106,7 +108,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
kbBrowse: 'Browse…',
|
||||
kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.',
|
||||
kbBrowseFailed: 'Browse failed: {detail}',
|
||||
kbSaveHint: 'Browse writes and saves automatically. Manual paste still needs Save.',
|
||||
kbSaveHint: 'Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.',
|
||||
kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}',
|
||||
kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)',
|
||||
kbStatusError: 'Knowledge base: error — {detail}',
|
||||
|
|
@ -176,6 +178,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
|||
nmsProviderHint: 'zte-ume',
|
||||
groupOps: 'ops',
|
||||
groupTopology: 'topology',
|
||||
groupBizMonitor: 'bizMonitor',
|
||||
groupKb: '知识库技能',
|
||||
groupInPreset: '在 Netx Ops 预设中启用',
|
||||
groupPublic: '对其他预设公开',
|
||||
|
|
@ -184,7 +187,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
|||
kbBrowse: '浏览…',
|
||||
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。',
|
||||
kbBrowseFailed: '浏览失败:{detail}',
|
||||
kbSaveHint: '点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。',
|
||||
kbSaveHint: '点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。',
|
||||
kbStatusConfigured: '知识库: {operator}({country}) v{version}',
|
||||
kbStatusUnconfigured: '知识库: 未配置(纯 netx)',
|
||||
kbStatusError: '知识库: 错误 — {detail}',
|
||||
|
|
|
|||
196
src/index.ts
196
src/index.ts
|
|
@ -35,8 +35,8 @@ import {
|
|||
publishAlarmPushStatus,
|
||||
resetAlarmPushStatus,
|
||||
} from './netx/alarm-push-status.ts'
|
||||
import { deliverAlarmToIm } from './netx/alarm-im.ts'
|
||||
import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts'
|
||||
import { dispatchAlarmToSinks } from './netx/alarm-dispatch.ts'
|
||||
import { resetAlarmSession } from './netx/alarm-session.ts'
|
||||
import {
|
||||
capabilityGroupsFromSettings,
|
||||
groupsForPlane,
|
||||
|
|
@ -72,6 +72,7 @@ import {
|
|||
sessionsExportHeadResponse,
|
||||
sessionsExportResponse,
|
||||
} from './netx/session-export.ts'
|
||||
import { mountNetxopsWebRoute } from './netx/netxops-web-rpc.ts'
|
||||
import { registerNetxTools } from './netx/tools.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
|
|
@ -151,6 +152,10 @@ export interface Config {
|
|||
groupTopologyInPreset: boolean
|
||||
/** Publish topology tools/skills to other presets (default off). */
|
||||
groupTopologyPublic: boolean
|
||||
/** biz-monitor (cutover / biz_state read) tools in the Netx Ops preset (default on). */
|
||||
groupBizMonitorInPreset: boolean
|
||||
/** Publish biz-monitor tools/skills to other presets (default off). */
|
||||
groupBizMonitorPublic: boolean
|
||||
/**
|
||||
* Absolute (or Host-local) path to an operator-subset knowledge package.
|
||||
* Empty = unconfigured. Plugin locates `MANIFEST.json` (≤3 levels) and
|
||||
|
|
@ -182,6 +187,8 @@ export const Config: z<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),
|
||||
|
|
@ -307,18 +314,18 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
imBotId: current.imBotId ?? '',
|
||||
imTargetId: current.imTargetId ?? '',
|
||||
})
|
||||
// Prefer target list presence; UI also mirrors this into alarmDeliverIm.
|
||||
const deliverIm = imTargets.length > 0
|
||||
stopAlarmPush = startAlarmPushClient({
|
||||
apiUrl,
|
||||
token,
|
||||
logger: ctx.logger,
|
||||
onAlarm: async (payload) => {
|
||||
if (deliverDsh) {
|
||||
await deliverAlarmToSession(ctx, payload, lang)
|
||||
}
|
||||
await deliverAlarmToIm(ctx, payload, {
|
||||
enabled: deliverIm,
|
||||
targets: imTargets,
|
||||
// Parallel sinks: a stuck/failing DSH sticky session must not block WhatsApp/IM.
|
||||
await dispatchAlarmToSinks(ctx, payload, {
|
||||
deliverDsh,
|
||||
deliverIm,
|
||||
imTargets,
|
||||
lang,
|
||||
})
|
||||
},
|
||||
|
|
@ -595,7 +602,112 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
}, 'netxops: dispose public skills')
|
||||
})
|
||||
|
||||
// Browser card: alarm-push status + IM catalog (RPC) and all-sessions ZIP (Fetch).
|
||||
const handleNetxopsRpc = async (endpoint: string, payload?: unknown): Promise<unknown> => {
|
||||
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<unknown> }
|
||||
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
|
||||
? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined
|
||||
: undefined
|
||||
const im = fromGet ?? (ctx as { dshIm?: DshImCatalog }).dshIm
|
||||
if (!im || typeof im.listDeliveryCatalog !== 'function') {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
available: false,
|
||||
options: [],
|
||||
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<unknown>,
|
||||
) => (() => void) | Promise<void>
|
||||
const rpc = (mountCtx.connection as { rpc?: { handle?: RpcHandle } } | undefined)?.rpc
|
||||
if (rpc && typeof rpc.handle === 'function') {
|
||||
mountCtx.logger.info('netxops: /netxops rpc mounted via connection.rpc.handle (legacy)')
|
||||
const legacy = rpc.handle(
|
||||
NETXOPS_RPC_CHANNEL,
|
||||
(endpoint, payload) => handleNetxopsRpc(endpoint, payload),
|
||||
)
|
||||
return () => { void legacy() }
|
||||
}
|
||||
mountCtx.logger.warn('netxops: /netxops rpc unavailable — settings card status disabled')
|
||||
return () => {}
|
||||
}, 'netxops: /netxops web rpc')
|
||||
})
|
||||
|
||||
// Browser card: sessions export download via shared /api Fetch route.
|
||||
ctx.inject(['connection'], (connCtx) => {
|
||||
const connection = connCtx.connection as {
|
||||
rpc?: { handle?: (
|
||||
|
|
@ -610,72 +722,6 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
}) => () => Promise<void>
|
||||
}
|
||||
} | 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<unknown> }
|
||||
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
|
||||
? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined
|
||||
: undefined
|
||||
const im = fromGet ?? (ctx as { dshIm?: DshImCatalog }).dshIm
|
||||
if (!im || typeof im.listDeliveryCatalog !== 'function') {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
available: false,
|
||||
options: [],
|
||||
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')
|
||||
|
|
|
|||
54
src/netx/alarm-dispatch.ts
Normal file
54
src/netx/alarm-dispatch.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Fan-out one key-alarm to optional DSH session + IM sinks.
|
||||
* Sinks run in parallel so a failing/slow session path cannot block WhatsApp/IM.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { deliverAlarmToIm, type AlarmImDeliveryOptions } from './alarm-im.ts'
|
||||
import { deliverAlarmToSession } from './alarm-session.ts'
|
||||
import type { KeyAlarmPayload } from './alarm-push.ts'
|
||||
import type { ImDeliveryTarget } from './im-targets.ts'
|
||||
|
||||
export interface AlarmSinkDispatchOptions {
|
||||
deliverDsh: boolean
|
||||
deliverIm: boolean
|
||||
imTargets: readonly ImDeliveryTarget[]
|
||||
lang: string
|
||||
}
|
||||
|
||||
export interface AlarmSinkFns {
|
||||
toSession?: typeof deliverAlarmToSession
|
||||
toIm?: typeof deliverAlarmToIm
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver to every enabled sink; never let one sink's rejection cancel the others.
|
||||
* @returns settled results in order: [session?, im] (session omitted when deliverDsh is false).
|
||||
*/
|
||||
export async function dispatchAlarmToSinks(
|
||||
ctx: Context,
|
||||
payload: KeyAlarmPayload,
|
||||
options: AlarmSinkDispatchOptions,
|
||||
sinks: AlarmSinkFns = {},
|
||||
): Promise<PromiseSettledResult<void>[]> {
|
||||
const toSession = sinks.toSession ?? deliverAlarmToSession
|
||||
const toIm = sinks.toIm ?? deliverAlarmToIm
|
||||
const jobs: Array<Promise<void>> = []
|
||||
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
|
||||
}
|
||||
|
|
@ -5,10 +5,11 @@
|
|||
*
|
||||
* - `ops` → skill `netx-ops` — NMS alarms/inventory/SQL + managed CLI + findTopologyPaths
|
||||
* - `topology` → skill `netx-topology` — canvas / fabric / dual_unit / layout
|
||||
* - `bizMonitor` → skill `netx-biz-monitor` — cutover / biz_state read + overnight analysis
|
||||
*/
|
||||
|
||||
/** Stable group ids used in settings, skill dirs, and registration filters. */
|
||||
export type NetxCapabilityGroupId = 'ops' | 'topology'
|
||||
export type NetxCapabilityGroupId = 'ops' | 'topology' | 'bizMonitor'
|
||||
|
||||
/** Per-group exposure knobs. */
|
||||
export interface NetxGroupExposure {
|
||||
|
|
@ -27,14 +28,17 @@ export interface NetxCapabilityGroupSettingsFields {
|
|||
groupOpsPublic: boolean
|
||||
groupTopologyInPreset: boolean
|
||||
groupTopologyPublic: boolean
|
||||
groupBizMonitorInPreset: boolean
|
||||
groupBizMonitorPublic: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default: ops in Ops preset; topology and all public off.
|
||||
* Default: ops in Ops preset; topology / bizMonitor and all public off.
|
||||
*/
|
||||
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
|
||||
ops: Object.freeze({ inPreset: true, public: false }),
|
||||
topology: Object.freeze({ inPreset: false, public: false }),
|
||||
bizMonitor: Object.freeze({ inPreset: true, public: false }),
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -76,17 +80,29 @@ export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly str
|
|||
'netx__analyzeTopologyViewLayout',
|
||||
'netx__sinkTopologyDualUnits',
|
||||
]),
|
||||
bizMonitor: Object.freeze([
|
||||
'netx__listBizMonitors',
|
||||
'netx__listBizMonitorBatches',
|
||||
'netx__getBizMonitorContext',
|
||||
'netx__getBizMonitorBoard',
|
||||
'netx__listBizMonitorReds',
|
||||
'netx__getBizMonitorDiffs',
|
||||
'netx__getBizCollectBatch',
|
||||
'netx__getBizCollectCommandRaw',
|
||||
]),
|
||||
})
|
||||
|
||||
/** Skill directory name under skills root / `presets/netxops/skills/<group>/`. */
|
||||
export const SKILL_DIR_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, string>> = Object.freeze({
|
||||
ops: 'ops',
|
||||
topology: 'topology',
|
||||
bizMonitor: 'biz-monitor',
|
||||
})
|
||||
|
||||
export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.freeze([
|
||||
'ops',
|
||||
'topology',
|
||||
'bizMonitor',
|
||||
])
|
||||
|
||||
/**
|
||||
|
|
@ -126,6 +142,11 @@ export function capabilityGroupsFromSettings(
|
|||
}
|
||||
}
|
||||
|
||||
let bizMonitorInPreset = true
|
||||
if (src.groupBizMonitorInPreset !== undefined) {
|
||||
bizMonitorInPreset = src.groupBizMonitorInPreset !== false
|
||||
}
|
||||
|
||||
return {
|
||||
ops: {
|
||||
inPreset: opsInPreset,
|
||||
|
|
@ -140,6 +161,10 @@ export function capabilityGroupsFromSettings(
|
|||
public: src.groupTopologyPublic === true
|
||||
|| src.groupTopologyLayoutPublic === true,
|
||||
},
|
||||
bizMonitor: {
|
||||
inPreset: bizMonitorInPreset,
|
||||
public: src.groupBizMonitorPublic === true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,9 +202,9 @@ export function groupsForced(
|
|||
* Tool name allow-list for the given groups.
|
||||
*/
|
||||
export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set<string> {
|
||||
const names = new Set<string>()
|
||||
const out = new Set<string>()
|
||||
for (const id of groupIds) {
|
||||
for (const tool of TOOLS_BY_GROUP[id]) names.add(tool)
|
||||
for (const name of TOOLS_BY_GROUP[id] ?? []) out.add(name)
|
||||
}
|
||||
return names
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { type NetxClient, quoteNeId, type NetxJson } from './http.ts'
|
||||
import { omitUndefined } from './json-safe.ts'
|
||||
|
||||
const EXEC_MAX_COMMANDS = 5
|
||||
|
||||
|
|
@ -330,3 +331,436 @@ 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<NetxJson> {
|
||||
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<Promise<NetxJson>> = []
|
||||
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<string, string | number | boolean> = {}
|
||||
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<NetxJson> {
|
||||
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<NetxJson> {
|
||||
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<string, string | number | boolean> = {}
|
||||
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<NetxJson> {
|
||||
const batchId = str(args, 'batch_id').trim()
|
||||
if (!batchId) return { ok: false, error: 'batch_id_required' }
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
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<NetxJson> {
|
||||
const projectId = str(args, 'project_id').trim()
|
||||
if (!projectId) return { ok: false, error: 'project_id_required' }
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
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<NetxJson> {
|
||||
const runId = str(args, 'run_id').trim()
|
||||
if (!runId) return { ok: false, error: 'run_id_required' }
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
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<NetxJson> {
|
||||
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<NetxJson> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
41
src/netx/json-safe.ts
Normal file
41
src/netx/json-safe.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* DSH tool outputs must be lossless JSON: no `undefined` property values,
|
||||
* no NaN/Infinity, no class instances. Build plain objects carefully, or
|
||||
* round-trip through JSON before returning from a tool.
|
||||
*/
|
||||
|
||||
import type { NetxJson } from './http.ts'
|
||||
|
||||
/**
|
||||
* Detach a plain JSON-compatible value for DSH tool results.
|
||||
* Drops object keys whose value is `undefined` (via JSON.stringify).
|
||||
* @param value - handler result (may contain undefined holes).
|
||||
* @returns parsed plain JSON object, or a safe error envelope.
|
||||
*/
|
||||
export function toLosslessJson(value: unknown): NetxJson {
|
||||
try {
|
||||
const text = JSON.stringify(value)
|
||||
if (text === undefined) {
|
||||
return { ok: false, error: 'tool_result_not_json', detail: 'undefined_root' }
|
||||
}
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as NetxJson
|
||||
}
|
||||
return { ok: true, data: { value: parsed } }
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
return { ok: false, error: 'tool_result_not_json', detail: detail.slice(0, 400) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a record omitting keys whose value is `undefined` (keeps null).
|
||||
*/
|
||||
export function omitUndefined(record: Record<string, unknown>): NetxJson {
|
||||
const out: NetxJson = {}
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (value !== undefined) out[key] = value as NetxJson[string]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -22,12 +22,23 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
|
|||
const localGuide = localRoot
|
||||
? [
|
||||
'',
|
||||
'**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,',
|
||||
'`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.',
|
||||
'They jail writes to `memories/` / `drafts/` / `suggestions/` only.',
|
||||
'Do **not** use workspace Write/bash for KB paths (sandbox).',
|
||||
'`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;',
|
||||
'file boundary issues as `suggestions/` instead.',
|
||||
'### Dual plane (mandatory)',
|
||||
'',
|
||||
'- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).',
|
||||
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
|
||||
'- **All KB writes via host tools** (workspace Write/bash cannot reach KB):',
|
||||
' `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /',
|
||||
' `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.',
|
||||
'',
|
||||
'### refs — site product knowledge (evolve every task)',
|
||||
'- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.',
|
||||
'- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.',
|
||||
'- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.',
|
||||
'- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).',
|
||||
'',
|
||||
'### memories — write immediately when valuable',
|
||||
'- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.',
|
||||
'- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`.',
|
||||
]
|
||||
: [
|
||||
'',
|
||||
|
|
@ -35,7 +46,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
|
|||
]
|
||||
return {
|
||||
description:
|
||||
'Operator knowledge-base context for this Host (paths + identity from MANIFEST).',
|
||||
'Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).',
|
||||
content: [
|
||||
'## Knowledge base (configured)',
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -18,12 +18,15 @@ import {
|
|||
draftFileName,
|
||||
memoryDir,
|
||||
memoryFileName,
|
||||
refDir,
|
||||
refFileName,
|
||||
resolveExistingWritableFile,
|
||||
resolveKbLocalRoot,
|
||||
resolveWritableLocalPath,
|
||||
suggestionDir,
|
||||
suggestionFileName,
|
||||
type MemoryBucket,
|
||||
type RefArea,
|
||||
type SuggestionKind,
|
||||
KB_LOCAL_WRITABLE_ROOTS,
|
||||
type KbLocalWritableRoot,
|
||||
|
|
@ -168,7 +171,49 @@ export function createSuggestion(
|
|||
}
|
||||
|
||||
/**
|
||||
* Replace body of an existing file under memories|drafts|suggestions.
|
||||
* Create/overwrite site product-knowledge under `refs/`
|
||||
* (inventory / devices / topology / business / commands / handbooks).
|
||||
*/
|
||||
export function createRef(
|
||||
snapshot: KbSnapshot,
|
||||
args: {
|
||||
area: RefArea
|
||||
slug: string
|
||||
body: string
|
||||
/** Required when area=devices — use host_name. */
|
||||
device?: string
|
||||
overwrite?: boolean
|
||||
},
|
||||
): KbLocalWriteResult {
|
||||
const localRoot = requireLocalRoot(snapshot)
|
||||
const name = refFileName(args.slug)
|
||||
const target = resolveWritableLocalPath(
|
||||
localRoot,
|
||||
refDir(args.area, args.device),
|
||||
name,
|
||||
)
|
||||
if (existsSync(target.absolutePath) && !args.overwrite) {
|
||||
throw new Error(
|
||||
`file already exists (pass overwrite=true to replace): ${target.relativePath}`,
|
||||
)
|
||||
}
|
||||
mkdirSync(dirname(target.absolutePath), { recursive: true })
|
||||
const existed = existsSync(target.absolutePath)
|
||||
writeFileSync(
|
||||
target.absolutePath,
|
||||
args.body.endsWith('\n') ? args.body : `${args.body}\n`,
|
||||
'utf8',
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
action: existed ? 'updated' : 'created',
|
||||
absolutePath: target.absolutePath,
|
||||
relativePath: target.relativePath,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace body of an existing file under memories|drafts|suggestions|refs.
|
||||
* Drafts keep/force `status: draft` in frontmatter.
|
||||
*/
|
||||
export function updateLocalFile(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Path jail for operator-subset `_local` agent writes.
|
||||
* Writable trees: memories / drafts / suggestions only (not identity / local_skills).
|
||||
* Writable: memories / drafts / suggestions / refs (not local_skills).
|
||||
*/
|
||||
|
||||
import { existsSync, realpathSync, statSync } from 'node:fs'
|
||||
|
|
@ -12,6 +12,7 @@ export const KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
|
|||
'memories',
|
||||
'drafts',
|
||||
'suggestions',
|
||||
'refs',
|
||||
] as const)
|
||||
|
||||
export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number]
|
||||
|
|
@ -19,6 +20,18 @@ export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number]
|
|||
export type MemoryBucket = 'note' | 'rca_review' | 'ai_trace'
|
||||
export type SuggestionKind = 'theory' | 'improvement'
|
||||
|
||||
/** Site product-knowledge areas under `refs/`. */
|
||||
export const REF_AREAS = Object.freeze([
|
||||
'inventory',
|
||||
'devices',
|
||||
'topology',
|
||||
'business',
|
||||
'commands',
|
||||
'handbooks',
|
||||
] as const)
|
||||
|
||||
export type RefArea = (typeof REF_AREAS)[number]
|
||||
|
||||
export const MEMORY_BUCKET_DIR: Readonly<Record<MemoryBucket, string>> = Object.freeze({
|
||||
note: '日常笔记',
|
||||
rca_review: '排障复盘',
|
||||
|
|
@ -110,6 +123,11 @@ export function suggestionFileName(opts: {
|
|||
return `${date}-${sanitizeSlug(opts.slug)}.md`
|
||||
}
|
||||
|
||||
export function refFileName(slug: string): string {
|
||||
const base = sanitizeSlug(slug, 'note')
|
||||
return base.toLowerCase().endsWith('.md') ? base : `${base}.md`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a candidate path and assert it lies under a writable local subtree.
|
||||
* Parent dirs need not exist yet (create path); uses resolve + prefix check.
|
||||
|
|
@ -207,3 +225,20 @@ export function draftDir(domain?: string): string {
|
|||
if (!d) return 'drafts'
|
||||
return join('drafts', sanitizeSlug(d, 'misc'))
|
||||
}
|
||||
|
||||
/**
|
||||
* `refs/<area>/…` — for `devices`, require `device` → `refs/devices/<device>/<file>`.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Host-side tools for writing under MANIFEST `paths.local`
|
||||
* (memories / drafts / suggestions). Bypasses workspace sandbox.
|
||||
* (memories / drafts / suggestions / refs). Bypasses workspace sandbox.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
|
@ -9,17 +9,24 @@ import type { KbSnapshot } from './kb-manifest.ts'
|
|||
import {
|
||||
createDraft,
|
||||
createMemory,
|
||||
createRef,
|
||||
createSuggestion,
|
||||
deleteLocalFile,
|
||||
listLocalFiles,
|
||||
updateLocalFile,
|
||||
} from './kb-local-ops.ts'
|
||||
import { kbLocalToolsEnabled, resolveKbLocalRoot } from './kb-local-path.ts'
|
||||
import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts'
|
||||
import { getKbContext } from './kb-runtime.ts'
|
||||
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) }]
|
||||
|
|
@ -37,6 +44,7 @@ function liveSnapshot(): KbSnapshot {
|
|||
function tool(
|
||||
name: string,
|
||||
description: string,
|
||||
/** DSH `defineTool` expects a flat parameter map — not a full JSON Schema object. */
|
||||
parameters: Record<string, unknown>,
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown,
|
||||
) {
|
||||
|
|
@ -49,7 +57,7 @@ function tool(
|
|||
isConcurrencySafe: () => false,
|
||||
async execute(args) {
|
||||
try {
|
||||
return await execute(args as Record<string, unknown>)
|
||||
return toLosslessJson(await execute(args as Record<string, unknown>))
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
|
@ -79,20 +87,16 @@ export function registerKbLocalTools(
|
|||
+ 'Default create-only; set overwrite=true to replace same filename. '
|
||||
+ 'Do not use workspace Write — KB is outside the sandbox.',
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['bucket', 'slug', 'body'],
|
||||
properties: {
|
||||
bucket: {
|
||||
type: 'string',
|
||||
enum: ['note', 'rca_review', 'ai_trace'],
|
||||
description: 'Memory subdirectory',
|
||||
},
|
||||
slug: str('Short filename stem (no path separators)'),
|
||||
body: str('Full markdown body'),
|
||||
date: str('Optional YYYY-MM-DD (default today)'),
|
||||
overwrite: bool('Replace if the target file already exists'),
|
||||
bucket: {
|
||||
type: 'string' as const,
|
||||
required: true as const,
|
||||
enum: ['note', 'rca_review', 'ai_trace'],
|
||||
description: 'Memory subdirectory',
|
||||
},
|
||||
slug: reqStr('Short filename stem (no path separators)'),
|
||||
body: reqStr('Full markdown body'),
|
||||
date: str('Optional YYYY-MM-DD (default today)'),
|
||||
overwrite: bool('Replace if the target file already exists'),
|
||||
},
|
||||
(args) => createMemory(liveSnapshot(), {
|
||||
bucket: args.bucket as 'note' | 'rca_review' | 'ai_trace',
|
||||
|
|
@ -106,18 +110,13 @@ export function registerKbLocalTools(
|
|||
'netx__kbWriteDraft',
|
||||
`Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. `
|
||||
+ 'Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. '
|
||||
+ 'Set overwrite=true to replace. Not for identity/ or formal RCA.',
|
||||
+ 'Set overwrite=true to replace. Not for formal RCA.',
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['slug', 'body'],
|
||||
properties: {
|
||||
slug: str('Case stem (DRAFT- prefix added if missing)'),
|
||||
body: str('Markdown body (RCA-ish draft)'),
|
||||
domain: str('Optional fault-domain subfolder under drafts/'),
|
||||
date: str('Optional event date YYYY-MM-DD or YYYYMMDD'),
|
||||
overwrite: bool('Replace if the target file already exists'),
|
||||
},
|
||||
slug: reqStr('Case stem (DRAFT- prefix added if missing)'),
|
||||
body: reqStr('Markdown body (RCA-ish draft)'),
|
||||
domain: str('Optional fault-domain subfolder under drafts/'),
|
||||
date: str('Optional event date YYYY-MM-DD or YYYYMMDD'),
|
||||
overwrite: bool('Replace if the target file already exists'),
|
||||
},
|
||||
(args) => createDraft(liveSnapshot(), {
|
||||
slug: String(args.slug ?? ''),
|
||||
|
|
@ -130,22 +129,18 @@ export function registerKbLocalTools(
|
|||
tool(
|
||||
'netx__kbWriteSuggestion',
|
||||
`Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. `
|
||||
+ 'Use for theory corrections or tool/process improvements — never rewrite identity/.',
|
||||
+ 'Use for HQ pack/theory corrections or tool/process improvements.',
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['kind', 'slug', 'body'],
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['theory', 'improvement'],
|
||||
description: 'theory = knowledge fix; improvement = tools/skills/process',
|
||||
},
|
||||
slug: str('Short filename stem'),
|
||||
body: str('Full markdown body'),
|
||||
date: str('Optional YYYY-MM-DD (default today)'),
|
||||
overwrite: bool('Replace if the target file already exists'),
|
||||
kind: {
|
||||
type: 'string' as const,
|
||||
required: true as const,
|
||||
enum: ['theory', 'improvement'],
|
||||
description: 'theory = knowledge fix; improvement = tools/skills/process',
|
||||
},
|
||||
slug: reqStr('Short filename stem'),
|
||||
body: reqStr('Full markdown body'),
|
||||
date: str('Optional YYYY-MM-DD (default today)'),
|
||||
overwrite: bool('Replace if the target file already exists'),
|
||||
},
|
||||
(args) => createSuggestion(liveSnapshot(), {
|
||||
kind: args.kind as 'theory' | 'improvement',
|
||||
|
|
@ -156,18 +151,39 @@ export function registerKbLocalTools(
|
|||
}),
|
||||
),
|
||||
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.',
|
||||
'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.',
|
||||
{
|
||||
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'),
|
||||
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 `
|
||||
+ `(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 ?? ''),
|
||||
|
|
@ -176,15 +192,10 @@ export function registerKbLocalTools(
|
|||
),
|
||||
tool(
|
||||
'netx__kbDeleteLocal',
|
||||
`Delete one existing markdown under memories|drafts|suggestions `
|
||||
+ `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`,
|
||||
`Delete one existing markdown under memories|drafts|suggestions|refs `
|
||||
+ `(absolute or relative to ${localRoot}).`,
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['path'],
|
||||
properties: {
|
||||
path: str('Absolute path or path relative to KB local root'),
|
||||
},
|
||||
path: reqStr('Absolute path or path relative to KB local root'),
|
||||
},
|
||||
(args) => deleteLocalFile(liveSnapshot(), {
|
||||
path: String(args.path ?? ''),
|
||||
|
|
@ -192,22 +203,18 @@ export function registerKbLocalTools(
|
|||
),
|
||||
tool(
|
||||
'netx__kbListLocal',
|
||||
`List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). `
|
||||
+ 'Does not include identity/. Read contents via absolute paths from the listing.',
|
||||
`List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). `
|
||||
+ 'Read contents via absolute paths from the listing.',
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
root: {
|
||||
type: 'string',
|
||||
enum: ['memories', 'drafts', 'suggestions', 'all'],
|
||||
description: 'Subtree to list (default all writable)',
|
||||
},
|
||||
limit: num('Max entries 1–200 (default 50)'),
|
||||
root: {
|
||||
type: 'string' as const,
|
||||
enum: ['memories', 'drafts', 'suggestions', 'refs', 'all'],
|
||||
description: 'Subtree to list (default all writable)',
|
||||
},
|
||||
limit: num('Max entries 1–200 (default 50)'),
|
||||
},
|
||||
(args) => listLocalFiles(liveSnapshot(), {
|
||||
root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'all' | undefined) ?? 'all',
|
||||
root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'refs' | 'all' | undefined) ?? 'all',
|
||||
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
239
src/netx/netxops-web-rpc.ts
Normal file
239
src/netx/netxops-web-rpc.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Mount `/netxops/*` on the plugin's own `webServer` inject (DSH ≥0.1.5).
|
||||
*
|
||||
* `connection.rpc.handle('/netxops')` registers via the connection plugin ctx,
|
||||
* which no longer injects `webServer` — the route never mounts and the settings
|
||||
* card sees HTTP 405 → "未配置". Same pattern as dsh-pocket `web-rpc.js`.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
/** Max buffered JSON body for control-plane RPC (status / kb.resolve paths). */
|
||||
const NETXOPS_RPC_BODY_MAX = 8 * 1024 * 1024
|
||||
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
const INVALID_REQUEST_RPC_ID = 'invalid-request'
|
||||
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1'])
|
||||
|
||||
export type NetxopsRpcHandler = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => Promise<unknown>
|
||||
|
||||
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<Context['logger'], 'error'>,
|
||||
) {
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
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<Response> },
|
||||
maxBodyBytes: number,
|
||||
): Promise<void> {
|
||||
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<void>((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<unknown>).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 () => {}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type NetxCapabilityGroups,
|
||||
} from './capability-groups.ts'
|
||||
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
|
||||
import { toLosslessJson } from './json-safe.ts'
|
||||
import { getNetxConnection } from './runtime.ts'
|
||||
import * as H from './handlers.ts'
|
||||
import * as T from './topology-handlers.ts'
|
||||
|
|
@ -74,10 +75,13 @@ function tool(
|
|||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const result = await handler(getClient(), args as NetxJson, exec.signal)
|
||||
if (result.ok === false) {
|
||||
throw new Error(JSON.stringify(result))
|
||||
// DSH rejects tool results that contain `undefined` property holes
|
||||
// ("value is not lossless JSON"). Round-trip strips them.
|
||||
const safe = toLosslessJson(result)
|
||||
if (result.ok === false || safe.ok === false) {
|
||||
throw new Error(JSON.stringify(safe))
|
||||
}
|
||||
return result
|
||||
return safe
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -316,6 +320,94 @@ 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',
|
||||
|
|
|
|||
67
test/alarm-dispatch.test.mjs
Normal file
67
test/alarm-dispatch.test.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { dispatchAlarmToSinks } from '../src/netx/alarm-dispatch.ts'
|
||||
|
||||
test('dispatchAlarmToSinks still delivers IM when DSH session rejects', async () => {
|
||||
let imCalls = 0
|
||||
const warnings = []
|
||||
const ctx = {
|
||||
logger: {
|
||||
warn: (...args) => { warnings.push(args.map(String).join(' ')) },
|
||||
info() {},
|
||||
},
|
||||
}
|
||||
const results = await dispatchAlarmToSinks(
|
||||
ctx,
|
||||
{
|
||||
action: 'inserted',
|
||||
rule_label: 'Power Down',
|
||||
alarm_key: 'k1',
|
||||
ne: { host_name: 'PE1' },
|
||||
},
|
||||
{
|
||||
deliverDsh: true,
|
||||
deliverIm: true,
|
||||
imTargets: [{ botId: 'bot', targetId: 'tgt' }],
|
||||
lang: 'zh',
|
||||
},
|
||||
{
|
||||
toSession: async () => {
|
||||
throw new Error('sticky session exploded')
|
||||
},
|
||||
toIm: async () => {
|
||||
imCalls += 1
|
||||
},
|
||||
},
|
||||
)
|
||||
assert.equal(imCalls, 1)
|
||||
assert.equal(results.length, 2)
|
||||
assert.equal(results[0].status, 'rejected')
|
||||
assert.equal(results[1].status, 'fulfilled')
|
||||
assert.ok(warnings.some((line) => line.includes('sink failed')))
|
||||
})
|
||||
|
||||
test('dispatchAlarmToSinks skips session job when deliverDsh is false', async () => {
|
||||
let sessionCalls = 0
|
||||
let imCalls = 0
|
||||
const ctx = { logger: { warn() {}, info() {} } }
|
||||
const results = await dispatchAlarmToSinks(
|
||||
ctx,
|
||||
{ action: 'inserted', alarm_key: 'k1' },
|
||||
{
|
||||
deliverDsh: false,
|
||||
deliverIm: true,
|
||||
imTargets: [{ botId: 'bot', targetId: 'tgt' }],
|
||||
lang: 'zh',
|
||||
},
|
||||
{
|
||||
toSession: async () => { sessionCalls += 1 },
|
||||
toIm: async () => { imCalls += 1 },
|
||||
},
|
||||
)
|
||||
assert.equal(sessionCalls, 0)
|
||||
assert.equal(imCalls, 1)
|
||||
assert.equal(results.length, 1)
|
||||
assert.equal(results[0].status, 'fulfilled')
|
||||
})
|
||||
|
|
@ -15,19 +15,21 @@ import {
|
|||
TOOLS_BY_GROUP,
|
||||
} from '../src/netx/capability-groups.ts'
|
||||
|
||||
test('defaults: ops in preset; topology and public off', () => {
|
||||
test('defaults: ops + bizMonitor in preset; topology and public off', () => {
|
||||
assert.deepEqual(DEFAULT_CAPABILITY_GROUPS, {
|
||||
ops: { inPreset: true, public: false },
|
||||
topology: { inPreset: false, public: false },
|
||||
bizMonitor: { inPreset: true, public: false },
|
||||
})
|
||||
assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops'])
|
||||
assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops', 'bizMonitor'])
|
||||
assert.deepEqual(groupsForPlane(undefined, 'public'), [])
|
||||
})
|
||||
|
||||
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas', () => {
|
||||
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => {
|
||||
assert.deepEqual(SKILL_DIR_BY_GROUP, {
|
||||
ops: 'ops',
|
||||
topology: 'topology',
|
||||
bizMonitor: 'biz-monitor',
|
||||
})
|
||||
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__findTopologyPaths'))
|
||||
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__execManagedNe'))
|
||||
|
|
@ -36,11 +38,16 @@ 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,
|
||||
})
|
||||
|
|
@ -49,6 +56,7 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults',
|
|||
assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView'))
|
||||
assert.ok(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'))
|
||||
assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false)
|
||||
assert.equal(toolNamesForGroups(['topology']).has('netx__getBizMonitorContext'), false)
|
||||
})
|
||||
|
||||
test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into topology', () => {
|
||||
|
|
@ -61,6 +69,7 @@ test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into t
|
|||
assert.equal(groups.ops.inPreset, false)
|
||||
assert.equal(groups.ops.public, true)
|
||||
assert.equal(groups.topology.inPreset, true)
|
||||
assert.equal(groups.bizMonitor.inPreset, true)
|
||||
})
|
||||
|
||||
test('legacy any-true turns ops inPreset on', () => {
|
||||
|
|
@ -74,4 +83,5 @@ test('legacy any-true turns ops inPreset on', () => {
|
|||
test('groupsForced ignores settings for per-export mounts', () => {
|
||||
assert.deepEqual(groupsForced(['ops']), ['ops'])
|
||||
assert.deepEqual(groupsForced(['ops', 'topology']), ['ops', 'topology'])
|
||||
assert.deepEqual(groupsForced(['bizMonitor']), ['bizMonitor'])
|
||||
})
|
||||
|
|
|
|||
40
test/json-safe.test.mjs
Normal file
40
test/json-safe.test.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Lossless JSON helpers for DSH tool results.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { omitUndefined, toLosslessJson } from '../src/netx/json-safe.ts'
|
||||
|
||||
test('omitUndefined drops undefined keys but keeps null/false/0', () => {
|
||||
assert.deepEqual(
|
||||
omitUndefined({ a: 1, b: undefined, c: null, d: false, e: 0 }),
|
||||
{ a: 1, c: null, d: false, e: 0 },
|
||||
)
|
||||
})
|
||||
|
||||
test('toLosslessJson strips undefined holes (listBizMonitors-style payload)', () => {
|
||||
const raw = {
|
||||
ok: true,
|
||||
data: {
|
||||
kind: 'projects',
|
||||
projects: [{ id: 'p1', name: 'demo' }],
|
||||
tasks: undefined,
|
||||
next: 'go',
|
||||
},
|
||||
}
|
||||
const safe = toLosslessJson(raw)
|
||||
assert.equal(safe.ok, true)
|
||||
const data = safe.data
|
||||
assert.ok(data && typeof data === 'object' && !Array.isArray(data))
|
||||
assert.equal(Object.hasOwn(data, 'tasks'), false)
|
||||
assert.deepEqual(data.projects, [{ id: 'p1', name: 'demo' }])
|
||||
})
|
||||
|
||||
test('toLosslessJson keeps failure envelopes serializable', () => {
|
||||
const safe = toLosslessJson({ ok: false, error: 'x', detail: undefined })
|
||||
assert.equal(safe.ok, false)
|
||||
assert.equal(safe.error, 'x')
|
||||
assert.equal(Object.hasOwn(safe, 'detail'), false)
|
||||
})
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
|
@ -11,6 +11,7 @@ import test from 'node:test'
|
|||
import {
|
||||
createDraft,
|
||||
createMemory,
|
||||
createRef,
|
||||
createSuggestion,
|
||||
deleteLocalFile,
|
||||
listLocalFiles,
|
||||
|
|
@ -62,13 +63,13 @@ test('draftFileName forces DRAFT- prefix', () => {
|
|||
assert.match(name, /^DRAFT-20260915-jakarta-down\.md$/)
|
||||
})
|
||||
|
||||
test('resolveWritableLocalPath rejects identity', () => {
|
||||
test('resolveWritableLocalPath rejects non-writable roots', () => {
|
||||
withTemp((root) => {
|
||||
const local = join(root, '_local')
|
||||
mkdirSync(local, { recursive: true })
|
||||
assert.throws(
|
||||
() => resolveWritableLocalPath(local, 'identity', '话术.md'),
|
||||
/identity|allowed under/,
|
||||
() => resolveWritableLocalPath(local, 'local_skills', 'x.md'),
|
||||
/allowed under/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -129,26 +130,50 @@ test('draft injects status: draft frontmatter', () => {
|
|||
})
|
||||
})
|
||||
|
||||
test('suggestion + refuse identity path update', () => {
|
||||
test('createRef device PROFILE + list refs', () => {
|
||||
withTemp((root) => {
|
||||
const s = snap(root)
|
||||
const idDir = join(root, '_local', 'identity')
|
||||
mkdirSync(idDir, { recursive: true })
|
||||
writeFileSync(join(idDir, '话术与边界.md'), 'secret\n', 'utf8')
|
||||
createSuggestion(s, {
|
||||
mkdirSync(join(root, '_local'), { recursive: true })
|
||||
assert.throws(
|
||||
() => createRef(s, { area: 'devices', slug: 'PROFILE', body: '# x\n' }),
|
||||
/requires device/,
|
||||
)
|
||||
const created = createRef(s, {
|
||||
area: 'devices',
|
||||
device: 'JKT-PE-01',
|
||||
slug: 'PROFILE',
|
||||
body: '# JKT-PE-01\n\nrole: PE\n',
|
||||
})
|
||||
assert.match(created.relativePath, /^refs\/devices\/JKT-PE-01\/PROFILE\.md$/)
|
||||
assert.equal(readFileSync(created.absolutePath, 'utf8'), '# JKT-PE-01\n\nrole: PE\n')
|
||||
|
||||
createRef(s, {
|
||||
area: 'inventory',
|
||||
slug: 'ledger',
|
||||
body: '# ledger\n',
|
||||
})
|
||||
const listed = listLocalFiles(s, { root: 'refs' })
|
||||
assert.equal(listed.entries.length, 2)
|
||||
|
||||
updateLocalFile(s, {
|
||||
path: created.relativePath,
|
||||
body: '# JKT-PE-01\n\nrole: PE\nupdated: true\n',
|
||||
})
|
||||
assert.match(readFileSync(created.absolutePath, 'utf8'), /updated: true/)
|
||||
})
|
||||
})
|
||||
|
||||
test('suggestion create works', () => {
|
||||
withTemp((root) => {
|
||||
const s = snap(root)
|
||||
mkdirSync(join(root, '_local'), { recursive: true })
|
||||
const created = createSuggestion(s, {
|
||||
kind: 'improvement',
|
||||
slug: 'tool-x',
|
||||
body: '# sug\n',
|
||||
date: '2026-09-15',
|
||||
})
|
||||
assert.throws(
|
||||
() => updateLocalFile(s, {
|
||||
path: 'identity/话术与边界.md',
|
||||
body: 'hacked\n',
|
||||
}),
|
||||
/identity|allowed under/,
|
||||
)
|
||||
assert.equal(readFileSync(join(idDir, '话术与边界.md'), 'utf8'), 'secret\n')
|
||||
assert.match(created.relativePath, /suggestions\/improvement\//)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue