@dotdrelle/wiki-manager 0.15.73 → 0.15.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.73",
3
+ "version": "0.15.74",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -682,6 +682,14 @@ function runtimeProvidersSection(session) {
682
682
  // never truncates and stays scannable.
683
683
  lines.push('capabilities:');
684
684
  for (const capability of capabilities) lines.push(` ${capability}`);
685
+ // Configured in agent-runtimes.json but not served by the gateway (the
686
+ // list above is the intersection): say so, one per line, or the operator
687
+ // reads a shorter list without knowing the gateway lost its /config.
688
+ const drift = (session.runtimeProviderDrift ?? []).find((item) => item.runtimeId === runtimeId);
689
+ if (drift && drift.missing.length > 0) {
690
+ lines.push('not served by the gateway:');
691
+ for (const capability of drift.missing) lines.push(` ${capability}`);
692
+ }
685
693
  }
686
694
  return sectionBlock('Agentic runtime', lines);
687
695
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.73",
3
- "commit": "b7de9bb"
2
+ "version": "0.15.74",
3
+ "commit": "4ca493b"
4
4
  }
package/src/core/env.js CHANGED
@@ -11,6 +11,13 @@ const TEMPLATE_AUTHORING_CHAT_TOOLS = [
11
11
  'wiki_outline', 'template_read', 'template_write', 'build_context_write',
12
12
  'wiki_read_deliverable',
13
13
  ];
14
+ // Same additive rule for the packaged cme allow-list: an install scaffolded
15
+ // before the live search tools existed keeps the three legacy reads forever,
16
+ // and /chat can never call cme_confluence_search — the LLM is offered a
17
+ // server with no search and loops. A hand-edited list (not a superset of the
18
+ // legacy default) is the operator's and stays untouched.
19
+ const LEGACY_DEFAULT_CME_CHAT_TOOLS = ['cme_status', 'cme_sources_list', 'cme_export_status'];
20
+ const CME_SEARCH_CHAT_TOOLS = ['cme_confluence_search', 'cme_wiki_search'];
14
21
 
15
22
  export function userManagerDir() {
16
23
  return process.cwd();
@@ -148,17 +155,25 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
148
155
  const missingWikiChatTools = migrateWikiChatTools
149
156
  ? TEMPLATE_AUTHORING_CHAT_TOOLS.filter((tool) => !wikiAllow.includes(tool))
150
157
  : [];
158
+ const cmeAllow = current.chatAccess?.servers?.cme?.allow;
159
+ const migrateCmeChatTools = Array.isArray(cmeAllow)
160
+ && LEGACY_DEFAULT_CME_CHAT_TOOLS.every((tool) => cmeAllow.includes(tool));
161
+ const missingCmeChatTools = migrateCmeChatTools
162
+ ? CME_SEARCH_CHAT_TOOLS.filter((tool) => !cmeAllow.includes(tool))
163
+ : [];
151
164
  if (missing.length > 0) {
152
165
  for (const key of missing) current[key] = example[key];
153
166
  }
154
167
  for (const key of missingServers) currentServers[key] = exampleServers[key];
155
168
  wikiAllow?.push(...missingWikiChatTools);
156
- if (missing.length > 0 || missingServers.length > 0 || missingWikiChatTools.length > 0) {
169
+ cmeAllow?.push(...missingCmeChatTools);
170
+ const missingChatTools = [...missingWikiChatTools, ...missingCmeChatTools];
171
+ if (missing.length > 0 || missingServers.length > 0 || missingChatTools.length > 0) {
157
172
  writeFileSync(endpointsFile, `${JSON.stringify(current, null, 2)}\n`);
158
173
  const changes = [
159
174
  missing.length > 0 ? `keys: ${missing.join(', ')}` : '',
160
175
  missingServers.length > 0 ? `servers: ${missingServers.join(', ')}` : '',
161
- missingWikiChatTools.length > 0 ? `chat tools: ${missingWikiChatTools.join(', ')}` : '',
176
+ missingChatTools.length > 0 ? `chat tools: ${missingChatTools.join(', ')}` : '',
162
177
  ].filter(Boolean).join('; ');
163
178
  created.push(`mcp.endpoints.json ${changes}`);
164
179
  }
@@ -141,6 +141,37 @@ test('scaffold upgrades the packaged wiki chat allow-list with template authorin
141
141
  });
142
142
  });
