Fix KB settings badge stuck on unconfigured after bind (0.1.38).

Reload Host kbRoot after browse/save so the status badge tracks the live package instead of a stale snapshot.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-16 12:50:04 +08:00
parent 0f31a6648a
commit 1b4c402723
9 changed files with 261 additions and 50 deletions

View file

@ -42,7 +42,7 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` /
| Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane |
| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteRef` / `WriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `refs\|memories\|drafts\|suggestions`. HQ pack is read-only; evolve site product knowledge with `kbWriteRef` (per-`host_name` PROFILE) and capture experience with `kbWriteMemory` as it appears. |
RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths).
RPC (channel `/netxops`): `kb.status` (published snapshot), `kb.reload` (re-resolve live `kbRoot` + republish), `kb.resolve` with `{ path }` (preview unsaved paths).
## Pack skill registration

View file

@ -591,6 +591,7 @@ function unconfiguredKbSnapshot() {
// src/client/kb-status-view.ts
var NETXOPS_RPC_CHANNEL2 = "/netxops";
var KB_STATUS_ENDPOINT = "kb.status";
var KB_RELOAD_ENDPOINT = "kb.reload";
var KB_RESOLVE_ENDPOINT = "kb.resolve";
function asKbSnapshot(value) {
const empty = unconfiguredKbSnapshot();
@ -624,22 +625,44 @@ function asKbSnapshot(value) {
errorMessage: typeof row.errorMessage === "string" ? row.errorMessage : ""
};
}
function asRpcOk(result) {
return result !== null && typeof result === "object" && result.ok === true;
}
async function fetchKbStatus(call, signal) {
const result = await call(NETXOPS_RPC_CHANNEL2, KB_STATUS_ENDPOINT, {}, signal);
if (result !== null && typeof result === "object" && result.ok === true) {
if (asRpcOk(result)) {
return asKbSnapshot(result.value);
}
return unconfiguredKbSnapshot();
if (result !== null && typeof result === "object" && result.ok === false) {
const message = String(result.error?.message ?? "kb.status rpc failed");
return { ...unconfiguredKbSnapshot(), status: "error", errorMessage: message };
}
return {
...unconfiguredKbSnapshot(),
status: "error",
errorMessage: "kb.status unavailable"
};
}
async function reloadKbStatus(call, signal) {
const result = await call(NETXOPS_RPC_CHANNEL2, KB_RELOAD_ENDPOINT, {}, signal);
if (asRpcOk(result)) {
return asKbSnapshot(result.value);
}
return fetchKbStatus(call, signal);
}
async function resolveKbPath(call, path, signal) {
let result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal);
if (!(result !== null && typeof result === "object" && result.ok === true)) {
if (!asRpcOk(result)) {
result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { args: { path } }, signal);
}
if (result !== null && typeof result === "object" && result.ok === true) {
if (asRpcOk(result)) {
return asKbSnapshot(result.value);
}
return unconfiguredKbSnapshot();
return {
...unconfiguredKbSnapshot(),
status: "error",
errorMessage: "kb.resolve unavailable"
};
}
function kbStatusTone(status) {
if (status === "configured")
@ -1886,7 +1909,7 @@ class CardForm {
}
async store(field, value) {
await this.scope.set(field, value);
return this.userLayer()?.[field] === value;
return this.sectionValue(field) === value || this.userLayer()?.[field] === value;
}
stage(field, edit) {
this.staged.set(field, edit);
@ -1945,7 +1968,9 @@ class NetxopsCardController {
kbDirectoryPicker;
kbBrowseInFlight = false;
kbStatusInFlight = false;
kbReloadInFlight = false;
kbUiError = null;
lastSyncedKbRoot = undefined;
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
sessionsExportStatus = null;
sessionsExportBusy = false;
@ -1983,6 +2008,7 @@ class NetxopsCardController {
scope.subscribe(() => {
this.readCredential();
this.refreshKbPreview();
this.syncKbRootFromSettings();
});
this.readCredential();
}
@ -2026,7 +2052,7 @@ class NetxopsCardController {
}
this.startStatusPoll();
this.refreshAlarmPushStatus();
this.refreshKbStatus();
this.reloadKbFromHost();
this.refreshKbPreview();
this.refreshImDeliveryCatalog();
this.refreshSessionsExportStatus();
@ -2114,15 +2140,42 @@ class NetxopsCardController {
this.kbStatusInFlight = true;
try {
const next = await fetchKbStatus(call);
const prev = this.kbStatus;
if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage)
return;
this.kbStatus = next;
this.store.set(this.projection());
this.applyKbSnapshot(next);
} catch {} finally {
this.kbStatusInFlight = false;
}
}
async reloadKbFromHost() {
const call = this.rpcCall;
if (call === undefined || this.kbReloadInFlight)
return;
this.kbReloadInFlight = true;
try {
const next = await reloadKbStatus(call);
this.applyKbSnapshot(next);
this.lastSyncedKbRoot = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
} catch {
this.refreshKbStatus();
} finally {
this.kbReloadInFlight = false;
}
}
applyKbSnapshot(next) {
const prev = this.kbStatus;
if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage)
return;
this.kbStatus = next;
this.store.set(this.projection());
}
async syncKbRootFromSettings() {
if (this.rpcCall === undefined)
return;
const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
if (saved === this.lastSyncedKbRoot)
return;
this.lastSyncedKbRoot = saved;
await this.reloadKbFromHost();
}
async refreshKbPreview() {
const call = this.rpcCall;
if (call === undefined)
@ -2145,6 +2198,30 @@ class NetxopsCardController {
this.store.set(this.projection());
} catch {}
}
async commitKbRoot(path) {
const trimmed = path.trim();
this.kbUiError = null;
this.store.set(this.projection());
await this.form.save();
const shell = this.form.shell();
const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
if (shell.failed || saved !== trimmed) {
this.kbUiError = saved === trimmed ? "kbRoot save failed" : `kbRoot save did not land (saved="${saved || "(empty)"}")`;
this.store.set(this.projection());
const call = this.rpcCall;
if (call !== undefined && trimmed !== "") {
try {
this.kbPreview = await resolveKbPath(call, trimmed);
this.store.set(this.projection());
} catch {}
}
return;
}
this.kbPreview = null;
this.lastSyncedKbRoot = undefined;
await this.reloadKbFromHost();
this.refreshKbPreview();
}
browseKbRoot() {
if (this.kbBrowseInFlight)
return;
@ -2153,11 +2230,7 @@ class NetxopsCardController {
this.store.set(this.projection());
const applyPath = (path) => {
this.form.actions().edit("kbRoot", path);
this.refreshKbPreview();
this.form.save().then(() => {
this.refreshKbStatus();
this.refreshKbPreview();
});
this.commitKbRoot(path);
};
const fail = (error) => {
this.kbUiError = error instanceof Error ? error.message : String(error);
@ -2406,7 +2479,7 @@ var en = {
kbBrowse: "Browse…",
kbBrowseUnavailable: "Directory picker unavailable — paste an absolute folder path, then Save.",
kbBrowseFailed: "Browse failed: {detail}",
kbSaveHint: "Browse writes and saves automatically. Manual paste still needs Save.",
kbSaveHint: "Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.",
kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}",
kbStatusUnconfigured: "Knowledge base: not configured (pure netx)",
kbStatusError: "Knowledge base: error — {detail}",
@ -2483,7 +2556,7 @@ var zh = {
kbBrowse: "浏览…",
kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对目录路径后点保存。",
kbBrowseFailed: "浏览失败:{detail}",
kbSaveHint: "点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。",
kbSaveHint: "点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。",
kbStatusConfigured: "知识库: {operator}({country}) v{version}",
kbStatusUnconfigured: "知识库: 未配置(纯 netx)",
kbStatusError: "知识库: 错误 — {detail}",

View file

@ -4878,6 +4878,20 @@ function apply(ctx, config = Config({})) {
if (endpoint === "kb.status") {
return { ok: true, value: getKbContext() };
}
if (endpoint === "kb.reload") {
const current = source();
const kb = resolveKbRoot(current.kbRoot ?? "");
publishKbContext(kb);
applyKbEnv(kb);
if (kb.status === "configured") {
connCtx.logger.info("netxops: kb.reload → %s (%s / %s v%s)", kb.realRoot, kb.operatorName, kb.country, kb.version);
} else if (kb.status === "error") {
connCtx.logger.warn("netxops: kb.reload error — %s", kb.errorMessage);
} else {
connCtx.logger.info("netxops: kb.reload → unconfigured (kbRoot empty)");
}
return { ok: true, value: kb };
}
if (endpoint === "kb.resolve") {
const path = extractKbResolvePath(payload);
return { ok: true, value: resolveKbRoot(path) };

View file

@ -1,6 +1,6 @@
{
"name": "dsh-netxops",
"version": "0.1.37",
"version": "0.1.38",
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
"license": "MIT",
"type": "module",

View file

@ -218,7 +218,9 @@ export class CardForm<T> {
private async store(field: string, value: unknown): Promise<boolean> {
await this.scope.set(field, value)
return this.userLayer()?.[field] === value
// `settings.mutate` can settle without throwing on a refused write; confirm
// via the resolved section value (user layer alone can lag or omit keys).
return this.sectionValue(field) === value || this.userLayer()?.[field] === value
}
private stage(field: string, edit: StagedEdit): void {

View file

@ -17,6 +17,7 @@ import {
} from './alarm-push-status-view.ts'
import {
fetchKbStatus,
reloadKbStatus,
resolveKbPath,
type KbSnapshot,
} from './kb-status-view.ts'
@ -155,7 +156,9 @@ export class NetxopsCardController {
private kbDirectoryPicker: DirectoryPickerRemote | undefined
private kbBrowseInFlight = false
private kbStatusInFlight = false
private kbReloadInFlight = false
private kbUiError: string | null = null
private lastSyncedKbRoot: string | undefined = undefined
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
private sessionsExportStatus: SessionsExportStatus | null = null
private sessionsExportBusy = false
@ -199,6 +202,7 @@ export class NetxopsCardController {
scope.subscribe(() => {
void this.readCredential()
void this.refreshKbPreview()
void this.syncKbRootFromSettings()
})
void this.readCredential()
}
@ -246,7 +250,7 @@ export class NetxopsCardController {
}
this.startStatusPoll()
void this.refreshAlarmPushStatus()
void this.refreshKbStatus()
void this.reloadKbFromHost()
void this.refreshKbPreview()
void this.refreshImDeliveryCatalog()
void this.refreshSessionsExportStatus()
@ -364,18 +368,7 @@ export class NetxopsCardController {
this.kbStatusInFlight = true
try {
const next = await fetchKbStatus(call)
const prev = this.kbStatus
if (
prev
&& prev.status === next.status
&& prev.realRoot === next.realRoot
&& prev.operatorName === next.operatorName
&& prev.country === next.country
&& prev.version === next.version
&& prev.errorMessage === next.errorMessage
) return
this.kbStatus = next
this.store.set(this.projection())
this.applyKbSnapshot(next)
} catch {
// Keep last good snapshot; next poll retries.
} finally {
@ -383,6 +376,50 @@ export class NetxopsCardController {
}
}
/**
* Ask Host to re-resolve kbRoot from live settings and republish.
* Prefer this after browse/save so the badge cannot stick on a stale snapshot.
*/
private async reloadKbFromHost(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.kbReloadInFlight) return
this.kbReloadInFlight = true
try {
const next = await reloadKbStatus(call)
this.applyKbSnapshot(next)
this.lastSyncedKbRoot = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
} catch {
// Fall back to published status poll.
void this.refreshKbStatus()
} finally {
this.kbReloadInFlight = false
}
}
private applyKbSnapshot(next: KbSnapshot): void {
const prev = this.kbStatus
if (
prev
&& prev.status === next.status
&& prev.realRoot === next.realRoot
&& prev.operatorName === next.operatorName
&& prev.country === next.country
&& prev.version === next.version
&& prev.errorMessage === next.errorMessage
) return
this.kbStatus = next
this.store.set(this.projection())
}
/** When settings document kbRoot changes, force Host reload (covers footer Save / Reset). */
private async syncKbRootFromSettings(): Promise<void> {
if (this.rpcCall === undefined) return
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
if (saved === this.lastSyncedKbRoot) return
this.lastSyncedKbRoot = saved
await this.reloadKbFromHost()
}
private async refreshKbPreview(): Promise<void> {
const call = this.rpcCall
if (call === undefined) return
@ -416,6 +453,38 @@ export class NetxopsCardController {
}
}
/**
* Persist kbRoot then force Host reload so the badge matches the bound package.
*/
private async commitKbRoot(path: string): Promise<void> {
const trimmed = path.trim()
this.kbUiError = null
this.store.set(this.projection())
await this.form.save()
const shell = this.form.shell()
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
if (shell.failed || saved !== trimmed) {
this.kbUiError = saved === trimmed
? 'kbRoot save failed'
: `kbRoot save did not land (saved="${saved || '(empty)'}")`
this.store.set(this.projection())
const call = this.rpcCall
if (call !== undefined && trimmed !== '') {
try {
this.kbPreview = await resolveKbPath(call, trimmed)
this.store.set(this.projection())
} catch {
// keep error banner
}
}
return
}
this.kbPreview = null
this.lastSyncedKbRoot = undefined
await this.reloadKbFromHost()
void this.refreshKbPreview()
}
/**
* Open the Host OS directory chooser (or /api directoryPicker/pick fallback)
* and write the path into kbRoot.
@ -428,12 +497,7 @@ export class NetxopsCardController {
const applyPath = (path: string): void => {
this.form.actions().edit('kbRoot', path)
void this.refreshKbPreview()
// Selecting a folder should land immediately — don't require a separate Save.
void this.form.save().then(() => {
void this.refreshKbStatus()
void this.refreshKbPreview()
})
void this.commitKbRoot(path)
}
const fail = (error: unknown): void => {

View file

@ -10,6 +10,7 @@ import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
export const NETXOPS_RPC_CHANNEL = '/netxops'
export const KB_STATUS_ENDPOINT = 'kb.status'
export const KB_RELOAD_ENDPOINT = 'kb.reload'
export const KB_RESOLVE_ENDPOINT = 'kb.resolve'
export type { KbSnapshot, KbStatus }
@ -50,6 +51,10 @@ export function asKbSnapshot(value: unknown): KbSnapshot {
}
}
function asRpcOk(result: unknown): result is { ok: true; value?: unknown } {
return result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true
}
/**
* Fetch the published KB snapshot from the Host.
*/
@ -58,10 +63,37 @@ export async function fetchKbStatus(
signal?: AbortSignal,
): Promise<KbSnapshot> {
const result = await call(NETXOPS_RPC_CHANNEL, KB_STATUS_ENDPOINT, {}, signal)
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asKbSnapshot((result as { value?: unknown }).value)
if (asRpcOk(result)) {
return asKbSnapshot(result.value)
}
return unconfiguredKbSnapshot()
// Do not pretend "unconfigured" on transport/shape miss — keep empty identity
// but mark error so the badge is not stuck on the soft "pure netx" copy.
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) {
const message = String(
(result as { error?: { message?: string } }).error?.message ?? 'kb.status rpc failed',
)
return { ...unconfiguredKbSnapshot(), status: 'error', errorMessage: message }
}
return {
...unconfiguredKbSnapshot(),
status: 'error',
errorMessage: 'kb.status unavailable',
}
}
/**
* Force Host to re-resolve `kbRoot` from live settings and republish.
*/
export async function reloadKbStatus(
call: AlarmPushRpcCall,
signal?: AbortSignal,
): Promise<KbSnapshot> {
const result = await call(NETXOPS_RPC_CHANNEL, KB_RELOAD_ENDPOINT, {}, signal)
if (asRpcOk(result)) {
return asKbSnapshot(result.value)
}
// Older hosts without kb.reload — fall back to status / resolve.
return fetchKbStatus(call, signal)
}
/**
@ -74,13 +106,17 @@ export async function resolveKbPath(
): Promise<KbSnapshot> {
// Prefer flat `{ path }` (netxops channel); also try `{ args: { path } }`.
let result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal)
if (!(result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true)) {
if (!asRpcOk(result)) {
result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { args: { path } }, signal)
}
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asKbSnapshot((result as { value?: unknown }).value)
if (asRpcOk(result)) {
return asKbSnapshot(result.value)
}
return {
...unconfiguredKbSnapshot(),
status: 'error',
errorMessage: 'kb.resolve unavailable',
}
return unconfiguredKbSnapshot()
}
/** Badge tone for the settings card. */

View file

@ -106,7 +106,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
kbBrowse: 'Browse…',
kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.',
kbBrowseFailed: 'Browse failed: {detail}',
kbSaveHint: 'Browse writes and saves automatically. Manual paste still needs Save.',
kbSaveHint: 'Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.',
kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}',
kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)',
kbStatusError: 'Knowledge base: error — {detail}',
@ -184,7 +184,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
kbBrowse: '浏览…',
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。',
kbBrowseFailed: '浏览失败:{detail}',
kbSaveHint: '点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。',
kbSaveHint: '点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。',
kbStatusConfigured: '知识库: {operator}({country}) v{version}',
kbStatusUnconfigured: '知识库: 未配置(纯 netx)',
kbStatusError: '知识库: 错误 — {detail}',

View file

@ -624,6 +624,28 @@ export function apply(ctx: Context, config: Config = Config({})): void {
if (endpoint === 'kb.status') {
return { ok: true, value: getKbContext() }
}
if (endpoint === 'kb.reload') {
// Re-read live settings and publish — used by the settings card after
// browse/save so the badge does not stick on a stale unconfigured snapshot.
const current = source()
const kb = resolveKbRoot(current.kbRoot ?? '')
publishKbContext(kb)
applyKbEnv(kb)
if (kb.status === 'configured') {
connCtx.logger.info(
'netxops: kb.reload → %s (%s / %s v%s)',
kb.realRoot,
kb.operatorName,
kb.country,
kb.version,
)
} else if (kb.status === 'error') {
connCtx.logger.warn('netxops: kb.reload error — %s', kb.errorMessage)
} else {
connCtx.logger.info('netxops: kb.reload → unconfigured (kbRoot empty)')
}
return { ok: true, value: kb }
}
if (endpoint === 'kb.resolve') {
const path = extractKbResolvePath(payload)
return { ok: true, value: resolveKbRoot(path) }