Fix preset publication, volatile settings and client form binding

This commit is contained in:
oliver 2026-10-11 12:54:02 +08:00
parent fe0fe70d33
commit 0b9a3a82bc
66 changed files with 8546 additions and 3690 deletions

View file

@ -10,6 +10,7 @@ import {
DEFAULT_CAPABILITY_GROUPS,
groupsForPlane,
groupsForced,
kbEnabledForPlane,
SKILL_DIR_BY_GROUP,
toolNamesForGroups,
TOOLS_BY_GROUP,
@ -23,6 +24,75 @@ test('defaults: ops + bizMonitor in preset; topology and public off', () => {
})
assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops', 'bizMonitor'])
assert.deepEqual(groupsForPlane(undefined, 'public'), [])
assert.deepEqual(groupsForPlane(undefined, 'host'), [])
})
test('host plane equals public only (not inPreset union)', () => {
const groups = capabilityGroupsFromSettings({
groupOpsInPreset: false,
groupOpsPublic: true,
groupBizMonitorInPreset: false,
groupTopologyInPreset: true,
})
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
assert.deepEqual(groupsForPlane(groups, 'public'), ['ops'])
assert.deepEqual(groupsForPlane(groups, 'host'), ['ops'])
})
test('unchecking public revokes host even when inPreset stays on', () => {
const groups = capabilityGroupsFromSettings({
groupOpsInPreset: true,
groupOpsPublic: false,
groupBizMonitorInPreset: true,
groupBizMonitorPublic: false,
})
assert.deepEqual(groupsForPlane(groups, 'preset'), ['ops', 'bizMonitor'])
assert.deepEqual(groupsForPlane(groups, 'host'), [])
assert.deepEqual(groupsForPlane(groups, 'public'), [])
})
test('preset and host planes are symmetric; only defaults differ', () => {
const both = capabilityGroupsFromSettings({
groupOpsInPreset: true,
groupOpsPublic: true,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: false,
groupBizMonitorPublic: true,
})
assert.deepEqual(groupsForPlane(both, 'preset'), ['ops'])
assert.deepEqual(groupsForPlane(both, 'host'), ['ops', 'bizMonitor'])
const neither = capabilityGroupsFromSettings({
groupOpsInPreset: false,
groupOpsPublic: false,
groupBizMonitorInPreset: false,
groupBizMonitorPublic: false,
})
assert.deepEqual(groupsForPlane(neither, 'preset'), [])
assert.deepEqual(groupsForPlane(neither, 'host'), [])
})
test('null boolean flags do not resurrect defaults incorrectly', () => {
// Settings projection may emit null for volatile booleans.
const groups = capabilityGroupsFromSettings({
groupOpsInPreset: /** @type {any} */ (null),
groupOpsPublic: /** @type {any} */ (null),
groupBizMonitorInPreset: /** @type {any} */ (null),
groupBizMonitorPublic: /** @type {any} */ (null),
})
// null is not a boolean → fall back to defaults (ops+bizMonitor inPreset, public off)
assert.deepEqual(groupsForPlane(groups, 'preset'), ['ops', 'bizMonitor'])
assert.deepEqual(groupsForPlane(groups, 'host'), [])
})
test('kbEnabledForPlane mirrors inPreset/public symmetry', () => {
assert.equal(kbEnabledForPlane({ groupKbInPreset: true, groupKbPublic: false }, 'preset'), true)
assert.equal(kbEnabledForPlane({ groupKbInPreset: true, groupKbPublic: false }, 'host'), false)
assert.equal(kbEnabledForPlane({ groupKbInPreset: false, groupKbPublic: true }, 'preset'), false)
assert.equal(kbEnabledForPlane({ groupKbInPreset: false, groupKbPublic: true }, 'host'), true)
assert.equal(kbEnabledForPlane({}, 'preset'), true)
assert.equal(kbEnabledForPlane({}, 'host'), false)
})
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => {
@ -38,7 +108,7 @@ test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas;
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__getTopologyTree'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'))
assert.equal(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'), false)
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitors'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches'))
@ -55,12 +125,12 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults',
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
assert.deepEqual(groupsForPlane(groups, 'public'), ['topology'])
assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView'))
assert.ok(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'))
assert.equal(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'), false)
assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false)
assert.equal(toolNamesForGroups(['topology']).has('netx__getBizMonitorContext'), false)
})
test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into topology', () => {
test('legacy groupNms/common/managedNe map to ops; absent topology flags use legacy layout', () => {
const groups = capabilityGroupsFromSettings({
groupNmsInPreset: false,
groupCommonInPreset: false,
@ -81,7 +151,7 @@ test('legacy any-true turns ops inPreset on', () => {
assert.equal(groups.ops.inPreset, true)
})
test('groupsForced ignores settings for per-export mounts', () => {
test('groupsForced normalizes group allow-lists independently from publication', () => {
assert.deepEqual(groupsForced(['ops']), ['ops'])
assert.deepEqual(groupsForced(['ops', 'topology']), ['ops', 'topology'])
assert.deepEqual(groupsForced(['bizMonitor']), ['bizMonitor'])

View file

@ -0,0 +1,107 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { runInNewContext } from 'node:vm'
import { Context, Service } from '@deepseek-ai/cordis'
const require = createRequire(import.meta.url)
// The shell seeds React from its own dependency tree, not the plugin's local
// development junction. Use that same installed peer tree for this built smoke.
const profileHome = process.env.DSH_HOME || join(homedir(), '.dsh')
const renderer = ['', 'desktop', 'web'].map(profile => join(profileHome, 'profiles', profile, 'node_modules',
'@deepseek-ai', 'dsh-client-ui-renderer', 'package.json')).find(existsSync)
const sharedRequire = renderer ? createRequire(renderer) : require
function loadClient() {
let client
runInNewContext(readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8'), {
window: { __ModuleLoader__: { load({ factory }) { client = factory(spec =>
spec === 'react' || spec.startsWith('react/') ? sharedRequire(spec) : require(spec)) } } },
setTimeout, clearTimeout, setInterval, clearInterval, console, AbortController,
})
return client
}
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
function hostForm() {
let snapshot = { status: 'ready', writable: true, value: { toolsEnabled: true, groupOpsPresets: 'standard', apiUrl: 'http://127.0.0.1:8890' },
user: {}, base: {}, revision: 1 }
const listeners = new Set(), writes = []
return {
writes, getSnapshot: () => snapshot,
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
async set(field, value) {
writes.push([field, value])
snapshot = { ...snapshot, value: { ...snapshot.value, [field]: value }, user: { ...snapshot.user, [field]: value }, revision: snapshot.revision + 1 }
for (const fn of listeners) fn()
return true
},
async unset(field) {
const user = { ...snapshot.user }; delete user[field]
snapshot = { ...snapshot, user, revision: snapshot.revision + 1 }
for (const fn of listeners) fn()
return true
},
}
}
async function fixture(t, legacy = false) {
const root = new Context(), owners = [], slots = new Map(), actual = hostForm()
class Slots extends Service {
constructor(ctx) { super(ctx, 'slots') }
inject(name, register) { return register() }
register(spec) {
const key = `${spec.name}:${spec.id ?? spec.key}`
slots.set(key, spec)
return () => slots.delete(key)
}
}
class Locale extends Service {
constructor(ctx) { super(ctx, 'locale') }
register() { return () => {} }
bind() { return key => key }
}
class Forms extends Service {
constructor(ctx) { super(ctx, legacy ? 'settingsScope' : 'configForms') }
get(id) { return id === 'netxops' ? actual : { getSnapshot: () => ({ status: 'unavailable', writable: false }) } }
bind({ namespace }) { return this.get(namespace) }
whileServed(ids, register) { return register(new Set(['netxops'])) }
}
t.after(async () => { for (const owner of owners.reverse()) await owner.dispose() })
for (const Provider of [Slots, Locale, Forms]) owners.push(await root.plugin(Provider))
const client = loadClient()
const owner = await root.plugin({ ...client, name: 'netxops-client-binding' })
owners.push(owner)
return { root, owner, slots, actual }
}
test('built settings card uses optional lookup in real Cordis and saves through the Host form', async t => {
const { owner, slots, actual } = await fixture(t)
assert.equal(owner.state, 2 /* ACTIVE */)
assert.throws(() => owner.ctx.configForms, /without inject/)
const section = slots.get('settings.section:dsh-netxops')
assert.ok(section)
const face = section.inject()
assert.equal(face.hooks.netxopsCard.getSnapshot().available, false)
await pause(80)
assert.equal(face.hooks.netxopsCard.getSnapshot().available, true)
assert.equal(face.hooks.netxopsCard.getSnapshot().writable, true)
assert.equal(face.hooks.netxopsCard.getSnapshot().groupOpsPresets.text, 'standard')
face.edit('toolsEnabled', 'false')
await pause(20)
assert.deepEqual(actual.writes, [['toolsEnabled', false]])
assert.equal(face.hooks.netxopsCard.getSnapshot().toolsEnabled.text, 'false')
assert.ok(slots.has('settings.plugin.item:netxops'))
})
test('built settings card still follows the legacy Host settings scope', async t => {
const { slots, actual } = await fixture(t, true)
const face = slots.get('settings.section:dsh-netxops').inject()
await pause(80)
assert.equal(face.hooks.netxopsCard.getSnapshot().available, true)
face.edit('groupOpsPresets', '')
await pause(20)
assert.deepEqual(actual.writes, [['groupOpsPresets', '']])
})

View file

@ -0,0 +1,27 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createWaitingSettingsScope, findReadySettingsForm } from '../src/client/settings-form.ts'
import { CardForm, booleanFieldPersistFalse } from '../src/client/card-form.ts'
test('unconnected settings cannot report a successful local-only publication save', async () => {
const scope = createWaitingSettingsScope({ toolsEnabled: true })
const form = new CardForm(scope, [booleanFieldPersistFalse('toolsEnabled')])
assert.equal(form.shell().available, false)
assert.equal(form.shell().writable, false)
form.actions().edit('toolsEnabled', 'false')
await form.save(['toolsEnabled'])
assert.equal(scope.getSnapshot().value.toolsEnabled, true)
assert.equal(form.shell().failed, true)
assert.equal(form.shell().dirty, true)
await assert.rejects(scope.set('toolsEnabled', false), /settings_transport_unavailable/)
})
test('form selection ignores unknown namespace placeholders and waits for the real namespace', () => {
let ready = false
const actual = { getSnapshot: () => ({ status: ready ? 'ready' : 'loading', writable: ready }) }
const placeholder = { getSnapshot: () => ({ status: 'unavailable', writable: false }) }
const forms = { get(id) { return id === 'netxops' ? actual : placeholder } }
assert.equal(findReadySettingsForm(forms, ['client-entry-id', 'netxops', 'dsh-netxops']), undefined)
ready = true
assert.deepEqual(findReadySettingsForm(forms, ['client-entry-id', 'netxops', 'dsh-netxops']), { ns: 'netxops', scope: actual })
})

View file

@ -0,0 +1,103 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { composeNetxopsPresetArtifacts, hostResolveBases, installDirectoryPreset, resolveActiveProfile, resolveStandardPresetDir, syncDeclarativeIntoProfilePatch } from '../src/agent-preset-install.ts'
const log = { info() {}, warn() {}, error() {} }
const patch = '- insert:\n - id: preset-netxops\n name: preset-plugin\n'
function home(t) {
const previous = process.env.DSH_HOME
const root = mkdtempSync(join(tmpdir(), 'netxops-install-'))
process.env.DSH_HOME = root
t.after(() => { if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous })
return root
}
const artifacts = { standardSource: 'fixture', standardKind: 'desktop-patch', standardDir: null,
agentCordis: '- id: netxops-tools\n name: dsh-netxops/tools\n', name: 'Netx Ops', description: '', order: '50', declarativePatch: patch }
test('updating Desktop patch leaves Web patch intact', t => {
const root = home(t)
for (const profile of ['desktop', 'web']) {
mkdirSync(join(root, 'profiles', profile), { recursive: true })
writeFileSync(join(root, 'profiles', profile, 'cordis.patch.yml'), '# user configuration\n[]\n')
}
const web = join(root, 'profiles', 'web', 'cordis.patch.yml'), original = readFileSync(web, 'utf8')
syncDeclarativeIntoProfilePatch('desktop', patch, log)
assert.equal(readFileSync(web, 'utf8'), original)
assert.match(readFileSync(join(root, 'profiles', 'desktop', 'cordis.patch.yml'), 'utf8'), /preset-netxops/)
})
test('unterminated managed block does not truncate user configuration', t => {
const root = home(t), folder = join(root, 'profiles', 'web')
mkdirSync(folder, { recursive: true })
const path = join(folder, 'cordis.patch.yml')
const original = '# BEGIN dsh-netxops-preset (managed)\n- id: preserve-me\n'
writeFileSync(path, original)
assert.throws(() => syncDeclarativeIntoProfilePatch('web', patch, log), /unterminated/)
assert.equal(readFileSync(path, 'utf8'), original)
})
test('unmanaged preset declaration remains authoritative', t => {
const root = home(t), folder = join(root, 'profiles', 'web')
mkdirSync(folder, { recursive: true })
const path = join(folder, 'cordis.patch.yml'), original = '- insert:\n - id: preset-netxops\n name: custom-preset\n'
writeFileSync(path, original)
syncDeclarativeIntoProfilePatch('web', patch, log)
assert.equal(readFileSync(path, 'utf8'), original)
})
test('managed directory update preserves edits and extra files', t => {
const root = home(t), folder = join(root, '.agent-presets', 'netxops')
installDirectoryPreset(artifacts, log)
writeFileSync(join(folder, 'notes.md'), 'user notes')
writeFileSync(join(folder, 'agent.cordis.yml'), '- id: user-edited\n')
installDirectoryPreset({ ...artifacts, agentCordis: '- id: updated\n' }, log)
assert.equal(readFileSync(join(folder, 'notes.md'), 'utf8'), 'user notes')
assert.equal(readFileSync(join(folder, 'agent.cordis.yml'), 'utf8'), '- id: user-edited\n')
assert.equal(existsSync(join(folder, 'skills')), false, 'runtime-controlled skills are not copied into auto-scanned roots')
})
test('profile identity follows the running profile path', () => {
const previous = process.env.DSH_PROFILE
delete process.env.DSH_PROFILE
try {
assert.equal(resolveActiveProfile('file:///C:/dsh/profiles/desktop/cordis.yml'), 'desktop')
assert.equal(resolveActiveProfile('C:\\dsh\\profiles\\web\\cordis.yml'), 'web')
} finally { if (previous !== undefined) process.env.DSH_PROFILE = previous }
})
test('fresh install overlays the seeded standard composition', t => {
const root = home(t), standard = join(root, 'standard')
mkdirSync(standard)
writeFileSync(join(standard, 'agent.cordis.yml'), '- id: standard-only\n')
installDirectoryPreset({ ...artifacts, standardDir: standard }, log)
assert.equal(readFileSync(join(root, '.agent-presets', 'netxops', 'agent.cordis.yml'), 'utf8'), artifacts.agentCordis)
})
test('each Host resolves its own standard before shared dependencies', t => {
const root = home(t)
for (const profile of ['desktop', 'web']) {
const folder = join(root, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard')
mkdirSync(folder, { recursive: true })
writeFileSync(join(folder, 'agent.cordis.yml'), `- id: ${profile}\n`)
writeFileSync(join(root, 'profiles', profile, 'package.json'), '{}')
assert.equal(resolveStandardPresetDir(profile), folder)
const other = profile === 'desktop' ? 'web' : 'desktop'
assert.equal(hostResolveBases(profile).some(path => path.startsWith(join(root, 'profiles', other))), false)
}
})
test('runtime composition preserves the Host standard dialect and workflow engine', t => {
home(t)
const original = "- id: tool-fs\n name: '@host-only/fs'\n disabled: !!js process.platform === 'win32'\n- id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n"
const composed = composeNetxopsPresetArtifacts('web', original)
assert.equal(composed.standardKind, 'host-registry')
assert.match(composed.agentCordis, /@host-only\/fs/)
assert.match(composed.agentCordis, /disabled: !!js process.platform === 'win32'/)
assert.match(composed.agentCordis, /workflow-worker-thread/)
assert.doesNotMatch(composed.agentCordis, /workflow-ptc/)
assert.match(composed.agentCordis, /name: dsh-netxops\/tools/)
assert.match(composed.agentCordis, /- id: persona/)
})

View file

@ -0,0 +1,71 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { CardForm, booleanFieldPersistFalse, textField, presetIdsField } from '../src/client/card-form.ts'
function formScope(options = {}) {
const value = { groupOpsPublic: false, groupOpsPresets: 'standard', apiUrl: 'http://saved' }
const listeners = new Set(), writes = []
const scope = { getSnapshot: () => ({ status: 'ready', writable: true, value, base: { ...value }, user: { ...value } }),
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
async set(field, next) {
writes.push([field, next]); await options.beforeWrite?.()
if (options.fail) throw new Error('save failed')
value[field] = next; for (const fn of listeners) fn()
}, async unset(field) { delete value[field] } }
return { scope, value, writes }
}
const specs = [booleanFieldPersistFalse('groupOpsPublic'), textField('apiUrl'), presetIdsField('groupOpsPresets')]
test('publication autosave does not commit unrelated drafts', async () => {
const { scope, value, writes } = formScope()
const form = new CardForm(scope, specs), actions = form.actions()
actions.edit('apiUrl', 'http://draft')
actions.edit('groupOpsPublic', 'true')
await form.save(['groupOpsPublic'])
assert.equal(value.apiUrl, 'http://saved')
assert.equal(form.field('apiUrl').text, 'http://draft')
assert.deepEqual(writes, [['groupOpsPublic', true]])
assert.equal(form.shell().dirty, true)
})
test('rapid on then off saves the latest edit without losing it', async () => {
let resolveWrite, started
const ready = new Promise(resolve => { started = resolve })
const gate = new Promise(resolve => { resolveWrite = resolve })
let first = true
const { scope, value, writes } = formScope({ beforeWrite: async () => { if (first) { first = false; started(); await gate } } })
const form = new CardForm(scope, specs), actions = form.actions()
actions.edit('groupOpsPublic', 'true')
const one = form.save(['groupOpsPublic'])
await ready
actions.edit('groupOpsPublic', 'false')
const two = form.save(['groupOpsPublic'])
resolveWrite()
await Promise.all([one, two])
assert.deepEqual(writes, [['groupOpsPublic', true], ['groupOpsPublic', false]])
assert.equal(value.groupOpsPublic, false)
assert.equal(form.shell().dirty, false)
})
test('write failure releases saving state and retains draft for retry', async () => {
const options = { fail: true }, { scope, value } = formScope(options)
const form = new CardForm(scope, specs)
form.actions().edit('groupOpsPublic', 'true')
await form.save(['groupOpsPublic'])
assert.equal(form.shell().saving, false)
assert.equal(form.shell().failed, true)
assert.equal(form.shell().dirty, true)
options.fail = false
await form.save(['groupOpsPublic'])
assert.equal(value.groupOpsPublic, true)
assert.equal(form.shell().failed, false)
})
test('empty target selection persists an explicit override', async () => {
const { scope, value, writes } = formScope()
const form = new CardForm(scope, specs)
form.actions().edit('groupOpsPresets', '')
await form.save(['groupOpsPresets'])
assert.equal(value.groupOpsPresets, '')
assert.deepEqual(writes, [['groupOpsPresets', '']])
})

View file

@ -0,0 +1,157 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { capabilityGroupsFromSettings, groupsForPreset, kbEnabledForPlane } from '../src/netx/capability-groups.ts'
import { applyGroupToolsPlugin } from '../src/netx/group-tools-plugin.ts'
import { registerNetxTools } from '../src/netx/tools.ts'
import { registerKbLocalTools } from '../src/netx/kb-local-tools.ts'
import { registerGroupSkills } from '../src/netx/group-skills.ts'
import { publishNetxConnection } from '../src/netx/runtime.ts'
import { getToolMountStatuses, publishToolMountStatus, resetToolMountStatus } from '../src/netx/tool-mount-status.ts'
import { asToolsMountView } from '../src/client/tools-status-view.ts'
const log = { info() {}, warn() {}, error() {} }
const pause = () => new Promise(resolve => setTimeout(resolve, 40))
function connection(fields = {}) {
return { apiUrl: 'http://127.0.0.1:8890', token: 'test-token', lang: 'zh', toolCallTimeoutMs: 1000,
toolsEnabled: fields.toolsEnabled ?? true, groups: capabilityGroupsFromSettings(fields),
groupKbInPreset: fields.groupKbInPreset ?? true, groupKbPublic: fields.groupKbPublic ?? false,
groupKbPresets: fields.groupKbPresets ?? '' }
}
function fakeContext(failAt = -1) {
const tools = new Map(), skills = new Map(), cleanups = []
let registrations = 0
const register = map => entry => {
if (map === tools && ++registrations === failAt) throw new Error('registration refused')
if (map.has(entry.name)) throw new Error(`duplicate: ${entry.name}`)
map.set(entry.name, entry)
return () => { if (map.get(entry.name) === entry) map.delete(entry.name) }
}
const ctx = { logger: log, tools: { register: register(tools) }, skills: { register: register(skills) },
get(name) { return this[name] }, inject(deps, fn) { fn(this) },
effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup); return cleanup } }
return { ctx, tools, skills, dispose() { for (const fn of cleanups.reverse()) fn() } }
}
function diagnosticHome(t) {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-publication-'))
t.after(() => { if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous })
}
test('selected presets are isolated and global publication is inherited once', () => {
const selected = connection({ groupOpsInPreset: false, groupOpsPresets: 'standard,review standard', groupBizMonitorInPreset: false })
assert.deepEqual(groupsForPreset(selected, 'standard'), ['ops'])
assert.deepEqual(groupsForPreset(selected, 'review'), ['ops'])
assert.deepEqual(groupsForPreset(selected, 'netxops'), [])
assert.deepEqual(groupsForPreset(selected, 'other'), [])
selected.groups.ops.public = true
assert.deepEqual(groupsForPreset(selected, 'standard'), [], 'public tools come from Host, with no duplicate local registration')
assert.equal(kbEnabledForPlane({ groupKbInPreset: true, groupKbPublic: true }, 'preset'), false)
})
test('master switch blocks selected, public, preset and explicit group mounts', async t => {
diagnosticHome(t)
const disabled = connection({ toolsEnabled: false, groupOpsPublic: true, groupOpsPresets: 'standard', groupKbPublic: true })
assert.deepEqual(groupsForPreset(disabled, 'standard'), [])
assert.equal(kbEnabledForPlane(disabled, 'host'), false)
const { ctx, tools } = fakeContext()
const dispose = registerNetxTools(ctx, disabled, { plane: 'host', forceGroups: ['ops'] })
assert.equal(tools.size, 0)
dispose()
})
test('failed tool registration rolls back every earlier tool', t => {
diagnosticHome(t)
const { ctx, tools } = fakeContext(3)
assert.throws(() => registerNetxTools(ctx, connection(), { plane: 'preset' }), /registration refused/)
assert.equal(tools.size, 0)
assert.ok(getToolMountStatuses().some(row => row.error === 'registration refused'))
resetToolMountStatus()
})
test('disabled group exports cannot force publication back on', async t => {
diagnosticHome(t)
publishNetxConnection(connection({ groupOpsInPreset: false, groupBizMonitorInPreset: false, groupKbInPreset: false }))
const mount = fakeContext()
applyGroupToolsPlugin(mount.ctx, { name: 'explicit-ops', mode: 'forced', only: ['ops'] })
await pause()
assert.equal(mount.tools.size, 0)
assert.equal(mount.skills.size, 0)
mount.dispose()
})
test('turning off a mounted group revokes tools, KB context and pending skill loads', async t => {
diagnosticHome(t)
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
const mount = fakeContext()
applyGroupToolsPlugin(mount.ctx, { name: 'lifecycle', mode: 'settings' })
assert.equal(mount.tools.size, 15)
assert.equal(mount.skills.has('kb-context'), true)
publishNetxConnection(connection({ toolsEnabled: false }))
await pause()
assert.equal(mount.tools.size, 0)
assert.equal(mount.skills.size, 0)
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
await pause()
assert.equal(mount.tools.size, 15)
assert.equal(mount.skills.has('netx-ops'), true)
mount.dispose()
await pause()
assert.equal(mount.tools.size, 0)
assert.equal(mount.skills.size, 0)
})
test('aborted skill load contributes nothing after asynchronous reads', async () => {
const mount = fakeContext()
const abort = new AbortController()
const pending = registerGroupSkills(mount.ctx, ['ops', 'topology'], 'aborted', abort.signal)
abort.abort()
const dispose = await pending
assert.equal(mount.skills.size, 0)
dispose()
})
test('mount inventory survives cross-bundle publishes and reports all scopes', async t => {
diagnosticHome(t)
resetToolMountStatus()
publishToolMountStatus({ id: 'host-test', version: 'test', plane: 'host', source: 'host', groups: [], names: [], count: 0, at: '' })
const entry = await import('../lib/agent-tools.js')
publishNetxConnection(connection({ groupBizMonitorInPreset: false, groupKbInPreset: false }))
const mount = fakeContext()
entry.apply(mount.ctx)
const statuses = getToolMountStatuses()
assert.ok(statuses.some(row => row.id === 'host-test'))
assert.ok(statuses.some(row => row.count === 15))
const view = asToolsMountView({ mounts: statuses, connection: { toolsEnabled: true } })
assert.equal(view.count, 15)
mount.dispose()
assert.equal(getToolMountStatuses().some(row => row.count > 0), false)
resetToolMountStatus()
})
test('write tools serialize while read tools allow concurrency', t => {
diagnosticHome(t)
const mount = fakeContext()
const dispose = registerNetxTools(mount.ctx, connection(), { plane: 'preset', forceGroups: ['ops', 'topology'] })
assert.equal(mount.tools.get('netx__queryNmsAlarms').isConcurrencySafe({}), true)
assert.equal(mount.tools.get('netx__execManagedNe').isConcurrencySafe({}), false)
assert.equal(mount.tools.get('netx__updateTopologyViewPositions').isConcurrencySafe({}), false)
dispose()
})
test('knowledge-base tools contribute to the mount inventory and disappear on disposal', t => {
diagnosticHome(t)
resetToolMountStatus()
const mount = fakeContext()
const snapshot = { status: 'configured', realRoot: process.env.DSH_HOME, paths: { local: '_local' } }
const dispose = registerKbLocalTools(mount.ctx, snapshot, { plane: 'preset', source: 'kb-test' })
assert.equal(mount.tools.size, 7)
const statuses = getToolMountStatuses()
assert.ok(statuses.some(row => row.source === 'kb-test' && row.count === 7 && row.groups.includes('kb')))
assert.equal(asToolsMountView({ mounts: statuses }).count, 7)
dispose()
assert.equal(mount.tools.size, 0)
assert.equal(getToolMountStatuses().some(row => row.source === 'kb-test'), false)
})

View file

@ -0,0 +1,103 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { Context, Service } from '@deepseek-ai/cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { installPresetPublication } from '../src/netx/preset-publication.ts'
import { publishNetxConnection } from '../src/netx/runtime.ts'
import { capabilityGroupsFromSettings } from '../src/netx/capability-groups.ts'
const layers = new Map()
const skillLayers = new Map()
let live = []
class Tools extends Service {
constructor(ctx) { super(ctx, 'tools') }
register(entry) {
const key = scopeOf(this.ctx)
let layer = layers.get(key)
if (!layer) layers.set(key, layer = new Map())
if (layer.has(entry.name)) throw new Error(`duplicate tool ${entry.name}`)
layer.set(entry.name, entry)
return () => layer.delete(entry.name)
}
}
class Skills extends Service {
constructor(ctx) { super(ctx, 'skills') }
register(entry) {
const key = scopeOf(this.ctx)
let layer = skillLayers.get(key)
if (!layer) skillLayers.set(key, layer = new Map())
if (layer.has(entry.name)) throw new Error(`duplicate skill ${entry.name}`)
layer.set(entry.name, entry)
return () => layer.delete(entry.name)
}
}
class Agents extends Service {
constructor(ctx) { super(ctx, 'agents') }
list() { return live }
}
class Presets extends Service {
constructor(ctx) { super(ctx, 'agentPresets') }
composedPreset(ctx) { return ctx.presetId }
}
const wait = () => new Promise(resolve => setTimeout(resolve, 50))
test('selected publication uses each exact session scope and cleans up on off, switch, and unload', async t => {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-selected-'))
const ctx = new Context()
const owners = []
t.after(async () => {
for (const owner of owners.reverse()) await owner.dispose()
if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous
layers.clear(); skillLayers.clear(); live = []
})
owners.push(await ctx.plugin(Tools))
owners.push(await ctx.plugin(Skills))
owners.push(await ctx.plugin(Agents))
owners.push(await ctx.plugin(Presets))
const scopes = []
owners.push(await ctx.plugin(Object.assign(inner => {
for (const id of ['standard', 'review']) {
const key = {}
const scope = createScope(inner, key)
const agent = { id, ctx: scope.ctx.extend({ presetId: id }) }
scopes.push({ key, scope, agent })
}
}, { inject: ['tools', 'skills'] })))
live = scopes.map(row => row.agent)
const publish = (overrides = {}) => publishNetxConnection({
apiUrl: 'http://127.0.0.1:8890', token: '', lang: 'zh', toolCallTimeoutMs: 1000,
toolsEnabled: true,
groups: capabilityGroupsFromSettings({ groupOpsPresets: 'standard', groupBizMonitorInPreset: false }),
groupKbInPreset: false, groupKbPresets: '', ...overrides,
})
publish()
const owner = await ctx.plugin(installPresetPublication)
owners.push(owner)
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 15)
assert.equal(skillLayers.get(scopes[0].key)?.has('netx-ops'), true)
assert.equal(layers.get(scopes[1].key)?.size ?? 0, 0)
publish({ toolsEnabled: false })
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 0)
assert.equal(skillLayers.get(scopes[0].key)?.size, 0)
publish()
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 15)
scopes[0].agent.ctx = scopes[0].agent.ctx.extend({ presetId: 'review' })
await ctx.serial('agent-preset/selected', 'standard', 'review')
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 0)
scopes[1].agent.ctx = scopes[1].agent.ctx.extend({ presetId: 'standard' })
await ctx.serial('agent/created', { agent: scopes[1].agent })
await wait()
assert.equal(layers.get(scopes[1].key)?.size, 15)
await owner.dispose()
await wait()
assert.equal(layers.get(scopes[1].key)?.size, 0)
assert.equal(skillLayers.get(scopes[1].key)?.size, 0)
})