143
143
 
144
+ test('scaffold upgrades the packaged cme chat allow-list with the live search tools', () => {
145
+ withTempManagerDir((dir) => {
146
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
147
+ const example = JSON.parse(readFileSync('mcp.endpoints.example.json', 'utf8'));
148
+ example.chatAccess.servers.cme.allow = ['cme_status', 'cme_sources_list', 'cme_export_status'];
149
+ writeFileSync(endpointsFile, JSON.stringify(example, null, 2));
150
+
151
+ const changes = ensureManagerScaffold();
152
+ const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
153
+
154
+ assert.ok(changes.some((item) => item.includes('cme_confluence_search')));
155
+ assert.deepEqual(after.chatAccess.servers.cme.allow, [
156
+ 'cme_status', 'cme_sources_list', 'cme_export_status', 'cme_confluence_search', 'cme_wiki_search',
157
+ ]);
158
+ });
159
+ });
160
+
161
+ test('scaffold leaves a custom cme chat allow-list untouched', () => {
162
+ withTempManagerDir((dir) => {
163
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
164
+ writeFileSync(endpointsFile, JSON.stringify({
165
+ mcpServers: {},
166
+ chatAccess: { servers: { cme: { allow: ['cme_status'] } } },
167
+ }, null, 2));
168
+
169
+ ensureManagerScaffold();
170
+ const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
171
+ assert.deepEqual(after.chatAccess.servers.cme.allow, ['cme_status']);
172
+ });
173
+ });
174
+
144
175
  test('scaffold leaves a custom wiki chat allow-list untouched', () => {
145
176
  withTempManagerDir((dir) => {
146
177
  const endpointsFile = join(dir, 'mcp.endpoints.json');
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.73';
4
+ const WIKI_MANAGER_VERSION = '0.15.74';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -357,13 +357,22 @@ export async function checkAgenticRuntimes({
357
357
  for (const { id, provider } of providers) {
358
358
  try {
359
359
  const description = await provider.describe();
360
+ const down = description.health === 'unavailable';
361
+ // Ask the runtime what it serves — describe() alone reflects /health,
362
+ // not the capability set, and a gateway that lost its config answers
363
+ // healthy with one default capability.
364
+ const capabilities = down ? [] : await provider.discoverCapabilities();
365
+ const missing = provider?.lastDiscovery?.missing ?? [];
360
366
  runtimes.push({
361
367
  name: id,
362
- status: description.health === 'unavailable' ? 'failed' : 'connected',
363
- capabilities: (description.capabilities ?? [])
368
+ status: down ? 'failed' : 'connected',
369
+ capabilities: (capabilities ?? [])
364
370
  .map((capability) => capability?.name ?? capability?.id ?? '')
365
371
  .filter(Boolean),
366
- reason: description.health === 'unavailable' ? (description.error ?? 'unavailable') : null,
372
+ ...(missing.length > 0 ? { missingCapabilities: [...missing] } : {}),
373
+ reason: down
374
+ ? (description.error ?? 'unavailable')
375
+ : (missing.length > 0 ? `does not serve configured: ${missing.join(', ')}` : null),
367
376
  });
368
377
  } catch (err) {
369
378
  runtimes.push({ name: id, status: 'failed', reason: commandError(err) });
@@ -327,3 +327,31 @@ test('checkAgenticRuntimes reports a down runtime as failed', async () => {
327
327
  assert.equal(result.context.runtimes[0].status, 'failed');
328
328
  assert.match(result.detail, /pending/);
329
329
  });
330
+
331
+ test('checkAgenticRuntimes asks the runtime what it serves and reports configured capabilities it does not', async () => {
332
+ const provider = {
333
+ runtime: 'deepagents',
334
+ lastDiscovery: null,
335
+ async describe() { return { runtime: 'deepagents', health: 'available', version: 't', protocolVersion: '1' }; },
336
+ async discoverCapabilities() {
337
+ this.lastDiscovery = { served: ['agent.review'], configured: ['agent.review', 'agent.notify'], missing: ['agent.notify'], extra: [] };
338
+ return [{ name: 'agent.review', operations: ['run'] }];
339
+ },
340
+ async execute() { throw new Error('unused'); },
341
+ async status() { throw new Error('unused'); },
342
+ async cancel() {},
343
+ async approve() {},
344
+ subscribe() { return () => {}; },
345
+ };
346
+ const result = await checkAgenticRuntimes({
347
+ loadConfig: () => [{ id: 'deepagents', type: 'deepagents', enabled: true }],
348
+ resolve: () => ({ providers: [{ id: 'deepagents', type: 'deepagents', provider }], skipped: [] }),
349
+ env: {},
350
+ });
351
+ assert.equal(result.ok, true);
352
+ const runtime = result.context.runtimes[0];
353
+ assert.equal(runtime.status, 'connected');
354
+ assert.deepEqual(runtime.capabilities, ['agent.review'], 'the served set, not the configured one');
355
+ assert.deepEqual(runtime.missingCapabilities, ['agent.notify']);
356
+ assert.match(runtime.reason, /does not serve configured: agent\.notify/);
357
+ });
@@ -56,7 +56,10 @@ export function createDeepAgentsProvider({
56
56
  }
57
57
  }
58
58
 
59
- return {
59
+ const provider = {
60
+ // Filled by discoverCapabilities: what the gateway served vs what the
61
+ // config declares. `null` until the first discovery.
62
+ lastDiscovery: null,
60
63
  async describe() {
61
64
  let health = 'available';
62
65
  let runtimeVersion = version ?? null;
@@ -77,10 +80,33 @@ export function createDeepAgentsProvider({
77
80
  ...(lastError ? { error: lastError } : {}),
78
81
  };
79
82
  },
83
+ // The gateway is ALWAYS asked what it serves. The static config (the
84
+ // manager's agent-runtimes.json entry) carries the richer metadata the
85
+ // resolver routes on — mutationClass, aliases, aliasOperations — but it is
86
+ // a declaration, not an observation: a gateway that lost its /config
87
+ // mount degrades to its built-in default and the manager, trusting the
88
+ // config alone, kept listing eight capabilities it could not govern.
89
+ // Only the capabilities BOTH declare are offered; the difference is
90
+ // exposed as `lastDiscovery` so /status and the runtime log can say why a
91
+ // configured capability is missing.
80
92
  async discoverCapabilities() {
81
- if (Array.isArray(capabilities)) return capabilities;
82
93
  const list = await httpJson('/capabilities');
83
- return Array.isArray(list) ? list : [];
94
+ const served = (Array.isArray(list) ? list : [])
95
+ .filter((item) => item && typeof item === 'object' && typeof item.name === 'string' && item.name.trim());
96
+ const servedNames = served.map((item) => item.name);
97
+ if (!Array.isArray(capabilities)) {
98
+ provider.lastDiscovery = { served: servedNames, configured: null, missing: [], extra: [] };
99
+ return served;
100
+ }
101
+ const configuredNames = capabilities.map((item) => String(item?.name ?? ''));
102
+ const offered = capabilities.filter((item) => servedNames.includes(String(item?.name ?? '')));
103
+ provider.lastDiscovery = {
104
+ served: servedNames,
105
+ configured: configuredNames,
106
+ missing: configuredNames.filter((name) => !servedNames.includes(name)),
107
+ extra: servedNames.filter((name) => !configuredNames.includes(name)),
108
+ };
109
+ return offered;
84
110
  },
85
111
  async execute(request = {}) {
86
112
  const accepted = await httpJson('/runs', {
@@ -165,4 +191,5 @@ export function createDeepAgentsProvider({
165
191
  return () => controller.abort();
166
192
  },
167
193
  };
194
+ return provider;
168
195
  }
@@ -74,16 +74,44 @@ test('discoverCapabilities fetches /capabilities', async () => {
74
74
  assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
75
75
  });
76
76
 
77
- test('discoverCapabilities uses the static config when provided', async () => {
78
- const fetchImpl = mockFetch({});
77
+ test('discoverCapabilities keeps the configured metadata but only for capabilities the gateway serves', async () => {
78
+ const fetchImpl = mockFetch({
79
+ 'GET /capabilities': () => jsonResponse(200, [
80
+ { name: 'agent.review', operations: ['run'] },
81
+ { name: 'agent.notify', operations: ['run'] },
82
+ ]),
83
+ });
79
84
  const provider = createDeepAgentsProvider({
80
85
  endpoint: 'http://agent-runtime:8080',
81
- capabilities: [{ name: 'agent.review', operations: ['run'] }],
86
+ capabilities: [
87
+ { name: 'agent.review', operations: ['run'], aliases: ['audit'] },
88
+ { name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
89
+ { name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
90
+ ],
82
91
  fetchImpl,
83
92
  });
84
93
 
94
+ const offered = await provider.discoverCapabilities();
95
+ assert.equal(fetchImpl.calls.length, 1, 'the gateway is always asked what it serves');
96
+ assert.deepEqual(offered.map((item) => item.name), ['agent.review', 'agent.notify']);
97
+ assert.equal(offered[0].aliases[0], 'audit', 'the configured metadata is what the resolver routes on');
98
+ assert.deepEqual(provider.lastDiscovery, {
99
+ served: ['agent.review', 'agent.notify'],
100
+ configured: ['agent.review', 'agent.research', 'agent.notify'],
101
+ missing: ['agent.research'],
102
+ extra: [],
103
+ });
104
+ });
105
+
106
+ test('discoverCapabilities with no static config serves the gateway list as-is', async () => {
107
+ const fetchImpl = mockFetch({
108
+ 'GET /capabilities': () => jsonResponse(200, [{ name: 'agent.review', operations: ['run'] }]),
109
+ });
110
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
111
+
85
112
  assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
86
- assert.equal(fetchImpl.calls.length, 0, 'no HTTP call when capabilities are static');
113
+ assert.deepEqual(provider.lastDiscovery.missing, []);
114
+ assert.equal(provider.lastDiscovery.configured, null);
87
115
  });
88
116
 
89
117
  test('execute POSTs /runs and returns the runId', async () => {
@@ -24,6 +24,11 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
24
24
  : (runtimeProviders?.list?.() ?? []);
25
25
  const agents = [];
26
26
  const unavailable = [];
27
+ // Configured-but-not-served capabilities, per runtime (deepagents providers
28
+ // fill `lastDiscovery`). A drift is not an outage: the runtime stays
29
+ // available with the capabilities it really serves, and the difference is
30
+ // reported so an operator can see WHY agent.research is not routable.
31
+ const drift = [];
27
32
 
28
33
  for (const entry of providers) {
29
34
  const provider = entry?.provider ?? entry;
@@ -53,9 +58,13 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
53
58
  for (const capability of capabilities ?? []) {
54
59
  agents.push(runtimeProviderAgent(runtimeId, provider, description, capability, health));
55
60
  }
61
+ const discovery = provider?.lastDiscovery;
62
+ if (discovery && Array.isArray(discovery.missing) && discovery.missing.length > 0) {
63
+ drift.push({ runtimeId, missing: [...discovery.missing], served: [...(discovery.served ?? [])] });
64
+ }
56
65
  }
57
66
 
58
- return { agents, unavailable };
67
+ return { agents, unavailable, drift };
59
68
  }
60
69
 
61
70
  /**
@@ -254,7 +263,10 @@ export async function discoverRuntimeProvidersOnce(session, {
254
263
  },
255
264
  });
256
265
  const resolved = resolveRuntimeProviders(entries);
257
- const { agents, unavailable } = await discoverRuntimeProviderAgents(resolved.providers);
266
+ const { agents, unavailable, drift } = await discoverRuntimeProviderAgents(resolved.providers);
267
+ // Exposed for /status: which configured capabilities the runtime does not
268
+ // serve right now. Replaced on every scan — a recovered gateway clears it.
269
+ session.runtimeProviderDrift = drift;
258
270
 
259
271
  // A failed probe is not a lost agent (same rule as agentRegistry): a runtime
260
272
  // that reported unavailable this round but for which we hold a last-known-good
@@ -330,6 +342,20 @@ export async function discoverRuntimeProvidersOnce(session, {
330
342
  : `agent-runtimes: ${item.runtimeId} unavailable (${item.error})`);
331
343
  }
332
344
  }
345
+ // Same edge-triggered rule for a capability drift: announced when it
346
+ // appears (or changes), silent while it persists, announced again after a
347
+ // recovery.
348
+ session._runtimeProviderDriftAnnounced ??= new Map();
349
+ const announced = session._runtimeProviderDriftAnnounced;
350
+ const currentDrift = new Map(drift.map((item) => [item.runtimeId, item.missing.join(',')]));
351
+ for (const item of drift) {
352
+ if (announced.get(item.runtimeId) === currentDrift.get(item.runtimeId)) continue;
353
+ dispatchRuntimeLog(session, `agent-runtimes: ${item.runtimeId} does not serve ${item.missing.length} configured capabilit${item.missing.length === 1 ? 'y' : 'ies'} (${item.missing.join(', ')}) — it serves ${item.served.join(', ') || 'nothing'}; check the gateway's /config mount (agent-runtimes.json)`);
354
+ }
355
+ for (const id of [...announced.keys()]) {
356
+ if (!currentDrift.has(id)) announced.delete(id);
357
+ }
358
+ for (const [id, key] of currentDrift) announced.set(id, key);
333
359
  session._runtimeProviderDown = currentDown;
334
360
  return effectiveAgents;
335
361
  }
@@ -265,6 +265,54 @@ test('discoverRuntimeProvidersOnce announces a down runtime only once across re-
265
265
  assert.equal(logs.length, 1, 'the degradation is announced once, not on every re-scan');
266
266
  });
267
267
 
268
+ test('a gateway serving fewer capabilities than configured is a drift: offered = intersection, announced once, cleared on recovery', async () => {
269
+ const session = sessionWithEvents();
270
+ const config = [{
271
+ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true,
272
+ capabilities: [
273
+ { name: 'agent.review', operations: ['run'] },
274
+ { name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
275
+ { name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
276
+ ],
277
+ }];
278
+ const originalFetch = globalThis.fetch;
279
+ let served = [{ name: 'agent.review', operations: ['run'] }];
280
+ globalThis.fetch = async (url) => {
281
+ if (String(url).includes('/health')) return new Response(JSON.stringify({ ok: true, version: 't' }), { status: 200, headers: { 'content-type': 'application/json' } });
282
+ if (String(url).includes('/capabilities')) return new Response(JSON.stringify(served), { status: 200, headers: { 'content-type': 'application/json' } });
283
+ throw new Error('unexpected probe ' + String(url));
284
+ };
285
+ try {
286
+ await discoverRuntimeProvidersOnce(session, { config });
287
+ await discoverRuntimeProvidersOnce(session, { config });
288
+ const driftLogs = () => (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
289
+ && String(event.payload?.message ?? '').includes('does not serve'));
290
+
291
+ // Only what the gateway serves is routable — a governed capability the
292
+ // gateway would run ungoverned is not offered at all.
293
+ assert.deepEqual(session.runtimeProviderAgents.map((agent) => agent.agentInstanceId), ['deepagents::agent.review']);
294
+ assert.deepEqual(session.runtimeProviderDrift, [{
295
+ runtimeId: 'deepagents', missing: ['agent.research', 'agent.notify'], served: ['agent.review'],
296
+ }]);
297
+ assert.equal(driftLogs().length, 1, 'announced once across re-scans');
298
+ assert.match(String(driftLogs()[0].payload.message), /does not serve 2 configured capabilities \(agent\.research, agent\.notify\)/);
299
+ assert.match(String(driftLogs()[0].payload.message), /\/config mount/);
300
+
301
+ // The gateway recovers its config: no drift, all three routable.
302
+ served = config[0].capabilities.map(({ name, operations }) => ({ name, operations }));
303
+ await discoverRuntimeProvidersOnce(session, { config });
304
+ assert.equal(session.runtimeProviderAgents.length, 3);
305
+ assert.deepEqual(session.runtimeProviderDrift, []);
306
+
307
+ // …and a later drift is announced again.
308
+ served = [{ name: 'agent.review', operations: ['run'] }];
309
+ await discoverRuntimeProvidersOnce(session, { config });
310
+ assert.equal(driftLogs().length, 2);
311
+ } finally {
312
+ globalThis.fetch = originalFetch;
313
+ }
314
+ });
315
+
268
316
  test('an HTTP 401 gateway probe is announced as not-ready with the ⚠ glyph, not as a failure', async () => {
269
317
  const session = sessionWithEvents();
270
318
  const originalFetch = globalThis.fetch;