mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 13:30:46 +08:00
Fix preset publication, volatile settings and client form binding
This commit is contained in:
parent
fe0fe70d33
commit
0b9a3a82bc
66 changed files with 8546 additions and 3690 deletions
|
|
@ -2,7 +2,7 @@
|
|||
* Install Netx Ops agent preset for both DSH host styles:
|
||||
*
|
||||
* - DSH ≥0.2 (`@deepseek-ai/dsh-agent-preset` + registry): declarative insert
|
||||
* row synced into **desktop and web** profile `cordis.patch.yml` on apply.
|
||||
* row synced into the **running** profile's `cordis.patch.yml` on apply.
|
||||
* Directory `$DSH_HOME/.agent-presets/netxops` is still written for older
|
||||
* hosts that scan `@deepseek-ai/dsh-agent-presets`, but 0.2 web/desktop no
|
||||
* longer load that roster — without the patch row, Settings → Agent presets
|
||||
|
|
@ -19,15 +19,14 @@ import {
|
|||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { ensureNetxopsHostInProfilePatches } from './netx/heal-profile-entry.ts'
|
||||
|
||||
export const NETXOPS_PRESET_ID = 'netxops'
|
||||
|
||||
|
|
@ -47,6 +46,17 @@ function resolveDshHome(): string {
|
|||
return join(homedir(), '.dsh')
|
||||
}
|
||||
|
||||
/** Current Host profile; installing one Host never edits a second Host's patch. */
|
||||
export function resolveActiveProfile(baseUrl = ''): string {
|
||||
const declared = process.env.DSH_PROFILE?.trim()
|
||||
if (declared && /^[\w-]+$/.test(declared)) return declared
|
||||
const fromBase = /[\\/]profiles[\\/]([\w-]+)[\\/]/.exec(baseUrl)?.[1]
|
||||
if (fromBase) return fromBase
|
||||
const flag = process.argv.indexOf('--profile')
|
||||
if (flag >= 0 && /^[\w-]+$/.test(process.argv[flag + 1] ?? '')) return process.argv[flag + 1]
|
||||
return process.versions.electron ? 'desktop' : 'web'
|
||||
}
|
||||
|
||||
function electronResourcesRoots(): string[] {
|
||||
const roots: string[] = []
|
||||
const rp = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
|
|
@ -70,13 +80,13 @@ function readableFile(path: string): boolean {
|
|||
* Prefer Electron `app.asar` (what the running Desktop actually loads) over
|
||||
* `$DSH_HOME/profiles/node_modules` (often the older CLI heal tree).
|
||||
*/
|
||||
export function resolveDesktopStandardPatchPath(): string | null {
|
||||
export function resolveDesktopStandardPatchPath(profile = resolveActiveProfile()): string | null {
|
||||
const dshHome = resolveDshHome()
|
||||
const envOverride = process.env.NETXOPS_STANDARD_PATCH?.trim()
|
||||
const candidates: string[] = []
|
||||
if (envOverride) candidates.push(envOverride)
|
||||
|
||||
for (const resources of electronResourcesRoots()) {
|
||||
for (const resources of profile === 'desktop' ? electronResourcesRoots() : []) {
|
||||
candidates.push(
|
||||
join(resources, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml'),
|
||||
join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml'),
|
||||
|
|
@ -84,11 +94,10 @@ export function resolveDesktopStandardPatchPath(): string | null {
|
|||
}
|
||||
|
||||
const requireAnchors = [
|
||||
join(dshHome, 'profiles', 'desktop', 'package.json'),
|
||||
join(dshHome, 'profiles', 'desktop', 'node_modules', 'dsh-netxops', 'package.json'),
|
||||
join(dshHome, 'profiles', profile, 'package.json'),
|
||||
join(dshHome, 'profiles', profile, 'node_modules', 'dsh-netxops', 'package.json'),
|
||||
join(packageRoot(), 'package.json'),
|
||||
join(dshHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'package.json'),
|
||||
join(dshHome, 'profiles', 'web', 'package.json'),
|
||||
]
|
||||
for (const anchor of requireAnchors) {
|
||||
if (!existsSync(anchor)) continue
|
||||
|
|
@ -117,11 +126,10 @@ export function resolveDesktopStandardPatchPath(): string | null {
|
|||
* Profile-local copies win over the shared `$DSH_HOME/profiles/node_modules`
|
||||
* heal tree, which is frequently an older CLI generation than Desktop.
|
||||
*/
|
||||
export function resolveStandardPresetDir(): string | null {
|
||||
export function resolveStandardPresetDir(profile = resolveActiveProfile()): string | null {
|
||||
const dshHome = resolveDshHome()
|
||||
const candidates = [
|
||||
join(dshHome, 'profiles', 'desktop', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'),
|
||||
join(dshHome, 'profiles', 'web', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'),
|
||||
join(dshHome, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'),
|
||||
// Source checkout used by `dsh` CLI / local Harness.
|
||||
join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'DeepSeekHarness', 'packages', 'preset', 'agent-presets', 'presets', 'standard'),
|
||||
// Shared heal tree last — often lags Desktop 0.2.
|
||||
|
|
@ -131,8 +139,7 @@ export function resolveStandardPresetDir(): string | null {
|
|||
if (existsSync(join(dir, 'agent.cordis.yml'))) return dir
|
||||
}
|
||||
for (const nm of [
|
||||
join(dshHome, 'profiles', 'desktop', 'node_modules'),
|
||||
join(dshHome, 'profiles', 'web', 'node_modules'),
|
||||
join(dshHome, 'profiles', profile, 'node_modules'),
|
||||
join(dshHome, 'profiles', 'node_modules'),
|
||||
]) {
|
||||
const pkgJson = join(nm, '@deepseek-ai', 'dsh-agent-presets', 'package.json')
|
||||
|
|
@ -173,23 +180,21 @@ export function desktopRuntimeLikelyPresent(): boolean {
|
|||
}
|
||||
|
||||
/** Host roots used for bare package health checks (same walk shape as DSH discovery). */
|
||||
export function hostResolveBases(): string[] {
|
||||
export function hostResolveBases(profile = resolveActiveProfile()): string[] {
|
||||
const dshHome = resolveDshHome()
|
||||
const bases: string[] = []
|
||||
for (const resources of electronResourcesRoots()) {
|
||||
for (const resources of profile === 'desktop' ? electronResourcesRoots() : []) {
|
||||
bases.push(join(resources, 'app.asar', 'dsh', 'package.json'))
|
||||
bases.push(join(resources, 'app.asar', 'dsh', 'node_modules'))
|
||||
}
|
||||
const localAppData = process.env.LOCALAPPDATA?.trim()
|
||||
if (localAppData) {
|
||||
if (localAppData && profile === 'desktop') {
|
||||
const asarDsh = join(localAppData, 'Programs', 'DeepSeek Harness', 'resources', 'app.asar', 'dsh')
|
||||
bases.push(join(asarDsh, 'package.json'), join(asarDsh, 'node_modules'))
|
||||
}
|
||||
bases.push(
|
||||
join(dshHome, 'profiles', 'desktop', 'package.json'),
|
||||
join(dshHome, 'profiles', 'desktop', 'node_modules'),
|
||||
join(dshHome, 'profiles', 'web', 'package.json'),
|
||||
join(dshHome, 'profiles', 'web', 'node_modules'),
|
||||
join(dshHome, 'profiles', profile, 'package.json'),
|
||||
join(dshHome, 'profiles', profile, 'node_modules'),
|
||||
join(dshHome, 'profiles', 'node_modules'),
|
||||
join(packageRoot(), 'package.json'),
|
||||
)
|
||||
|
|
@ -389,7 +394,7 @@ export interface NetxopsPresetArtifacts {
|
|||
/** Human-readable source path (directory or patch file). */
|
||||
standardSource: string
|
||||
/** Kind of host standard used for composition. */
|
||||
standardKind: 'desktop-patch' | 'directory'
|
||||
standardKind: 'desktop-patch' | 'directory' | 'host-registry'
|
||||
/** Directory to seed `.agent-presets/netxops` from; null when patch-only. */
|
||||
standardDir: string | null
|
||||
name: string
|
||||
|
|
@ -410,12 +415,8 @@ function readPresetMeta(metaPath: string): { name: string; description: string;
|
|||
}
|
||||
}
|
||||
|
||||
function desktopProfilePresent(): boolean {
|
||||
return existsSync(join(resolveDshHome(), 'profiles', 'desktop'))
|
||||
}
|
||||
|
||||
/** Compose standard + Netx Ops overlays into directory + declarative artifacts. */
|
||||
export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts {
|
||||
export function composeNetxopsPresetArtifacts(profile = resolveActiveProfile(), standardDocument?: string): NetxopsPresetArtifacts {
|
||||
const overlayRoot = join(packageRoot(), 'presets', NETXOPS_PRESET_ID)
|
||||
const personaPath = join(overlayRoot, 'PERSONA.md')
|
||||
const metaPath = join(overlayRoot, 'preset.yml')
|
||||
|
|
@ -423,29 +424,35 @@ export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts {
|
|||
throw new Error(`bundled preset overlay missing under ${overlayRoot}`)
|
||||
}
|
||||
const meta = readPresetMeta(metaPath)
|
||||
const bases = hostResolveBases()
|
||||
const patchPath = resolveDesktopStandardPatchPath()
|
||||
const bases = hostResolveBases(profile)
|
||||
const patchPath = standardDocument === undefined ? resolveDesktopStandardPatchPath(profile) : null
|
||||
const usePatch = patchPath !== null && (
|
||||
process.env.NETXOPS_PREFER_DESKTOP_PATCH !== '0'
|
||||
) && (
|
||||
typeof process.versions.electron === 'string'
|
||||
|| process.env.NETXOPS_PREFER_DESKTOP_PATCH === '1'
|
||||
|| desktopProfilePresent()
|
||||
|| desktopRuntimeLikelyPresent()
|
||||
|| profile === 'desktop'
|
||||
|| resolveStandardPresetDir(profile) === null
|
||||
)
|
||||
|
||||
let standardSource: string
|
||||
let standardKind: 'desktop-patch' | 'directory'
|
||||
let standardKind: NetxopsPresetArtifacts['standardKind']
|
||||
let standardDir: string | null
|
||||
let agentCordis: string
|
||||
|
||||
if (usePatch && patchPath !== null) {
|
||||
if (standardDocument !== undefined) {
|
||||
if (!standardDocument.trim()) throw new Error('Host standard preset composition is empty')
|
||||
standardSource = `Host ${profile}: standard preset`
|
||||
standardKind = 'host-registry'
|
||||
standardDir = null
|
||||
agentCordis = standardDocument
|
||||
} else if (usePatch && patchPath !== null) {
|
||||
standardSource = patchPath
|
||||
standardKind = 'desktop-patch'
|
||||
standardDir = null
|
||||
agentCordis = pluginsBodyToAgentCordis(extractPluginsBodyFromStandardPatch(readFileSync(patchPath, 'utf8')))
|
||||
} else {
|
||||
const dir = resolveStandardPresetDir()
|
||||
const dir = resolveStandardPresetDir(profile)
|
||||
if (dir === null) {
|
||||
throw new Error(
|
||||
'cannot find host standard preset (Desktop standard.patch.yml or @deepseek-ai/dsh-agent-presets)',
|
||||
|
|
@ -463,14 +470,18 @@ export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts {
|
|||
personaEntryFromMarkdown(readFileSync(personaPath, 'utf8')),
|
||||
)
|
||||
agentCordis = ensureNetxopsToolsRow(agentCordis)
|
||||
agentCordis = alignWorkflowEngineForHost(agentCordis, bases)
|
||||
if (standardDocument === undefined) {
|
||||
agentCordis = alignWorkflowEngineForHost(agentCordis, bases, profile === 'desktop' ? 'desktop' : 'web')
|
||||
}
|
||||
if (!agentCordis.endsWith('\n')) agentCordis += '\n'
|
||||
|
||||
const missing = unresolvableCompositionPackages(agentCordis, bases)
|
||||
// A declaration read from the running Host preserves its Loader dialect and
|
||||
// package resolution. Filesystem anchors may point at a different checkout.
|
||||
const missing = (standardDocument === undefined ? unresolvableCompositionPackages(agentCordis, bases) : [])
|
||||
.filter((name) => !name.startsWith('dsh-netxops'))
|
||||
// Packaged Desktop keeps most `@deepseek-ai/*` only inside app.asar; plain
|
||||
// Node cannot probe them, but the Host can. Do not block the write for those.
|
||||
const desktopAsar = desktopRuntimeLikelyPresent()
|
||||
const desktopAsar = profile === 'desktop' && desktopRuntimeLikelyPresent()
|
||||
const blocking = missing.filter((name) => !(desktopAsar && name.startsWith('@deepseek-ai/')))
|
||||
if (blocking.length > 0) {
|
||||
throw new Error(
|
||||
|
|
@ -498,31 +509,47 @@ ${pluginsBody}
|
|||
return { standardSource, standardKind, standardDir, ...meta, agentCordis, declarativePatch }
|
||||
}
|
||||
|
||||
function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Context['logger']): void {
|
||||
export function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Context['logger']): void {
|
||||
const overlayRoot = join(packageRoot(), 'presets', NETXOPS_PRESET_ID)
|
||||
const destParent = join(resolveDshHome(), '.agent-presets')
|
||||
const dest = join(destParent, NETXOPS_PRESET_ID)
|
||||
mkdirSync(destParent, { recursive: true })
|
||||
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
|
||||
const marker = join(dest, '.dsh-netxops-managed')
|
||||
if (existsSync(dest) && !existsSync(marker)) {
|
||||
logger.warn('netxops: preserving user-managed preset at %s', dest)
|
||||
return
|
||||
}
|
||||
const existed = existsSync(dest)
|
||||
mkdirSync(dest, { recursive: true })
|
||||
if (artifacts.standardDir !== null && existsSync(artifacts.standardDir)) {
|
||||
if (!existed && artifacts.standardDir !== null && existsSync(artifacts.standardDir)) {
|
||||
cpSync(artifacts.standardDir, dest, { recursive: true })
|
||||
}
|
||||
writeFileSync(join(dest, 'preset.yml'), readFileSync(join(overlayRoot, 'preset.yml'), 'utf8'))
|
||||
writeFileSync(join(dest, 'PERSONA.md'), readFileSync(join(overlayRoot, 'PERSONA.md'), 'utf8'))
|
||||
writeFileSync(join(dest, 'agent.cordis.yml'), artifacts.agentCordis)
|
||||
const skillsSrc = join(overlayRoot, 'skills')
|
||||
if (existsSync(skillsSrc)) cpSync(skillsSrc, join(dest, 'skills'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(dest, '.dsh-netxops-managed'),
|
||||
[
|
||||
`composed-from: ${artifacts.standardKind}`,
|
||||
`standard-source: ${artifacts.standardSource}`,
|
||||
`at: ${new Date().toISOString()}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
)
|
||||
let hashes: Record<string, string> = {}
|
||||
if (existsSync(marker)) {
|
||||
try { hashes = JSON.parse(readFileSync(marker, 'utf8')).files ?? {} }
|
||||
catch { /* Legacy marker: preserve differing files on the first upgrade. */ }
|
||||
}
|
||||
const hash = (text: string): string => createHash('sha256').update(text).digest('hex')
|
||||
const files = {
|
||||
'preset.yml': readFileSync(join(overlayRoot, 'preset.yml'), 'utf8'),
|
||||
'PERSONA.md': readFileSync(join(overlayRoot, 'PERSONA.md'), 'utf8'),
|
||||
'agent.cordis.yml': artifacts.agentCordis,
|
||||
}
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const path = join(dest, name)
|
||||
if (existed && existsSync(path)) {
|
||||
const current = readFileSync(path, 'utf8')
|
||||
if (current === content) { hashes[name] = hash(content); continue }
|
||||
if (hash(current) !== hashes[name]) {
|
||||
logger.warn('netxops: preserving edited preset file %s', path)
|
||||
continue
|
||||
}
|
||||
}
|
||||
writeFileSync(path, content, 'utf8')
|
||||
hashes[name] = hash(content)
|
||||
}
|
||||
// Skills are published exclusively by the runtime policy, rather than copied into auto-scanned roots.
|
||||
writeFileSync(marker, `${JSON.stringify({ standardSource: artifacts.standardSource, files: hashes }, null, 2)}\n`, 'utf8')
|
||||
logger.info(
|
||||
'netxops: directory preset composed from %s → %s',
|
||||
artifacts.standardSource,
|
||||
|
|
@ -536,7 +563,7 @@ function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Conte
|
|||
* Both `desktop` and `web` need the `@deepseek-ai/dsh-agent-preset` insert;
|
||||
* directory `.agent-presets` alone is invisible on 0.2.
|
||||
*/
|
||||
function syncDeclarativeIntoProfilePatch(
|
||||
export function syncDeclarativeIntoProfilePatch(
|
||||
profileName: string,
|
||||
declarativePatch: string,
|
||||
logger: Context['logger'],
|
||||
|
|
@ -546,16 +573,26 @@ function syncDeclarativeIntoProfilePatch(
|
|||
const patchPath = join(profileDir, 'cordis.patch.yml')
|
||||
if (!existsSync(profileDir)) return
|
||||
|
||||
let patch = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '[]\n'
|
||||
const original = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '[]\n'
|
||||
let patch = original
|
||||
// Drop previous managed blocks (literal markers — do not RegExp `(managed)`).
|
||||
patch = stripManagedPresetBlocks(patch)
|
||||
// Drop a bare enable stub left by older installs.
|
||||
patch = patch.replace(/^- id: preset-netxops\n(?: .*\n)*/m, '')
|
||||
if (/^\s*- id: preset-netxops\s*$/m.test(patch)) {
|
||||
logger.warn('netxops: preserving user-declared preset-netxops in %s', patchPath)
|
||||
return
|
||||
}
|
||||
if (patch.trim() === '' || patch.trim() === '[]') patch = ''
|
||||
if (!patch.endsWith('\n') && patch.length > 0) patch += '\n'
|
||||
|
||||
const managed = `${DESKTOP_PATCH_BEGIN}\n${declarativePatch.trimEnd()}\n${DESKTOP_PATCH_END}\n`
|
||||
writeFileSync(patchPath, `${patch}${managed}`, 'utf8')
|
||||
const next = `${patch}${managed}`
|
||||
if (next === original) return
|
||||
if (existsSync(patchPath) && readFileSync(patchPath, 'utf8') !== original) {
|
||||
logger.warn('netxops: profile patch changed during preset installation; preserving %s', patchPath)
|
||||
return
|
||||
}
|
||||
writeFileSync(patchPath, next, 'utf8')
|
||||
logger.info(
|
||||
'netxops: declarative preset synced into profiles/%s/cordis.patch.yml (restart %s to load)',
|
||||
profileName,
|
||||
|
|
@ -571,8 +608,7 @@ function stripManagedPresetBlocks(patch: string): string {
|
|||
if (start < 0) break
|
||||
const end = out.indexOf(DESKTOP_PATCH_END, start)
|
||||
if (end < 0) {
|
||||
out = out.slice(0, start)
|
||||
break
|
||||
throw new Error('unterminated managed preset block; preserving profile patch')
|
||||
}
|
||||
let cut = end + DESKTOP_PATCH_END.length
|
||||
if (out[cut] === '\r') cut += 1
|
||||
|
|
@ -585,10 +621,10 @@ function stripManagedPresetBlocks(patch: string): string {
|
|||
/**
|
||||
* Build directory preset (web) + sync declarative patch into Desktop profiles.
|
||||
*/
|
||||
export function ensureAgentPresetInstalled(logger: Context['logger']): void {
|
||||
export function ensureAgentPresetInstalled(logger: Context['logger'], profile = resolveActiveProfile(), standardDocument?: string): void {
|
||||
let artifacts: NetxopsPresetArtifacts
|
||||
try {
|
||||
artifacts = composeNetxopsPresetArtifacts()
|
||||
artifacts = composeNetxopsPresetArtifacts(profile, standardDocument)
|
||||
} catch (error) {
|
||||
logger.warn('netxops: skip preset install: %s', error)
|
||||
return
|
||||
|
|
@ -599,24 +635,8 @@ export function ensureAgentPresetInstalled(logger: Context['logger']): void {
|
|||
logger.error('netxops: failed to install directory preset: %s', error)
|
||||
}
|
||||
try {
|
||||
// Refresh shipped declarative file inside the package for next bundle consumers.
|
||||
writeFileSync(join(packageRoot(), 'presets', 'netxops.preset.patch.yml'), artifacts.declarativePatch)
|
||||
} catch {
|
||||
// package may be read-only in some installs
|
||||
}
|
||||
try {
|
||||
for (const profile of ['desktop', 'web']) {
|
||||
syncDeclarativeIntoProfilePatch(profile, artifacts.declarativePatch, logger)
|
||||
}
|
||||
syncDeclarativeIntoProfilePatch(profile, artifacts.declarativePatch, logger)
|
||||
} catch (error) {
|
||||
logger.error('netxops: failed to sync declarative preset into profile patch: %s', error)
|
||||
}
|
||||
try {
|
||||
// (1) Sparse `- id: netxops` from Plugin Manager → expand missing config keys.
|
||||
// (2) Missing host row after dirty reinstall (preset channel self-healed,
|
||||
// bundle host insert lost) → insert full `name: dsh-netxops` block.
|
||||
ensureNetxopsHostInProfilePatches(logger)
|
||||
} catch (error) {
|
||||
logger.error('netxops: failed to heal host profile entry: %s', error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,12 @@ import {
|
|||
} from '../netx/im-targets.ts'
|
||||
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
|
||||
import { kbStatusTone, type KbSnapshot } from './kb-status-view.ts'
|
||||
import { toolsMountTone, type ToolsMountView } from './tools-status-view.ts'
|
||||
import { ensureStyles } from './styles.ts'
|
||||
import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts'
|
||||
import { parsePresetIds, TOOLS_BY_GROUP } from '../netx/capability-groups.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
|
||||
function phaseLocaleKey(phase: AlarmPushPhase): NetxopsLocaleKey {
|
||||
switch (phase) {
|
||||
|
|
@ -78,6 +81,24 @@ function fillTemplate(template: string, vars: Record<string, string>): string {
|
|||
))
|
||||
}
|
||||
|
||||
function toolsBadgeLabel(
|
||||
t: (key: NetxopsLocaleKey) => string,
|
||||
view: ToolsMountView | null,
|
||||
): string {
|
||||
if (!view) return t('toolsStatusUnknown')
|
||||
if (!view.toolsEnabled) return t('toolsDisabled')
|
||||
if (view.error) {
|
||||
return fillTemplate(t('toolsStatusError'), { detail: view.error })
|
||||
}
|
||||
if (view.count > 0) {
|
||||
return fillTemplate(t('toolsStatusMounted'), {
|
||||
count: String(view.count),
|
||||
version: view.version || '?',
|
||||
})
|
||||
}
|
||||
return t('toolsStatusEmpty')
|
||||
}
|
||||
|
||||
function exportStatusMessage(
|
||||
t: (key: NetxopsLocaleKey) => string,
|
||||
status: NonNullable<NetxopsCardState['sessionsExportStatus']>,
|
||||
|
|
@ -108,7 +129,7 @@ function exportErrorMessage(
|
|||
}
|
||||
|
||||
export type NetxopsCardProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
(PropsRuntime<'settings.section'> | PropsRuntime<'settings.plugin.item'>)
|
||||
& PropsLocale<'settings.netxops'>
|
||||
& InjectFace<NetxopsCardFace>
|
||||
|
||||
|
|
@ -309,6 +330,35 @@ function ImDeliveryPicker(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function PresetTargets(props: {
|
||||
field: CardFieldState
|
||||
state: NetxopsCardState
|
||||
disabled: boolean
|
||||
onEdit: (value: string) => void
|
||||
t: (key: NetxopsLocaleKey) => string
|
||||
}) {
|
||||
const selected = parsePresetIds(props.field.text)
|
||||
const rows = props.state.presetCatalog.filter(row => row.id !== 'netxops')
|
||||
const unknown = selected.filter(id => !rows.some(row => row.id === id))
|
||||
return (
|
||||
<details className="dsh-nx-targets">
|
||||
<summary>{props.t('selectedPresets')} · {selected.length}</summary>
|
||||
<p className="dsh-nx-hint">{props.t('publicationHint')}</p>
|
||||
{props.state.presetCatalogError ? <p className="dsh-nx-invalid">{props.t('presetCatalogUnavailable')}</p> : null}
|
||||
{[...rows, ...unknown.map(id => ({ id, name: undefined, broken: undefined }))].map(row => (
|
||||
<label key={row.id} className="dsh-nx-checkRow">
|
||||
<input type="checkbox" checked={selected.includes(row.id)} disabled={props.disabled}
|
||||
onChange={event => { props.onEdit((event.target.checked
|
||||
? [...selected, row.id] : selected.filter(id => id !== row.id)).join(',')) }} />
|
||||
<span>{row.name || row.id}{row.name && row.name !== row.id ? ` (${row.id})` : ''}</span>
|
||||
{row.broken ? <span className="dsh-nx-invalid" title={row.broken}>{props.t('presetUnavailable')}</span> : null}
|
||||
</label>
|
||||
))}
|
||||
{rows.length === 0 && unknown.length === 0 ? <p className="dsh-nx-hint">{props.t('noOtherPresets')}</p> : null}
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function CapabilityGroupBlock(props: {
|
||||
title: string
|
||||
inPresetLabel: string
|
||||
|
|
@ -318,6 +368,7 @@ function CapabilityGroupBlock(props: {
|
|||
disabled: boolean
|
||||
onEditInPreset: (checked: boolean) => void
|
||||
onEditPublic: (checked: boolean) => void
|
||||
targets?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="dsh-nx-groupBlock">
|
||||
|
|
@ -342,6 +393,7 @@ function CapabilityGroupBlock(props: {
|
|||
<span>{props.publicLabel}</span>
|
||||
</label>
|
||||
</div>
|
||||
{props.targets}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -419,7 +471,8 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
if (!state.available) {
|
||||
return (
|
||||
<section className="dsh-nx-settings" aria-label={t('title')}>
|
||||
<div className="dsh-nx-settings-empty">{t('sessionsExportUnavailable')}</div>
|
||||
<h2>{t('title')}</h2>
|
||||
<div className="dsh-nx-settings-empty" role="status">{t('settingsUnavailable')}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -427,6 +480,11 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
const disabled = !state.writable
|
||||
const blocked = !state.dirty || state.invalid || state.saving
|
||||
const pushStatus = state.alarmPushStatus
|
||||
const publicationDisabled = disabled || state.toolsEnabled.text !== 'true'
|
||||
const targets = (field: string, value: CardFieldState) => (
|
||||
<PresetTargets field={value} state={state} disabled={publicationDisabled} t={t}
|
||||
onEdit={text => { props.edit(field, text) }} />
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="dsh-nx-settings" aria-label={t('title')}>
|
||||
|
|
@ -441,6 +499,25 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
|
||||
<div className="dsh-nx-settings-card">
|
||||
<h3>{t('sectionConnection')}</h3>
|
||||
<div className="dsh-nx-settings-field">
|
||||
<div className="dsh-nx-field-head">
|
||||
<span>{t('toolsStatus')}</span>
|
||||
<StatusBadge
|
||||
tone={toolsMountTone(state.toolsStatus)}
|
||||
label={toolsBadgeLabel(t, state.toolsStatus)}
|
||||
/>
|
||||
</div>
|
||||
<p className="dsh-nx-hint">{t('mountStatusHint')}</p>
|
||||
{state.toolsStatus && state.toolsStatus.mounts.length > 0 ? (
|
||||
<details><summary>{t('mountDetails')}</summary>
|
||||
{state.toolsStatus.mounts.map((mount, index) => (
|
||||
<p key={`${mount.source}:${index}`} className={mount.error ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
|
||||
{mount.source} · {mount.count} · {mount.groups.join(', ') || '—'}{mount.error ? ` · ${mount.error}` : ''}
|
||||
</p>
|
||||
))}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="dsh-nx-settings-field">
|
||||
<div className="dsh-nx-field-head">
|
||||
<label htmlFor="netxops-api-token">{t('apiToken')}</label>
|
||||
|
|
@ -469,7 +546,7 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
field={state.apiUrl}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
invalidLabel={t('apiUrlInvalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('apiUrl', text) }}
|
||||
onReset={() => { props.resetField('apiUrl') }}
|
||||
|
|
@ -536,13 +613,20 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
|
||||
<div className="dsh-nx-settings-card">
|
||||
<h3>{t('sectionCapabilities')}</h3>
|
||||
<ToggleField id="netxops-tools-enabled" label={t('toolsEnabled')}
|
||||
checked={state.toolsEnabled.text === 'true'} disabled={disabled}
|
||||
overridden={state.toolsEnabled.overridden} overriddenLabel={t('overridden')} resetLabel={t('reset')}
|
||||
onToggle={checked => { props.edit('toolsEnabled', String(checked)) }}
|
||||
onReset={() => { props.resetField('toolsEnabled') }}
|
||||
hint={<p className="dsh-nx-hint">{t('toolsEnabledHint')}</p>} />
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupOps')}
|
||||
title={`${t('groupOps')} · ${TOOLS_BY_GROUP.ops.length}`}
|
||||
targets={targets('groupOpsPresets', state.groupOpsPresets)}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
inPreset={state.groupOpsInPreset}
|
||||
published={state.groupOpsPublic}
|
||||
disabled={disabled}
|
||||
disabled={publicationDisabled}
|
||||
onEditInPreset={(checked) => {
|
||||
props.edit('groupOpsInPreset', checked ? 'true' : 'false')
|
||||
}}
|
||||
|
|
@ -551,12 +635,13 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
}}
|
||||
/>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupTopology')}
|
||||
title={`${t('groupTopology')} · ${TOOLS_BY_GROUP.topology.length}`}
|
||||
targets={targets('groupTopologyPresets', state.groupTopologyPresets)}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
inPreset={state.groupTopologyInPreset}
|
||||
published={state.groupTopologyPublic}
|
||||
disabled={disabled}
|
||||
disabled={publicationDisabled}
|
||||
onEditInPreset={(checked) => {
|
||||
props.edit('groupTopologyInPreset', checked ? 'true' : 'false')
|
||||
}}
|
||||
|
|
@ -565,12 +650,13 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
}}
|
||||
/>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupBizMonitor')}
|
||||
title={`${t('groupBizMonitor')} · ${TOOLS_BY_GROUP.bizMonitor.length}`}
|
||||
targets={targets('groupBizMonitorPresets', state.groupBizMonitorPresets)}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
inPreset={state.groupBizMonitorInPreset}
|
||||
published={state.groupBizMonitorPublic}
|
||||
disabled={disabled}
|
||||
disabled={publicationDisabled}
|
||||
onEditInPreset={(checked) => {
|
||||
props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false')
|
||||
}}
|
||||
|
|
@ -650,11 +736,12 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
</div>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupKb')}
|
||||
targets={targets('groupKbPresets', state.groupKbPresets)}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
inPreset={state.groupKbInPreset}
|
||||
published={state.groupKbPublic}
|
||||
disabled={disabled}
|
||||
disabled={publicationDisabled}
|
||||
onEditInPreset={(checked) => {
|
||||
props.edit('groupKbInPreset', checked ? 'true' : 'false')
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
* Self-contained (cannot value-import ui-settings-plugins — client bundle purity).
|
||||
*/
|
||||
|
||||
import { normalizeApiUrl } from '../netx/http.ts'
|
||||
import { parsePresetIds } from '../netx/capability-groups.ts'
|
||||
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
|
||||
import { createSnapshotStore, type SnapshotStore } from './snapshot-store.ts'
|
||||
|
||||
|
|
@ -62,6 +64,33 @@ export function textField(field: string): CardFieldSpec {
|
|||
}
|
||||
}
|
||||
|
||||
/** Empty selection must override a profile's target list rather than restore it. */
|
||||
export function presetIdsField(field: string): CardFieldSpec {
|
||||
return {
|
||||
field,
|
||||
format: value => typeof value === 'string' ? value : '',
|
||||
parse: text => ({ kind: 'set', value: parsePresetIds(text).join(',') }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* http(s) API root — rejects credentials-in-URL and non-http schemes.
|
||||
* Empty clears to schema default; save stores the normalized URL.
|
||||
*/
|
||||
export function apiUrlField(field: string): CardFieldSpec {
|
||||
return {
|
||||
field,
|
||||
format: value => typeof value === 'string' ? value : '',
|
||||
parse: (text) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return { kind: 'clear' }
|
||||
const normalized = normalizeApiUrl(trimmed)
|
||||
if (!normalized.ok) return undefined
|
||||
return { kind: 'set', value: normalized.url }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Boolean settings field rendered as a checkbox (stores true / clears when false). */
|
||||
export function booleanField(field: string): CardFieldSpec {
|
||||
return {
|
||||
|
|
@ -108,6 +137,7 @@ export class CardForm<T> {
|
|||
private readonly listeners = new Set<() => void>()
|
||||
private saving = false
|
||||
private failed = false
|
||||
private saveQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
private readonly scope: SettingsFormScope<T>,
|
||||
|
|
@ -128,11 +158,18 @@ export class CardForm<T> {
|
|||
shell(): CardShell {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
const plan = this.plan()
|
||||
let committedInvalid = false
|
||||
for (const field of this.specs.keys()) {
|
||||
if (this.field(field).invalid) {
|
||||
committedInvalid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return {
|
||||
available: snapshot.status === 'ready',
|
||||
writable: snapshot.writable,
|
||||
dirty: plan.length > 0,
|
||||
invalid: plan.some(item => item.run === undefined),
|
||||
invalid: committedInvalid || plan.some(item => item.run === undefined),
|
||||
saving: this.saving,
|
||||
failed: this.failed,
|
||||
}
|
||||
|
|
@ -145,7 +182,15 @@ export class CardForm<T> {
|
|||
}
|
||||
const spec = this.spec(field)
|
||||
if (staged === undefined) {
|
||||
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
||||
const text = spec.format(this.sectionValue(field))
|
||||
// Surface committed bad values (e.g. file:// apiUrl) so Save stays blocked
|
||||
// until the operator fixes them — not only while the field is staged.
|
||||
const write = text.trim() === '' ? { kind: 'clear' as const } : spec.parse(text)
|
||||
return {
|
||||
text,
|
||||
overridden: this.stored(field),
|
||||
invalid: text.trim() !== '' && write === undefined,
|
||||
}
|
||||
}
|
||||
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
||||
return {
|
||||
|
|
@ -171,21 +216,43 @@ export class CardForm<T> {
|
|||
}
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
const plan = this.plan()
|
||||
/** Serialize writes and save only requested fields for immediate publication toggles. */
|
||||
save(fields?: readonly string[]): Promise<void> {
|
||||
const task = this.saveQueue.then(() => this.saveNow(fields))
|
||||
this.saveQueue = task.catch(() => {})
|
||||
return task
|
||||
}
|
||||
|
||||
private async saveNow(fields?: readonly string[]): Promise<void> {
|
||||
const snapshot = this.snapshotOf()
|
||||
if (snapshot.status !== 'ready' || !snapshot.writable) {
|
||||
this.failed = true
|
||||
this.publish()
|
||||
return
|
||||
}
|
||||
const staged = new Map([...this.staged].filter(([field]) => !fields || fields.includes(field)))
|
||||
const plan = this.plan().filter(item => !fields || fields.includes(item.field))
|
||||
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
|
||||
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
|
||||
if (writes.length !== plan.length) return
|
||||
this.saving = true
|
||||
this.failed = false
|
||||
this.publish()
|
||||
let landed = true
|
||||
for (const write of writes) {
|
||||
landed = await write() && landed
|
||||
try {
|
||||
for (const write of writes) landed = await write() && landed
|
||||
} catch {
|
||||
landed = false
|
||||
} finally {
|
||||
// A newer edit made during a write remains staged for the next queued save.
|
||||
if (landed) {
|
||||
for (const [field, edit] of staged) {
|
||||
if (this.staged.get(field) === edit) this.staged.delete(field)
|
||||
}
|
||||
}
|
||||
this.saving = false
|
||||
this.failed = !landed
|
||||
this.publish()
|
||||
}
|
||||
if (landed) this.staged.clear()
|
||||
this.saving = false
|
||||
this.failed = !landed
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private plan(): PlannedWrite[] {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
|||
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
|
||||
import type { SnapshotStore } from './snapshot-store.ts'
|
||||
import {
|
||||
CardForm, textField, booleanField, booleanFieldPersistFalse,
|
||||
CardForm, apiUrlField, textField, presetIdsField, booleanFieldPersistFalse,
|
||||
type CardActions, type CardFieldState, type CardShell,
|
||||
} from './card-form.ts'
|
||||
import {
|
||||
|
|
@ -15,6 +15,10 @@ import {
|
|||
type AlarmPushRpcCall,
|
||||
type AlarmPushStatus,
|
||||
} from './alarm-push-status-view.ts'
|
||||
import {
|
||||
fetchToolsStatus,
|
||||
type ToolsMountView,
|
||||
} from './tools-status-view.ts'
|
||||
import {
|
||||
fetchKbStatus,
|
||||
reloadKbStatus,
|
||||
|
|
@ -35,12 +39,23 @@ import {
|
|||
} from './sessions-export-view.ts'
|
||||
import type { SessionsExportReasonCode } from '../session-export-shared.ts'
|
||||
|
||||
/** Host Config / configForms namespace (must match cordis entry id). */
|
||||
export const NETXOPS_NS = 'netxops'
|
||||
/**
|
||||
* Settings sidebar slot id — must stay distinct from NETXOPS_NS.
|
||||
* Colliding with the Config namespace silently drops nav registration on Desktop 0.2.
|
||||
*/
|
||||
export const NETXOPS_SECTION_ID = 'dsh-netxops'
|
||||
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
||||
const API_TOKEN_FIELD = 'apiToken'
|
||||
const STATUS_POLL_MS = 2_000
|
||||
|
||||
export interface NetxopsSettings {
|
||||
toolsEnabled?: boolean
|
||||
groupOpsPresets?: string
|
||||
groupTopologyPresets?: string
|
||||
groupBizMonitorPresets?: string
|
||||
groupKbPresets?: string
|
||||
apiUrl?: string
|
||||
lang?: string
|
||||
thinkingLanguage?: string
|
||||
|
|
@ -76,6 +91,13 @@ interface CredentialState {
|
|||
}
|
||||
|
||||
export interface NetxopsCardState extends CardShell {
|
||||
toolsEnabled: CardFieldState
|
||||
groupOpsPresets: CardFieldState
|
||||
groupTopologyPresets: CardFieldState
|
||||
groupBizMonitorPresets: CardFieldState
|
||||
groupKbPresets: CardFieldState
|
||||
presetCatalog: Array<{ id: string; name?: string; broken?: string }>
|
||||
presetCatalogError: boolean
|
||||
apiUrl: CardFieldState
|
||||
lang: CardFieldState
|
||||
thinkingLanguage: CardFieldState
|
||||
|
|
@ -102,6 +124,8 @@ export interface NetxopsCardState extends CardShell {
|
|||
apiTokenRemoteReady: boolean
|
||||
/** Host WSS status when Connection RPC is available; otherwise null. */
|
||||
alarmPushStatus: AlarmPushStatus | null
|
||||
/** Host `netx__*` tool mount snapshot; null when RPC unavailable. */
|
||||
toolsStatus: ToolsMountView | null
|
||||
/** Latest KB snapshot (saved root, or live preview while editing). */
|
||||
kbStatus: KbSnapshot | null
|
||||
/** Soft-injected `remote.directoryPicker` is available. */
|
||||
|
|
@ -136,7 +160,7 @@ export interface NetxopsCardFace extends CardActions {
|
|||
}
|
||||
|
||||
type DirectoryPickerRemote = {
|
||||
pick: (signal?: AbortSignal) => Promise<string | null>
|
||||
pick: (signal?: AbortSignal) => Promise<unknown>
|
||||
}
|
||||
|
||||
type CredentialsRemote = {
|
||||
|
|
@ -155,6 +179,11 @@ export class NetxopsCardController {
|
|||
}
|
||||
private rpcCall: AlarmPushRpcCall | undefined
|
||||
private alarmPushStatus: AlarmPushStatus | null = null
|
||||
private toolsStatus: ToolsMountView | null = null
|
||||
private toolsStatusInFlight = false
|
||||
private presetCatalog: NetxopsCardState['presetCatalog'] = []
|
||||
private presetCatalogError = false
|
||||
private presetCatalogInFlight = false
|
||||
private kbStatus: KbSnapshot | null = null
|
||||
private kbPreview: KbSnapshot | null = null
|
||||
private kbDirectoryPicker: DirectoryPickerRemote | undefined
|
||||
|
|
@ -181,24 +210,31 @@ export class NetxopsCardController {
|
|||
this.form = new CardForm(
|
||||
scope,
|
||||
[
|
||||
textField('apiUrl'),
|
||||
booleanFieldPersistFalse('toolsEnabled'),
|
||||
presetIdsField('groupOpsPresets'),
|
||||
presetIdsField('groupTopologyPresets'),
|
||||
presetIdsField('groupBizMonitorPresets'),
|
||||
presetIdsField('groupKbPresets'),
|
||||
apiUrlField('apiUrl'),
|
||||
textField('lang'),
|
||||
textField('thinkingLanguage'),
|
||||
textField('replyLanguage'),
|
||||
textField('nmsProvider'),
|
||||
textField('kbRoot'),
|
||||
// Persist false explicitly: `booleanField` clears on off, which lets a
|
||||
// profile patch (`groupOpsPublic: true`) win again — uncheck looked
|
||||
// instant in the UI then tools stayed published.
|
||||
booleanFieldPersistFalse('groupOpsInPreset'),
|
||||
// Default is true — must persist false explicitly or "off" snaps back.
|
||||
booleanFieldPersistFalse('groupOpsPublic'),
|
||||
booleanField('groupTopologyInPreset'),
|
||||
booleanField('groupTopologyPublic'),
|
||||
booleanFieldPersistFalse('groupTopologyInPreset'),
|
||||
booleanFieldPersistFalse('groupTopologyPublic'),
|
||||
booleanFieldPersistFalse('groupBizMonitorInPreset'),
|
||||
booleanField('groupBizMonitorPublic'),
|
||||
booleanFieldPersistFalse('groupBizMonitorPublic'),
|
||||
booleanFieldPersistFalse('groupKbInPreset'),
|
||||
booleanField('groupKbPublic'),
|
||||
booleanField('alarmPushEnabled'),
|
||||
booleanFieldPersistFalse('groupKbPublic'),
|
||||
booleanFieldPersistFalse('alarmPushEnabled'),
|
||||
booleanFieldPersistFalse('alarmDeliverDsh'),
|
||||
booleanField('alarmDeliverIm'),
|
||||
booleanFieldPersistFalse('alarmDeliverIm'),
|
||||
textField('imTargets'),
|
||||
textField('imBotId'),
|
||||
textField('imTargetId'),
|
||||
|
|
@ -239,6 +275,13 @@ export class NetxopsCardController {
|
|||
this.alarmPushStatus = null
|
||||
changed = true
|
||||
}
|
||||
if (this.toolsStatus !== null) {
|
||||
this.toolsStatus = null
|
||||
changed = true
|
||||
}
|
||||
this.presetCatalog = []
|
||||
this.presetCatalogError = true
|
||||
changed = true
|
||||
if (this.kbStatus !== null || this.kbPreview !== null) {
|
||||
this.kbStatus = null
|
||||
this.kbPreview = null
|
||||
|
|
@ -257,6 +300,8 @@ export class NetxopsCardController {
|
|||
}
|
||||
this.startStatusPoll()
|
||||
void this.refreshAlarmPushStatus()
|
||||
void this.refreshToolsStatus()
|
||||
void this.refreshPresetCatalog()
|
||||
void this.reloadKbFromHost()
|
||||
void this.refreshKbPreview()
|
||||
void this.refreshImDeliveryCatalog()
|
||||
|
|
@ -279,6 +324,8 @@ export class NetxopsCardController {
|
|||
if (this.pollTimer !== undefined) return
|
||||
this.pollTimer = setInterval(() => {
|
||||
void this.refreshAlarmPushStatus()
|
||||
void this.refreshToolsStatus()
|
||||
void this.refreshPresetCatalog()
|
||||
void this.refreshKbStatus()
|
||||
void this.refreshImDeliveryCatalog()
|
||||
void this.refreshSessionsExportStatus()
|
||||
|
|
@ -316,6 +363,51 @@ export class NetxopsCardController {
|
|||
}
|
||||
}
|
||||
|
||||
private async refreshToolsStatus(): Promise<void> {
|
||||
const call = this.rpcCall
|
||||
if (call === undefined || this.toolsStatusInFlight) return
|
||||
this.toolsStatusInFlight = true
|
||||
try {
|
||||
const next = await fetchToolsStatus(call)
|
||||
const prev = this.toolsStatus
|
||||
if (
|
||||
prev
|
||||
&& prev.count === next.count
|
||||
&& prev.version === next.version
|
||||
&& prev.plane === next.plane
|
||||
&& prev.error === next.error
|
||||
&& prev.tokenConfigured === next.tokenConfigured
|
||||
&& prev.apiUrl === next.apiUrl
|
||||
&& JSON.stringify(prev) === JSON.stringify(next)
|
||||
) return
|
||||
this.toolsStatus = next
|
||||
this.store.set(this.projection())
|
||||
} catch {
|
||||
// Keep last good snapshot; next poll retries.
|
||||
} finally {
|
||||
this.toolsStatusInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPresetCatalog(): Promise<void> {
|
||||
const call = this.rpcCall
|
||||
if (!call || this.presetCatalogInFlight) return
|
||||
this.presetCatalogInFlight = true
|
||||
try {
|
||||
const result = await call('/netxops', 'presets.catalog', {}) as {
|
||||
ok?: boolean; value?: { presets?: NetxopsCardState['presetCatalog'] }
|
||||
}
|
||||
if (call !== this.rpcCall) return
|
||||
this.presetCatalogError = result?.ok !== true || !Array.isArray(result.value?.presets)
|
||||
if (!this.presetCatalogError) this.presetCatalog = result.value!.presets!
|
||||
this.store.set(this.projection())
|
||||
} catch {
|
||||
if (call !== this.rpcCall) return
|
||||
this.presetCatalogError = true
|
||||
this.store.set(this.projection())
|
||||
} finally { this.presetCatalogInFlight = false }
|
||||
}
|
||||
|
||||
private async refreshImDeliveryCatalog(): Promise<void> {
|
||||
const call = this.rpcCall
|
||||
if (call === undefined || this.catalogInFlight) return
|
||||
|
|
@ -467,7 +559,7 @@ export class NetxopsCardController {
|
|||
const trimmed = path.trim()
|
||||
this.kbUiError = null
|
||||
this.store.set(this.projection())
|
||||
await this.form.save()
|
||||
await this.form.save(['kbRoot'])
|
||||
const shell = this.form.shell()
|
||||
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
|
||||
if (shell.failed || saved !== trimmed) {
|
||||
|
|
@ -595,6 +687,13 @@ export class NetxopsCardController {
|
|||
|
||||
private projection(): NetxopsCardState {
|
||||
return {
|
||||
toolsEnabled: this.form.field('toolsEnabled'),
|
||||
groupOpsPresets: this.form.field('groupOpsPresets'),
|
||||
groupTopologyPresets: this.form.field('groupTopologyPresets'),
|
||||
groupBizMonitorPresets: this.form.field('groupBizMonitorPresets'),
|
||||
groupKbPresets: this.form.field('groupKbPresets'),
|
||||
presetCatalog: this.presetCatalog,
|
||||
presetCatalogError: this.presetCatalogError,
|
||||
...this.form.shell(),
|
||||
apiUrl: this.form.field('apiUrl'),
|
||||
lang: this.form.field('lang'),
|
||||
|
|
@ -621,6 +720,7 @@ export class NetxopsCardController {
|
|||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||
apiTokenRemoteReady: this.credential.remoteReady,
|
||||
alarmPushStatus: this.alarmPushStatus,
|
||||
toolsStatus: this.toolsStatus,
|
||||
kbStatus: this.kbPreview ?? this.kbStatus,
|
||||
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined,
|
||||
kbUiError: this.kbUiError,
|
||||
|
|
@ -681,6 +781,11 @@ export class NetxopsCardController {
|
|||
inject(): NetxopsCardFace {
|
||||
const actions = this.form.actions()
|
||||
const capabilityFields = new Set([
|
||||
'toolsEnabled',
|
||||
'groupOpsPresets',
|
||||
'groupTopologyPresets',
|
||||
'groupBizMonitorPresets',
|
||||
'groupKbPresets',
|
||||
'groupOpsInPreset',
|
||||
'groupOpsPublic',
|
||||
'groupTopologyInPreset',
|
||||
|
|
@ -698,7 +803,7 @@ export class NetxopsCardController {
|
|||
if (field === 'kbRoot') void this.refreshKbPreview()
|
||||
// Capability toggles must hit the host Config immediately — staged-only
|
||||
// edits never publish public tools/skills to other presets.
|
||||
if (capabilityFields.has(field)) void this.form.save()
|
||||
if (capabilityFields.has(field)) void this.form.save([field])
|
||||
},
|
||||
discard: () => {
|
||||
actions.discard()
|
||||
|
|
@ -707,7 +812,7 @@ export class NetxopsCardController {
|
|||
resetField: (field) => {
|
||||
actions.resetField(field)
|
||||
if (field === 'kbRoot') void this.refreshKbPreview()
|
||||
if (capabilityFields.has(field)) void this.form.save()
|
||||
if (capabilityFields.has(field)) void this.form.save([field])
|
||||
},
|
||||
exportAllSessions: () => { this.exportAllSessions() },
|
||||
browseKbRoot: () => { this.browseKbRoot() },
|
||||
|
|
|
|||
|
|
@ -6,23 +6,32 @@
|
|||
* inject surfaces as `waiting for service: settingsScope`, the host never
|
||||
* publishes a connection, and Netx Ops sessions get zero `netx__*` tools.
|
||||
*
|
||||
* Surfaces (dual-stack), matching working plugins like dsh-im-ops:
|
||||
* Desktop 0.2 boot contract (aligned with dsh-search-mcp / dsh-trilium):
|
||||
* - `dsh.client.immediately: false` — stage-one prefetch must not race Host.
|
||||
* - Never `ctx.inject(['configForms'])` during apply; poll after apply returns
|
||||
* (sync inject / accessor wait freezes "Loading plugins…").
|
||||
* - `settings.section` id ≠ Config namespace (`dsh-netxops` vs `netxops`).
|
||||
*
|
||||
* Surfaces (dual-stack):
|
||||
* - Always register `settings.section` in `apply` (Settings sidebar).
|
||||
* - Form transport: soft `configForms` (0.2); one-shot `ctx.get('settingsScope')`
|
||||
* for ≤0.1.5 only — never `ctx.inject(['settingsScope'])`.
|
||||
* - Form transport: deferred `configForms` poll (0.2); one-shot
|
||||
* `ctx.get('settingsScope')` for ≤0.1.5 only — never inject settingsScope.
|
||||
* - Soft-attach real form scopes into a deferred memory scope so Save
|
||||
* still writes the host entry when the transport is ready.
|
||||
* - On ≥0.1.7 also register `plugins.item` via `configForms.whileServed`.
|
||||
* - Also register `settings.plugin.item` via `configForms.whileServed`.
|
||||
*/
|
||||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
|
||||
import { NetxopsCard } from './NetxopsCard.tsx'
|
||||
import { NETXOPS_NS, NetxopsCardController } from './controller.ts'
|
||||
import { NETXOPS_NS, NETXOPS_SECTION_ID, NetxopsCardController } from './controller.ts'
|
||||
import { en, zh, type NetxopsLocaleKey } from './locales.ts'
|
||||
import type { SettingsFormScope } from './settings-form.ts'
|
||||
import { createWaitingSettingsScope, findReadySettingsForm } from './settings-form.ts'
|
||||
import type { NetxopsSettings } from './controller.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
|
@ -55,7 +64,6 @@ function namespaceCandidates(ctx: ClientContext): string[] {
|
|||
fiber?.options?.id,
|
||||
fiber?.id,
|
||||
fiber?.options?.name,
|
||||
(ctx as { name?: string }).name,
|
||||
NETXOPS_NS,
|
||||
'dsh-netxops',
|
||||
]
|
||||
|
|
@ -88,42 +96,9 @@ type DeferredSettingsScope = SettingsFormScope<NetxopsSettings> & {
|
|||
attach: (real: SettingsFormScope<NetxopsSettings>) => void
|
||||
}
|
||||
|
||||
/** In-memory form so Settings nav can mount before configForms attaches. */
|
||||
/** Waiting form so the sidebar can mount without pretending settings were saved. */
|
||||
function createMemoryScope(initial: NetxopsSettings): SettingsFormScope<NetxopsSettings> {
|
||||
let value: NetxopsSettings = { ...initial }
|
||||
let revision = 0
|
||||
const listeners = new Set<() => void>()
|
||||
const notify = (): void => {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
return {
|
||||
getSnapshot: () => ({
|
||||
status: 'ready',
|
||||
value,
|
||||
writable: true,
|
||||
revision,
|
||||
}),
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
set: async (field, next) => {
|
||||
value = { ...value, [field]: next as never }
|
||||
revision += 1
|
||||
notify()
|
||||
return true
|
||||
},
|
||||
unset: async (field) => {
|
||||
const next = { ...value }
|
||||
delete next[field as keyof NetxopsSettings]
|
||||
value = next
|
||||
revision += 1
|
||||
notify()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return createWaitingSettingsScope({ ...initial })
|
||||
}
|
||||
|
||||
/** Swap the live transport without recreating the card controller. */
|
||||
|
|
@ -161,6 +136,11 @@ function createDeferredScope(
|
|||
}
|
||||
|
||||
const MEMORY_DEFAULTS: NetxopsSettings = {
|
||||
toolsEnabled: true,
|
||||
groupOpsPresets: '',
|
||||
groupTopologyPresets: '',
|
||||
groupBizMonitorPresets: '',
|
||||
groupKbPresets: '',
|
||||
apiUrl: 'http://127.0.0.1:8890',
|
||||
lang: 'zh',
|
||||
thinkingLanguage: 'auto',
|
||||
|
|
@ -174,10 +154,10 @@ const MEMORY_DEFAULTS: NetxopsSettings = {
|
|||
imTargetId: '',
|
||||
imTargets: '',
|
||||
groupOpsInPreset: true,
|
||||
groupOpsPublic: true,
|
||||
groupOpsPublic: false,
|
||||
groupTopologyInPreset: false,
|
||||
groupTopologyPublic: false,
|
||||
groupBizMonitorInPreset: false,
|
||||
groupBizMonitorInPreset: true,
|
||||
groupBizMonitorPublic: false,
|
||||
kbRoot: '',
|
||||
groupKbInPreset: true,
|
||||
|
|
@ -216,11 +196,11 @@ export function apply(ctx: ClientContext): void {
|
|||
}
|
||||
}
|
||||
|
||||
/** Settings sidebar — same unconditional surface im-ops uses. */
|
||||
/** Settings sidebar — id must not equal Config ns `netxops`. */
|
||||
try {
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: NETXOPS_NS,
|
||||
id: NETXOPS_SECTION_ID,
|
||||
order: 24,
|
||||
label: () => t('title'),
|
||||
locale: LOCALE_NS,
|
||||
|
|
@ -237,11 +217,9 @@ export function apply(ctx: ClientContext): void {
|
|||
const registerPluginsItem = (id: string): (() => void) => {
|
||||
if (pluginsItemId === id) return () => {}
|
||||
try {
|
||||
const off = ctx.slots.inject('plugins.item', () => ctx.slots.register({
|
||||
name: 'plugins.item',
|
||||
id,
|
||||
order: 24,
|
||||
label: () => t('title'),
|
||||
const off = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
key: id,
|
||||
locale: LOCALE_NS,
|
||||
inject: () => {
|
||||
ensureExtras()
|
||||
|
|
@ -263,16 +241,7 @@ export function apply(ctx: ClientContext): void {
|
|||
forms: ConfigFormsLike,
|
||||
candidates: readonly string[],
|
||||
): { ns: string, scope: SettingsFormScope<NetxopsSettings> } | undefined => {
|
||||
if (typeof forms.get !== 'function') return undefined
|
||||
for (const ns of candidates) {
|
||||
try {
|
||||
const scope = forms.get(ns)
|
||||
if (scope && typeof scope.getSnapshot === 'function') return { ns, scope }
|
||||
} catch {
|
||||
// wrong ns — try next
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
return findReadySettingsForm(forms, candidates)
|
||||
}
|
||||
|
||||
const attachForm = (
|
||||
|
|
@ -285,67 +254,86 @@ export function apply(ctx: ClientContext): void {
|
|||
return registerPluginsItem(hit.ns)
|
||||
}
|
||||
|
||||
// DSH ≥0.1.7 / 0.2.0 — configForms (persist + plugins.item).
|
||||
ctx.inject(['configForms'], (formsCtx) => {
|
||||
const forms = (formsCtx as { configForms?: ConfigFormsLike }).configForms
|
||||
if (!forms) {
|
||||
formsCtx.logger?.warn?.('netxops: configForms inject fired but service missing')
|
||||
return
|
||||
}
|
||||
const candidates = namespaceCandidates(formsCtx)
|
||||
formsCtx.logger?.info?.('netxops: configForms candidates=%s', candidates.join(','))
|
||||
|
||||
if (typeof forms.whileServed === 'function') {
|
||||
formsCtx.effect(() => forms.whileServed!(candidates, (served) => {
|
||||
const ns = candidates.find((id) => served.has(id))
|
||||
if (!ns) {
|
||||
formsCtx.logger?.warn?.(
|
||||
'netxops: whileServed fired but none of %s are served (have=%s)',
|
||||
candidates.join(','),
|
||||
[...served].join(',') || '(empty)',
|
||||
)
|
||||
return () => {}
|
||||
}
|
||||
const hit = tryGetForm(forms, [ns, ...candidates])
|
||||
if (!hit) {
|
||||
formsCtx.logger?.warn?.('netxops: served %s but configForms.get failed', ns)
|
||||
return () => {}
|
||||
}
|
||||
return attachForm(hit, 'whileServed')
|
||||
}), 'netxops: configForms whileServed')
|
||||
}
|
||||
|
||||
const immediate = tryGetForm(forms, candidates)
|
||||
if (immediate) {
|
||||
formsCtx.effect(
|
||||
() => attachForm(immediate, 'eager'),
|
||||
'netxops: configForms eager attach',
|
||||
// DSH ≥0.1.7 / 0.2.0 — attach configForms by deferred poll.
|
||||
// Never ctx.inject(['configForms']) or read ctx.configForms during apply:
|
||||
// on Desktop 0.2 that waits and freezes "Loading plugins…".
|
||||
let formsAttached = false
|
||||
const tryAttachForms = (): boolean => {
|
||||
if (formsAttached) return true
|
||||
try {
|
||||
// Direct ctx.configForms requires an inject edge even after apply. get()
|
||||
// is Cordis' non-blocking lookup for an optional, currently active service.
|
||||
const forms = ctx.get('configForms') as ConfigFormsLike | undefined
|
||||
if (!forms || typeof forms.get !== 'function') return false
|
||||
const candidates = namespaceCandidates(ctx)
|
||||
const hit = tryGetForm(forms, candidates)
|
||||
if (!hit) return false
|
||||
ctx.logger?.info?.('netxops: configForms candidates=%s', candidates.join(','))
|
||||
ctx.effect(
|
||||
() => attachForm(hit, 'poll'),
|
||||
'netxops: configForms poll attach',
|
||||
)
|
||||
} else if (typeof forms.whileServed !== 'function') {
|
||||
formsCtx.logger?.warn?.(
|
||||
'netxops: configForms present but no get/whileServed match for %s',
|
||||
candidates.join(','),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// DSH ≤0.1.5 — one-shot settingsScope bind. Do NOT ctx.inject(['settingsScope']):
|
||||
// on Desktop 0.2 that service is gone and a waiting inject shows up as
|
||||
// `dsh-netxops: pending (waiting for service: settingsScope)`, which
|
||||
// prevents the host bridge from publishing a connection → no netx__* tools.
|
||||
try {
|
||||
const getter = (ctx as { get?: (name: string) => unknown }).get
|
||||
if (typeof getter === 'function' && getter.call(ctx, 'configForms') === undefined) {
|
||||
const binder = getter.call(ctx, 'settingsScope') as SettingsScopeLike | undefined
|
||||
if (binder && typeof binder.bind === 'function') {
|
||||
ctx.logger?.info?.('netxops: attach form via settingsScope (legacy one-shot)')
|
||||
deferred.attach(binder.bind({ namespace: NETXOPS_NS }))
|
||||
ensureExtras()
|
||||
if (typeof forms.whileServed === 'function') {
|
||||
ctx.effect(() => forms.whileServed!(candidates, (served) => {
|
||||
const ns = candidates.find((id) => served.has(id))
|
||||
if (!ns) {
|
||||
ctx.logger?.warn?.(
|
||||
'netxops: whileServed fired but none of %s are served (have=%s)',
|
||||
candidates.join(','),
|
||||
[...served].join(',') || '(empty)',
|
||||
)
|
||||
return () => {}
|
||||
}
|
||||
const next = tryGetForm(forms, [ns, ...candidates])
|
||||
if (!next) {
|
||||
ctx.logger?.warn?.('netxops: served %s but configForms.get failed', ns)
|
||||
return () => {}
|
||||
}
|
||||
return attachForm(next, 'whileServed')
|
||||
}), 'netxops: configForms whileServed')
|
||||
}
|
||||
formsAttached = true
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger?.warn?.('netxops: legacy settingsScope probe failed: %s', error)
|
||||
}
|
||||
/** DSH ≤0.1.5 only — one-shot bind when configForms never appears. */
|
||||
const tryLegacySettingsScope = (): boolean => {
|
||||
if (formsAttached) return true
|
||||
try {
|
||||
const getter = (ctx as { get?: (name: string) => unknown }).get
|
||||
if (typeof getter !== 'function') return false
|
||||
const binder = getter.call(ctx, 'settingsScope') as SettingsScopeLike | undefined
|
||||
if (!binder || typeof binder.bind !== 'function') return false
|
||||
ctx.logger?.info?.('netxops: attach form via settingsScope (legacy one-shot)')
|
||||
deferred.attach(binder.bind({ namespace: NETXOPS_NS }))
|
||||
ensureExtras()
|
||||
formsAttached = true
|
||||
return true
|
||||
} catch (error) {
|
||||
ctx.logger?.warn?.('netxops: legacy settingsScope probe failed: %s', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defer all form-transport probes until after apply returns.
|
||||
const startFormsPoll = (): void => {
|
||||
if (tryAttachForms() || tryLegacySettingsScope()) return
|
||||
let ticks = 0
|
||||
const timer = setInterval(() => {
|
||||
ticks += 1
|
||||
if (tryAttachForms() || (ticks >= 3 && tryLegacySettingsScope())) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1_000)
|
||||
try {
|
||||
ctx.effect(() => () => clearInterval(timer), 'netxops: configForms poll')
|
||||
} catch {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}
|
||||
setTimeout(startFormsPoll, 0)
|
||||
}
|
||||
|
||||
/** Soft-wire credentials / connection / directoryPicker when present. */
|
||||
|
|
@ -378,7 +366,8 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController):
|
|||
}, 'remote.credentials')
|
||||
|
||||
soft(['connection'], (connCtx) => {
|
||||
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc)
|
||||
const connection = connCtx.get('connection') as { rpc?: { call?: AlarmPushRpcCall } } | undefined
|
||||
const call = connection?.rpc?.call?.bind(connection.rpc)
|
||||
if (typeof call !== 'function') {
|
||||
connCtx.logger?.warn?.('netxops: connection.rpc.call unavailable — alarm status UI disabled')
|
||||
return
|
||||
|
|
@ -389,21 +378,19 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController):
|
|||
}, 'netxops: clear alarm-push rpc')
|
||||
}, 'connection')
|
||||
|
||||
const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<string | null> } | undefined): void => {
|
||||
const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<unknown> } | undefined): void => {
|
||||
if (!picker || typeof picker.pick !== 'function') return
|
||||
card.setDirectoryPicker(picker as { pick: (signal?: AbortSignal) => Promise<string | null> })
|
||||
card.setDirectoryPicker({ pick: picker.pick.bind(picker) })
|
||||
}
|
||||
bindDirectoryPicker(
|
||||
(ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
|
||||
.remote?.directoryPicker,
|
||||
)
|
||||
bindDirectoryPicker(ctx.get('remote.directoryPicker') as
|
||||
{ pick?: (signal?: AbortSignal) => Promise<unknown> } | undefined)
|
||||
soft(['remote.directoryPicker'], (dpCtx) => {
|
||||
const viaGet = typeof (dpCtx as { get?: (name: string) => unknown }).get === 'function'
|
||||
? (dpCtx as { get: (name: string) => unknown }).get('remote.directoryPicker') as
|
||||
| { pick?: (signal?: AbortSignal) => Promise<string | null> }
|
||||
| { pick?: (signal?: AbortSignal) => Promise<unknown> }
|
||||
| undefined
|
||||
: undefined
|
||||
const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
|
||||
const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<unknown> } } })
|
||||
.remote?.directoryPicker
|
||||
bindDirectoryPicker(viaGet ?? viaNested)
|
||||
dpCtx.effect(() => () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
/** Locale keys for the Netx Ops settings section. */
|
||||
|
||||
export type NetxopsLocaleKey =
|
||||
| 'settingsUnavailable'
|
||||
| 'toolsEnabled'
|
||||
| 'toolsEnabledHint'
|
||||
| 'toolsDisabled'
|
||||
| 'selectedPresets'
|
||||
| 'publicationHint'
|
||||
| 'presetCatalogUnavailable'
|
||||
| 'presetUnavailable'
|
||||
| 'noOtherPresets'
|
||||
| 'mountStatusHint'
|
||||
| 'mountDetails'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'sectionConnection'
|
||||
|
|
@ -10,6 +21,7 @@ export type NetxopsLocaleKey =
|
|||
| 'sectionExport'
|
||||
| 'apiUrl'
|
||||
| 'apiUrlHint'
|
||||
| 'apiUrlInvalid'
|
||||
| 'lang'
|
||||
| 'langHint'
|
||||
| 'thinkingLanguage'
|
||||
|
|
@ -54,6 +66,11 @@ export type NetxopsLocaleKey =
|
|||
| 'apiTokenUnavailable'
|
||||
| 'apiTokenSet'
|
||||
| 'apiTokenUnset'
|
||||
| 'toolsStatus'
|
||||
| 'toolsStatusMounted'
|
||||
| 'toolsStatusEmpty'
|
||||
| 'toolsStatusError'
|
||||
| 'toolsStatusUnknown'
|
||||
| 'sessionsExport'
|
||||
| 'sessionsExportCount'
|
||||
| 'sessionsExportUnavailable'
|
||||
|
|
@ -79,6 +96,17 @@ export type NetxopsLocaleKey =
|
|||
| 'configSaved'
|
||||
|
||||
export const en: Record<NetxopsLocaleKey, string> = {
|
||||
settingsUnavailable: 'Waiting for Host settings. Saving is disabled until connected. If this persists, check that the Host plugin is enabled.',
|
||||
toolsEnabled: 'Publish Netx capabilities',
|
||||
toolsEnabledHint: 'Auto-saves. Turn off to withdraw all Netx tools and skills; your group selections are retained.',
|
||||
toolsDisabled: 'All Netx tools and skills are off',
|
||||
selectedPresets: 'Selected other presets',
|
||||
publicationHint: 'Tools and their skills are published together. Changes auto-save and apply to the next model request in current and new sessions. Public capabilities are inherited by every preset, including Netx Ops.',
|
||||
presetCatalogUnavailable: 'Preset roster unavailable. Existing selections are retained and can be removed.',
|
||||
presetUnavailable: 'Preset unavailable',
|
||||
noOtherPresets: 'No other presets available on this Host.',
|
||||
mountStatusHint: 'Actual mounts on this Host. Selected other presets mount when a session is active. This is registration status; API access is checked during tool calls.',
|
||||
mountDetails: 'Mount details',
|
||||
title: 'Netx Ops',
|
||||
description: 'API, token, capabilities, knowledge base, and alarm delivery.',
|
||||
sectionConnection: 'Connection',
|
||||
|
|
@ -88,6 +116,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
sectionExport: 'Session export',
|
||||
apiUrl: 'API URL',
|
||||
apiUrlHint: 'e.g. http://127.0.0.1:8890',
|
||||
apiUrlInvalid: 'Must be http(s) without username/password (no file:/javascript:)',
|
||||
lang: 'API / alarm language',
|
||||
langHint: 'zh / en — netx API query + alarm prompt copy only (not model reply)',
|
||||
thinkingLanguage: 'Thinking language',
|
||||
|
|
@ -97,12 +126,12 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
capabilityGroups: 'Capability groups',
|
||||
nmsProvider: 'NMS provider',
|
||||
nmsProviderHint: 'zte-ume',
|
||||
groupOps: 'ops',
|
||||
groupTopology: 'topology',
|
||||
groupBizMonitor: 'bizMonitor',
|
||||
groupOps: 'Alarms, inventory and CLI',
|
||||
groupTopology: 'Topology and layout',
|
||||
groupBizMonitor: 'Cutover monitoring',
|
||||
groupKb: 'Knowledge skills',
|
||||
groupInPreset: 'In Netx Ops preset',
|
||||
groupPublic: 'Publish to other presets (auto-saves; hot-reloads)',
|
||||
groupInPreset: 'In Netx Ops preset (auto-saves; mount/unmount immediately)',
|
||||
groupPublic: 'Publish to every preset (auto-saves)',
|
||||
kbRoot: 'Package root',
|
||||
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from paths.skills/_skills and paths.localSkills when hasSkills.',
|
||||
kbBrowse: 'Browse…',
|
||||
|
|
@ -132,6 +161,11 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
apiTokenUnavailable: 'remote.credentials unavailable — set NETX_API_TOKEN via script.',
|
||||
apiTokenSet: 'Configured',
|
||||
apiTokenUnset: 'Not set',
|
||||
toolsStatus: 'Agent tools',
|
||||
toolsStatusMounted: '{count} netx__* tools (v{version})',
|
||||
toolsStatusEmpty: 'No active Netx tool mounts',
|
||||
toolsStatusError: 'Tool mount failed: {detail}',
|
||||
toolsStatusUnknown: 'Host status unavailable',
|
||||
sessionsExport: 'Export all sessions',
|
||||
sessionsExportCount: '{count} sessions',
|
||||
sessionsExportUnavailable: 'Export unavailable',
|
||||
|
|
@ -158,6 +192,17 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
}
|
||||
|
||||
export const zh: Record<NetxopsLocaleKey, string> = {
|
||||
settingsUnavailable: '正在等待 Host 设置,连接就绪前不能保存。若持续等待,请检查 Host 插件是否启用。',
|
||||
toolsEnabled: '启用 Netx 能力发布',
|
||||
toolsEnabledHint: '自动保存。关闭会撤销全部 Netx 工具和 skill,保留各组选择,便于随时恢复。',
|
||||
toolsDisabled: '全部 Netx 工具和 skill 已关闭',
|
||||
selectedPresets: '指定其他预设',
|
||||
publicationHint: '工具与对应 skill 一起发布。选择自动保存,对当前及新会话的下一次模型请求生效。公开能力由所有预设继承,也包括 Netx Ops。',
|
||||
presetCatalogUnavailable: '暂时无法读取预设列表,已有选择会保留,也可以取消。',
|
||||
presetUnavailable: '预设不可用',
|
||||
noOtherPresets: '此 Host 暂无其他预设。',
|
||||
mountStatusHint: '显示此 Host 的实际挂载。指定其他预设在会话活跃时挂载;此状态表示注册结果,API 权限在调用工具时验证。',
|
||||
mountDetails: '查看实际挂载明细',
|
||||
title: 'Netx Ops',
|
||||
description: 'API、Token、能力组、知识库与告警投递。',
|
||||
sectionConnection: '连接',
|
||||
|
|
@ -167,6 +212,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
|||
sectionExport: '会话导出',
|
||||
apiUrl: 'API 地址',
|
||||
apiUrlHint: '例如 http://127.0.0.1:8890',
|
||||
apiUrlInvalid: '须为无账号密码的 http(s) 地址(不支持 file:/javascript:)',
|
||||
lang: 'API / 告警语言',
|
||||
langHint: 'zh / en — 仅影响 netx API 与告警文案,不控制模型回复',
|
||||
thinkingLanguage: '思考语言',
|
||||
|
|
@ -176,12 +222,12 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
|||
capabilityGroups: '能力组',
|
||||
nmsProvider: 'NMS 提供方',
|
||||
nmsProviderHint: 'zte-ume',
|
||||
groupOps: 'ops',
|
||||
groupTopology: 'topology',
|
||||
groupBizMonitor: 'bizMonitor',
|
||||
groupOps: '告警、资产与 CLI 运维',
|
||||
groupTopology: '拓扑与布局',
|
||||
groupBizMonitor: '割接业务监控',
|
||||
groupKb: '知识库技能',
|
||||
groupInPreset: '在 Netx Ops 预设中启用',
|
||||
groupPublic: '对其他预设公开(自动保存,即时挂载/卸载)',
|
||||
groupInPreset: '在 Netx Ops 预设中启用(自动保存,即时挂载/卸载)',
|
||||
groupPublic: '对所有预设公开(自动保存)',
|
||||
kbRoot: '知识包根目录',
|
||||
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 paths.skills/_skills 与 paths.localSkills。',
|
||||
kbBrowse: '浏览…',
|
||||
|
|
@ -211,6 +257,11 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
|||
apiTokenUnavailable: '未提供 remote.credentials — 请用脚本写入 NETX_API_TOKEN。',
|
||||
apiTokenSet: '已配置',
|
||||
apiTokenUnset: '未设置',
|
||||
toolsStatus: 'Agent 工具',
|
||||
toolsStatusMounted: '已挂载 {count} 个 netx__*(v{version})',
|
||||
toolsStatusEmpty: '当前没有活跃的 Netx 工具挂载',
|
||||
toolsStatusError: '工具挂载失败:{detail}',
|
||||
toolsStatusUnknown: '无法读取 Host 状态',
|
||||
sessionsExport: '导出全部会话',
|
||||
sessionsExportCount: '{count} 个会话',
|
||||
sessionsExportUnavailable: '无法导出',
|
||||
|
|
|
|||
|
|
@ -22,3 +22,29 @@ export interface SettingsFormScope<T> {
|
|||
set(field: string, value: unknown): Promise<boolean | void>
|
||||
unset(field: string): Promise<boolean | void>
|
||||
}
|
||||
|
||||
/** Read-only placeholder until an actual Host settings namespace is served. */
|
||||
export function createWaitingSettingsScope<T>(initial: T): SettingsFormScope<T> {
|
||||
const snapshot: SettingsFormSnapshot<T> = { status: 'loading', value: initial, writable: false }
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: () => () => {},
|
||||
set: async () => { throw new Error('settings_transport_unavailable') },
|
||||
unset: async () => { throw new Error('settings_transport_unavailable') },
|
||||
}
|
||||
}
|
||||
|
||||
/** get() may return a placeholder for any id; only a served namespace is usable. */
|
||||
export function findReadySettingsForm<T>(
|
||||
forms: { get?: (id: string) => SettingsFormScope<T> },
|
||||
namespaces: readonly string[],
|
||||
): { ns: string; scope: SettingsFormScope<T> } | undefined {
|
||||
if (!forms.get) return undefined
|
||||
for (const ns of namespaces) {
|
||||
try {
|
||||
const scope = forms.get(ns)
|
||||
if (scope?.getSnapshot().status === 'ready') return { ns, scope }
|
||||
} catch { /* Wrong namespace or service not ready; keep trying. */ }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ const CSS = `
|
|||
.dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3);padding-top:12px}
|
||||
.dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)}
|
||||
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:8px;padding-left:2px}
|
||||
.dsh-nx-targets{margin-top:10px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2,#dfe1e5)}
|
||||
.dsh-nx-settings summary{cursor:pointer;font-size:12px;font-weight:500;line-height:1.6}
|
||||
.dsh-nx-targets[open] .dsh-nx-checkRow{padding:5px 0;flex-wrap:wrap}
|
||||
.dsh-nx-settings input:focus-visible,.dsh-nx-settings button:focus-visible,.dsh-nx-settings summary:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:2px}
|
||||
.dsh-nx-settings>.dsh-nx-settings-actions{position:sticky;bottom:0;padding:12px;background:var(--dsw-alias-bg-base,#fff);border-top:1px solid var(--dsw-alias-border-l2,#dfe1e5);flex-wrap:wrap}
|
||||
.dsh-nx-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
|
||||
.dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch}
|
||||
.dsh-nx-pathRow>input{flex:1;min-width:0}
|
||||
|
|
|
|||
108
src/client/tools-status-view.ts
Normal file
108
src/client/tools-status-view.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Browser-side Host tool-mount status (RPC `tools.status`).
|
||||
*/
|
||||
|
||||
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
|
||||
|
||||
/** Same channel as host `NETXOPS_RPC_CHANNEL`. */
|
||||
export const NETXOPS_RPC_CHANNEL = '/netxops'
|
||||
|
||||
export const TOOLS_STATUS_ENDPOINT = 'tools.status'
|
||||
|
||||
export interface ToolsMountView {
|
||||
toolsEnabled: boolean
|
||||
mounts: Array<{ source: string; count: number; groups: string[]; plane: string; error?: string }>
|
||||
count: number
|
||||
version: string
|
||||
groups: string[]
|
||||
plane: string
|
||||
error?: string
|
||||
tokenConfigured: boolean | null
|
||||
apiUrl: string
|
||||
}
|
||||
|
||||
const EMPTY: ToolsMountView = {
|
||||
toolsEnabled: true,
|
||||
mounts: [],
|
||||
count: 0,
|
||||
version: '',
|
||||
groups: [],
|
||||
plane: '',
|
||||
tokenConfigured: null,
|
||||
apiUrl: '',
|
||||
}
|
||||
|
||||
/** Normalize host `tools.status` RPC value. */
|
||||
export function asToolsMountView(value: unknown): ToolsMountView {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { ...EMPTY }
|
||||
const row = value as Record<string, unknown>
|
||||
const mount = row.mount
|
||||
const connection = row.connection
|
||||
let count = 0
|
||||
let version = ''
|
||||
let groups: string[] = []
|
||||
let plane = ''
|
||||
let error: string | undefined
|
||||
if (mount !== null && typeof mount === 'object' && !Array.isArray(mount)) {
|
||||
const m = mount as Record<string, unknown>
|
||||
count = typeof m.count === 'number' ? m.count : 0
|
||||
version = typeof m.version === 'string' ? m.version : ''
|
||||
plane = typeof m.plane === 'string' ? m.plane : ''
|
||||
error = typeof m.error === 'string' ? m.error : undefined
|
||||
if (Array.isArray(m.groups)) {
|
||||
groups = m.groups.filter((g): g is string => typeof g === 'string')
|
||||
}
|
||||
}
|
||||
let tokenConfigured: boolean | null = null
|
||||
let apiUrl = ''
|
||||
if (connection !== null && typeof connection === 'object' && !Array.isArray(connection)) {
|
||||
const c = connection as Record<string, unknown>
|
||||
tokenConfigured = c.tokenConfigured === true
|
||||
apiUrl = typeof c.apiUrl === 'string' ? c.apiUrl : ''
|
||||
}
|
||||
const mounts: ToolsMountView['mounts'] = []
|
||||
const names = new Set<string>()
|
||||
if (Array.isArray(row.mounts)) {
|
||||
for (const raw of row.mounts) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
||||
const m = raw as Record<string, unknown>
|
||||
const entry = {
|
||||
source: typeof m.source === 'string' ? m.source : String(m.plane ?? ''),
|
||||
count: typeof m.count === 'number' ? m.count : 0,
|
||||
groups: Array.isArray(m.groups) ? m.groups.filter((g): g is string => typeof g === 'string') : [],
|
||||
plane: typeof m.plane === 'string' ? m.plane : '',
|
||||
error: typeof m.error === 'string' ? m.error : undefined,
|
||||
}
|
||||
mounts.push(entry)
|
||||
if (Array.isArray(m.names)) for (const name of m.names) if (typeof name === 'string') names.add(name)
|
||||
if (entry.error) error = entry.error
|
||||
if (typeof m.version === 'string' && m.version) version = m.version
|
||||
}
|
||||
count = names.size
|
||||
groups = [...new Set(mounts.flatMap(m => m.groups))]
|
||||
}
|
||||
const toolsEnabled = !(connection && typeof connection === 'object'
|
||||
&& (connection as Record<string, unknown>).toolsEnabled === false)
|
||||
return { count, version, groups, plane, error, tokenConfigured, apiUrl, mounts, toolsEnabled }
|
||||
}
|
||||
|
||||
/** Fetch Host tool mount snapshot. */
|
||||
export async function fetchToolsStatus(
|
||||
call: AlarmPushRpcCall,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolsMountView> {
|
||||
const result = await call(NETXOPS_RPC_CHANNEL, TOOLS_STATUS_ENDPOINT, {}, signal)
|
||||
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||
return asToolsMountView((result as { value?: unknown }).value)
|
||||
}
|
||||
return { ...EMPTY }
|
||||
}
|
||||
|
||||
/** Badge tone for the tools mount line. */
|
||||
export function toolsMountTone(view: ToolsMountView | null): 'ok' | 'warn' | 'err' | 'neutral' {
|
||||
if (!view) return 'neutral'
|
||||
if (view.error) return 'err'
|
||||
if (!view.toolsEnabled) return 'neutral'
|
||||
if (view.count > 0) return 'ok'
|
||||
return 'warn'
|
||||
}
|
||||
571
src/index.ts
571
src/index.ts
|
|
@ -19,6 +19,9 @@
|
|||
* @module dsh-netxops
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
|
|
@ -29,7 +32,13 @@ export {
|
|||
ensureAgentPresetInstalled,
|
||||
NETXOPS_PRESET_ID,
|
||||
} from './agent-preset-install.ts'
|
||||
import { ensureAgentPresetInstalled } from './agent-preset-install.ts'
|
||||
export {
|
||||
mergeHostConfigForHeal,
|
||||
NETXOPS_HOST_CONFIG_DEFAULTS,
|
||||
writePatchIfUnchanged,
|
||||
} from './netx/heal-profile-entry.ts'
|
||||
export { normalizeApiUrl } from './netx/http.ts'
|
||||
import { ensureAgentPresetInstalled, resolveActiveProfile } from './agent-preset-install.ts'
|
||||
import { startAlarmPushClient } from './netx/alarm-push.ts'
|
||||
import {
|
||||
getAlarmPushStatus,
|
||||
|
|
@ -41,6 +50,7 @@ import { resetAlarmSession } from './netx/alarm-session.ts'
|
|||
import {
|
||||
capabilityGroupsFromSettings,
|
||||
groupsForPlane,
|
||||
kbEnabledForPlane,
|
||||
type NetxCapabilityGroupSettingsFields,
|
||||
} from './netx/capability-groups.ts'
|
||||
import { registerGroupSkills } from './netx/group-skills.ts'
|
||||
|
|
@ -66,6 +76,7 @@ import {
|
|||
thinkingInstruction,
|
||||
thinkingReminder,
|
||||
} from './netx/model-language.ts'
|
||||
import { normalizeApiUrl, type NormalizeApiUrlResult } from './netx/http.ts'
|
||||
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
|
||||
import {
|
||||
getSessionsExportStatus,
|
||||
|
|
@ -74,13 +85,33 @@ import {
|
|||
sessionsExportResponse,
|
||||
} from './netx/session-export.ts'
|
||||
import { mountNetxopsWebRoute } from './netx/netxops-web-rpc.ts'
|
||||
import {
|
||||
getToolMountStatus,
|
||||
getToolMountStatuses,
|
||||
publishToolMountStatus,
|
||||
resetToolMountStatus,
|
||||
} from './netx/tool-mount-status.ts'
|
||||
import { registerNetxTools } from './netx/tools.ts'
|
||||
import { installPresetPublication } from './netx/preset-publication.ts'
|
||||
import { readConfigSnapshot, readConfigValue, type ConfigInput } from './netx/config-values.ts'
|
||||
|
||||
// The event is absent from older peer declarations, where it is never emitted.
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
'loader/volatile-update'(paths: readonly (readonly string[])[]): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'netxops'
|
||||
|
||||
/** Wait for the credentials store before publishing a Bearer snapshot. */
|
||||
export const inject = ['credentials']
|
||||
/**
|
||||
* Hard-inject `tools` (trilium / official cookbook pattern). Soft `credentials`
|
||||
* previously left connection unpublished when pending; tools soft-inject could
|
||||
* also never fire — Host then registered zero `netx__*` globally. Credentials
|
||||
* stay soft below; we provisional-publish immediately.
|
||||
*/
|
||||
export const inject = ['tools']
|
||||
|
||||
/** Connection RPC channel for browser status reads. */
|
||||
export const NETXOPS_RPC_CHANNEL = '/netxops'
|
||||
|
|
@ -91,8 +122,23 @@ export const NETXOPS_SETTINGS_NAMESPACE = 'netxops'
|
|||
/** Default credential reference for the netx API bearer token. */
|
||||
export const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
||||
|
||||
/** Default tool-call timeout when settings omit / null the volatile field. */
|
||||
export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 120_000
|
||||
|
||||
/** Default REST root when settings projection emits null/empty for apiUrl. */
|
||||
export const DEFAULT_API_URL = 'http://127.0.0.1:8890'
|
||||
|
||||
/** Default API lang query when settings projection emits null/empty for lang. */
|
||||
export const DEFAULT_LANG = 'zh'
|
||||
|
||||
/** Plugin / settings section shape. */
|
||||
export interface Config {
|
||||
/** Master switch for every Netx tool and skill publication. */
|
||||
toolsEnabled: boolean
|
||||
groupOpsPresets: string
|
||||
groupTopologyPresets: string
|
||||
groupBizMonitorPresets: string
|
||||
groupKbPresets: string
|
||||
/** netx REST root (no trailing slash required). */
|
||||
apiUrl: string
|
||||
/** Passed as `lang` query when starting with `en` (API + alarm copy only). */
|
||||
|
|
@ -145,7 +191,7 @@ export interface Config {
|
|||
nmsProvider: string
|
||||
/** ops tools/skills in the Netx Ops preset (default on) — NMS + CLI + paths. */
|
||||
groupOpsInPreset: boolean
|
||||
/** Publish ops tools/skills to other presets (default on). */
|
||||
/** Publish ops tools/skills to other presets (default off — Netx Ops only). */
|
||||
groupOpsPublic: boolean
|
||||
/** topology canvas / layout tools in the Netx Ops preset (default off). */
|
||||
groupTopologyInPreset: boolean
|
||||
|
|
@ -177,7 +223,12 @@ function vol<S>(schema: S): S {
|
|||
return typeof candidate.volatile === 'function' ? candidate.volatile() : schema
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
export const Config: z<Config, ConfigInput<Config>> = z.object({
|
||||
toolsEnabled: vol(z.boolean().default(true)),
|
||||
groupOpsPresets: vol(z.string().default('')),
|
||||
groupTopologyPresets: vol(z.string().default('')),
|
||||
groupBizMonitorPresets: vol(z.string().default('')),
|
||||
groupKbPresets: vol(z.string().default('')),
|
||||
apiUrl: vol(z.string().default('http://127.0.0.1:8890')),
|
||||
lang: vol(z.string().default('zh')),
|
||||
thinkingLanguage: vol(z.string().default('auto')),
|
||||
|
|
@ -193,7 +244,7 @@ export const Config: z<Config> = z.object({
|
|||
imTargetId: vol(z.string().default('')),
|
||||
nmsProvider: vol(z.string().default('zte-ume')),
|
||||
groupOpsInPreset: vol(z.boolean().default(true)),
|
||||
groupOpsPublic: vol(z.boolean().default(true)),
|
||||
groupOpsPublic: vol(z.boolean().default(false)),
|
||||
groupTopologyInPreset: vol(z.boolean().default(false)),
|
||||
groupTopologyPublic: vol(z.boolean().default(false)),
|
||||
groupBizMonitorInPreset: vol(z.boolean().default(true)),
|
||||
|
|
@ -203,13 +254,117 @@ export const Config: z<Config> = z.object({
|
|||
groupKbPublic: vol(z.boolean().default(false)),
|
||||
})
|
||||
|
||||
/** Merge settings projection without letting null/undefined wipe profile defaults. */
|
||||
export function mergeConfigSkippingNullish(
|
||||
base: ConfigInput<Config>,
|
||||
patch: Record<string, unknown>,
|
||||
): Config {
|
||||
const live = Object.fromEntries(
|
||||
Object.entries(readConfigSnapshot(patch)).filter(([, v]) => v !== null && v !== undefined),
|
||||
)
|
||||
return { ...readConfigSnapshot(base), ...live }
|
||||
}
|
||||
|
||||
/** Positive finite timeout for `defineTool`; fall back when projection emits null. */
|
||||
export function resolveToolCallTimeoutMs(
|
||||
value: unknown,
|
||||
fallback = DEFAULT_TOOL_CALL_TIMEOUT_MS,
|
||||
): number {
|
||||
value = readConfigValue(value)
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
type ApiUrlNormalizeError = Extract<NormalizeApiUrlResult, { ok: false }>['error']
|
||||
|
||||
/** Heal null/empty lang from settings projection. */
|
||||
export function resolveLang(value: unknown, fallback = DEFAULT_LANG): string {
|
||||
value = readConfigValue(value)
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bearer token from the credentials seam (or empty when unset).
|
||||
* Requires `inject: ['credentials']` so the store is live before apply.
|
||||
* Resolve a publishable apiUrl.
|
||||
* null/undefined/"" → default localhost; invalid non-empty → "" (keep warn path).
|
||||
*/
|
||||
async function resolveToken(ctx: Context, refName: string): Promise<string> {
|
||||
const hit = await ctx.credentials.resolve(credentialRef(refName))
|
||||
return hit?.value ?? ''
|
||||
export function resolveApiUrlForConnection(value: unknown): {
|
||||
url: string
|
||||
usedDefault: boolean
|
||||
error?: ApiUrlNormalizeError
|
||||
} {
|
||||
value = readConfigValue(value)
|
||||
const raw = typeof value === 'string' ? value.trim() : ''
|
||||
if (!raw) {
|
||||
const normalized = normalizeApiUrl(DEFAULT_API_URL)
|
||||
return {
|
||||
url: normalized.ok ? normalized.url : DEFAULT_API_URL,
|
||||
usedDefault: true,
|
||||
}
|
||||
}
|
||||
const normalized = normalizeApiUrl(raw)
|
||||
if (normalized.ok) return { url: normalized.url, usedDefault: false }
|
||||
return { url: '', usedDefault: false, error: normalized.error }
|
||||
}
|
||||
|
||||
function packageVersion(): string {
|
||||
try {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
|
||||
return typeof pkg.version === 'string' ? pkg.version : '0'
|
||||
} catch {
|
||||
return '0'
|
||||
}
|
||||
}
|
||||
|
||||
/** Heal null/empty/invalid tokenCredentialRef from settings projection. */
|
||||
export function resolveTokenCredentialRef(value: unknown): string {
|
||||
value = readConfigValue(value)
|
||||
return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value.trim())
|
||||
? value.trim()
|
||||
: DEFAULT_TOKEN_REF
|
||||
}
|
||||
|
||||
type CredentialsResolve = {
|
||||
resolve?: (ref: unknown) => Promise<{ value?: string } | undefined>
|
||||
}
|
||||
|
||||
function credentialsFrom(ctx: Context): CredentialsResolve | undefined {
|
||||
const direct = (ctx as { credentials?: CredentialsResolve }).credentials
|
||||
if (direct && typeof direct.resolve === 'function') return direct
|
||||
if (typeof (ctx as { get?: (name: string) => unknown }).get === 'function') {
|
||||
const viaGet = (ctx as { get: (name: string) => unknown }).get('credentials') as CredentialsResolve | undefined
|
||||
if (viaGet && typeof viaGet.resolve === 'function') return viaGet
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bearer token from the credentials seam (or env fallback).
|
||||
* Never throws — invalid/null ref names fall back to {@link DEFAULT_TOKEN_REF}.
|
||||
*/
|
||||
async function resolveToken(
|
||||
ctx: Context,
|
||||
refName: unknown,
|
||||
log?: { warn: (...args: unknown[]) => void },
|
||||
): Promise<string> {
|
||||
const ref = resolveTokenCredentialRef(refName)
|
||||
const fromEnv = (): string => process.env[ref]?.trim() ?? ''
|
||||
try {
|
||||
const credentials = credentialsFrom(ctx)
|
||||
if (!credentials?.resolve) {
|
||||
const env = fromEnv()
|
||||
if (!env) log?.warn('netxops: credentials service unavailable; %s empty', ref)
|
||||
return env
|
||||
}
|
||||
const hit = await credentials.resolve(credentialRef(ref))
|
||||
const value = typeof hit?.value === 'string' ? hit.value.trim() : ''
|
||||
if (value) return value
|
||||
const env = fromEnv()
|
||||
if (!env) log?.warn('netxops: credential %s resolve returned empty', ref)
|
||||
return env
|
||||
} catch (error) {
|
||||
log?.warn('netxops: credential %s resolve failed: %s', ref, error)
|
||||
return fromEnv()
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize settings.describe() across DSH generations (array or { namespaces }). */
|
||||
|
|
@ -236,9 +391,9 @@ function describeRows(describe: unknown): Array<{ ns?: string, value?: unknown }
|
|||
*/
|
||||
function installNetxopsSettings(
|
||||
ctx: Context,
|
||||
entry: Config,
|
||||
entry: ConfigInput<Config>,
|
||||
hooks: {
|
||||
setSource: (current: () => Config) => void
|
||||
setSource: (current: () => ConfigInput<Config>) => void
|
||||
onChange: () => void
|
||||
},
|
||||
): void {
|
||||
|
|
@ -246,13 +401,13 @@ function installNetxopsSettings(
|
|||
installSettingsSection?: (
|
||||
context: Context,
|
||||
ns: string,
|
||||
schema: z<Config>,
|
||||
schema: z<Config, ConfigInput<Config>>,
|
||||
base: Config,
|
||||
sectionHooks: typeof hooks,
|
||||
) => void
|
||||
}).installSettingsSection
|
||||
if (typeof legacy === 'function') {
|
||||
legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
|
||||
legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, readConfigSnapshot(entry), hooks)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +416,7 @@ function installNetxopsSettings(
|
|||
installSection?: (
|
||||
context: Context,
|
||||
ns: string,
|
||||
schema: z<Config>,
|
||||
schema: z<Config, ConfigInput<Config>>,
|
||||
base: Config,
|
||||
sectionHooks: typeof hooks,
|
||||
) => void
|
||||
|
|
@ -269,7 +424,7 @@ function installNetxopsSettings(
|
|||
}
|
||||
|
||||
if (typeof settings.installSection === 'function') {
|
||||
settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
|
||||
settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, readConfigSnapshot(entry), hooks)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -279,9 +434,11 @@ function installNetxopsSettings(
|
|||
const readLive = (): Config => {
|
||||
const row = describeRows(settings.describe).find(item => item.ns === NETXOPS_SETTINGS_NAMESPACE)
|
||||
if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) {
|
||||
return { ...entry, ...(row.value as Partial<Config>) }
|
||||
// Settings projection may emit null for volatile scalars; never let null/undefined
|
||||
// wipe profile defaults (e.g. toolCallTimeoutMs → defineTool throw → zero tools).
|
||||
return mergeConfigSkippingNullish(entry, row.value as Record<string, unknown>)
|
||||
}
|
||||
return entry
|
||||
return readConfigSnapshot(entry)
|
||||
}
|
||||
hooks.setSource(readLive)
|
||||
hooks.onChange()
|
||||
|
|
@ -317,14 +474,178 @@ function installNetxopsSettings(
|
|||
/**
|
||||
* Apply the Netx Ops host bridge (settings + connection publish + optional alarm push).
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
let source: () => Config = () => config
|
||||
let publishing: Promise<void> = Promise.resolve()
|
||||
export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as Config)): void {
|
||||
let source: () => Config = () => readConfigSnapshot(config)
|
||||
const initial = source()
|
||||
let generation = 0
|
||||
let disposed = false
|
||||
let resolvedTokenRef = resolveTokenCredentialRef(initial.tokenCredentialRef)
|
||||
let stopAlarmPush: (() => void) | undefined
|
||||
/** Prefer the soft-injected credentials fiber — parent ctx may lack the service. */
|
||||
let credentialsCtx: Context = ctx
|
||||
|
||||
if (config.installAgentPreset) {
|
||||
ensureAgentPresetInstalled(ctx.logger)
|
||||
if (initial.installAgentPreset) {
|
||||
const profile = resolveActiveProfile(ctx.baseUrl)
|
||||
let scheduled = false
|
||||
const fallback = setTimeout(() => {
|
||||
if (!scheduled && !disposed && source().installAgentPreset) {
|
||||
ensureAgentPresetInstalled(ctx.logger, profile)
|
||||
}
|
||||
}, 1000)
|
||||
ctx.inject(['agentPresets'], presetCtx => {
|
||||
scheduled = true
|
||||
clearTimeout(fallback)
|
||||
let active = true
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
const registry = presetCtx.get('agentPresets') as {
|
||||
readDocument?: (id: string) => Promise<{ content: string }>
|
||||
}
|
||||
let document: string | undefined
|
||||
try { document = (await registry.readDocument?.('standard'))?.content }
|
||||
catch (error) { presetCtx.logger.warn('netxops: cannot read Host standard preset: %s', error) }
|
||||
if (active && !disposed && source().installAgentPreset) {
|
||||
ensureAgentPresetInstalled(presetCtx.logger, profile, document)
|
||||
}
|
||||
})().catch(error => { presetCtx.logger.warn('netxops: preset installation failed: %s', error) })
|
||||
}, 0)
|
||||
presetCtx.effect(() => () => { active = false; clearTimeout(timer) }, 'netxops: Host standard composition')
|
||||
})
|
||||
ctx.effect(() => () => { clearTimeout(fallback) }, 'netxops: preset installation fallback')
|
||||
}
|
||||
|
||||
const connectionSnapshot = (current: Config, token: string) => {
|
||||
const resolved = resolveApiUrlForConnection(current.apiUrl)
|
||||
if (resolved.error) {
|
||||
ctx.logger.warn(
|
||||
'netxops: invalid apiUrl (%s) — using empty url until fixed; use http(s) without embedded credentials',
|
||||
resolved.error,
|
||||
)
|
||||
} else if (resolved.usedDefault) {
|
||||
ctx.logger.warn(
|
||||
'netxops: apiUrl missing/null in settings — falling back to %s',
|
||||
resolved.url,
|
||||
)
|
||||
}
|
||||
return {
|
||||
toolsEnabled: current.toolsEnabled !== false,
|
||||
groupKbPresets: current.groupKbPresets ?? '',
|
||||
apiUrl: resolved.url,
|
||||
token,
|
||||
lang: resolveLang(current.lang),
|
||||
toolCallTimeoutMs: resolveToolCallTimeoutMs(current.toolCallTimeoutMs),
|
||||
groups: capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields),
|
||||
groupKbInPreset: typeof current.groupKbInPreset === 'boolean'
|
||||
? current.groupKbInPreset
|
||||
: true,
|
||||
groupKbPublic: current.groupKbPublic === true,
|
||||
}
|
||||
}
|
||||
|
||||
// Sync provisional publish BEFORE any async work so preset `dsh-netxops/tools`
|
||||
// can remount in the same tick (credentials resolve may take a while).
|
||||
const provisional = connectionSnapshot(initial, '')
|
||||
publishNetxConnection(provisional)
|
||||
installPresetPublication(ctx)
|
||||
ctx.logger.info(
|
||||
'netxops: provisional connection published → %s (token pending credentials)',
|
||||
provisional.apiUrl || '(invalid apiUrl)',
|
||||
)
|
||||
|
||||
// Host-global tools FIRST (trilium pattern). Hard-inject guarantees tools;
|
||||
// smoke/unit may call apply without it — resolve via get() so a bare
|
||||
// `ctx.tools` access cannot throw "without inject". Register before settings
|
||||
// / credentials so a later soft-inject stall cannot leave the catalog empty.
|
||||
const toolsApi = (() => {
|
||||
try {
|
||||
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
|
||||
? (ctx as { get: (name: string) => unknown }).get('tools')
|
||||
: undefined
|
||||
if (fromGet && typeof (fromGet as { register?: unknown }).register === 'function') {
|
||||
return fromGet as { register: (entry: unknown) => () => void }
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const direct = (ctx as { tools?: { register: (entry: unknown) => () => void } }).tools
|
||||
return direct && typeof direct.register === 'function' ? direct : undefined
|
||||
})()
|
||||
if (!toolsApi) {
|
||||
ctx.logger.warn(
|
||||
'netxops: tools missing on apply — host tool mount skipped (loader should inject tools)',
|
||||
)
|
||||
publishToolMountStatus({
|
||||
version: 'unknown',
|
||||
plane: 'host',
|
||||
groups: [],
|
||||
names: [],
|
||||
count: 0,
|
||||
at: new Date().toISOString(),
|
||||
error: 'tools service missing on host apply',
|
||||
source: 'host',
|
||||
})
|
||||
} else {
|
||||
let disposeToolsMount: (() => void) | undefined
|
||||
let disposeKbLocal: (() => void) | undefined
|
||||
const remountHostTools = (): void => {
|
||||
if (disposed) return
|
||||
disposeToolsMount?.()
|
||||
disposeToolsMount = undefined
|
||||
const connection = getNetxConnection()
|
||||
if (!connection) return
|
||||
const enabled = connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host')
|
||||
// trilium-style: register inside ctx.effect so fiber teardown owns the mount
|
||||
disposeToolsMount = ctx.effect(() => {
|
||||
try {
|
||||
const unregister = registerNetxTools(ctx, connection, { plane: 'host' })
|
||||
ctx.logger.info(
|
||||
'netxops: host tools groups=[%s] count=%s',
|
||||
enabled.join(',') || '(none)',
|
||||
getToolMountStatus()?.count ?? 0,
|
||||
)
|
||||
return () => { unregister() }
|
||||
} catch (error) {
|
||||
publishToolMountStatus({
|
||||
version: packageVersion(),
|
||||
plane: 'host',
|
||||
groups: [...enabled],
|
||||
names: [],
|
||||
count: 0,
|
||||
at: new Date().toISOString(),
|
||||
error: String(error),
|
||||
})
|
||||
ctx.logger.error('netxops: host tools register failed: %s', error)
|
||||
return () => {}
|
||||
}
|
||||
}, 'netxops: host tools mount')
|
||||
}
|
||||
const remountKbLocal = (): void => {
|
||||
if (disposed) return
|
||||
disposeKbLocal?.()
|
||||
disposeKbLocal = undefined
|
||||
const connection = getNetxConnection()
|
||||
if (!connection) return
|
||||
// Host plane = public only (same rule as capability groups).
|
||||
if (!kbEnabledForPlane(connection, 'host')) return
|
||||
disposeKbLocal = ctx.effect(() => {
|
||||
const unregister = registerKbLocalTools(ctx, getKbContext(), { plane: 'host', source: 'netxops-host:kb-local' })
|
||||
return () => { unregister?.() }
|
||||
}, 'netxops: host kb-local tools')
|
||||
}
|
||||
const stopWatch = watchNetxConnection(() => {
|
||||
remountHostTools()
|
||||
remountKbLocal()
|
||||
})
|
||||
const stopKbWatch = watchKbContext(() => { remountKbLocal() })
|
||||
remountHostTools()
|
||||
remountKbLocal()
|
||||
ctx.effect(() => () => {
|
||||
stopWatch()
|
||||
stopKbWatch()
|
||||
disposeToolsMount?.()
|
||||
disposeKbLocal?.()
|
||||
resetToolMountStatus()
|
||||
}, 'netxops: dispose host tools')
|
||||
}
|
||||
|
||||
const restartAlarmPush = (current: Config, apiUrl: string, token: string): void => {
|
||||
|
|
@ -346,7 +667,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
})
|
||||
return
|
||||
}
|
||||
const lang = current.lang
|
||||
const lang = resolveLang(current.lang)
|
||||
const deliverDsh = current.alarmDeliverDsh !== false
|
||||
const imTargets = resolveImTargets({
|
||||
imTargets: current.imTargets ?? '',
|
||||
|
|
@ -372,30 +693,40 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
}
|
||||
|
||||
const publish = (): void => {
|
||||
publishing = publishing.then(async () => {
|
||||
const gen = ++generation
|
||||
const current = source()
|
||||
const token = await resolveToken(ctx, current.tokenCredentialRef)
|
||||
if (gen !== generation) return
|
||||
if (disposed) return
|
||||
const gen = ++generation
|
||||
// Sync publish first (reuse last token) so capability toggles remount in the
|
||||
// same tick — do not wait on credentials.resolve for group on/off.
|
||||
const currentSync = source()
|
||||
const previous = getNetxConnection()
|
||||
const sameCredential = resolveTokenCredentialRef(currentSync.tokenCredentialRef) === resolvedTokenRef
|
||||
const sameHost = resolveApiUrlForConnection(currentSync.apiUrl).url === previous?.apiUrl
|
||||
const prevToken = sameCredential && sameHost ? previous?.token ?? '' : ''
|
||||
publishNetxConnection(connectionSnapshot(currentSync, prevToken))
|
||||
const kb = resolveKbRoot(currentSync.kbRoot ?? '')
|
||||
publishKbContext(kb)
|
||||
applyKbEnv(kb)
|
||||
if (!currentSync.alarmPushEnabled) {
|
||||
stopAlarmPush?.()
|
||||
stopAlarmPush = undefined
|
||||
resetAlarmPushStatus()
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const current = source()
|
||||
const tokenRef = resolveTokenCredentialRef(current.tokenCredentialRef)
|
||||
const token = await resolveToken(credentialsCtx, tokenRef, ctx.logger)
|
||||
if (disposed || gen !== generation) return
|
||||
resolvedTokenRef = tokenRef
|
||||
|
||||
const apiUrl = current.apiUrl.replace(/\/$/, '')
|
||||
const tokenConfigured = token.trim().length > 0
|
||||
const groups = capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields)
|
||||
if (current.nmsProvider && current.nmsProvider !== 'zte-ume') {
|
||||
ctx.logger.warn(
|
||||
'netxops: nmsProvider=%s is not implemented yet; using zte-ume REST adapter',
|
||||
current.nmsProvider,
|
||||
)
|
||||
}
|
||||
publishNetxConnection({
|
||||
apiUrl,
|
||||
token,
|
||||
lang: current.lang,
|
||||
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
||||
groups,
|
||||
groupKbInPreset: current.groupKbInPreset !== false,
|
||||
groupKbPublic: current.groupKbPublic === true,
|
||||
})
|
||||
const snapshot = connectionSnapshot(current, token)
|
||||
publishNetxConnection(snapshot)
|
||||
const kb = resolveKbRoot(current.kbRoot ?? '')
|
||||
publishKbContext(kb)
|
||||
applyKbEnv(kb)
|
||||
|
|
@ -410,12 +741,13 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
} else if (kb.status === 'error') {
|
||||
ctx.logger.warn('netxops: knowledge base error — %s', kb.errorMessage)
|
||||
}
|
||||
restartAlarmPush(current, apiUrl, token)
|
||||
restartAlarmPush(current, snapshot.apiUrl, token)
|
||||
const tokenConfigured = token.trim().length > 0
|
||||
if (!tokenConfigured) {
|
||||
ctx.logger.warn(
|
||||
'netxops: published connection → %s tokenConfigured=false (set credential %s)',
|
||||
apiUrl,
|
||||
current.tokenCredentialRef,
|
||||
snapshot.apiUrl,
|
||||
tokenRef,
|
||||
)
|
||||
} else {
|
||||
const imSinkCount = resolveImTargets({
|
||||
|
|
@ -424,32 +756,41 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
imTargetId: current.imTargetId ?? '',
|
||||
}).length
|
||||
ctx.logger.info(
|
||||
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]',
|
||||
apiUrl,
|
||||
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s host=[%s]',
|
||||
snapshot.apiUrl,
|
||||
current.alarmPushEnabled === true,
|
||||
current.alarmDeliverDsh !== false,
|
||||
imSinkCount,
|
||||
groupsForPlane(groups, 'public').join(',') || '(none)',
|
||||
groupsForPlane(snapshot.groups, 'host').join(',') || '(none)',
|
||||
)
|
||||
}
|
||||
}).catch((error) => {
|
||||
})().catch((error) => {
|
||||
ctx.logger.error('netxops: connection publish error: %s', error)
|
||||
})
|
||||
}
|
||||
|
||||
publish()
|
||||
|
||||
// Current Loader commits volatile references in place and notifies this
|
||||
// owning fiber. Read them again and revoke/remount immediately on UI saves.
|
||||
ctx.on('loader/volatile-update', () => { publish() })
|
||||
|
||||
installNetxopsSettings(ctx, config, {
|
||||
setSource: (current) => {
|
||||
source = current
|
||||
source = () => readConfigSnapshot(current())
|
||||
},
|
||||
onChange: () => {
|
||||
publish()
|
||||
},
|
||||
})
|
||||
|
||||
ctx.on('credentials/reference-updated', (ref) => {
|
||||
if (String(ref) === source().tokenCredentialRef) publish()
|
||||
// Soft credentials — never block Host apply on this service.
|
||||
ctx.inject(['credentials'], (credCtx) => {
|
||||
credentialsCtx = credCtx
|
||||
publish()
|
||||
credCtx.on('credentials/reference-updated', (ref) => {
|
||||
if (String(ref) === resolveTokenCredentialRef(source().tokenCredentialRef)) publish()
|
||||
})
|
||||
})
|
||||
|
||||
// Host-global model language: every preset on this Host inherits these
|
||||
|
|
@ -475,7 +816,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
})()
|
||||
: undefined
|
||||
if (section !== null && typeof section === 'object' && !Array.isArray(section)) {
|
||||
return { ...source(), ...(section as Partial<Config>) }
|
||||
return mergeConfigSkippingNullish(source(), section as Record<string, unknown>)
|
||||
}
|
||||
return source()
|
||||
}
|
||||
|
|
@ -525,94 +866,64 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
}, 'netxops: model language prompts')
|
||||
})
|
||||
|
||||
// Optional host-layer publish: groups with `public=true` become visible to other presets.
|
||||
ctx.inject(['tools'], (toolsCtx) => {
|
||||
let unregisterTools: (() => void) | undefined
|
||||
let unregisterKbLocal: (() => void) | undefined
|
||||
const remountPublicTools = (): void => {
|
||||
unregisterTools?.()
|
||||
unregisterTools = undefined
|
||||
const connection = getNetxConnection()
|
||||
if (!connection) return
|
||||
const enabled = groupsForPlane(connection.groups, 'public')
|
||||
try {
|
||||
unregisterTools = registerNetxTools(toolsCtx, connection, { plane: 'public' })
|
||||
toolsCtx.logger.info(
|
||||
'netxops: host public tools groups=[%s]',
|
||||
enabled.join(',') || '(none)',
|
||||
)
|
||||
} catch (error) {
|
||||
toolsCtx.logger.error('netxops: host public tools register failed: %s', error)
|
||||
}
|
||||
}
|
||||
const remountKbLocal = (): void => {
|
||||
unregisterKbLocal?.()
|
||||
unregisterKbLocal = undefined
|
||||
const connection = getNetxConnection()
|
||||
if (!connection || connection.groupKbPublic !== true) return
|
||||
unregisterKbLocal = registerKbLocalTools(toolsCtx, getKbContext())
|
||||
}
|
||||
remountPublicTools()
|
||||
remountKbLocal()
|
||||
const stopWatch = watchNetxConnection(() => {
|
||||
remountPublicTools()
|
||||
remountKbLocal()
|
||||
})
|
||||
const stopKbWatch = watchKbContext(() => { remountKbLocal() })
|
||||
toolsCtx.effect(() => () => {
|
||||
stopWatch()
|
||||
stopKbWatch()
|
||||
unregisterTools?.()
|
||||
unregisterKbLocal?.()
|
||||
}, 'netxops: dispose public tools')
|
||||
})
|
||||
|
||||
ctx.inject(['skills'], (skillsCtx) => {
|
||||
let skillAbort = new AbortController()
|
||||
let packAbort = new AbortController()
|
||||
let unregisterSkills: (() => void) | undefined
|
||||
let unregisterKbSkill: (() => void) | undefined
|
||||
let unregisterKbPack: (() => void) | undefined
|
||||
let generation = 0
|
||||
let packGeneration = 0
|
||||
const remountPublicSkills = (): void => {
|
||||
const remountHostSkills = (): void => {
|
||||
if (disposed) return
|
||||
const gen = ++generation
|
||||
skillAbort.abort()
|
||||
skillAbort = new AbortController()
|
||||
unregisterSkills?.()
|
||||
unregisterSkills = undefined
|
||||
const connection = getNetxConnection()
|
||||
if (!connection) return
|
||||
const enabled = groupsForPlane(connection.groups, 'public')
|
||||
void registerGroupSkills(skillsCtx, enabled, 'netxops-public').then((dispose) => {
|
||||
const enabled = connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host')
|
||||
void registerGroupSkills(skillsCtx, enabled, 'netxops-host', skillAbort.signal).then((dispose) => {
|
||||
if (gen !== generation) {
|
||||
dispose()
|
||||
return
|
||||
}
|
||||
unregisterSkills = dispose
|
||||
skillsCtx.logger.info(
|
||||
'netxops: host public skills groups=[%s]',
|
||||
'netxops: host skills groups=[%s]',
|
||||
enabled.join(',') || '(none)',
|
||||
)
|
||||
}).catch((error) => {
|
||||
skillsCtx.logger.warn('netxops: public skill register failed: %s', error)
|
||||
skillsCtx.logger.warn('netxops: host skill register failed: %s', error)
|
||||
})
|
||||
}
|
||||
const remountKbSkill = (): void => {
|
||||
if (disposed) return
|
||||
unregisterKbSkill?.()
|
||||
unregisterKbSkill = undefined
|
||||
const connection = getNetxConnection()
|
||||
if (!kbEnabledForPlane(connection, 'host')) return
|
||||
const snapshot = getKbContext()
|
||||
unregisterKbSkill = registerKbContextSkill(skillsCtx, snapshot)
|
||||
skillsCtx.logger.info(
|
||||
'netxops: kb-context skill status=%s',
|
||||
'netxops: kb-context skill status=%s (host/public)',
|
||||
snapshot.status,
|
||||
)
|
||||
}
|
||||
const remountKbPack = (): void => {
|
||||
if (disposed) return
|
||||
const gen = ++packGeneration
|
||||
packAbort.abort()
|
||||
packAbort = new AbortController()
|
||||
unregisterKbPack?.()
|
||||
unregisterKbPack = undefined
|
||||
const connection = getNetxConnection()
|
||||
const enabled = connection?.groupKbPublic === true
|
||||
const enabled = kbEnabledForPlane(connection, 'host') && Boolean(connection)
|
||||
void registerKbPackSkills(skillsCtx, getKbContext(), {
|
||||
enabled,
|
||||
providerLabel: 'netxops-kb-pack-public',
|
||||
providerLabel: 'netxops-kb-pack-host',
|
||||
signal: packAbort.signal,
|
||||
}).then((dispose) => {
|
||||
if (gen !== packGeneration) {
|
||||
dispose()
|
||||
|
|
@ -620,32 +931,64 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
}
|
||||
unregisterKbPack = dispose
|
||||
}).catch((error) => {
|
||||
skillsCtx.logger.warn('netxops: public kb pack skill register failed: %s', error)
|
||||
skillsCtx.logger.warn('netxops: host kb pack skill register failed: %s', error)
|
||||
})
|
||||
}
|
||||
remountPublicSkills()
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
const stopWatch = watchNetxConnection(() => {
|
||||
remountPublicSkills()
|
||||
remountHostSkills()
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
})
|
||||
const stopKbWatch = watchKbContext(() => {
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
})
|
||||
remountHostSkills()
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
skillsCtx.effect(() => () => {
|
||||
generation += 1
|
||||
packGeneration += 1
|
||||
skillAbort.abort()
|
||||
packAbort.abort()
|
||||
stopWatch()
|
||||
stopKbWatch()
|
||||
unregisterSkills?.()
|
||||
unregisterKbSkill?.()
|
||||
unregisterKbPack?.()
|
||||
}, 'netxops: dispose public skills')
|
||||
}, 'netxops: dispose host skills')
|
||||
})
|
||||
|
||||
const handleNetxopsRpc = async (endpoint: string, payload?: unknown): Promise<unknown> => {
|
||||
if (endpoint === 'presets.catalog') {
|
||||
const registry = ctx.get('agentPresets') as {
|
||||
list?: () => Promise<Array<{ id: string; name?: string; broken?: string }>>
|
||||
} | undefined
|
||||
if (!registry?.list) return { ok: false, error: 'preset_registry_unavailable' }
|
||||
const presets = await registry.list()
|
||||
return { ok: true, value: { presets: presets.map(({ id, name, broken }) => ({ id, name, broken })) } }
|
||||
}
|
||||
if (endpoint === 'tools.status') {
|
||||
const mount = getToolMountStatus()
|
||||
const connection = getNetxConnection()
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
mount: mount ?? null,
|
||||
mounts: getToolMountStatuses(),
|
||||
connection: connection
|
||||
? {
|
||||
apiUrl: connection.apiUrl,
|
||||
toolsEnabled: connection.toolsEnabled !== false,
|
||||
tokenConfigured: connection.token.trim().length > 0,
|
||||
hostGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host'),
|
||||
presetGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'preset'),
|
||||
publicGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'public'),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (endpoint === 'alarm-push.status') {
|
||||
return { ok: true, value: getAlarmPushStatus() }
|
||||
}
|
||||
|
|
@ -736,14 +1079,14 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
channel: string,
|
||||
handler: (endpoint: string, payload?: unknown) => Promise<unknown>,
|
||||
) => (() => void) | Promise<void>
|
||||
const rpc = (mountCtx.connection as { rpc?: { handle?: RpcHandle } } | undefined)?.rpc
|
||||
const rpc = (mountCtx.get('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() }
|
||||
return () => { if (typeof legacy === 'function') legacy() }
|
||||
}
|
||||
mountCtx.logger.warn('netxops: /netxops rpc unavailable — settings card status disabled')
|
||||
return () => {}
|
||||
|
|
@ -752,7 +1095,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
|
||||
// Browser card: sessions export download via shared /api Fetch route.
|
||||
ctx.inject(['connection'], (connCtx) => {
|
||||
const connection = connCtx.connection as {
|
||||
const connection = connCtx.get('connection') as {
|
||||
rpc?: { handle?: (
|
||||
channel: string,
|
||||
handler: (endpoint: string, payload?: unknown) => Promise<unknown>,
|
||||
|
|
@ -770,7 +1113,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
connCtx.logger.warn('netxops: connection.fetch.register unavailable — sessions export download disabled')
|
||||
} else {
|
||||
connCtx.effect(() => {
|
||||
const dispose = fetchApi.register({
|
||||
const dispose = fetchApi.register!({
|
||||
path: NETXOPS_SESSIONS_EXPORT_PATH,
|
||||
methods: ['GET', 'HEAD'],
|
||||
fetch: async (request) => {
|
||||
|
|
@ -786,7 +1129,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
})
|
||||
|
||||
ctx.effect(() => () => {
|
||||
disposed = true
|
||||
generation += 1
|
||||
publishNetxConnection({ ...provisional, token: '', toolsEnabled: false })
|
||||
stopAlarmPush?.()
|
||||
stopAlarmPush = undefined
|
||||
resetAlarmSession()
|
||||
|
|
|
|||
|
|
@ -18,10 +18,15 @@ export function alarmSubscribeUrl(apiUrl: string): string {
|
|||
} catch {
|
||||
return ''
|
||||
}
|
||||
// Only promote http(s) → ws(s); never rewrite file:/data:/javascript: etc.
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
|
||||
if (url.username || url.password) return ''
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.pathname = '/v1/integrations/dsh-alarm/ws'
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
url.username = ''
|
||||
url.password = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
* - `bizMonitor` → skill `netx-biz-monitor` — cutover / biz_state read + overnight analysis
|
||||
*/
|
||||
|
||||
import { readConfigSnapshot, readConfigValue } from './config-values.ts'
|
||||
|
||||
/** Stable group ids used in settings, skill dirs, and registration filters. */
|
||||
export type NetxCapabilityGroupId = 'ops' | 'topology' | 'bizMonitor'
|
||||
|
||||
|
|
@ -17,6 +19,8 @@ export interface NetxGroupExposure {
|
|||
inPreset: boolean
|
||||
/** Mount on the host tool/skill layer so other presets can see the group. */
|
||||
public: boolean
|
||||
/** Additional preset ids selected by the operator. */
|
||||
presets?: readonly string[]
|
||||
}
|
||||
|
||||
/** Full capability-group policy published with the connection snapshot. */
|
||||
|
|
@ -24,6 +28,10 @@ export type NetxCapabilityGroups = Record<NetxCapabilityGroupId, NetxGroupExposu
|
|||
|
||||
/** Flat settings fields (Plugins card + schemastery Config). */
|
||||
export interface NetxCapabilityGroupSettingsFields {
|
||||
toolsEnabled?: boolean
|
||||
groupOpsPresets?: string
|
||||
groupTopologyPresets?: string
|
||||
groupBizMonitorPresets?: string
|
||||
groupOpsInPreset: boolean
|
||||
groupOpsPublic: boolean
|
||||
groupTopologyInPreset: boolean
|
||||
|
|
@ -33,10 +41,10 @@ export interface NetxCapabilityGroupSettingsFields {
|
|||
}
|
||||
|
||||
/**
|
||||
* Default: ops in Ops preset; topology / bizMonitor and all public off.
|
||||
* Default: ops + bizMonitor in Ops preset; topology and all public off.
|
||||
*/
|
||||
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
|
||||
ops: Object.freeze({ inPreset: true, public: true }),
|
||||
ops: Object.freeze({ inPreset: true, public: false }),
|
||||
topology: Object.freeze({ inPreset: false, public: false }),
|
||||
bizMonitor: Object.freeze({ inPreset: true, public: false }),
|
||||
})
|
||||
|
|
@ -79,7 +87,6 @@ export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly str
|
|||
'netx__layoutTopologyView',
|
||||
'netx__suggestSinkHubs',
|
||||
'netx__analyzeTopologyViewLayout',
|
||||
'netx__sinkTopologyDualUnits',
|
||||
]),
|
||||
bizMonitor: Object.freeze([
|
||||
'netx__listBizMonitors',
|
||||
|
|
@ -112,7 +119,7 @@ export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.fre
|
|||
* Legacy aliases (all map into `ops`):
|
||||
* - `groupNms*` / `groupCommon*` / `groupManagedNe*`
|
||||
*
|
||||
* Legacy `groupTopologyLayout*` ORs into `topology`.
|
||||
* Legacy `groupTopologyLayout*` applies only when the current flag is absent.
|
||||
*/
|
||||
export function capabilityGroupsFromSettings(
|
||||
fields: Partial<NetxCapabilityGroupSettingsFields & {
|
||||
|
|
@ -126,17 +133,17 @@ export function capabilityGroupsFromSettings(
|
|||
groupTopologyLayoutPublic?: boolean
|
||||
}> | null | undefined,
|
||||
): NetxCapabilityGroups {
|
||||
const src = fields ?? {}
|
||||
const src = readConfigSnapshot(fields ?? {})
|
||||
|
||||
let opsInPreset = true
|
||||
if (src.groupOpsInPreset !== undefined) {
|
||||
opsInPreset = src.groupOpsInPreset !== false
|
||||
if (typeof src.groupOpsInPreset === 'boolean') {
|
||||
opsInPreset = src.groupOpsInPreset
|
||||
} else {
|
||||
const legacy = [
|
||||
src.groupNmsInPreset,
|
||||
src.groupCommonInPreset,
|
||||
src.groupManagedNeInPreset,
|
||||
].filter((v): v is boolean => v !== undefined)
|
||||
].filter((v): v is boolean => typeof v === 'boolean')
|
||||
if (legacy.length > 0) {
|
||||
// Any explicit true → on; all explicit false → off.
|
||||
opsInPreset = legacy.some((v) => v === true)
|
||||
|
|
@ -144,57 +151,88 @@ export function capabilityGroupsFromSettings(
|
|||
}
|
||||
|
||||
let bizMonitorInPreset = true
|
||||
if (src.groupBizMonitorInPreset !== undefined) {
|
||||
bizMonitorInPreset = src.groupBizMonitorInPreset !== false
|
||||
if (typeof src.groupBizMonitorInPreset === 'boolean') {
|
||||
bizMonitorInPreset = src.groupBizMonitorInPreset
|
||||
}
|
||||
|
||||
return {
|
||||
ops: {
|
||||
inPreset: opsInPreset,
|
||||
public: (() => {
|
||||
if (src.groupOpsPublic !== undefined) return src.groupOpsPublic !== false
|
||||
if (typeof src.groupOpsPublic === 'boolean') return src.groupOpsPublic
|
||||
const legacy = [
|
||||
src.groupNmsPublic,
|
||||
src.groupCommonPublic,
|
||||
src.groupManagedNePublic,
|
||||
].filter((v): v is boolean => v !== undefined)
|
||||
// No explicit flag → default on (other presets see ops after install).
|
||||
if (legacy.length === 0) return true
|
||||
].filter((v): v is boolean => typeof v === 'boolean')
|
||||
// No explicit flag → default off (Netx Ops preset only).
|
||||
if (legacy.length === 0) return false
|
||||
return legacy.some((v) => v === true)
|
||||
})(),
|
||||
...(src.groupOpsPresets ? { presets: parsePresetIds(src.groupOpsPresets) } : {}),
|
||||
},
|
||||
topology: {
|
||||
inPreset: src.groupTopologyInPreset === true
|
||||
|| src.groupTopologyLayoutInPreset === true,
|
||||
public: src.groupTopologyPublic === true
|
||||
|| src.groupTopologyLayoutPublic === true,
|
||||
inPreset: typeof src.groupTopologyInPreset === 'boolean'
|
||||
? src.groupTopologyInPreset : src.groupTopologyLayoutInPreset === true,
|
||||
public: typeof src.groupTopologyPublic === 'boolean'
|
||||
? src.groupTopologyPublic : src.groupTopologyLayoutPublic === true,
|
||||
...(src.groupTopologyPresets ? { presets: parsePresetIds(src.groupTopologyPresets) } : {}),
|
||||
},
|
||||
bizMonitor: {
|
||||
inPreset: bizMonitorInPreset,
|
||||
public: src.groupBizMonitorPublic === true,
|
||||
...(src.groupBizMonitorPresets ? { presets: parsePresetIds(src.groupBizMonitorPresets) } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KB exposure mirrors capability groups: preset plane ↔ inPreset, host/public ↔ public.
|
||||
* Defaults: inPreset on, public off (same strategy as ops/bizMonitor).
|
||||
*/
|
||||
export function kbEnabledForPlane(
|
||||
flags: { toolsEnabled?: boolean, groupKbInPreset?: boolean, groupKbPublic?: boolean, groupKbPresets?: string } | undefined,
|
||||
plane: 'preset' | 'public' | 'host',
|
||||
presetId = 'netxops',
|
||||
): boolean {
|
||||
if (flags) flags = readConfigSnapshot(flags)
|
||||
if (flags?.toolsEnabled === false) return false
|
||||
if (!flags) return plane === 'preset'
|
||||
if (plane === 'preset') {
|
||||
return !flags.groupKbPublic && (presetId === 'netxops'
|
||||
? flags.groupKbInPreset !== false
|
||||
: parsePresetIds(flags.groupKbPresets ?? '').includes(presetId))
|
||||
}
|
||||
return flags.groupKbPublic === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups enabled for one registration plane.
|
||||
* - `preset` — `inPreset` (Netx Ops standing mount via `dsh-netxops/tools`)
|
||||
* - `public` — `public` only
|
||||
* - `host` — same as `public` (Host-global registry for other presets)
|
||||
*
|
||||
* Host must NOT union `inPreset`: otherwise unchecking「公开」leaves tools on the
|
||||
* global registry whenever「预设内」is still on, so revoke never takes effect.
|
||||
*/
|
||||
export function groupsForPlane(
|
||||
groups: NetxCapabilityGroups | undefined,
|
||||
plane: 'preset' | 'public',
|
||||
plane: 'preset' | 'public' | 'host',
|
||||
only?: readonly NetxCapabilityGroupId[],
|
||||
): NetxCapabilityGroupId[] {
|
||||
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS
|
||||
const enabled = CAPABILITY_GROUP_IDS.filter((id) => (
|
||||
plane === 'preset' ? policy[id].inPreset : policy[id].public
|
||||
))
|
||||
const enabled = CAPABILITY_GROUP_IDS.filter((id) => {
|
||||
if (plane === 'preset') return policy[id].inPreset
|
||||
// host === public: only explicitly published groups leave the Ops preset.
|
||||
return policy[id].public
|
||||
})
|
||||
if (!only || only.length === 0) return enabled
|
||||
const allow = new Set(only)
|
||||
return enabled.filter((id) => allow.has(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-enable the listed groups (for `dsh-netxops/tools-<group>` mounts).
|
||||
* Normalize a group allow-list; publication policy still controls registration.
|
||||
* Legacy `nms` / `common` aliases resolve to `ops`.
|
||||
*/
|
||||
export function groupsForced(
|
||||
|
|
@ -216,3 +254,23 @@ export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]):
|
|||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Parse the persisted preset-id list. Empty is an explicit empty selection. */
|
||||
export function parsePresetIds(value: unknown): string[] {
|
||||
const current = readConfigValue(value)
|
||||
if (typeof current !== 'string') return []
|
||||
return [...new Set(current.split(/[\s,,]+/).filter(Boolean))]
|
||||
}
|
||||
|
||||
/** Groups mounted locally in a preset; public groups are inherited from Host. */
|
||||
export function groupsForPreset(
|
||||
connection: { toolsEnabled?: boolean, groups?: NetxCapabilityGroups },
|
||||
presetId: string,
|
||||
only?: readonly NetxCapabilityGroupId[],
|
||||
): NetxCapabilityGroupId[] {
|
||||
if (connection.toolsEnabled === false) return []
|
||||
const policy = connection.groups ?? DEFAULT_CAPABILITY_GROUPS
|
||||
return CAPABILITY_GROUP_IDS.filter(id => (!only?.length || only.includes(id))
|
||||
&& !policy[id].public
|
||||
&& (presetId === 'netxops' ? policy[id].inPreset : policy[id].presets?.includes(presetId)))
|
||||
}
|
||||
|
|
|
|||
28
src/netx/config-values.ts
Normal file
28
src/netx/config-values.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/** The current Harness keeps volatile scalars in stable references. Older Hosts
|
||||
* supply ordinary values. Keep references at the boundary and read a snapshot
|
||||
* for each operation so Loader updates are visible without a plugin restart.
|
||||
*/
|
||||
export interface ConfigReference<T> {
|
||||
get(): T
|
||||
}
|
||||
|
||||
export type ConfigInput<T extends object> = {
|
||||
[K in keyof T]: T[K] | ConfigReference<T[K]>
|
||||
}
|
||||
|
||||
// Official cosmokit protocol, shared across its ESM/CJS copies. Do not import
|
||||
// isVolatile from an external peer: older Harness builds do not export it.
|
||||
const volatileWrite = Symbol.for('cosmokit.volatile.write')
|
||||
|
||||
export function readConfigValue(value: unknown): unknown {
|
||||
if (value !== null && typeof value === 'object' && volatileWrite in value
|
||||
&& 'get' in value && typeof value.get === 'function') {
|
||||
return value.get()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Our Config fields are scalars; this never descends into arbitrary objects. */
|
||||
export function readConfigSnapshot<T extends object>(input: ConfigInput<T>): T {
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, readConfigValue(value)])) as T
|
||||
}
|
||||
|
|
@ -199,6 +199,7 @@ export async function registerGroupSkills(
|
|||
ctx: Context,
|
||||
groupIds: readonly NetxCapabilityGroupId[],
|
||||
providerLabel: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<() => void> {
|
||||
const skillsApi = (ctx as { skills?: { register: (skill: {
|
||||
name: string
|
||||
|
|
@ -217,31 +218,42 @@ export async function registerGroupSkills(
|
|||
const disposers: Array<() => void> = []
|
||||
const enabled = new Set(groupIds)
|
||||
const log = (ctx as { logger?: { warn: (msg: string, ...args: unknown[]) => void } }).logger
|
||||
for (const groupId of CAPABILITY_GROUP_IDS) {
|
||||
if (!enabled.has(groupId)) continue
|
||||
const { skills, skipped } = await loadGroupSkills(root, groupId)
|
||||
for (const miss of skipped) {
|
||||
log?.warn?.('%s: skip skill dir %s (%s)', providerLabel, miss.directory, miss.reason)
|
||||
try {
|
||||
for (const groupId of CAPABILITY_GROUP_IDS) {
|
||||
if (!enabled.has(groupId)) continue
|
||||
const { skills, skipped } = await loadGroupSkills(root, groupId)
|
||||
if (signal?.aborted) break
|
||||
for (const miss of skipped) {
|
||||
log?.warn?.('%s: skip skill dir %s (%s)', providerLabel, miss.directory, miss.reason)
|
||||
}
|
||||
if (skills.length === 0) {
|
||||
log?.warn?.(
|
||||
'%s: no skills loaded for group=%s from %s',
|
||||
providerLabel,
|
||||
groupId,
|
||||
join(root, SKILL_DIR_BY_GROUP[groupId]),
|
||||
)
|
||||
}
|
||||
for (const skill of skills) {
|
||||
disposers.push(skillsApi.register({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: groupId === 'topology'
|
||||
? `${skill.content}\n\n## DeepSeek Harness REST capabilities\nUse only tools present in this session. Native layout supports catalog and move_nodes/sink_nodes. Dual-unit/orbit/polish recipes require a separately installed netx-topology MCP. Native analyzeTopologyViewLayout reports basic graph statistics; suggestSinkHubs uses degree ranking. Native sinkTopologyDualUnits is unavailable.\n`
|
||||
: skill.content,
|
||||
path: skill.path,
|
||||
resourceBase: { kind: 'directory', path: skill.directory },
|
||||
provider: providerLabel,
|
||||
source: 'custom',
|
||||
}))
|
||||
}
|
||||
}
|
||||
if (skills.length === 0) {
|
||||
log?.warn?.(
|
||||
'%s: no skills loaded for group=%s from %s',
|
||||
providerLabel,
|
||||
groupId,
|
||||
join(root, SKILL_DIR_BY_GROUP[groupId]),
|
||||
)
|
||||
}
|
||||
for (const skill of skills) {
|
||||
disposers.push(skillsApi.register({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: skill.content,
|
||||
path: skill.path,
|
||||
resourceBase: { kind: 'directory', path: skill.directory },
|
||||
provider: providerLabel,
|
||||
source: 'custom',
|
||||
}))
|
||||
if (signal?.aborted) {
|
||||
for (const dispose of disposers.splice(0)) dispose()
|
||||
}
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.splice(0)) dispose()
|
||||
throw error
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
* Shared agent-plane mount: tools + skills for one or more capability groups.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
groupsForced,
|
||||
groupsForPlane,
|
||||
groupsForPreset,
|
||||
kbEnabledForPlane,
|
||||
type NetxCapabilityGroupId,
|
||||
} from './capability-groups.ts'
|
||||
import { registerGroupSkills } from './group-skills.ts'
|
||||
|
|
@ -14,20 +17,36 @@ import { registerKbContextSkill } from './kb-context-skill.ts'
|
|||
import { registerKbLocalTools } from './kb-local-tools.ts'
|
||||
import { registerKbPackSkills } from './kb-pack-skills.ts'
|
||||
import { getKbContext, watchKbContext } from './kb-runtime.ts'
|
||||
import { tryRecoverMissingHostBridge } from './heal-profile-entry.ts'
|
||||
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
|
||||
import {
|
||||
getNetxConnection,
|
||||
requireNetxConnection,
|
||||
watchNetxConnection,
|
||||
} from './runtime.ts'
|
||||
import { getToolMountId, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
|
||||
import { registerNetxTools } from './tools.ts'
|
||||
|
||||
function packageVersion(): string {
|
||||
try {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
|
||||
return typeof pkg.version === 'string' ? pkg.version : '0'
|
||||
} catch {
|
||||
return '0'
|
||||
}
|
||||
}
|
||||
|
||||
export interface GroupToolsPluginOptions {
|
||||
/** Cordis plugin name. */
|
||||
name: string
|
||||
/**
|
||||
* `settings` — honor inPreset/public from connection (Ops aggregate mount).
|
||||
* `forced` — always register `only` groups when connection exists (per-group export).
|
||||
* `forced` — legacy export label; only filters groups and obeys the publication policy.
|
||||
*/
|
||||
mode: 'settings' | 'forced'
|
||||
/** Group allow-list; omit in settings mode to use all groups. */
|
||||
only?: readonly NetxCapabilityGroupId[]
|
||||
/** Explicit target for live session mounts; otherwise resolve the calling preset. */
|
||||
presetId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -38,44 +57,50 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
let unregisterKbLocal: (() => void) | undefined
|
||||
let unregisterSkills: (() => void) | undefined
|
||||
let skillGeneration = 0
|
||||
const presetId = (): string => {
|
||||
if (options.presetId) return options.presetId
|
||||
const registry = ctx.get('agentPresets') as { composedPreset?: (ctx: Context) => string | undefined } | undefined
|
||||
return registry?.composedPreset?.(ctx) ?? 'netxops'
|
||||
}
|
||||
const mountId = getToolMountId(ctx, options.name)
|
||||
|
||||
const resolveGroups = (): NetxCapabilityGroupId[] => {
|
||||
const connection = getNetxConnection()
|
||||
if (options.mode === 'forced') {
|
||||
return groupsForced(options.only ?? [])
|
||||
}
|
||||
return groupsForPlane(connection?.groups, 'preset', options.only)
|
||||
const connection = requireNetxConnection()
|
||||
return groupsForPreset(connection, presetId(), options.only)
|
||||
}
|
||||
|
||||
const remountTools = (): void => {
|
||||
unregisterTools?.()
|
||||
unregisterTools = undefined
|
||||
const connection = getNetxConnection()
|
||||
if (connection === undefined) {
|
||||
// Preset channel can self-heal while the Host bundle row is gone — restore
|
||||
// `dsh-netxops` into cordis.patch.yml once, then require a full restart.
|
||||
tryRecoverMissingHostBridge(ctx.logger, options.name)
|
||||
return
|
||||
}
|
||||
const connection = requireNetxConnection()
|
||||
const enabled = resolveGroups()
|
||||
if (enabled.length === 0) {
|
||||
ctx.logger.info('%s: no groups enabled', options.name)
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Always call register (incl. empty) so revoke publishes count=0 and matches host.
|
||||
unregisterTools = registerNetxTools(ctx, connection, {
|
||||
plane: 'preset',
|
||||
only: options.mode === 'forced' ? enabled : options.only,
|
||||
forceGroups: options.mode === 'forced' ? enabled : undefined,
|
||||
forceGroups: enabled,
|
||||
source: options.name,
|
||||
})
|
||||
ctx.logger.info(
|
||||
'%s: groups=[%s] → %s tokenConfigured=%s',
|
||||
'%s: groups=[%s] → %s tokenConfigured=%s live=%s',
|
||||
options.name,
|
||||
enabled.join(',') || '(none)',
|
||||
connection.apiUrl,
|
||||
connection.token.trim().length > 0,
|
||||
getNetxConnection() !== undefined,
|
||||
)
|
||||
} catch (error) {
|
||||
publishToolMountStatus({
|
||||
id: mountId,
|
||||
version: packageVersion(),
|
||||
plane: 'preset',
|
||||
groups: [...enabled],
|
||||
names: [],
|
||||
count: 0,
|
||||
at: new Date().toISOString(),
|
||||
error: String(error),
|
||||
source: options.name,
|
||||
})
|
||||
ctx.logger.error('%s: registerNetxTools failed: %s', options.name, error)
|
||||
}
|
||||
}
|
||||
|
|
@ -83,31 +108,48 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
const remountKbLocal = (): void => {
|
||||
unregisterKbLocal?.()
|
||||
unregisterKbLocal = undefined
|
||||
const connection = getNetxConnection()
|
||||
if (connection === undefined) return
|
||||
if (connection.groupKbInPreset === false) return
|
||||
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext())
|
||||
const connection = requireNetxConnection()
|
||||
if (options.only || !kbEnabledForPlane(connection, 'preset', presetId())) return
|
||||
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext(), { plane: 'preset', source: `${options.name}:kb-local` })
|
||||
}
|
||||
|
||||
remountTools()
|
||||
remountKbLocal()
|
||||
// Subscribe BEFORE the first remount. Host publish is async (credentials
|
||||
// resolve); if it lands between remount and watch, tools stay empty forever.
|
||||
const stopToolWatch = watchNetxConnection(() => {
|
||||
remountTools()
|
||||
remountKbLocal()
|
||||
})
|
||||
const stopKbToolWatch = watchKbContext(() => { remountKbLocal() })
|
||||
remountTools()
|
||||
remountKbLocal()
|
||||
|
||||
// Backup poll: covers publish that raced before watch, and slow credentials.
|
||||
let pollTicks = 0
|
||||
const pollTimer = setInterval(() => {
|
||||
pollTicks += 1
|
||||
if (getNetxConnection() !== undefined) {
|
||||
remountTools()
|
||||
remountKbLocal()
|
||||
clearInterval(pollTimer)
|
||||
return
|
||||
}
|
||||
if (pollTicks >= 40) clearInterval(pollTimer) // ~20s
|
||||
}, 500)
|
||||
|
||||
ctx.inject(['skills'], (skillsCtx) => {
|
||||
let skillAbort = new AbortController()
|
||||
let packAbort = new AbortController()
|
||||
let unregisterKbSkill: (() => void) | undefined
|
||||
let unregisterKbPack: (() => void) | undefined
|
||||
let packGeneration = 0
|
||||
const remountSkills = (): void => {
|
||||
const gen = ++skillGeneration
|
||||
skillAbort.abort()
|
||||
skillAbort = new AbortController()
|
||||
unregisterSkills?.()
|
||||
unregisterSkills = undefined
|
||||
if (getNetxConnection() === undefined) return
|
||||
const enabled = resolveGroups()
|
||||
void registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => {
|
||||
void registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => {
|
||||
if (gen !== skillGeneration) {
|
||||
dispose()
|
||||
return
|
||||
|
|
@ -120,17 +162,22 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
const remountKbSkill = (): void => {
|
||||
unregisterKbSkill?.()
|
||||
unregisterKbSkill = undefined
|
||||
const connection = requireNetxConnection()
|
||||
if (options.only || !kbEnabledForPlane(connection, 'preset', presetId())) return
|
||||
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext())
|
||||
}
|
||||
const remountKbPack = (): void => {
|
||||
const gen = ++packGeneration
|
||||
packAbort.abort()
|
||||
packAbort = new AbortController()
|
||||
unregisterKbPack?.()
|
||||
unregisterKbPack = undefined
|
||||
const connection = getNetxConnection()
|
||||
const enabled = connection?.groupKbInPreset !== false
|
||||
const connection = requireNetxConnection()
|
||||
const enabled = !options.only && kbEnabledForPlane(connection, 'preset', presetId())
|
||||
void registerKbPackSkills(skillsCtx, getKbContext(), {
|
||||
enabled,
|
||||
providerLabel: `${options.name}-kb-pack`,
|
||||
signal: packAbort.signal,
|
||||
}).then((dispose) => {
|
||||
if (gen !== packGeneration) {
|
||||
dispose()
|
||||
|
|
@ -142,20 +189,23 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
})
|
||||
}
|
||||
|
||||
remountSkills()
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
const stopSkillWatch = watchNetxConnection(() => {
|
||||
remountSkills()
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
})
|
||||
const stopKbWatch = watchKbContext(() => {
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
})
|
||||
remountSkills()
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
skillsCtx.effect(() => () => {
|
||||
skillGeneration += 1
|
||||
packGeneration += 1
|
||||
skillAbort.abort()
|
||||
packAbort.abort()
|
||||
stopSkillWatch()
|
||||
stopKbWatch()
|
||||
unregisterSkills?.()
|
||||
|
|
@ -168,11 +218,13 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
})
|
||||
|
||||
ctx.effect(() => () => {
|
||||
clearInterval(pollTimer)
|
||||
stopToolWatch()
|
||||
stopKbToolWatch()
|
||||
unregisterTools?.()
|
||||
unregisterTools = undefined
|
||||
unregisterKbLocal?.()
|
||||
unregisterKbLocal = undefined
|
||||
removeToolMountStatus(mountId)
|
||||
}, `${options.name}: dispose tools`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
* recovery when only the preset side is still alive).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
|
@ -25,6 +25,11 @@ const HOST_HEAL_ONCE = Symbol.for('dsh-netxops.host-bridge-heal-attempted')
|
|||
|
||||
/** Defaults merged into missing keys only — never overwrite user values. */
|
||||
export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | boolean | number>> = Object.freeze({
|
||||
toolsEnabled: true,
|
||||
groupOpsPresets: '',
|
||||
groupTopologyPresets: '',
|
||||
groupBizMonitorPresets: '',
|
||||
groupKbPresets: '',
|
||||
apiUrl: 'http://127.0.0.1:8890',
|
||||
lang: 'zh',
|
||||
thinkingLanguage: 'auto',
|
||||
|
|
@ -40,8 +45,8 @@ export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | bool
|
|||
imTargetId: '',
|
||||
imTargets: '',
|
||||
groupOpsInPreset: true,
|
||||
/** Default on: other presets inherit ops tools/skills after install. */
|
||||
groupOpsPublic: true,
|
||||
/** Default off: ops stays Netx-Ops-only unless the operator publishes. */
|
||||
groupOpsPublic: false,
|
||||
groupTopologyInPreset: false,
|
||||
groupTopologyPublic: false,
|
||||
groupBizMonitorInPreset: true,
|
||||
|
|
@ -171,8 +176,22 @@ function needsHeal(entry: HostEntry): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill missing default keys only — never overwrite existing user values,
|
||||
* and keep unknown user keys for round-trip safety.
|
||||
*/
|
||||
export function mergeHostConfigForHeal(
|
||||
existing: Readonly<Record<string, string | boolean | number>>,
|
||||
): Record<string, string | boolean | number> {
|
||||
const out: Record<string, string | boolean | number> = { ...existing }
|
||||
for (const [key, value] of Object.entries(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
||||
if (!Object.hasOwn(out, key)) out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function renderHostEntry(entry: Pick<HostEntry, 'disabled' | 'name' | 'config'>): string[] {
|
||||
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }
|
||||
const config = mergeHostConfigForHeal(entry.config)
|
||||
const out: string[] = [
|
||||
'- id: netxops',
|
||||
' name: dsh-netxops',
|
||||
|
|
@ -180,12 +199,35 @@ function renderHostEntry(entry: Pick<HostEntry, 'disabled' | 'name' | 'config'>)
|
|||
if (entry.disabled === true) out.push(' disabled: true')
|
||||
else out.push(' disabled: false')
|
||||
out.push(' config:')
|
||||
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
||||
out.push(` ${key}: ${formatScalar(config[key]!)}`)
|
||||
}
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (Object.hasOwn(NETXOPS_HOST_CONFIG_DEFAULTS, key)) continue
|
||||
out.push(` ${key}: ${formatScalar(value)}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Write only when the on-disk contents still match `expectedRaw`.
|
||||
* Aborts (returns false) if Plugin Manager / another heal raced us.
|
||||
*/
|
||||
export function writePatchIfUnchanged(
|
||||
patchPath: string,
|
||||
expectedRaw: string,
|
||||
nextRaw: string,
|
||||
): boolean {
|
||||
if (nextRaw === expectedRaw) return false
|
||||
const existed = existsSync(patchPath)
|
||||
const mtimeMs = existed ? statSync(patchPath).mtimeMs : 0
|
||||
const current = existed ? readFileSync(patchPath, 'utf8') : ''
|
||||
if (current !== expectedRaw) return false
|
||||
if (existed && statSync(patchPath).mtimeMs !== mtimeMs) return false
|
||||
writeFileSync(patchPath, nextRaw, 'utf8')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or expand the Host `dsh-netxops` row in desktop/web `cordis.patch.yml`.
|
||||
* @returns counts of profiles rewritten (sparse) vs freshly inserted (missing).
|
||||
|
|
@ -232,8 +274,16 @@ export function ensureNetxopsHostInProfilePatches(logger: Context['logger']): Ho
|
|||
if (nextLines === null || kind === null) continue
|
||||
const normalized = nextLines.join(nl)
|
||||
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`
|
||||
if (withNl === raw) continue
|
||||
writeFileSync(patchPath, withNl, 'utf8')
|
||||
if (!writePatchIfUnchanged(patchPath, raw, withNl)) {
|
||||
if (withNl !== raw) {
|
||||
logger.warn(
|
||||
'netxops: skipped heal write for profiles/%s/cordis.patch.yml '
|
||||
+ '(file changed concurrently — Plugin Manager/HMR race)',
|
||||
profileName,
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (kind === 'insert') {
|
||||
inserted += 1
|
||||
logger.error(
|
||||
|
|
|
|||
|
|
@ -8,10 +8,40 @@ export interface NetxConnection {
|
|||
timeoutMs: number
|
||||
/** Read the latest bearer on each request (do not freeze at client creation). */
|
||||
getToken: () => string
|
||||
/** Read the latest apiUrl on each request (settings may heal after first mount). */
|
||||
getApiUrl?: () => string
|
||||
}
|
||||
|
||||
export type NetxJson = Record<string, unknown>
|
||||
|
||||
export type NormalizeApiUrlResult =
|
||||
| { ok: true, url: string }
|
||||
| { ok: false, error: 'empty' | 'invalid_url' | 'unsupported_scheme' | 'credentials_in_url' }
|
||||
|
||||
/**
|
||||
* Accept only bare http(s) API roots — no credentials-in-URL, no file/data/etc.
|
||||
* Trailing slash is stripped; path prefixes (e.g. `/netx`) are kept.
|
||||
*/
|
||||
export function normalizeApiUrl(raw: string): NormalizeApiUrlResult {
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : ''
|
||||
if (!trimmed) return { ok: false, error: 'empty' }
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(trimmed)
|
||||
} catch {
|
||||
return { ok: false, error: 'invalid_url' }
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { ok: false, error: 'unsupported_scheme' }
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
return { ok: false, error: 'credentials_in_url' }
|
||||
}
|
||||
const path = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/$/, '')
|
||||
const search = parsed.search && parsed.search !== '?' ? parsed.search : ''
|
||||
return { ok: true, url: `${parsed.origin}${path}${search}` }
|
||||
}
|
||||
|
||||
const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
|
||||
其他: 'Other',
|
||||
时钟: 'Clock',
|
||||
|
|
@ -19,9 +49,13 @@ const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
|
|||
电源: 'Power',
|
||||
}
|
||||
|
||||
function asLang(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/** Localize a few protocol bucket keys when lang starts with en (match netx-mcp). */
|
||||
function localizePayload(lang: string, data: NetxJson): NetxJson {
|
||||
if (!lang.trim().toLowerCase().startsWith('en')) return data
|
||||
if (!asLang(lang).trim().toLowerCase().startsWith('en')) return data
|
||||
const proto = data.protocol_summary
|
||||
if (!Array.isArray(proto)) return data
|
||||
for (const row of proto) {
|
||||
|
|
@ -44,15 +78,13 @@ function encodeQuery(params: Record<string, string | number | boolean>): string
|
|||
}
|
||||
|
||||
/**
|
||||
* Build a client bound to apiUrl / lang / timeout; Bearer is resolved per request.
|
||||
* @param connection - apiUrl / lang / timeout / getToken.
|
||||
* Build a client bound to apiUrl / lang / timeout; Bearer + apiUrl are resolved per request.
|
||||
* @param connection - apiUrl / lang / timeout / getToken / optional getApiUrl.
|
||||
* @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`.
|
||||
*/
|
||||
export function createNetxClient(connection: NetxConnection) {
|
||||
const base = connection.apiUrl.replace(/\/$/, '')
|
||||
|
||||
const langParams = (): Record<string, string> => {
|
||||
const lang = connection.lang.trim().toLowerCase()
|
||||
const lang = asLang(connection.lang).trim().toLowerCase()
|
||||
if (lang.startsWith('en')) return { lang: 'en' }
|
||||
return {}
|
||||
}
|
||||
|
|
@ -67,7 +99,20 @@ export function createNetxClient(connection: NetxConnection) {
|
|||
signal?: AbortSignal
|
||||
} = {},
|
||||
): Promise<NetxJson> {
|
||||
const token = connection.getToken().trim()
|
||||
const rawUrl = typeof connection.getApiUrl === 'function'
|
||||
? connection.getApiUrl()
|
||||
: connection.apiUrl
|
||||
const normalized = normalizeApiUrl(typeof rawUrl === 'string' ? rawUrl : '')
|
||||
const base = normalized.ok ? normalized.url : ''
|
||||
if (!normalized.ok || !base) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'netx_api_url_invalid',
|
||||
detail: `apiUrl must be http(s) without embedded credentials (${normalized.ok ? 'empty' : normalized.error}).`,
|
||||
}
|
||||
}
|
||||
const rawToken = connection.getToken()
|
||||
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
|
||||
if (token.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import type { NetxJson } from './http.ts'
|
||||
export type LosslessJsonValue = string | number | boolean | null | LosslessJsonValue[] | { [key: string]: LosslessJsonValue }
|
||||
|
||||
/**
|
||||
* Detach a plain JSON-compatible value for DSH tool results.
|
||||
|
|
@ -12,15 +13,15 @@ import type { NetxJson } from './http.ts'
|
|||
* @param value - handler result (may contain undefined holes).
|
||||
* @returns parsed plain JSON object, or a safe error envelope.
|
||||
*/
|
||||
export function toLosslessJson(value: unknown): NetxJson {
|
||||
export function toLosslessJson(value: unknown): Record<string, LosslessJsonValue> {
|
||||
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)
|
||||
const parsed = JSON.parse(text) as LosslessJsonValue
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as NetxJson
|
||||
return parsed
|
||||
}
|
||||
return { ok: true, data: { value: parsed } }
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts'
|
||||
import { getKbContext } from './kb-runtime.ts'
|
||||
import { toLosslessJson } from './json-safe.ts'
|
||||
import { getToolMountId, getToolMountStatus, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
|
||||
|
||||
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
|
||||
const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) })
|
||||
|
|
@ -33,7 +34,7 @@ function renderJson(_args: unknown, value: unknown) {
|
|||
}
|
||||
|
||||
const jsonOut = {
|
||||
schema: { type: 'json' as const },
|
||||
schema: { type: 'object' as const, additionalProperties: true as const },
|
||||
render: renderJson,
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +73,7 @@ function tool(
|
|||
export function registerKbLocalTools(
|
||||
ctx: Context,
|
||||
snapshot: KbSnapshot = getKbContext(),
|
||||
options: { plane?: 'host' | 'preset'; source?: string } = {},
|
||||
): () => void {
|
||||
if (!kbLocalToolsEnabled(snapshot)) return () => {}
|
||||
|
||||
|
|
@ -220,7 +222,19 @@ export function registerKbLocalTools(
|
|||
),
|
||||
]
|
||||
|
||||
const disposers = entries.map((entry) => toolsApi.register(entry))
|
||||
const disposers: Array<() => void> = []
|
||||
const source = options.source ?? 'netxops-kb-local'
|
||||
const id = getToolMountId(ctx, source)
|
||||
const status = { id, source, version: getToolMountStatus()?.version ?? '', plane: options.plane ?? 'preset',
|
||||
groups: ['kb'], at: new Date().toISOString() }
|
||||
try {
|
||||
for (const entry of entries) disposers.push(toolsApi.register(entry))
|
||||
publishToolMountStatus({ ...status, names: entries.map(entry => entry.name), count: entries.length })
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) })
|
||||
throw error
|
||||
}
|
||||
ctx.logger.info(
|
||||
'netxops: kb local tools registered count=%s localRoot=%s',
|
||||
disposers.length,
|
||||
|
|
@ -228,5 +242,6 @@ export function registerKbLocalTools(
|
|||
)
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
removeToolMountStatus(id)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ function parseContent(raw: unknown): KbContentFlags {
|
|||
const mapped = LEGACY_CONTENT_KEY[key]
|
||||
if (mapped !== undefined) {
|
||||
// Short keys only fill has* when the contract key is absent (no dual truth).
|
||||
if (on && !present.has(mapped)) out[mapped] = true
|
||||
if (on && !present.has(String(mapped))) out[mapped] = true
|
||||
continue
|
||||
}
|
||||
out[key] = on
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ async function registerSkillsUnderRoot(
|
|||
skillsApi: { register: SkillsRegister },
|
||||
root: string,
|
||||
provider: string,
|
||||
options: { logMissing: boolean; label: string },
|
||||
options: { logMissing: boolean; label: string; signal?: AbortSignal },
|
||||
): Promise<Array<() => void>> {
|
||||
let entries: string[]
|
||||
try {
|
||||
|
|
@ -63,40 +63,47 @@ async function registerSkillsUnderRoot(
|
|||
if (entries.length === 0) return []
|
||||
|
||||
const disposers: Array<() => void> = []
|
||||
for (const entry of entries) {
|
||||
const full = join(root, entry)
|
||||
let isDir = false
|
||||
try {
|
||||
isDir = (await stat(full)).isDirectory()
|
||||
} catch {
|
||||
continue
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
if (options.signal?.aborted) break
|
||||
const full = join(root, entry)
|
||||
let isDir = false
|
||||
try {
|
||||
isDir = (await stat(full)).isDirectory()
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!isDir) continue
|
||||
let skill
|
||||
try {
|
||||
skill = await loadSkillBundle(full)
|
||||
} catch (error) {
|
||||
ctx.logger.warn(
|
||||
'netxops: kb %s skill load failed at %s: %s',
|
||||
options.label,
|
||||
full,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!skill) {
|
||||
ctx.logger.warn('netxops: kb %s skill skipped (invalid SKILL.md): %s', options.label, full)
|
||||
continue
|
||||
}
|
||||
if (options.signal?.aborted) break
|
||||
disposers.push(skillsApi.register({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: skill.content,
|
||||
path: skill.path,
|
||||
resourceBase: { kind: 'directory', path: skill.directory },
|
||||
provider,
|
||||
source: 'custom',
|
||||
}))
|
||||
}
|
||||
if (!isDir) continue
|
||||
let skill
|
||||
try {
|
||||
skill = await loadSkillBundle(full)
|
||||
} catch (error) {
|
||||
ctx.logger.warn(
|
||||
'netxops: kb %s skill load failed at %s: %s',
|
||||
options.label,
|
||||
full,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!skill) {
|
||||
ctx.logger.warn('netxops: kb %s skill skipped (invalid SKILL.md): %s', options.label, full)
|
||||
continue
|
||||
}
|
||||
disposers.push(skillsApi.register({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: skill.content,
|
||||
path: skill.path,
|
||||
resourceBase: { kind: 'directory', path: skill.directory },
|
||||
provider,
|
||||
source: 'custom',
|
||||
}))
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.splice(0)) dispose()
|
||||
throw error
|
||||
}
|
||||
return disposers
|
||||
}
|
||||
|
|
@ -113,6 +120,7 @@ export async function registerKbPackSkills(
|
|||
options: {
|
||||
enabled: boolean
|
||||
providerLabel?: string
|
||||
signal?: AbortSignal
|
||||
},
|
||||
): Promise<() => void> {
|
||||
if (!options.enabled || !kbPackSkillsEnabled(snapshot)) {
|
||||
|
|
@ -128,17 +136,27 @@ export async function registerKbPackSkills(
|
|||
const disposers: Array<() => void> = []
|
||||
|
||||
const skillsRoot = resolveKbSkillsRoot(snapshot)
|
||||
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
|
||||
logMissing: true,
|
||||
label: 'pack',
|
||||
}))
|
||||
|
||||
const localRoot = resolveKbLocalSkillsRoot(snapshot)
|
||||
if (localRoot) {
|
||||
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, {
|
||||
logMissing: false,
|
||||
label: 'local',
|
||||
try {
|
||||
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
|
||||
logMissing: true,
|
||||
label: 'pack',
|
||||
signal: options.signal,
|
||||
}))
|
||||
|
||||
if (localRoot) {
|
||||
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, {
|
||||
logMissing: false,
|
||||
label: 'local',
|
||||
signal: options.signal,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.splice(0)) dispose()
|
||||
throw error
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
for (const dispose of disposers.splice(0)) dispose()
|
||||
}
|
||||
|
||||
if (disposers.length > 0) {
|
||||
|
|
|
|||
|
|
@ -55,12 +55,15 @@ export function getKbContext(): KbSnapshot {
|
|||
*/
|
||||
export function publishKbContext(next: KbSnapshot): void {
|
||||
const state = store()
|
||||
if (JSON.stringify(state.snapshot) === JSON.stringify(next)) return
|
||||
state.snapshot = {
|
||||
...next,
|
||||
content: { ...next.content },
|
||||
paths: { ...next.paths },
|
||||
}
|
||||
for (const listener of state.listeners) listener()
|
||||
for (const listener of [...state.listeners]) {
|
||||
try { listener() } catch (error) { console.error('netxops: knowledge subscriber failed', error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
65
src/netx/preset-publication.ts
Normal file
65
src/netx/preset-publication.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/** Publish selected capabilities into live session scopes without rewriting presets. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createScope, scopeOf, type Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { groupsForPreset, kbEnabledForPlane } from './capability-groups.ts'
|
||||
import { applyGroupToolsPlugin } from './group-tools-plugin.ts'
|
||||
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
'agent-preset/selected': (sessionId: string, presetId: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
interface LiveAgent { id: string; ctx: Context }
|
||||
interface PresetRoster {
|
||||
composedPreset(ctx: Context): string | undefined
|
||||
list(): Promise<Array<{ id: string; name?: string; broken?: string }>>
|
||||
}
|
||||
|
||||
/** Attach selected groups to existing and newly created sessions on either Host. */
|
||||
export function installPresetPublication(ctx: Context): void {
|
||||
ctx.inject(['agents', 'agentPresets', 'tools'], child => {
|
||||
const agents = child.get('agents') as { list(): LiveAgent[] }
|
||||
const presets = child.get('agentPresets') as PresetRoster
|
||||
const mounts = new Map<string, { presetId: string; scope: Scope }>()
|
||||
let active = true
|
||||
const release = (id: string): void => {
|
||||
const mount = mounts.get(id)
|
||||
if (!mount) return
|
||||
mounts.delete(id)
|
||||
mount.scope.rawDispose()
|
||||
}
|
||||
const sync = (agent: LiveAgent): void => {
|
||||
if (!active) return
|
||||
const presetId = presets.composedPreset(agent.ctx)
|
||||
const connection = getNetxConnection()
|
||||
// Netx Ops owns its standing aggregate mount. Other targets use live scopes.
|
||||
const enabled = connection && presetId && presetId !== 'netxops'
|
||||
&& (groupsForPreset(connection, presetId).length > 0
|
||||
|| kbEnabledForPlane(connection, 'preset', presetId))
|
||||
const previous = mounts.get(agent.id)
|
||||
if (!enabled || previous?.presetId !== presetId) release(agent.id)
|
||||
if (!enabled || mounts.has(agent.id)) return
|
||||
const key = scopeOf(agent.ctx)
|
||||
if (!key) {
|
||||
child.logger.warn('netxops: selected preset %s has no session scope', presetId)
|
||||
return
|
||||
}
|
||||
// Register on the exact live scope; a sibling scope would be invisible to the agent.
|
||||
const scope = createScope(child, key)
|
||||
mounts.set(agent.id, { presetId, scope })
|
||||
applyGroupToolsPlugin(scope.ctx, { name: `netxops-selected:${presetId}:${agent.id}`, mode: 'settings', presetId })
|
||||
}
|
||||
const syncAll = (): void => { for (const agent of agents.list()) sync(agent) }
|
||||
child.on('agent/created', ({ agent }: { agent: LiveAgent }) => { sync(agent) })
|
||||
child.on('agent/disposed', ({ agent }: { agent: LiveAgent }) => { release(agent.id) })
|
||||
child.on('agent-preset/selected', () => { syncAll() })
|
||||
const stop = watchNetxConnection(syncAll)
|
||||
syncAll()
|
||||
child.effect(() => () => {
|
||||
active = false
|
||||
stop()
|
||||
for (const id of [...mounts.keys()]) release(id)
|
||||
}, 'netxops: selected preset publication')
|
||||
})
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
* even when Bun emits them as separate ESM files.
|
||||
*/
|
||||
|
||||
import { DEFAULT_CAPABILITY_GROUPS } from './capability-groups.ts'
|
||||
import { normalizeApiUrl } from './http.ts'
|
||||
import type { NetxToolConnection } from './tools.ts'
|
||||
|
||||
type Listener = () => void
|
||||
|
|
@ -33,8 +35,11 @@ function store(): Store {
|
|||
*/
|
||||
export function publishNetxConnection(next: NetxToolConnection): void {
|
||||
const state = store()
|
||||
if (JSON.stringify(state.connection) === JSON.stringify(next)) return
|
||||
state.connection = next
|
||||
for (const listener of state.listeners) listener()
|
||||
for (const listener of [...state.listeners]) {
|
||||
try { listener() } catch (error) { console.error('netxops: connection subscriber failed', error) }
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns the last published connection, if any. */
|
||||
|
|
@ -42,6 +47,33 @@ export function getNetxConnection(): NetxToolConnection | undefined {
|
|||
return store().connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback used when the Host bridge has not published yet (credentials still
|
||||
* pending, or host apply delayed). Publication stays disabled until the Host
|
||||
* publishes its actual policy, so a detached preset cannot bypass revocation.
|
||||
*/
|
||||
export function provisionalNetxConnection(): NetxToolConnection {
|
||||
const envUrl = process.env.NETX_API_URL?.trim() ?? ''
|
||||
const normalized = envUrl ? normalizeApiUrl(envUrl) : normalizeApiUrl('http://127.0.0.1:8890')
|
||||
return {
|
||||
toolsEnabled: false,
|
||||
apiUrl: normalized.ok ? normalized.url : 'http://127.0.0.1:8890',
|
||||
token: process.env.NETX_API_TOKEN?.trim() ?? '',
|
||||
lang: (typeof process.env.NETX_LANG === 'string' && process.env.NETX_LANG.trim()
|
||||
? process.env.NETX_LANG.trim()
|
||||
: 'zh'),
|
||||
toolCallTimeoutMs: 120_000,
|
||||
groups: DEFAULT_CAPABILITY_GROUPS,
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Live snapshot, or a disabled provisional connection until the Host is ready. */
|
||||
export function requireNetxConnection(): NetxToolConnection {
|
||||
return getNetxConnection() ?? provisionalNetxConnection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to connection publishes (settings / credential remounts).
|
||||
* @param listener - called synchronously after each publish.
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ function resolveSessionStore(ctx: Context): SessionStoreFlushApi | undefined {
|
|||
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
|
||||
? (ctx as { get: (name: string) => unknown }).get('sessions')
|
||||
: undefined
|
||||
const sessions = (fromGet ?? (ctx as { sessions?: SessionStoreFlushApi }).sessions) as
|
||||
const sessions = (fromGet ?? (ctx as { sessions?: object }).sessions) as
|
||||
| SessionStoreFlushApi
|
||||
| undefined
|
||||
if (!sessions || typeof sessions.get !== 'function' || typeof sessions.flush !== 'function') {
|
||||
|
|
|
|||
63
src/netx/tool-mount-status.ts
Normal file
63
src/netx/tool-mount-status.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/** Process-wide mount inventory shared by independently bundled plugin entries. */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
export interface NetxToolMountStatus {
|
||||
id?: string
|
||||
version: string
|
||||
plane: string
|
||||
groups: string[]
|
||||
names: string[]
|
||||
count: number
|
||||
at: string
|
||||
error?: string
|
||||
source?: string
|
||||
}
|
||||
interface Store {
|
||||
mounts: Map<string, NetxToolMountStatus>
|
||||
contexts: WeakMap<object, number>
|
||||
sequence: number
|
||||
}
|
||||
const KEY = Symbol.for('dsh-netxops.mount-inventory')
|
||||
function store(): Store {
|
||||
const root = globalThis as typeof globalThis & { [KEY]?: Store }
|
||||
return root[KEY] ??= { mounts: new Map(), contexts: new WeakMap(), sequence: 0 }
|
||||
}
|
||||
/** Stable registration-owner id, shared across independently built exports. */
|
||||
export function getToolMountId(ctx: object, source: string): string {
|
||||
const state = store()
|
||||
let id = state.contexts.get(ctx)
|
||||
if (id === undefined) { id = ++state.sequence; state.contexts.set(ctx, id) }
|
||||
return `${source}:${id}`
|
||||
}
|
||||
/** Copies of every mount; remounting one preset never replaces another. */
|
||||
export function getToolMountStatuses(): NetxToolMountStatus[] {
|
||||
return [...store().mounts.values()].map(row => ({ ...row, groups: [...row.groups], names: [...row.names] }))
|
||||
}
|
||||
/** Legacy singular view always prefers the Host mount. */
|
||||
export function getToolMountStatus(): NetxToolMountStatus | undefined {
|
||||
const rows = getToolMountStatuses()
|
||||
return rows.find(row => row.plane === 'host') ?? rows.at(-1)
|
||||
}
|
||||
function persist(): void {
|
||||
try {
|
||||
const path = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'netxops-mount-status.json')
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)}\n`, 'utf8')
|
||||
} catch {
|
||||
// Diagnostics are best-effort on read-only installations.
|
||||
}
|
||||
}
|
||||
export function publishToolMountStatus(next: NetxToolMountStatus): void {
|
||||
store().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] })
|
||||
persist()
|
||||
}
|
||||
export function removeToolMountStatus(id: string): void {
|
||||
store().mounts.delete(id)
|
||||
persist()
|
||||
}
|
||||
export function resetToolMountStatus(): void {
|
||||
store().mounts.clear()
|
||||
persist()
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@
|
|||
* Register native DSH tools (`netx__*`) that call netx REST.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
|
|
@ -13,10 +16,23 @@ import {
|
|||
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
|
||||
import { toLosslessJson } from './json-safe.ts'
|
||||
import { getNetxConnection } from './runtime.ts'
|
||||
import { getToolMountId, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
|
||||
import * as H from './handlers.ts'
|
||||
import * as T from './topology-handlers.ts'
|
||||
|
||||
function packageVersion(): string {
|
||||
try {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
|
||||
return typeof pkg.version === 'string' ? pkg.version : '0'
|
||||
} catch {
|
||||
return '0'
|
||||
}
|
||||
}
|
||||
|
||||
export interface NetxToolConnection {
|
||||
toolsEnabled?: boolean
|
||||
groupKbPresets?: string
|
||||
apiUrl: string
|
||||
token: string
|
||||
lang: string
|
||||
|
|
@ -33,6 +49,11 @@ export interface NetxToolConnection {
|
|||
}
|
||||
|
||||
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
|
||||
const MUTATING_TOOLS = new Set([
|
||||
'netx__execManagedNe', 'netx__createTopologyFolder', 'netx__addTopologyViewNodes',
|
||||
'netx__removeTopologyViewNodes', 'netx__copyTopologyViewNodes', 'netx__updateTopologyViewPositions',
|
||||
'netx__projectTopologyNeighbors', 'netx__classifyTopologyFabricNodes', 'netx__layoutTopologyView',
|
||||
])
|
||||
|
||||
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
|
||||
const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) })
|
||||
|
|
@ -53,8 +74,15 @@ function renderJson(_args: unknown, value: unknown) {
|
|||
return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-compatible free-form object output.
|
||||
* Prefer this over author-only `{ type: 'json' }`: Desktop Host ToolRuntime
|
||||
* often comes from app.asar while `defineTool` resolves from the linked
|
||||
* checkout — mismatched copies can reject `json` at register time, which
|
||||
* leaves skills mounted and `netx__*` absent.
|
||||
*/
|
||||
const jsonOut = {
|
||||
schema: { type: 'json' as const },
|
||||
schema: { type: 'object' as const, additionalProperties: true as const },
|
||||
render: renderJson,
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +100,7 @@ function tool(
|
|||
parameters: parameters as never,
|
||||
output: jsonOut,
|
||||
timeoutMs,
|
||||
isConcurrencySafe: () => true,
|
||||
isConcurrencySafe: () => !MUTATING_TOOLS.has(name),
|
||||
async execute(args, exec) {
|
||||
const result = await handler(getClient(), args as NetxJson, exec.signal)
|
||||
// DSH rejects tool results that contain `undefined` property holes
|
||||
|
|
@ -89,13 +117,15 @@ function tool(
|
|||
export interface RegisterNetxToolsOptions {
|
||||
/**
|
||||
* `preset` — groups with `inPreset` (Netx Ops preset scope).
|
||||
* `public` — groups with `public` (host layer for other presets).
|
||||
* `public` — groups with `public` only.
|
||||
* `host` — same as `public` (Host-global registry for other presets).
|
||||
*/
|
||||
plane: 'preset' | 'public'
|
||||
plane: 'preset' | 'public' | 'host'
|
||||
/** Optional intersect with settings-enabled groups. */
|
||||
only?: readonly NetxCapabilityGroupId[]
|
||||
/** When set, register exactly these groups (ignore plane flags). */
|
||||
forceGroups?: readonly NetxCapabilityGroupId[]
|
||||
source?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -111,19 +141,43 @@ export function registerNetxTools(
|
|||
connection: NetxToolConnection,
|
||||
options: RegisterNetxToolsOptions,
|
||||
): () => void {
|
||||
const enabledGroups = options.forceGroups
|
||||
?? groupsForPlane(connection.groups, options.plane, options.only)
|
||||
const enabledGroups = connection.toolsEnabled === false ? [] : (options.forceGroups
|
||||
?? groupsForPlane(connection.groups, options.plane, options.only))
|
||||
const id = getToolMountId(ctx, options.source ?? options.plane)
|
||||
const allow = toolNamesForGroups(enabledGroups)
|
||||
if (allow.size === 0) return () => {}
|
||||
if (allow.size === 0) {
|
||||
publishToolMountStatus({
|
||||
id,
|
||||
version: packageVersion(),
|
||||
plane: options.plane,
|
||||
groups: [...enabledGroups],
|
||||
names: [],
|
||||
count: 0,
|
||||
at: new Date().toISOString(),
|
||||
source: options.source ?? options.plane,
|
||||
})
|
||||
return () => { removeToolMountStatus(id) }
|
||||
}
|
||||
|
||||
// Guard: DSH defineTool throws if timeoutMs is null/NaN/≤0 (settings projection can emit null).
|
||||
const t = typeof connection.toolCallTimeoutMs === 'number'
|
||||
&& Number.isFinite(connection.toolCallTimeoutMs)
|
||||
&& connection.toolCallTimeoutMs > 0
|
||||
? connection.toolCallTimeoutMs
|
||||
: 120_000
|
||||
const client = createNetxClient({
|
||||
apiUrl: connection.apiUrl,
|
||||
lang: connection.lang,
|
||||
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45_000),
|
||||
getToken: () => getNetxConnection()?.token ?? '',
|
||||
timeoutMs: Math.min(t, 45_000),
|
||||
getToken: () => {
|
||||
const live = getNetxConnection()
|
||||
// A resolved empty token means revoked; never resurrect an ambient token.
|
||||
return live ? live.token.trim() : connection.token.trim()
|
||||
},
|
||||
// Live apiUrl: settings may heal null→default after the first mount.
|
||||
getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl,
|
||||
})
|
||||
const getClient = () => client
|
||||
const t = connection.toolCallTimeoutMs
|
||||
|
||||
const catalog = [
|
||||
tool(
|
||||
|
|
@ -666,11 +720,39 @@ export function registerNetxTools(
|
|||
),
|
||||
]
|
||||
|
||||
const disposers = catalog
|
||||
.filter((entry) => allow.has(entry.name))
|
||||
.map((entry) => ctx.tools.register(entry))
|
||||
const selected = catalog.filter((entry) => allow.has(entry.name))
|
||||
const names = selected.map((entry) => entry.name)
|
||||
const disposers: Array<() => void> = []
|
||||
try {
|
||||
for (const entry of selected) disposers.push(ctx.tools.register(entry))
|
||||
publishToolMountStatus({
|
||||
id,
|
||||
version: packageVersion(),
|
||||
plane: options.plane,
|
||||
groups: [...enabledGroups],
|
||||
names,
|
||||
count: names.length,
|
||||
at: new Date().toISOString(),
|
||||
source: options.source ?? options.plane,
|
||||
})
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
publishToolMountStatus({
|
||||
id,
|
||||
version: packageVersion(),
|
||||
plane: options.plane,
|
||||
groups: [...enabledGroups],
|
||||
names: [],
|
||||
count: 0,
|
||||
at: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
source: options.source ?? options.plane,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
removeToolMountStatus(id)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue