mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 15:33:11 +08:00
Add operator-subset knowledge base wiring for Netx Ops P1.
Locate and validate MANIFEST under kbRoot, show anti-mixup status in settings, and inject KB_* env plus kb-context so sessions degrade to pure netx when unset. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e6f9d062ed
commit
de7c6a0c4b
23 changed files with 2694 additions and 68 deletions
|
|
@ -19,6 +19,7 @@ import {
|
|||
type ImDeliveryTarget,
|
||||
} 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 { ensureStyles } from './styles.ts'
|
||||
import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
|
|
@ -38,10 +39,12 @@ function phaseLocaleKey(phase: AlarmPushPhase): NetxopsLocaleKey {
|
|||
}
|
||||
|
||||
function StatusBadge(props: {
|
||||
phase: AlarmPushPhase
|
||||
phase?: AlarmPushPhase
|
||||
tone?: 'ok' | 'warn' | 'err' | 'neutral'
|
||||
label: string
|
||||
}) {
|
||||
const tone = alarmPushTone(props.phase)
|
||||
const tone = props.tone
|
||||
?? (props.phase !== undefined ? alarmPushTone(props.phase) : 'neutral')
|
||||
const className = tone === 'ok'
|
||||
? 'dsh-nx-status dsh-nx-statusOk'
|
||||
: tone === 'warn'
|
||||
|
|
@ -52,6 +55,23 @@ function StatusBadge(props: {
|
|||
return <span className={className}>{props.label}</span>
|
||||
}
|
||||
|
||||
function kbBadgeLabel(
|
||||
t: (key: NetxopsLocaleKey) => string,
|
||||
snapshot: KbSnapshot | null,
|
||||
): string {
|
||||
if (!snapshot || snapshot.status === 'unconfigured') {
|
||||
return t('kbStatusUnconfigured')
|
||||
}
|
||||
if (snapshot.status === 'error') {
|
||||
return fillTemplate(t('kbStatusError'), { detail: snapshot.errorMessage || 'error' })
|
||||
}
|
||||
return fillTemplate(t('kbStatusConfigured'), {
|
||||
operator: snapshot.operatorName,
|
||||
country: snapshot.country,
|
||||
version: snapshot.version,
|
||||
})
|
||||
}
|
||||
|
||||
function fillTemplate(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) => (
|
||||
Object.prototype.hasOwnProperty.call(vars, name) ? vars[name]! : match
|
||||
|
|
@ -461,6 +481,68 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="dsh-nx-settings-card">
|
||||
<h3>{t('sectionKnowledge')}</h3>
|
||||
<div className="dsh-nx-settings-field">
|
||||
<div className="dsh-nx-field-head">
|
||||
<label htmlFor="netxops-kb-root">{t('kbRoot')}</label>
|
||||
<span className="dsh-nx-badges">
|
||||
<StatusBadge
|
||||
tone={kbStatusTone(state.kbStatus?.status ?? 'unconfigured')}
|
||||
label={kbBadgeLabel(t, state.kbStatus)}
|
||||
/>
|
||||
{state.kbRoot.overridden
|
||||
? (
|
||||
<>
|
||||
<span className="dsh-nx-badge">{t('overridden')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-reset"
|
||||
disabled={disabled}
|
||||
onClick={() => { props.resetField('kbRoot') }}
|
||||
>
|
||||
{t('reset')}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dsh-nx-pathRow">
|
||||
<input
|
||||
id="netxops-kb-root"
|
||||
className={state.kbRoot.invalid ? 'dsh-nx-inputInvalid' : undefined}
|
||||
type="text"
|
||||
value={state.kbRoot.text}
|
||||
disabled={disabled}
|
||||
aria-invalid={state.kbRoot.invalid || undefined}
|
||||
onChange={(event) => { props.edit('kbRoot', event.target.value) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-btn"
|
||||
disabled={disabled || !state.kbDirectoryPickerReady}
|
||||
onClick={() => { props.browseKbRoot() }}
|
||||
>
|
||||
{t('kbBrowse')}
|
||||
</button>
|
||||
</div>
|
||||
<p className={state.kbRoot.invalid ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
|
||||
{state.kbRoot.invalid
|
||||
? t('invalid')
|
||||
: state.kbDirectoryPickerReady
|
||||
? t('kbRootHint')
|
||||
: t('kbBrowseUnavailable')}
|
||||
</p>
|
||||
{state.kbStatus?.status === 'error' && state.kbStatus.errorMessage
|
||||
? <p className="dsh-nx-invalid" role="status">{state.kbStatus.errorMessage}</p>
|
||||
: null}
|
||||
{state.kbStatus?.status === 'configured' && state.kbStatus.realRoot
|
||||
? <p className="dsh-nx-hint">{state.kbStatus.realRoot}</p>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dsh-nx-settings-card">
|
||||
<h3>{t('sectionAlarms')}</h3>
|
||||
<ToggleField
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ import {
|
|||
type AlarmPushRpcCall,
|
||||
type AlarmPushStatus,
|
||||
} from './alarm-push-status-view.ts'
|
||||
import {
|
||||
fetchKbStatus,
|
||||
resolveKbPath,
|
||||
type KbSnapshot,
|
||||
} from './kb-status-view.ts'
|
||||
import { unconfiguredKbSnapshot } from '../netx/kb-manifest.ts'
|
||||
import {
|
||||
EMPTY_IM_DELIVERY_CATALOG,
|
||||
fetchImDeliveryCatalog,
|
||||
|
|
@ -49,6 +55,8 @@ export interface NetxopsSettings {
|
|||
groupTopologyInPreset?: boolean
|
||||
groupTopologyPublic?: boolean
|
||||
nmsProvider?: string
|
||||
/** Operator-subset knowledge package root (Host-local path). */
|
||||
kbRoot?: string
|
||||
}
|
||||
|
||||
interface CredentialState {
|
||||
|
|
@ -66,6 +74,7 @@ export interface NetxopsCardState extends CardShell {
|
|||
groupTopologyInPreset: CardFieldState
|
||||
groupTopologyPublic: CardFieldState
|
||||
nmsProvider: CardFieldState
|
||||
kbRoot: CardFieldState
|
||||
alarmPushEnabled: CardFieldState
|
||||
alarmDeliverDsh: CardFieldState
|
||||
alarmDeliverIm: CardFieldState
|
||||
|
|
@ -78,6 +87,10 @@ export interface NetxopsCardState extends CardShell {
|
|||
apiTokenRemoteReady: boolean
|
||||
/** Host WSS status when Connection RPC is available; otherwise null. */
|
||||
alarmPushStatus: AlarmPushStatus | null
|
||||
/** Latest KB snapshot (saved root, or live preview while editing). */
|
||||
kbStatus: KbSnapshot | null
|
||||
/** Soft-injected `remote.directoryPicker` is available. */
|
||||
kbDirectoryPickerReady: boolean
|
||||
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
|
||||
imDeliveryCatalog: ImDeliveryCatalog
|
||||
/** Bulk session-export readiness from Host RPC; null when RPC is absent. */
|
||||
|
|
@ -101,6 +114,12 @@ export interface NetxopsCardFace extends CardActions {
|
|||
}
|
||||
/** Download every durable session on this Host as one ZIP. */
|
||||
exportAllSessions: () => void
|
||||
/** Open the Host directory picker and write the chosen path into kbRoot. */
|
||||
browseKbRoot: () => void
|
||||
}
|
||||
|
||||
type DirectoryPickerRemote = {
|
||||
pick: (signal?: AbortSignal) => Promise<string | null>
|
||||
}
|
||||
|
||||
type CredentialsRemote = {
|
||||
|
|
@ -119,6 +138,11 @@ export class NetxopsCardController {
|
|||
}
|
||||
private rpcCall: AlarmPushRpcCall | undefined
|
||||
private alarmPushStatus: AlarmPushStatus | null = null
|
||||
private kbStatus: KbSnapshot | null = null
|
||||
private kbPreview: KbSnapshot | null = null
|
||||
private kbDirectoryPicker: DirectoryPickerRemote | undefined
|
||||
private kbBrowseInFlight = false
|
||||
private kbStatusInFlight = false
|
||||
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||
private sessionsExportStatus: SessionsExportStatus | null = null
|
||||
private sessionsExportBusy = false
|
||||
|
|
@ -140,6 +164,7 @@ export class NetxopsCardController {
|
|||
textField('apiUrl'),
|
||||
textField('lang'),
|
||||
textField('nmsProvider'),
|
||||
textField('kbRoot'),
|
||||
booleanFieldPersistFalse('groupOpsInPreset'),
|
||||
booleanField('groupOpsPublic'),
|
||||
booleanField('groupTopologyInPreset'),
|
||||
|
|
@ -154,7 +179,10 @@ export class NetxopsCardController {
|
|||
[{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }],
|
||||
)
|
||||
this.store = this.form.bind(() => this.projection())
|
||||
scope.subscribe(() => { void this.readCredential() })
|
||||
scope.subscribe(() => {
|
||||
void this.readCredential()
|
||||
void this.refreshKbPreview()
|
||||
})
|
||||
void this.readCredential()
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +199,7 @@ export class NetxopsCardController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Soft-inject Host Connection RPC used to read alarm-push WSS status.
|
||||
* Soft-inject Host Connection RPC used to read alarm-push WSS status + KB status.
|
||||
* @param call - `ctx.connection.rpc.call`, or undefined when connection leaves.
|
||||
*/
|
||||
setAlarmPushRpc(call: AlarmPushRpcCall | undefined): void {
|
||||
|
|
@ -183,6 +211,11 @@ export class NetxopsCardController {
|
|||
this.alarmPushStatus = null
|
||||
changed = true
|
||||
}
|
||||
if (this.kbStatus !== null || this.kbPreview !== null) {
|
||||
this.kbStatus = null
|
||||
this.kbPreview = null
|
||||
changed = true
|
||||
}
|
||||
if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) {
|
||||
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||
changed = true
|
||||
|
|
@ -196,14 +229,29 @@ export class NetxopsCardController {
|
|||
}
|
||||
this.startStatusPoll()
|
||||
void this.refreshAlarmPushStatus()
|
||||
void this.refreshKbStatus()
|
||||
void this.refreshKbPreview()
|
||||
void this.refreshImDeliveryCatalog()
|
||||
void this.refreshSessionsExportStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-inject `remote.directoryPicker` for the knowledge-base browse button.
|
||||
*/
|
||||
setDirectoryPicker(picker: DirectoryPickerRemote | undefined): void {
|
||||
const ready = picker !== undefined
|
||||
if (this.kbDirectoryPicker === picker && (this.kbDirectoryPicker !== undefined) === ready) {
|
||||
return
|
||||
}
|
||||
this.kbDirectoryPicker = picker
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
|
||||
private startStatusPoll(): void {
|
||||
if (this.pollTimer !== undefined) return
|
||||
this.pollTimer = setInterval(() => {
|
||||
void this.refreshAlarmPushStatus()
|
||||
void this.refreshKbStatus()
|
||||
void this.refreshImDeliveryCatalog()
|
||||
void this.refreshSessionsExportStatus()
|
||||
}, STATUS_POLL_MS)
|
||||
|
|
@ -293,6 +341,86 @@ export class NetxopsCardController {
|
|||
}
|
||||
}
|
||||
|
||||
private async refreshKbStatus(): Promise<void> {
|
||||
const call = this.rpcCall
|
||||
if (call === undefined || this.kbStatusInFlight) return
|
||||
this.kbStatusInFlight = true
|
||||
try {
|
||||
const next = await fetchKbStatus(call)
|
||||
const prev = this.kbStatus
|
||||
if (
|
||||
prev
|
||||
&& prev.status === next.status
|
||||
&& prev.realRoot === next.realRoot
|
||||
&& prev.operatorName === next.operatorName
|
||||
&& prev.country === next.country
|
||||
&& prev.version === next.version
|
||||
&& prev.errorMessage === next.errorMessage
|
||||
) return
|
||||
this.kbStatus = next
|
||||
this.store.set(this.projection())
|
||||
} catch {
|
||||
// Keep last good snapshot; next poll retries.
|
||||
} finally {
|
||||
this.kbStatusInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshKbPreview(): Promise<void> {
|
||||
const call = this.rpcCall
|
||||
if (call === undefined) return
|
||||
const draft = this.form.field('kbRoot').text.trim()
|
||||
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
|
||||
if (draft === saved) {
|
||||
if (this.kbPreview !== null) {
|
||||
this.kbPreview = null
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const next = draft === ''
|
||||
? unconfiguredKbSnapshot()
|
||||
: await resolveKbPath(call, draft)
|
||||
const prev = this.kbPreview
|
||||
if (
|
||||
prev
|
||||
&& prev.status === next.status
|
||||
&& prev.realRoot === next.realRoot
|
||||
&& prev.operatorName === next.operatorName
|
||||
&& prev.country === next.country
|
||||
&& prev.version === next.version
|
||||
&& prev.errorMessage === next.errorMessage
|
||||
) return
|
||||
this.kbPreview = next
|
||||
this.store.set(this.projection())
|
||||
} catch {
|
||||
// Keep last preview; edits retry via scope subscribe.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Host OS directory chooser and write the path into kbRoot.
|
||||
*/
|
||||
browseKbRoot(): void {
|
||||
const picker = this.kbDirectoryPicker
|
||||
if (picker === undefined || this.kbBrowseInFlight) return
|
||||
this.kbBrowseInFlight = true
|
||||
void picker.pick()
|
||||
.then((path) => {
|
||||
if (typeof path === 'string' && path.trim() !== '') {
|
||||
this.form.actions().edit('kbRoot', path)
|
||||
void this.refreshKbPreview()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Picker cancel / host refusal — leave draft unchanged.
|
||||
})
|
||||
.finally(() => {
|
||||
this.kbBrowseInFlight = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Download every durable session as one ZIP through the browser download manager.
|
||||
*/
|
||||
|
|
@ -337,6 +465,7 @@ export class NetxopsCardController {
|
|||
apiUrl: this.form.field('apiUrl'),
|
||||
lang: this.form.field('lang'),
|
||||
nmsProvider: this.form.field('nmsProvider'),
|
||||
kbRoot: this.form.field('kbRoot'),
|
||||
groupOpsInPreset: this.form.field('groupOpsInPreset'),
|
||||
groupOpsPublic: this.form.field('groupOpsPublic'),
|
||||
groupTopologyInPreset: this.form.field('groupTopologyInPreset'),
|
||||
|
|
@ -352,6 +481,8 @@ export class NetxopsCardController {
|
|||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||
apiTokenRemoteReady: this.credential.remoteReady,
|
||||
alarmPushStatus: this.alarmPushStatus,
|
||||
kbStatus: this.kbPreview ?? this.kbStatus,
|
||||
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined,
|
||||
imDeliveryCatalog: this.imDeliveryCatalog,
|
||||
sessionsExportStatus: this.sessionsExportStatus,
|
||||
sessionsExportBusy: this.sessionsExportBusy,
|
||||
|
|
@ -407,10 +538,24 @@ export class NetxopsCardController {
|
|||
}
|
||||
|
||||
inject(): NetxopsCardFace {
|
||||
const actions = this.form.actions()
|
||||
return {
|
||||
hooks: { netxopsCard: this.store },
|
||||
...this.form.actions(),
|
||||
...actions,
|
||||
edit: (field, text) => {
|
||||
actions.edit(field, text)
|
||||
if (field === 'kbRoot') void this.refreshKbPreview()
|
||||
},
|
||||
discard: () => {
|
||||
actions.discard()
|
||||
void this.refreshKbPreview()
|
||||
},
|
||||
resetField: (field) => {
|
||||
actions.resetField(field)
|
||||
if (field === 'kbRoot') void this.refreshKbPreview()
|
||||
},
|
||||
exportAllSessions: () => { this.exportAllSessions() },
|
||||
browseKbRoot: () => { this.browseKbRoot() },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function apply(ctx: ClientContext): void {
|
|||
}, 'netxops: credential invalidations')
|
||||
})
|
||||
|
||||
// Soft-inject Connection so the card can poll host WSS status.
|
||||
// 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') {
|
||||
|
|
@ -78,6 +78,20 @@ export function apply(ctx: ClientContext): void {
|
|||
}, 'netxops: clear alarm-push rpc')
|
||||
})
|
||||
|
||||
// Soft-inject Host directory picker for knowledge-base browse.
|
||||
ctx.inject(['remote.directoryPicker'], (dpCtx) => {
|
||||
const picker = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
|
||||
.remote?.directoryPicker
|
||||
if (!picker || typeof picker.pick !== 'function') {
|
||||
dpCtx.logger.warn('netxops: remote.directoryPicker.pick unavailable — browse button disabled')
|
||||
return
|
||||
}
|
||||
card.setDirectoryPicker(picker)
|
||||
dpCtx.effect(() => () => {
|
||||
card.setDirectoryPicker(undefined)
|
||||
}, 'netxops: clear directory picker')
|
||||
})
|
||||
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: NETXOPS_NS,
|
||||
|
|
|
|||
80
src/client/kb-status-view.ts
Normal file
80
src/client/kb-status-view.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Browser helpers for knowledge-base status via Host Connection RPC.
|
||||
*/
|
||||
|
||||
import type { KbSnapshot, KbStatus } from '../netx/kb-manifest.ts'
|
||||
import { unconfiguredKbSnapshot } from '../netx/kb-manifest.ts'
|
||||
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
|
||||
|
||||
/** Same channel as host `NETXOPS_RPC_CHANNEL`. */
|
||||
export const NETXOPS_RPC_CHANNEL = '/netxops'
|
||||
|
||||
export const KB_STATUS_ENDPOINT = 'kb.status'
|
||||
export const KB_RESOLVE_ENDPOINT = 'kb.resolve'
|
||||
|
||||
export type { KbSnapshot, KbStatus }
|
||||
|
||||
/**
|
||||
* Normalize a host KB snapshot for the card.
|
||||
*/
|
||||
export function asKbSnapshot(value: unknown): KbSnapshot {
|
||||
const empty = unconfiguredKbSnapshot()
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return empty
|
||||
const row = value as Record<string, unknown>
|
||||
const status = row.status === 'configured' || row.status === 'error' || row.status === 'unconfigured'
|
||||
? row.status
|
||||
: 'unconfigured'
|
||||
const contentRaw = row.content
|
||||
const content = { ...empty.content }
|
||||
if (contentRaw !== null && typeof contentRaw === 'object' && !Array.isArray(contentRaw)) {
|
||||
for (const [key, flag] of Object.entries(contentRaw as Record<string, unknown>)) {
|
||||
content[key] = flag === true
|
||||
}
|
||||
}
|
||||
return {
|
||||
status,
|
||||
realRoot: typeof row.realRoot === 'string' ? row.realRoot : '',
|
||||
operatorName: typeof row.operatorName === 'string' ? row.operatorName : '',
|
||||
country: typeof row.country === 'string' ? row.country : '',
|
||||
version: typeof row.version === 'string' ? row.version : '',
|
||||
content,
|
||||
errorMessage: typeof row.errorMessage === 'string' ? row.errorMessage : '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the published KB snapshot from the Host.
|
||||
*/
|
||||
export async function fetchKbStatus(
|
||||
call: AlarmPushRpcCall,
|
||||
signal?: AbortSignal,
|
||||
): Promise<KbSnapshot> {
|
||||
const result = await call(NETXOPS_RPC_CHANNEL, KB_STATUS_ENDPOINT, {}, signal)
|
||||
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||
return asKbSnapshot((result as { value?: unknown }).value)
|
||||
}
|
||||
return unconfiguredKbSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview resolve for a path that may not be saved yet.
|
||||
*/
|
||||
export async function resolveKbPath(
|
||||
call: AlarmPushRpcCall,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<KbSnapshot> {
|
||||
const result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal)
|
||||
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||
return asKbSnapshot((result as { value?: unknown }).value)
|
||||
}
|
||||
return unconfiguredKbSnapshot()
|
||||
}
|
||||
|
||||
/** Badge tone for the settings card. */
|
||||
export function kbStatusTone(status: KbStatus): 'ok' | 'warn' | 'err' | 'neutral' {
|
||||
if (status === 'configured') return 'ok'
|
||||
if (status === 'error') return 'err'
|
||||
if (status === 'unconfigured') return 'warn'
|
||||
return 'neutral'
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export type NetxopsLocaleKey =
|
|||
| 'description'
|
||||
| 'sectionConnection'
|
||||
| 'sectionCapabilities'
|
||||
| 'sectionKnowledge'
|
||||
| 'sectionAlarms'
|
||||
| 'sectionExport'
|
||||
| 'apiUrl'
|
||||
|
|
@ -18,6 +19,13 @@ export type NetxopsLocaleKey =
|
|||
| 'groupTopology'
|
||||
| 'groupInPreset'
|
||||
| 'groupPublic'
|
||||
| 'kbRoot'
|
||||
| 'kbRootHint'
|
||||
| 'kbBrowse'
|
||||
| 'kbBrowseUnavailable'
|
||||
| 'kbStatusConfigured'
|
||||
| 'kbStatusUnconfigured'
|
||||
| 'kbStatusError'
|
||||
| 'alarmPushEnabled'
|
||||
| 'alarmPushStatus'
|
||||
| 'alarmPushPhaseDisabled'
|
||||
|
|
@ -64,9 +72,10 @@ export type NetxopsLocaleKey =
|
|||
|
||||
export const en: Record<NetxopsLocaleKey, string> = {
|
||||
title: 'Netx Ops',
|
||||
description: 'API, token, capabilities, and alarm delivery.',
|
||||
description: 'API, token, capabilities, knowledge base, and alarm delivery.',
|
||||
sectionConnection: 'Connection',
|
||||
sectionCapabilities: 'Capability groups',
|
||||
sectionKnowledge: 'Knowledge base',
|
||||
sectionAlarms: 'Key-alarm delivery',
|
||||
sectionExport: 'Session export',
|
||||
apiUrl: 'API URL',
|
||||
|
|
@ -80,6 +89,13 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
groupTopology: 'topology',
|
||||
groupInPreset: 'In Netx Ops preset',
|
||||
groupPublic: 'Publish to other presets',
|
||||
kbRoot: 'Package root',
|
||||
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.',
|
||||
kbBrowse: 'Browse…',
|
||||
kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute path.',
|
||||
kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}',
|
||||
kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)',
|
||||
kbStatusError: 'Knowledge base: error — {detail}',
|
||||
alarmPushEnabled: 'Key-alarm push',
|
||||
alarmPushStatus: 'Status',
|
||||
alarmPushPhaseDisabled: 'Off',
|
||||
|
|
@ -127,9 +143,10 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
|
||||
export const zh: Record<NetxopsLocaleKey, string> = {
|
||||
title: 'Netx Ops',
|
||||
description: 'API、Token、能力组与告警投递。',
|
||||
description: 'API、Token、能力组、知识库与告警投递。',
|
||||
sectionConnection: '连接',
|
||||
sectionCapabilities: '能力组',
|
||||
sectionKnowledge: '知识库',
|
||||
sectionAlarms: '关键告警投递',
|
||||
sectionExport: '会话导出',
|
||||
apiUrl: 'API 地址',
|
||||
|
|
@ -143,6 +160,13 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
|||
groupTopology: 'topology',
|
||||
groupInPreset: '在 Netx Ops 预设中启用',
|
||||
groupPublic: '对其他预设公开',
|
||||
kbRoot: '知识包根目录',
|
||||
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。',
|
||||
kbBrowse: '浏览…',
|
||||
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对路径。',
|
||||
kbStatusConfigured: '知识库: {operator}({country}) v{version}',
|
||||
kbStatusUnconfigured: '知识库: 未配置(纯 netx)',
|
||||
kbStatusError: '知识库: 错误 — {detail}',
|
||||
alarmPushEnabled: '关键告警推送',
|
||||
alarmPushStatus: '状态',
|
||||
alarmPushPhaseDisabled: '未开启',
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ const CSS = `
|
|||
.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-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
|
||||
.dsh-nx-settings-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px}
|
||||
.dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch}
|
||||
.dsh-nx-pathRow>input{flex:1;min-width:0}
|
||||
.dsh-nx-pathRow>.dsh-nx-btn{flex:none;align-self:stretch}
|
||||
.dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
|
||||
.dsh-nx-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)}
|
||||
.dsh-nx-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)}
|
||||
|
|
|
|||
86
src/index.ts
86
src/index.ts
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups)
|
||||
* + credentials (NETX_API_TOKEN) publish a connection snapshot. The Ops preset
|
||||
* mounts selected `netx__*` groups via `dsh-netxops/tools`; groups marked public
|
||||
* also register on the host tool/skill layer for other presets.
|
||||
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups /
|
||||
* optional knowledge-base root) + credentials (NETX_API_TOKEN) publish a
|
||||
* connection snapshot. The Ops preset mounts selected `netx__*` groups via
|
||||
* `dsh-netxops/tools`; groups marked public also register on the host tool/skill
|
||||
* layer for other presets.
|
||||
*
|
||||
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
|
||||
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
|
||||
|
|
@ -11,6 +12,10 @@
|
|||
* (`GET /api/netxops.sessions.export`) for HQ analysis — including cloud Hosts,
|
||||
* where the browser receives the archive.
|
||||
*
|
||||
* Knowledge base (P1): settings `kbRoot` → locate/validate MANIFEST.json →
|
||||
* `process.env.KB_*` + dynamic `kb-context` skill. Unconfigured/error degrades
|
||||
* to pure netx (no invented operator).
|
||||
*
|
||||
* @module dsh-netxops
|
||||
*/
|
||||
|
||||
|
|
@ -39,6 +44,15 @@ import {
|
|||
} from './netx/capability-groups.ts'
|
||||
import { registerGroupSkills } from './netx/group-skills.ts'
|
||||
import { resolveImTargets } from './netx/im-targets.ts'
|
||||
import { registerKbContextSkill } from './netx/kb-context-skill.ts'
|
||||
import { resolveKbRoot } from './netx/kb-manifest.ts'
|
||||
import {
|
||||
applyKbEnv,
|
||||
getKbContext,
|
||||
publishKbContext,
|
||||
resetKbContext,
|
||||
watchKbContext,
|
||||
} from './netx/kb-runtime.ts'
|
||||
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
|
||||
import {
|
||||
getSessionsExportStatus,
|
||||
|
|
@ -115,6 +129,12 @@ export interface Config {
|
|||
groupTopologyInPreset: boolean
|
||||
/** Publish topology tools/skills to other presets (default off). */
|
||||
groupTopologyPublic: boolean
|
||||
/**
|
||||
* Absolute (or Host-local) path to an operator-subset knowledge package.
|
||||
* Empty = unconfigured. Plugin locates `MANIFEST.json` (≤3 levels) and
|
||||
* injects `KB_*` + `kb-context` skill.
|
||||
*/
|
||||
kbRoot: string
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
|
|
@ -134,6 +154,7 @@ export const Config: z<Config> = z.object({
|
|||
groupOpsPublic: z.boolean().default(false),
|
||||
groupTopologyInPreset: z.boolean().default(false),
|
||||
groupTopologyPublic: z.boolean().default(false),
|
||||
kbRoot: z.string().default(''),
|
||||
})
|
||||
|
||||
/** Package root (parent of `lib/` or `src/` depending on launch). */
|
||||
|
|
@ -297,6 +318,20 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
||||
groups,
|
||||
})
|
||||
const kb = resolveKbRoot(current.kbRoot ?? '')
|
||||
publishKbContext(kb)
|
||||
applyKbEnv(kb)
|
||||
if (kb.status === 'configured') {
|
||||
ctx.logger.info(
|
||||
'netxops: knowledge base configured → %s (%s / %s v%s)',
|
||||
kb.realRoot,
|
||||
kb.operatorName,
|
||||
kb.country,
|
||||
kb.version,
|
||||
)
|
||||
} else if (kb.status === 'error') {
|
||||
ctx.logger.warn('netxops: knowledge base error — %s', kb.errorMessage)
|
||||
}
|
||||
restartAlarmPush(current, apiUrl, token)
|
||||
if (!tokenConfigured) {
|
||||
ctx.logger.warn(
|
||||
|
|
@ -364,6 +399,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
|
||||
ctx.inject(['skills'], (skillsCtx) => {
|
||||
let unregisterSkills: (() => void) | undefined
|
||||
let unregisterKbSkill: (() => void) | undefined
|
||||
let generation = 0
|
||||
const remountPublicSkills = (): void => {
|
||||
const gen = ++generation
|
||||
|
|
@ -386,19 +422,36 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
skillsCtx.logger.warn('netxops: public skill register failed: %s', error)
|
||||
})
|
||||
}
|
||||
const remountKbSkill = (): void => {
|
||||
unregisterKbSkill?.()
|
||||
unregisterKbSkill = undefined
|
||||
const snapshot = getKbContext()
|
||||
unregisterKbSkill = registerKbContextSkill(skillsCtx, snapshot)
|
||||
skillsCtx.logger.info(
|
||||
'netxops: kb-context skill status=%s',
|
||||
snapshot.status,
|
||||
)
|
||||
}
|
||||
remountPublicSkills()
|
||||
remountKbSkill()
|
||||
const stopWatch = watchNetxConnection(() => { remountPublicSkills() })
|
||||
const stopKbWatch = watchKbContext(() => { remountKbSkill() })
|
||||
skillsCtx.effect(() => () => {
|
||||
generation += 1
|
||||
stopWatch()
|
||||
stopKbWatch()
|
||||
unregisterSkills?.()
|
||||
unregisterKbSkill?.()
|
||||
}, 'netxops: dispose public skills')
|
||||
})
|
||||
|
||||
// Browser card: alarm-push status + IM catalog (RPC) and all-sessions ZIP (Fetch).
|
||||
ctx.inject(['connection'], (connCtx) => {
|
||||
const connection = connCtx.connection as {
|
||||
rpc?: { handle?: (channel: string, handler: (endpoint: string) => Promise<unknown>) => (() => void) | Promise<void> }
|
||||
rpc?: { handle?: (
|
||||
channel: string,
|
||||
handler: (endpoint: string, payload?: unknown) => Promise<unknown>,
|
||||
) => (() => void) | Promise<void> }
|
||||
fetch?: {
|
||||
register?: (route: {
|
||||
readonly path: string
|
||||
|
|
@ -414,10 +467,17 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
connCtx.effect(() => {
|
||||
const dispose = rpc.handle(
|
||||
NETXOPS_RPC_CHANNEL,
|
||||
async (endpoint: string) => {
|
||||
async (endpoint: string, payload?: unknown) => {
|
||||
if (endpoint === 'alarm-push.status') {
|
||||
return { ok: true, value: getAlarmPushStatus() }
|
||||
}
|
||||
if (endpoint === 'kb.status') {
|
||||
return { ok: true, value: getKbContext() }
|
||||
}
|
||||
if (endpoint === 'kb.resolve') {
|
||||
const path = extractKbResolvePath(payload)
|
||||
return { ok: true, value: resolveKbRoot(path) }
|
||||
}
|
||||
if (endpoint === 'sessions.export.status') {
|
||||
const value = await getSessionsExportStatus(ctx)
|
||||
return { ok: true, value }
|
||||
|
|
@ -492,5 +552,19 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
stopAlarmPush = undefined
|
||||
resetAlarmSession()
|
||||
resetAlarmPushStatus()
|
||||
resetKbContext()
|
||||
}, 'netxops: dispose host bridge')
|
||||
}
|
||||
|
||||
/** Pull `path` from either `{ path }` or `{ args: { path } }` RPC payloads. */
|
||||
function extractKbResolvePath(payload: unknown): string {
|
||||
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return ''
|
||||
const row = payload as Record<string, unknown>
|
||||
if (typeof row.path === 'string') return row.path
|
||||
const args = row.args
|
||||
if (args !== null && typeof args === 'object' && !Array.isArray(args)) {
|
||||
const path = (args as Record<string, unknown>).path
|
||||
if (typeof path === 'string') return path
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
type NetxCapabilityGroupId,
|
||||
} from './capability-groups.ts'
|
||||
import { registerGroupSkills } from './group-skills.ts'
|
||||
import { registerKbContextSkill } from './kb-context-skill.ts'
|
||||
import { getKbContext, watchKbContext } from './kb-runtime.ts'
|
||||
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
|
||||
import { registerNetxTools } from './tools.ts'
|
||||
|
||||
|
|
@ -72,6 +74,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
const stopToolWatch = watchNetxConnection(() => { remountTools() })
|
||||
|
||||
ctx.inject(['skills'], (skillsCtx) => {
|
||||
let unregisterKbSkill: (() => void) | undefined
|
||||
const remountSkills = (): void => {
|
||||
const gen = ++skillGeneration
|
||||
unregisterSkills?.()
|
||||
|
|
@ -88,14 +91,24 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
skillsCtx.logger.warn('%s: skill register failed: %s', options.name, error)
|
||||
})
|
||||
}
|
||||
const remountKbSkill = (): void => {
|
||||
unregisterKbSkill?.()
|
||||
unregisterKbSkill = undefined
|
||||
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext())
|
||||
}
|
||||
|
||||
remountSkills()
|
||||
remountKbSkill()
|
||||
const stopSkillWatch = watchNetxConnection(() => { remountSkills() })
|
||||
const stopKbWatch = watchKbContext(() => { remountKbSkill() })
|
||||
skillsCtx.effect(() => () => {
|
||||
skillGeneration += 1
|
||||
stopSkillWatch()
|
||||
stopKbWatch()
|
||||
unregisterSkills?.()
|
||||
unregisterSkills = undefined
|
||||
unregisterKbSkill?.()
|
||||
unregisterKbSkill = undefined
|
||||
}, `${options.name}: dispose skills`)
|
||||
})
|
||||
|
||||
|
|
|
|||
85
src/netx/kb-context-skill.ts
Normal file
85
src/netx/kb-context-skill.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Dynamic `kb-context` skill — model-visible annotation of the KB snapshot.
|
||||
* Tools/scripts still use `process.env.KB_*` from `applyKbEnv`.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { KbSnapshot } from './kb-manifest.ts'
|
||||
|
||||
const SKILL_NAME = 'kb-context'
|
||||
|
||||
function skillBody(snapshot: KbSnapshot): { description: string; content: string } {
|
||||
if (snapshot.status === 'configured') {
|
||||
const flags = Object.entries(snapshot.content)
|
||||
.filter(([, on]) => on)
|
||||
.map(([key]) => key)
|
||||
.join(', ') || '(none)'
|
||||
return {
|
||||
description:
|
||||
'Operator knowledge-base context for this Host (paths + identity from MANIFEST).',
|
||||
content: [
|
||||
'## Knowledge base (configured)',
|
||||
'',
|
||||
'When troubleshooting with operator playbooks or docs, **compose paths from this root**.',
|
||||
'Do not invent another operator or country.',
|
||||
'',
|
||||
`| Field | Value |`,
|
||||
`| --- | --- |`,
|
||||
`| kbStatus | configured |`,
|
||||
`| kbRoot | \`${snapshot.realRoot}\` |`,
|
||||
`| kbOperator | ${snapshot.operatorName} |`,
|
||||
`| kbCountry | ${snapshot.country} |`,
|
||||
`| kbVersion | ${snapshot.version} |`,
|
||||
`| kbContent (on) | ${flags} |`,
|
||||
'',
|
||||
'Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`.',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
const reason = snapshot.status === 'error'
|
||||
? (snapshot.errorMessage || 'invalid knowledge base')
|
||||
: 'kbRoot is empty'
|
||||
|
||||
return {
|
||||
description:
|
||||
'Knowledge-base context is unavailable — stay on pure netx evidence.',
|
||||
content: [
|
||||
'## Knowledge base (unavailable)',
|
||||
'',
|
||||
`kbStatus=\`${snapshot.status}\`. ${reason}`,
|
||||
'',
|
||||
'**Do not invent an operator, country, or KB paths.**',
|
||||
'Use only netx tools / live evidence (alarms, inventory, CLI, topology).',
|
||||
'Operator-specific playbooks are out of scope until a valid MANIFEST is configured.',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or replace) the `kb-context` skill for the current snapshot.
|
||||
* @returns disposer that unregisters the skill.
|
||||
*/
|
||||
export function registerKbContextSkill(
|
||||
ctx: Context,
|
||||
snapshot: KbSnapshot,
|
||||
): () => void {
|
||||
const skillsApi = (ctx as { skills?: { register: (skill: {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
provider?: string
|
||||
source: string
|
||||
}) => () => void } }).skills
|
||||
if (!skillsApi || typeof skillsApi.register !== 'function') {
|
||||
return () => {}
|
||||
}
|
||||
const body = skillBody(snapshot)
|
||||
return skillsApi.register({
|
||||
name: SKILL_NAME,
|
||||
description: body.description,
|
||||
content: body.content,
|
||||
provider: 'netxops-kb',
|
||||
source: 'custom',
|
||||
})
|
||||
}
|
||||
261
src/netx/kb-manifest.ts
Normal file
261
src/netx/kb-manifest.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* Locate and validate an operator-subset MANIFEST.json under a kbRoot.
|
||||
*
|
||||
* Contract (MANIFEST v1.0):
|
||||
* - schemaVersion === "1.0"
|
||||
* - packageType === "operator-subset"
|
||||
* - operator.name / operator.country, version required
|
||||
* - content.* boolean flags (missing → false)
|
||||
*
|
||||
* Location: `${kbRoot}/MANIFEST.json`, else recurse ≤ maxDepth and accept
|
||||
* exactly one hit (0 or >1 → error).
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
/** Known content flags; unknown keys are preserved when boolean. */
|
||||
export interface KbContentFlags {
|
||||
regions: boolean
|
||||
theory: boolean
|
||||
packet: boolean
|
||||
skills: boolean
|
||||
[key: string]: boolean
|
||||
}
|
||||
|
||||
export type KbStatus = 'unconfigured' | 'configured' | 'error'
|
||||
|
||||
export interface KbSnapshot {
|
||||
status: KbStatus
|
||||
/** Absolute real package root (directory that holds MANIFEST.json). */
|
||||
realRoot: string
|
||||
operatorName: string
|
||||
country: string
|
||||
version: string
|
||||
content: KbContentFlags
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
const EMPTY_CONTENT: KbContentFlags = {
|
||||
regions: false,
|
||||
theory: false,
|
||||
packet: false,
|
||||
skills: false,
|
||||
}
|
||||
|
||||
/** Empty / unconfigured snapshot (kbRoot blank). */
|
||||
export function unconfiguredKbSnapshot(): KbSnapshot {
|
||||
return {
|
||||
status: 'unconfigured',
|
||||
realRoot: '',
|
||||
operatorName: '',
|
||||
country: '',
|
||||
version: '',
|
||||
content: { ...EMPTY_CONTENT },
|
||||
errorMessage: '',
|
||||
}
|
||||
}
|
||||
|
||||
function errorSnapshot(message: string): KbSnapshot {
|
||||
return {
|
||||
status: 'error',
|
||||
realRoot: '',
|
||||
operatorName: '',
|
||||
country: '',
|
||||
version: '',
|
||||
content: { ...EMPTY_CONTENT },
|
||||
errorMessage: message,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect `MANIFEST.json` paths under `root`, depth-limited.
|
||||
* Depth 0 = root itself; children are depth 1..maxDepth.
|
||||
*/
|
||||
export function findManifest(
|
||||
kbRoot: string,
|
||||
maxDepth = 3,
|
||||
): { paths: string[]; error?: string } {
|
||||
const root = resolve(kbRoot.trim())
|
||||
if (!kbRoot.trim()) {
|
||||
return { paths: [], error: 'kbRoot is empty' }
|
||||
}
|
||||
let rootStat
|
||||
try {
|
||||
rootStat = statSync(root)
|
||||
} catch {
|
||||
return { paths: [], error: `kbRoot not found: ${root}` }
|
||||
}
|
||||
if (!rootStat.isDirectory()) {
|
||||
return { paths: [], error: `kbRoot is not a directory: ${root}` }
|
||||
}
|
||||
|
||||
const direct = join(root, 'MANIFEST.json')
|
||||
if (existsSync(direct)) {
|
||||
try {
|
||||
if (statSync(direct).isFile()) return { paths: [direct] }
|
||||
} catch {
|
||||
// fall through to search
|
||||
}
|
||||
}
|
||||
|
||||
const found: string[] = []
|
||||
const walk = (dir: string, depth: number): void => {
|
||||
if (depth > maxDepth) return
|
||||
const candidate = join(dir, 'MANIFEST.json')
|
||||
if (existsSync(candidate)) {
|
||||
try {
|
||||
if (statSync(candidate).isFile()) found.push(candidate)
|
||||
} catch {
|
||||
// ignore unreadable
|
||||
}
|
||||
}
|
||||
if (depth === maxDepth) return
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (name === 'node_modules' || name === '.git') continue
|
||||
const full = join(dir, name)
|
||||
let st
|
||||
try {
|
||||
st = statSync(full)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (st.isDirectory()) walk(full, depth + 1)
|
||||
}
|
||||
}
|
||||
// Root already checked; search nested packages at depth 1..maxDepth.
|
||||
let topEntries: string[]
|
||||
try {
|
||||
topEntries = readdirSync(root)
|
||||
} catch {
|
||||
return { paths: [], error: `cannot read kbRoot: ${root}` }
|
||||
}
|
||||
for (const name of topEntries) {
|
||||
if (name === 'node_modules' || name === '.git') continue
|
||||
const full = join(root, name)
|
||||
let st
|
||||
try {
|
||||
st = statSync(full)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (st.isDirectory()) walk(full, 1)
|
||||
}
|
||||
return { paths: found }
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`MANIFEST missing required string field: ${field}`)
|
||||
}
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function parseContent(raw: unknown): KbContentFlags {
|
||||
const out: KbContentFlags = { ...EMPTY_CONTENT }
|
||||
if (raw === undefined || raw === null) {
|
||||
throw new Error('MANIFEST missing required object field: content')
|
||||
}
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error('MANIFEST content must be an object')
|
||||
}
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
out[key] = value === true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate MANIFEST JSON text (UTF-8).
|
||||
* @throws Error with a short message when invalid.
|
||||
*/
|
||||
export function parseManifest(raw: string): {
|
||||
operatorName: string
|
||||
country: string
|
||||
version: string
|
||||
content: KbContentFlags
|
||||
} {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`MANIFEST is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new Error('MANIFEST root must be an object')
|
||||
}
|
||||
const row = data as Record<string, unknown>
|
||||
if (row.schemaVersion !== '1.0') {
|
||||
throw new Error(
|
||||
`unsupported schemaVersion (want "1.0", got ${JSON.stringify(row.schemaVersion)})`,
|
||||
)
|
||||
}
|
||||
if (row.packageType !== 'operator-subset') {
|
||||
throw new Error(
|
||||
`unsupported packageType (want "operator-subset", got ${JSON.stringify(row.packageType)})`,
|
||||
)
|
||||
}
|
||||
const operator = row.operator
|
||||
if (operator === null || typeof operator !== 'object' || Array.isArray(operator)) {
|
||||
throw new Error('MANIFEST missing required object field: operator')
|
||||
}
|
||||
const op = operator as Record<string, unknown>
|
||||
return {
|
||||
operatorName: asNonEmptyString(op.name, 'operator.name'),
|
||||
country: asNonEmptyString(op.country, 'operator.country'),
|
||||
version: asNonEmptyString(row.version, 'version'),
|
||||
content: parseContent(row.content),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve kbRoot → KbSnapshot (unconfigured / configured / error).
|
||||
*/
|
||||
export function resolveKbRoot(kbRoot: string, maxDepth = 3): KbSnapshot {
|
||||
const trimmed = kbRoot.trim()
|
||||
if (!trimmed) return unconfiguredKbSnapshot()
|
||||
|
||||
const located = findManifest(trimmed, maxDepth)
|
||||
if (located.error) return errorSnapshot(located.error)
|
||||
if (located.paths.length === 0) {
|
||||
return errorSnapshot(`no MANIFEST.json under ${resolve(trimmed)} (maxDepth=${maxDepth})`)
|
||||
}
|
||||
if (located.paths.length > 1) {
|
||||
return errorSnapshot(
|
||||
`ambiguous MANIFEST.json (${located.paths.length} hits); pick a unique package root`,
|
||||
)
|
||||
}
|
||||
|
||||
const manifestPath = located.paths[0]!
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(manifestPath, 'utf8')
|
||||
} catch (error) {
|
||||
return errorSnapshot(
|
||||
`cannot read MANIFEST: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseManifest(raw)
|
||||
return {
|
||||
status: 'configured',
|
||||
realRoot: dirname(manifestPath),
|
||||
operatorName: parsed.operatorName,
|
||||
country: parsed.country,
|
||||
version: parsed.version,
|
||||
content: parsed.content,
|
||||
errorMessage: '',
|
||||
}
|
||||
} catch (error) {
|
||||
return errorSnapshot(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
90
src/netx/kb-runtime.ts
Normal file
90
src/netx/kb-runtime.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Process-local knowledge-base snapshot shared between the host settings
|
||||
* bridge, RPC, and dynamic kb-context skill registration.
|
||||
*
|
||||
* Uses `Symbol.for` on `globalThis` so host + agent-tools bundles share one store
|
||||
* even when Bun emits them as separate ESM files.
|
||||
*/
|
||||
|
||||
import type { KbSnapshot } from './kb-manifest.ts'
|
||||
import { unconfiguredKbSnapshot } from './kb-manifest.ts'
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
interface Store {
|
||||
snapshot: KbSnapshot
|
||||
listeners: Set<Listener>
|
||||
}
|
||||
|
||||
const STORE_KEY = Symbol.for('dsh-netxops.kb-store')
|
||||
|
||||
const ENV_KEYS = [
|
||||
'KB_ROOT',
|
||||
'KB_OPERATOR',
|
||||
'KB_COUNTRY',
|
||||
'KB_VERSION',
|
||||
'KB_CONTENT',
|
||||
'KB_STATUS',
|
||||
] as const
|
||||
|
||||
function store(): Store {
|
||||
const root = globalThis as typeof globalThis & { [STORE_KEY]?: Store }
|
||||
let current = root[STORE_KEY]
|
||||
if (current === undefined) {
|
||||
current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set() }
|
||||
root[STORE_KEY] = current
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/** @returns the last published KB snapshot. */
|
||||
export function getKbContext(): KbSnapshot {
|
||||
return { ...store().snapshot, content: { ...store().snapshot.content } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the latest KB snapshot for UI / skill remounts.
|
||||
* @param next - resolved snapshot from `resolveKbRoot`.
|
||||
*/
|
||||
export function publishKbContext(next: KbSnapshot): void {
|
||||
const state = store()
|
||||
state.snapshot = {
|
||||
...next,
|
||||
content: { ...next.content },
|
||||
}
|
||||
for (const listener of state.listeners) listener()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to KB snapshot publishes (settings remounts).
|
||||
* @param listener - called synchronously after each publish.
|
||||
* @returns disposer.
|
||||
*/
|
||||
export function watchKbContext(listener: Listener): () => void {
|
||||
const state = store()
|
||||
state.listeners.add(listener)
|
||||
return () => { state.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the snapshot into `process.env.KB_*` for tools / future KB skill packs.
|
||||
* Clears identity fields when status is not `configured`.
|
||||
*/
|
||||
export function applyKbEnv(snapshot: KbSnapshot): void {
|
||||
for (const key of ENV_KEYS) {
|
||||
delete process.env[key]
|
||||
}
|
||||
process.env.KB_STATUS = snapshot.status
|
||||
if (snapshot.status !== 'configured') return
|
||||
process.env.KB_ROOT = snapshot.realRoot
|
||||
process.env.KB_OPERATOR = snapshot.operatorName
|
||||
process.env.KB_COUNTRY = snapshot.country
|
||||
process.env.KB_VERSION = snapshot.version
|
||||
process.env.KB_CONTENT = JSON.stringify(snapshot.content)
|
||||
}
|
||||
|
||||
/** Reset store + env to unconfigured (plugin dispose). */
|
||||
export function resetKbContext(): void {
|
||||
publishKbContext(unconfiguredKbSnapshot())
|
||||
applyKbEnv(unconfiguredKbSnapshot())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue