diff --git a/lib/client.js b/lib/client.js index 346f59a..da307a3 100644 --- a/lib/client.js +++ b/lib/client.js @@ -2629,9 +2629,7 @@ var zh = { var LOCALE_NS = "settings.netxops"; var inject = [ "slots", - "locale", - "remote", - "settingsScope" + "locale" ]; function apply(ctx) { ctx.effect(() => { @@ -2646,53 +2644,79 @@ function apply(ctx) { }; } }, "netxops: locales"); - const card = new NetxopsCardController(ctx.settingsScope.bind({ namespace: NETXOPS_NS }), ctx); const t = ctx.locale.bind(LOCALE_NS); - ctx.inject(["remote.credentials"], (credCtx) => { - card.setCredentialsAvailable(true); - credCtx.effect(() => { - const off = credCtx.remote.$on("credentials/reference-updated", (ref) => { - card.refreshCredential(String(ref)); - }); - return () => { - off(); - card.setCredentialsAvailable(false); - }; - }, "netxops: credential invalidations"); - }); - ctx.inject(["connection"], (connCtx) => { - const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc); - if (typeof call !== "function") { - connCtx.logger.warn("netxops: connection.rpc.call unavailable — alarm status UI disabled"); + let mounted = false; + const mountCard = (scope) => { + if (mounted) + return; + mounted = true; + const card = new NetxopsCardController(scope, ctx); + ctx.inject(["remote.credentials"], (credCtx) => { + card.setCredentialsAvailable(true); + credCtx.effect(() => { + const off = credCtx.remote.$on("credentials/reference-updated", (ref) => { + card.refreshCredential(String(ref)); + }); + return () => { + off(); + card.setCredentialsAvailable(false); + }; + }, "netxops: credential invalidations"); + }); + ctx.inject(["connection"], (connCtx) => { + const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc); + if (typeof call !== "function") { + connCtx.logger.warn("netxops: connection.rpc.call unavailable — alarm status UI disabled"); + return; + } + card.setAlarmPushRpc(call); + connCtx.effect(() => () => { + card.setAlarmPushRpc(undefined); + }, "netxops: clear alarm-push rpc"); + }); + const bindDirectoryPicker = (picker) => { + if (!picker || typeof picker.pick !== "function") + return; + card.setDirectoryPicker(picker); + }; + bindDirectoryPicker(ctx.remote?.directoryPicker); + ctx.inject(["remote.directoryPicker"], (dpCtx) => { + const viaGet = typeof dpCtx.get === "function" ? dpCtx.get("remote.directoryPicker") : undefined; + const viaNested = dpCtx.remote?.directoryPicker; + bindDirectoryPicker(viaGet ?? viaNested); + dpCtx.effect(() => () => { + card.setDirectoryPicker(undefined); + }, "netxops: clear directory picker"); + }); + ctx.slots.inject("settings.section", () => ctx.slots.register({ + name: "settings.section", + id: NETXOPS_NS, + order: 24, + label: () => t("title"), + locale: LOCALE_NS, + inject: () => card.inject() + }, NetxopsCard)); + }; + ctx.inject(["configForms"], (formsCtx) => { + const forms = formsCtx.configForms; + if (!forms || typeof forms.get !== "function") { + formsCtx.logger?.warn?.("netxops: configForms present but .get missing"); return; } - card.setAlarmPushRpc(call); - connCtx.effect(() => () => { - card.setAlarmPushRpc(undefined); - }, "netxops: clear alarm-push rpc"); + formsCtx.logger?.info?.("netxops: settings card via configForms"); + mountCard(forms.get(NETXOPS_NS)); }); - const bindDirectoryPicker = (picker) => { - if (!picker || typeof picker.pick !== "function") + ctx.inject(["settingsScope"], (scopeCtx) => { + const binder = scopeCtx.settingsScope; + if (!binder || typeof binder.bind !== "function") { + scopeCtx.logger?.warn?.("netxops: settingsScope present but .bind missing"); return; - card.setDirectoryPicker(picker); - }; - bindDirectoryPicker(ctx.remote?.directoryPicker); - ctx.inject(["remote.directoryPicker"], (dpCtx) => { - const viaGet = typeof dpCtx.get === "function" ? dpCtx.get("remote.directoryPicker") : undefined; - const viaNested = dpCtx.remote?.directoryPicker; - bindDirectoryPicker(viaGet ?? viaNested); - dpCtx.effect(() => () => { - card.setDirectoryPicker(undefined); - }, "netxops: clear directory picker"); + } + if (mounted) + return; + scopeCtx.logger?.info?.("netxops: settings card via settingsScope"); + mountCard(binder.bind({ namespace: NETXOPS_NS })); }); - ctx.slots.inject("settings.section", () => ctx.slots.register({ - name: "settings.section", - id: NETXOPS_NS, - order: 24, - label: () => t("title"), - locale: LOCALE_NS, - inject: () => card.inject() - }, NetxopsCard)); } return module.exports; diff --git a/lib/index.js b/lib/index.js index 9aa0732..ba2bfd3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5236,30 +5236,34 @@ var NETXOPS_RPC_CHANNEL = "/netxops"; var NETXOPS_SETTINGS_NAMESPACE = "netxops"; var NETXOPS_PRESET_ID = "netxops"; var DEFAULT_TOKEN_REF = "NETX_API_TOKEN"; +function vol(schema) { + const candidate = schema; + return typeof candidate.volatile === "function" ? candidate.volatile() : schema; +} var Config = z.object({ - apiUrl: z.string().default("http://127.0.0.1:8890"), - lang: z.string().default("zh"), - thinkingLanguage: z.string().default("auto"), - replyLanguage: z.string().default("follow-user"), - tokenCredentialRef: z.string().role("credential-ref").default(DEFAULT_TOKEN_REF), - toolCallTimeoutMs: z.number().step(1).min(1000).default(120000), - installAgentPreset: z.boolean().default(true), - alarmPushEnabled: z.boolean().default(false), - alarmDeliverDsh: z.boolean().default(true), - alarmDeliverIm: z.boolean().default(false), - imTargets: z.string().default(""), - imBotId: z.string().default(""), - imTargetId: z.string().default(""), - nmsProvider: z.string().default("zte-ume"), - groupOpsInPreset: z.boolean().default(true), - groupOpsPublic: z.boolean().default(false), - groupTopologyInPreset: z.boolean().default(false), - groupTopologyPublic: z.boolean().default(false), - groupBizMonitorInPreset: z.boolean().default(true), - groupBizMonitorPublic: z.boolean().default(false), - kbRoot: z.string().default(""), - groupKbInPreset: z.boolean().default(true), - groupKbPublic: z.boolean().default(false) + apiUrl: vol(z.string().default("http://127.0.0.1:8890")), + lang: vol(z.string().default("zh")), + thinkingLanguage: vol(z.string().default("auto")), + replyLanguage: vol(z.string().default("follow-user")), + tokenCredentialRef: vol(z.string().role("credential-ref").default(DEFAULT_TOKEN_REF)), + toolCallTimeoutMs: vol(z.number().step(1).min(1000).default(120000)), + installAgentPreset: vol(z.boolean().default(true)), + alarmPushEnabled: vol(z.boolean().default(false)), + alarmDeliverDsh: vol(z.boolean().default(true)), + alarmDeliverIm: vol(z.boolean().default(false)), + imTargets: vol(z.string().default("")), + imBotId: vol(z.string().default("")), + imTargetId: vol(z.string().default("")), + nmsProvider: vol(z.string().default("zte-ume")), + groupOpsInPreset: 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)), + groupBizMonitorPublic: vol(z.boolean().default(false)), + kbRoot: vol(z.string().default("")), + groupKbInPreset: vol(z.boolean().default(true)), + groupKbPublic: vol(z.boolean().default(false)) }); function packageRoot() { return join7(dirname4(fileURLToPath2(import.meta.url)), ".."); @@ -5296,6 +5300,19 @@ async function resolveToken(ctx, refName) { const hit = await ctx.credentials.resolve(credentialRef(refName)); return hit?.value ?? ""; } +function describeRows(describe) { + if (typeof describe !== "function") + return []; + try { + const raw = describe(); + if (Array.isArray(raw)) + return raw; + if (raw && typeof raw === "object" && Array.isArray(raw.namespaces)) { + return raw.namespaces; + } + } catch {} + return []; +} function installNetxopsSettings(ctx, entry, hooks) { const legacy = DshSettings.installSettingsSection; if (typeof legacy === "function") { @@ -5303,7 +5320,33 @@ function installNetxopsSettings(ctx, entry, hooks) { return; } ctx.inject(["settings"], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks); + const settings = settingsCtx.settings; + if (typeof settings.installSection === "function") { + settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks); + return; + } + const readLive = () => { + 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 }; + } + return entry; + }; + hooks.setSource(readLive); + hooks.onChange(); + let lastFingerprint = JSON.stringify(readLive()); + const timer = setInterval(() => { + const next = readLive(); + const fingerprint = JSON.stringify(next); + if (fingerprint === lastFingerprint) + return; + lastFingerprint = fingerprint; + hooks.onChange(); + }, 2000); + settingsCtx.effect(() => () => { + clearInterval(timer); + }, "netxops: settings describe poll"); + settingsCtx.logger.info("netxops: following settings via describe() (no installSection — DSH ≥0.1.7 path)"); }); } function apply(ctx, config = Config({})) { diff --git a/package.json b/package.json index dc33283..4c1e6c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.41", + "version": "0.1.42", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", @@ -63,10 +63,10 @@ }, "client": { "platform": "web", + "immediately": true, "inject": [ - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-api-remotes" + "@deepseek-ai/dsh-client-ui-slots", + "@deepseek-ai/dsh-client-locale" ] } }, diff --git a/src/client/card-form.ts b/src/client/card-form.ts index 8da547a..fdbf028 100644 --- a/src/client/card-form.ts +++ b/src/client/card-form.ts @@ -3,7 +3,7 @@ * Self-contained (cannot value-import ui-settings-plugins — client bundle purity). */ -import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts' import { createSnapshotStore, type SnapshotStore } from './snapshot-store.ts' export type FieldWrite = { kind: 'set'; value: unknown } | { kind: 'clear' } @@ -110,7 +110,7 @@ export class CardForm { private failed = false constructor( - private readonly scope: SettingsScope, + private readonly scope: SettingsFormScope, specs: CardFieldSpec[], secrets: CardSecretSpec[] = [], ) { @@ -235,7 +235,7 @@ export class CardForm { return spec } - private snapshotOf(): SettingsScopeSnapshot { + private snapshotOf(): SettingsFormSnapshot { return this.scope.getSnapshot() } diff --git a/src/client/controller.ts b/src/client/controller.ts index da93e4b..0540222 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -4,7 +4,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts' import type { SnapshotStore } from './snapshot-store.ts' import { CardForm, textField, booleanField, booleanFieldPersistFalse, @@ -175,7 +175,7 @@ export class NetxopsCardController { private exportInFlight: Promise | undefined constructor( - private readonly scope: SettingsScope, + private readonly scope: SettingsFormScope, private readonly ctx: ClientContext, ) { this.form = new CardForm( @@ -708,7 +708,7 @@ export class NetxopsCardController { } } -function refOf(snapshot: SettingsScopeSnapshot): string { +function refOf(snapshot: SettingsFormSnapshot): string { const declared = snapshot.value?.tokenCredentialRef return declared !== undefined && declared.length > 0 ? declared : DEFAULT_TOKEN_REF } diff --git a/src/client/index.ts b/src/client/index.ts index d38eb29..0a63b73 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,19 +1,23 @@ /** * Browser half — Settings → Netx Ops section (uds-auth-style page). * - * Do not hard-inject `remote.credentials`: shipped `@deepseek-ai/dsh` 0.1.1-rc.2 - * remotes assembly does not mount that namespace (Models/Plugins cards only - * inject `remote`). Soft-inject when a newer Host provides it. + * Hard-inject only services present on both DSH ≤0.1.5 and ≥0.1.7. + * Do NOT hard-inject `settingsScope`: DSH ≥0.1.7 / 0.2.0 removed it + * (`configForms` replaced it) and a hard wait kills web boot. + * + * Soft-inject `configForms` first (0.2.0 desktop), then `settingsScope` + * (≤0.1.5). Soft-inject credentials / connection / directoryPicker when present. */ 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-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' import { NetxopsCard } from './NetxopsCard.tsx' import { NETXOPS_NS, NetxopsCardController } from './controller.ts' import { en, zh, type NetxopsLocaleKey } from './locales.ts' +import type { SettingsFormScope } from './settings-form.ts' +import type { NetxopsSettings } from './controller.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -23,12 +27,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const LOCALE_NS = 'settings.netxops' -/** Match shipped settings shell inject (no remote.credentials). */ +/** + * Services that exist on every supported DSH client roster. + * Settings transport (`configForms` / `settingsScope`) is soft-injected below. + */ export const inject = [ 'slots', 'locale', - 'remote', - 'settingsScope', ] export function apply(ctx: ClientContext): void { @@ -45,69 +50,98 @@ export function apply(ctx: ClientContext): void { } }, 'netxops: locales') - const card = new NetxopsCardController( - ctx.settingsScope.bind({ namespace: NETXOPS_NS }), - ctx, - ) const t = ctx.locale.bind(LOCALE_NS) as (key: NetxopsLocaleKey) => string + let mounted = false - // Optional: newer remotes that mount credentials unlock the token field. - ctx.inject(['remote.credentials'], (credCtx) => { - card.setCredentialsAvailable(true) - credCtx.effect(() => { - const off = credCtx.remote.$on('credentials/reference-updated', (ref) => { - card.refreshCredential(String(ref)) - }) - return () => { - off() - card.setCredentialsAvailable(false) + const mountCard = (scope: SettingsFormScope): void => { + if (mounted) return + mounted = true + + const card = new NetxopsCardController(scope, ctx) + + // Optional: newer remotes that mount credentials unlock the token field. + ctx.inject(['remote.credentials'], (credCtx) => { + card.setCredentialsAvailable(true) + credCtx.effect(() => { + const off = credCtx.remote.$on('credentials/reference-updated', (ref) => { + card.refreshCredential(String(ref)) + }) + return () => { + off() + card.setCredentialsAvailable(false) + } + }, 'netxops: credential invalidations') + }) + + // Soft-inject Connection so the card can poll host WSS status + KB status. + ctx.inject(['connection'], (connCtx) => { + const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc) + if (typeof call !== 'function') { + connCtx.logger.warn('netxops: connection.rpc.call unavailable — alarm status UI disabled') + return } - }, 'netxops: credential invalidations') - }) + card.setAlarmPushRpc(call) + connCtx.effect(() => () => { + card.setAlarmPushRpc(undefined) + }, 'netxops: clear alarm-push rpc') + }) - // Soft-inject Connection so the card can poll host WSS status + KB status. - ctx.inject(['connection'], (connCtx) => { - const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc) - if (typeof call !== 'function') { - connCtx.logger.warn('netxops: connection.rpc.call unavailable — alarm status UI disabled') + // Soft-inject Host directory picker for knowledge-base browse. + const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise } | undefined): void => { + if (!picker || typeof picker.pick !== 'function') return + card.setDirectoryPicker(picker as { pick: (signal?: AbortSignal) => Promise }) + } + bindDirectoryPicker( + (ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise } } }) + .remote?.directoryPicker, + ) + ctx.inject(['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 } + | undefined + : undefined + const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise } } }) + .remote?.directoryPicker + bindDirectoryPicker(viaGet ?? viaNested) + dpCtx.effect(() => () => { + card.setDirectoryPicker(undefined) + }, 'netxops: clear directory picker') + }) + + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: NETXOPS_NS, + order: 24, + label: () => t('title'), + locale: LOCALE_NS, + inject: () => card.inject(), + }, NetxopsCard)) + } + + // DSH ≥0.1.7 / 0.2.0 — profile entry id matches cordis.patch.yml `id: netxops`. + ctx.inject(['configForms'], (formsCtx) => { + const forms = (formsCtx as { configForms?: { get: (id: string) => SettingsFormScope } }) + .configForms + if (!forms || typeof forms.get !== 'function') { + formsCtx.logger?.warn?.('netxops: configForms present but .get missing') return } - card.setAlarmPushRpc(call) - connCtx.effect(() => () => { - card.setAlarmPushRpc(undefined) - }, 'netxops: clear alarm-push rpc') + formsCtx.logger?.info?.('netxops: settings card via configForms') + mountCard(forms.get(NETXOPS_NS)) }) - // Soft-inject Host directory picker for knowledge-base browse. - // Prefer the dedicated remote namespace; also accept ctx.remote.directoryPicker. - const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise } | undefined): void => { - if (!picker || typeof picker.pick !== 'function') return - card.setDirectoryPicker(picker as { pick: (signal?: AbortSignal) => Promise }) - } - bindDirectoryPicker( - (ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise } } }) - .remote?.directoryPicker, - ) - ctx.inject(['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 } - | undefined - : undefined - const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise } } }) - .remote?.directoryPicker - bindDirectoryPicker(viaGet ?? viaNested) - dpCtx.effect(() => () => { - card.setDirectoryPicker(undefined) - }, 'netxops: clear directory picker') + // DSH ≤0.1.5 — settingsScope binder. + ctx.inject(['settingsScope'], (scopeCtx) => { + const binder = (scopeCtx as { + settingsScope?: { bind: (spec: { namespace: string }) => SettingsFormScope } + }).settingsScope + if (!binder || typeof binder.bind !== 'function') { + scopeCtx.logger?.warn?.('netxops: settingsScope present but .bind missing') + return + } + if (mounted) return + scopeCtx.logger?.info?.('netxops: settings card via settingsScope') + mountCard(binder.bind({ namespace: NETXOPS_NS })) }) - - ctx.slots.inject('settings.section', () => ctx.slots.register({ - name: 'settings.section', - id: NETXOPS_NS, - order: 24, - label: () => t('title'), - locale: LOCALE_NS, - inject: () => card.inject(), - }, NetxopsCard)) } diff --git a/src/client/settings-form.ts b/src/client/settings-form.ts new file mode 100644 index 0000000..ffbe924 --- /dev/null +++ b/src/client/settings-form.ts @@ -0,0 +1,24 @@ +/** + * Shared settings-form face used by the Plugins card. + * + * DSH ≤0.1.5 exposes `ctx.settingsScope.bind({ namespace })`. + * DSH ≥0.1.7 / 0.2.0 exposes `ctx.configForms.get(entryId)`. + * Snapshot + set/unset/subscribe shapes match, so the card stays agnostic. + */ + +export interface SettingsFormSnapshot { + status: string + value?: T + base?: unknown + user?: unknown + revision?: number + writable: boolean + mode?: string +} + +export interface SettingsFormScope { + getSnapshot(): SettingsFormSnapshot + subscribe(listener: () => void): () => void + set(field: string, value: unknown): Promise + unset(field: string): Promise +} diff --git a/src/index.ts b/src/index.ts index 475e92f..9e68f15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,30 +168,40 @@ export interface Config { groupKbPublic: boolean } +/** + * DSH ≥0.1.7 / 0.2.0 only projects Config fields marked `.volatile()` into the + * settings UI. Zero volatile fields → the whole entry is silently filtered. + * Older schemastery without `volatile` keeps the bare schema (≤0.1.5 path). + */ +function vol(schema: S): S { + const candidate = schema as { volatile?: () => S } + return typeof candidate.volatile === 'function' ? candidate.volatile() : schema +} + export const Config: z = z.object({ - apiUrl: z.string().default('http://127.0.0.1:8890'), - lang: z.string().default('zh'), - thinkingLanguage: z.string().default('auto'), - replyLanguage: z.string().default('follow-user'), - tokenCredentialRef: z.string().role('credential-ref').default(DEFAULT_TOKEN_REF), - toolCallTimeoutMs: z.number().step(1).min(1000).default(120_000), - installAgentPreset: z.boolean().default(true), - alarmPushEnabled: z.boolean().default(false), - alarmDeliverDsh: z.boolean().default(true), - alarmDeliverIm: z.boolean().default(false), - imTargets: z.string().default(''), - imBotId: z.string().default(''), - imTargetId: z.string().default(''), - nmsProvider: z.string().default('zte-ume'), - groupOpsInPreset: z.boolean().default(true), - groupOpsPublic: z.boolean().default(false), - groupTopologyInPreset: z.boolean().default(false), - groupTopologyPublic: z.boolean().default(false), - groupBizMonitorInPreset: z.boolean().default(true), - groupBizMonitorPublic: z.boolean().default(false), - kbRoot: z.string().default(''), - groupKbInPreset: z.boolean().default(true), - groupKbPublic: z.boolean().default(false), + apiUrl: vol(z.string().default('http://127.0.0.1:8890')), + lang: vol(z.string().default('zh')), + thinkingLanguage: vol(z.string().default('auto')), + replyLanguage: vol(z.string().default('follow-user')), + tokenCredentialRef: vol(z.string().role('credential-ref').default(DEFAULT_TOKEN_REF)), + toolCallTimeoutMs: vol(z.number().step(1).min(1000).default(120_000)), + installAgentPreset: vol(z.boolean().default(true)), + alarmPushEnabled: vol(z.boolean().default(false)), + alarmDeliverDsh: vol(z.boolean().default(true)), + alarmDeliverIm: vol(z.boolean().default(false)), + imTargets: vol(z.string().default('')), + imBotId: vol(z.string().default('')), + imTargetId: vol(z.string().default('')), + nmsProvider: vol(z.string().default('zte-ume')), + groupOpsInPreset: 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)), + groupBizMonitorPublic: vol(z.boolean().default(false)), + kbRoot: vol(z.string().default('')), + groupKbInPreset: vol(z.boolean().default(true)), + groupKbPublic: vol(z.boolean().default(false)), }) /** Package root (parent of `lib/` or `src/` depending on launch). */ @@ -243,10 +253,27 @@ async function resolveToken(ctx: Context, refName: string): Promise { return hit?.value ?? '' } +/** Normalize settings.describe() across DSH generations (array or { namespaces }). */ +function describeRows(describe: unknown): Array<{ ns?: string, value?: unknown }> { + if (typeof describe !== 'function') return [] + try { + const raw = (describe as () => unknown)() + if (Array.isArray(raw)) return raw as Array<{ ns?: string, value?: unknown }> + if (raw && typeof raw === 'object' && Array.isArray((raw as { namespaces?: unknown }).namespaces)) { + return (raw as { namespaces: Array<{ ns?: string, value?: unknown }> }).namespaces + } + } catch { + // describe can throw while the provider is settling + } + return [] +} + /** - * Register the settings namespace on both dsh generations: + * Register / follow the settings namespace across DSH generations: * - 0.1.1-rc.2: standalone `installSettingsSection` * - ≥0.1.2-rc.1: `settings.installSection` on the provider + * - ≥0.1.7 / 0.2.0: Config is projected from the Loader entry; no owner scope — + * re-read via `settings.describe()` and poll for UI writes (no scope.watch). */ function installNetxopsSettings( ctx: Context, @@ -271,7 +298,47 @@ function installNetxopsSettings( } ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks) + const settings = settingsCtx.settings as { + installSection?: ( + context: Context, + ns: string, + schema: z, + base: Config, + sectionHooks: typeof hooks, + ) => void + describe?: () => unknown + } + + if (typeof settings.installSection === 'function') { + settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks) + return + } + + // DSH ≥0.1.7: profile Config projection — follow describe() for live values. + 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) } + } + return entry + } + hooks.setSource(readLive) + hooks.onChange() + + let lastFingerprint = JSON.stringify(readLive()) + const timer = setInterval(() => { + const next = readLive() + const fingerprint = JSON.stringify(next) + if (fingerprint === lastFingerprint) return + lastFingerprint = fingerprint + hooks.onChange() + }, 2_000) + settingsCtx.effect(() => () => { + clearInterval(timer) + }, 'netxops: settings describe poll') + settingsCtx.logger.info( + 'netxops: following settings via describe() (no installSection — DSH ≥0.1.7 path)', + ) }) }