@dotdrelle/wiki-manager 0.14.10 → 0.14.13

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.
@@ -2,17 +2,88 @@ import { execFile } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { parseComposePsJson } from './compose.js';
5
+ import { activeCacertPath, cacertEnv } from './cacert.js';
6
+ import { COMPOSE_SERVICES, parseComposePsJson, serviceStates } from './compose.js';
7
+ import { buildMcpStatus, discoverMcpTools } from './mcp.js';
6
8
  import { listWikircProfiles, loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
7
9
  import { listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
8
10
 
9
11
  const execFileAsync = promisify(execFile);
12
+ const DEFAULT_CONNECTIVITY_URL = 'https://registry.npmjs.org/-/ping';
10
13
 
11
- async function checkAgents() {
14
+ function commandError(err) {
15
+ return String(err?.stderr ?? err?.stdout ?? err?.message ?? err ?? 'unknown error').trim();
16
+ }
17
+
18
+ function dockerFailure(err) {
19
+ if (err?.code === 'ENOENT') return { ok: false, context: { dockerMissing: true } };
20
+ const detail = commandError(err);
21
+ const unavailable = /Cannot connect to the Docker daemon|Is the docker daemon running|docker daemon|connection refused/i.test(detail);
22
+ return {
23
+ ok: false,
24
+ context: unavailable
25
+ ? { dockerUnavailable: true, dockerError: detail }
26
+ : { dockerUnavailable: true, dockerError: detail || 'Docker availability check failed.' },
27
+ };
28
+ }
29
+
30
+ export async function checkDockerAvailability({ exec = execFileAsync } = {}) {
31
+ try {
32
+ const { stdout } = await exec('docker', ['info', '--format', '{{json .ServerVersion}}'], {
33
+ cwd: managerRoot(),
34
+ env: process.env,
35
+ timeout: 5000,
36
+ maxBuffer: 1024 * 1024,
37
+ });
38
+ return { ok: true, detail: String(stdout ?? '').trim().replace(/^"|"$/g, '') || 'available' };
39
+ } catch (err) {
40
+ return dockerFailure(err);
41
+ }
42
+ }
43
+
44
+ export async function checkInternetConnectivity({
45
+ exec = execFileAsync,
46
+ url = process.env.WIKI_MANAGER_CONNECTIVITY_URL ?? DEFAULT_CONNECTIVITY_URL,
47
+ timeoutMs = 7000,
48
+ } = {}) {
49
+ const cacertPath = activeCacertPath();
50
+ const proxyUrl = process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY ?? null;
51
+ const proxyEnabled = process.env.NODE_USE_ENV_PROXY === '1';
52
+ if (cacertPath && !existsSync(cacertPath)) {
53
+ return {
54
+ ok: false,
55
+ context: { url, error: `CA certificate not found: ${cacertPath}`, cacertPath, proxyUrl, proxyEnabled },
56
+ };
57
+ }
58
+ const node = process.versions.bun ? (process.env.WIKI_MANAGER_NODE_BIN ?? 'node') : process.execPath;
59
+ const script = [
60
+ 'const controller = new AbortController();',
61
+ `const timer = setTimeout(() => controller.abort(), ${Math.max(1000, Number(timeoutMs) || 7000)});`,
62
+ "try { const response = await fetch(process.argv[1], { method: 'GET', signal: controller.signal }); if (!response.ok) throw new Error('HTTP ' + response.status); } finally { clearTimeout(timer); }",
63
+ ].join(' ');
64
+ try {
65
+ await exec(node, ['--input-type=module', '-e', script, url], {
66
+ cwd: managerRoot(),
67
+ // Run in a fresh Node process so both environment-proxy support and a
68
+ // custom CA are applied at process startup, just like the runtime.
69
+ env: { ...process.env, ...cacertEnv(cacertPath) },
70
+ timeout: Math.max(2000, Number(timeoutMs) + 1000),
71
+ maxBuffer: 1024 * 1024,
72
+ });
73
+ return { ok: true, detail: url, context: { url, cacertPath, proxyUrl, proxyEnabled } };
74
+ } catch (err) {
75
+ return {
76
+ ok: false,
77
+ context: { url, error: commandError(err) || 'Internet connectivity check failed.', cacertPath, proxyUrl, proxyEnabled },
78
+ };
79
+ }
80
+ }
81
+
82
+ async function checkAgents({ exec = execFileAsync } = {}) {
12
83
  const composeFile = join(managerRoot(), 'agents.docker-compose.yml');
13
84
  if (!existsSync(composeFile)) return null;
14
85
  try {
15
- const { stdout } = await execFileAsync('docker', [
86
+ const { stdout } = await exec('docker', [
16
87
  'compose',
17
88
  '--project-directory',
18
89
  managerRoot(),
@@ -115,16 +186,278 @@ function checkWikirc(workspace) {
115
186
  }
116
187
  }
117
188
 
118
- export async function runChecks() {
119
- const workspaces = listWorkspaces();
120
- const agents = await checkAgents();
189
+ function startupSession(workspace) {
190
+ const session = {
191
+ workspace: workspace.name,
192
+ workspacePath: workspace.workspacePath,
193
+ workspaceEnvFile: workspace.envFile,
194
+ workspaceEnv: workspace.env,
195
+ wikirc: null,
196
+ wikircConfig: null,
197
+ };
198
+ try {
199
+ const loaded = loadWikircProfile(workspace.workspacePath, 'default');
200
+ session.wikirc = {
201
+ profile: loaded.profile.name,
202
+ fileName: loaded.profile.fileName,
203
+ path: loaded.profile.path,
204
+ };
205
+ session.wikircConfig = loaded.config;
206
+ } catch { /* configuration gap is reported separately */ }
207
+ return session;
208
+ }
209
+
210
+ export async function checkWorkspaceContainers(workspace) {
211
+ try {
212
+ const states = await serviceStates(startupSession(workspace));
213
+ const unavailable = COMPOSE_SERVICES.filter((name) => !states[name]?.running);
214
+ return {
215
+ ok: unavailable.length === 0,
216
+ pending: unavailable.length > 0,
217
+ detail: unavailable.length === 0 ? `${COMPOSE_SERVICES.length} running` : `To start: ${unavailable.join(', ')}`,
218
+ context: { states, unavailable, command: `wiki-workspace up ${workspace.name}` },
219
+ };
220
+ } catch (err) {
221
+ return {
222
+ ok: false,
223
+ pending: true,
224
+ detail: 'Workspace containers remain to be started',
225
+ context: { error: commandError(err), command: `wiki-workspace up ${workspace.name}` },
226
+ };
227
+ }
228
+ }
229
+
230
+ function isLocalEndpoint(url) {
231
+ try {
232
+ const host = new URL(url).hostname.toLowerCase();
233
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === 'host.docker.internal';
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+
239
+ function mcpFailureKind(value) {
240
+ if (value.status === 'missing') return 'configuration';
241
+ const message = String(value.toolError ?? '');
242
+ if (/\b(401|403|unauthorized|forbidden)\b/i.test(message)) return 'authentication';
243
+ if (/initialize|json|protocol|parse|session id/i.test(message)) return 'protocol';
244
+ if (/fetch failed|ECONNREFUSED|ENOTFOUND|timeout|aborted|socket/i.test(message)) return 'unreachable';
245
+ return 'unavailable';
246
+ }
247
+
248
+ export async function checkMcpConnections(workspace, {
249
+ internetAvailable = true,
250
+ buildStatus = buildMcpStatus,
251
+ discover = discoverMcpTools,
252
+ } = {}) {
253
+ let configured;
254
+ try {
255
+ configured = buildStatus(startupSession(workspace));
256
+ } catch (err) {
257
+ return {
258
+ ok: false,
259
+ detail: 'MCP configuration invalid',
260
+ context: { error: commandError(err), endpoints: [] },
261
+ };
262
+ }
263
+ const skipped = {};
264
+ const candidates = {};
265
+ for (const [name, endpoint] of Object.entries(configured)) {
266
+ if (!internetAvailable && endpoint.external && !isLocalEndpoint(endpoint.url)) {
267
+ skipped[name] = { ...endpoint, tools: [], toolError: 'Skipped: Internet unavailable', preflightSkipped: true };
268
+ } else {
269
+ candidates[name] = endpoint;
270
+ }
271
+ }
272
+ let discovered;
273
+ try {
274
+ discovered = await discover(candidates);
275
+ } catch (err) {
276
+ return {
277
+ ok: false,
278
+ detail: 'MCP handshake failed',
279
+ context: { error: commandError(err), endpoints: [] },
280
+ };
281
+ }
282
+ const endpoints = { ...discovered, ...skipped };
283
+ const details = Object.entries(endpoints).map(([name, value]) => ({
284
+ name,
285
+ status: value.preflightSkipped ? 'skipped' : value.status === 'connected' ? 'connected' : 'failed',
286
+ tools: Array.isArray(value.tools) ? value.tools.length : 0,
287
+ reason: value.preflightSkipped ? 'internet unavailable' : value.status === 'connected' ? null : mcpFailureKind(value),
288
+ error: value.toolError ?? value.detail ?? null,
289
+ }));
290
+ const connected = details.filter((item) => item.status === 'connected');
291
+ const failed = details.filter((item) => item.status === 'failed');
292
+ const skippedCount = details.filter((item) => item.status === 'skipped').length;
293
+ return {
294
+ ok: failed.length === 0,
295
+ pending: failed.length > 0 || skippedCount > 0,
296
+ detail: `${connected.length} connected${failed.length ? `, ${failed.length} pending` : ''}${skippedCount ? `, ${skippedCount} waiting for Internet` : ''}`,
297
+ context: { endpoints: details, command: '/mcp status' },
298
+ };
299
+ }
300
+
301
+ function preflightStatus(gaps, checks) {
302
+ const setupRequired = gaps.some((gap) => gap.kind === 'workspace' || gap.kind === 'llm');
303
+ if (setupRequired) return 'setup_required';
304
+ return checks.every((check) => check.ok || check.skipped) ? 'ready' : 'degraded';
305
+ }
306
+
307
+ function compactNames(names, limit = 6) {
308
+ const visible = names.slice(0, limit);
309
+ return `${visible.join(', ')}${names.length > limit ? ` +${names.length - limit} more` : ''}`;
310
+ }
311
+
312
+ function compactContainerSummary(results, pending) {
313
+ if (pending.length === 0) {
314
+ return `${results.length}/${results.length} workspaces running: ${compactNames(results.map((item) => item.workspace.name))}`;
315
+ }
316
+ const groups = new Map();
317
+ for (const item of pending) {
318
+ const services = item.result.context?.unavailable?.length
319
+ ? item.result.context.unavailable.join(', ')
320
+ : 'services';
321
+ const names = groups.get(services) ?? [];
322
+ names.push(item.workspace.name);
323
+ groups.set(services, names);
324
+ }
325
+ const grouped = [...groups.entries()].slice(0, 3).map(([services, names]) =>
326
+ `${compactNames(names, 4)} — services: ${services}`);
327
+ const hiddenGroups = groups.size > 3 ? `; +${groups.size - 3} service group(s)` : '';
328
+ return `${pending.length}/${results.length} workspaces to start: ${grouped.join('; ')}${hiddenGroups}`;
329
+ }
330
+
331
+ export async function runChecks({
332
+ dockerCheck = checkDockerAvailability,
333
+ internetCheck = checkInternetConnectivity,
334
+ agentsCheck = checkAgents,
335
+ workspaceContainersCheck = checkWorkspaceContainers,
336
+ mcpCheck = checkMcpConnections,
337
+ onCheck = () => {},
338
+ } = {}) {
339
+ // Keep infrastructure checks first and sequential: the order is part of the
340
+ // startup contract and makes proxy/CA diagnostics understandable.
341
+ const docker = await dockerCheck();
342
+ onCheck({ kind: 'docker', ...docker });
343
+ const internet = await internetCheck();
344
+ onCheck({ kind: 'internet', ...internet });
345
+
346
+ const workspaces = [...listWorkspaces()].sort((a, b) => a.name.localeCompare(b.name));
347
+ const agents = docker.ok ? await agentsCheck() : null;
121
348
  const gaps = [];
349
+ if (!docker.ok) gaps.push({ kind: 'agents', context: docker.context ?? { dockerUnavailable: true } });
350
+ if (!internet.ok) gaps.push({ kind: 'network', context: internet.context ?? {} });
122
351
  if (agents) gaps.push(agents);
352
+ onCheck({
353
+ kind: 'agents',
354
+ ok: docker.ok && !agents,
355
+ skipped: !docker.ok,
356
+ pending: !docker.ok || Boolean(agents),
357
+ detail: !docker.ok
358
+ ? 'Waiting for Docker'
359
+ : agents?.context?.downServices?.length
360
+ ? `To start: ${agents.context.downServices.join(', ')}`
361
+ : 'Running',
362
+ context: { ...(agents?.context ?? {}), command: 'wiki-workspace agents up' },
363
+ });
123
364
  const workspaceGap = checkWorkspace(workspaces);
124
365
  if (workspaceGap) {
125
366
  gaps.push(workspaceGap);
367
+ onCheck({ kind: 'workspace', ok: false, detail: 'No valid workspace initialization' });
368
+ onCheck({ kind: 'containers', ok: false, skipped: true, pending: true, detail: 'Waiting for workspace initialization' });
369
+ onCheck({ kind: 'mcp', ok: false, skipped: true, pending: true, detail: 'Waiting for workspace initialization' });
126
370
  return gaps;
127
371
  }
128
- gaps.push(...checkWikirc(workspaces[0] ?? null));
372
+ const workspaceChecks = workspaces.map((workspace) => ({ workspace, gaps: checkWikirc(workspace) }));
373
+ const wikircGaps = workspaceChecks.flatMap((item) => item.gaps);
374
+ const invalidWorkspaces = workspaceChecks
375
+ .filter((item) => item.gaps.some((gap) => gap.kind === 'llm'))
376
+ .map((item) => item.workspace.name);
377
+ gaps.push(...wikircGaps);
378
+ onCheck({
379
+ kind: 'workspace',
380
+ ok: invalidWorkspaces.length === 0,
381
+ pending: invalidWorkspaces.length > 0,
382
+ detail: `${workspaces.length} registered: ${compactNames(workspaces.map((workspace) => workspace.name))}${invalidWorkspaces.length ? ` — configuration pending: ${compactNames(invalidWorkspaces)}` : ''}`,
383
+ context: { workspaces: workspaces.map((workspace) => workspace.name), invalidWorkspaces },
384
+ });
385
+ if (!docker.ok) {
386
+ onCheck({
387
+ kind: 'containers',
388
+ ok: false,
389
+ skipped: true,
390
+ pending: true,
391
+ detail: `Waiting for Docker — ${workspaces.length} workspace(s) to check`,
392
+ context: { command: `wiki-workspace up ${workspaces[0].name}` },
393
+ });
394
+ } else {
395
+ const results = await Promise.all(workspaces.map(async (workspace) => ({
396
+ workspace,
397
+ result: await workspaceContainersCheck(workspace),
398
+ })));
399
+ const pending = results.filter((item) => !item.result.ok);
400
+ onCheck({
401
+ kind: 'containers',
402
+ ok: pending.length === 0,
403
+ pending: pending.length > 0,
404
+ detail: compactContainerSummary(results, pending),
405
+ context: {
406
+ workspaces: results.map((item) => ({ name: item.workspace.name, ...item.result })),
407
+ unavailable: pending.flatMap((item) => item.result.context?.unavailable ?? []),
408
+ command: pending[0]?.result.context?.command ?? `wiki-workspace up ${pending[0]?.workspace.name ?? workspaces[0].name}`,
409
+ },
410
+ });
411
+ }
412
+ // MCP endpoints are independent connections. Keep probing them even when
413
+ // Docker is down: a remote or host-native MCP may still be healthy, while
414
+ // local container-backed endpoints will report their own reachability.
415
+ const mcpResults = await Promise.all(workspaces.map(async (workspace) => ({
416
+ workspace,
417
+ result: await mcpCheck(workspace, { internetAvailable: internet.ok }),
418
+ })));
419
+ const mcpPending = mcpResults.filter((item) => !item.result.ok);
420
+ const endpointChecks = mcpResults.flatMap((item) =>
421
+ (item.result.context?.endpoints ?? []).map((endpoint) => ({ workspace: item.workspace.name, ...endpoint })));
422
+ const connectedEndpoints = endpointChecks.filter((endpoint) => endpoint.status === 'connected').length;
423
+ const pendingEndpoints = endpointChecks.filter((endpoint) => endpoint.status === 'failed').length;
424
+ const skippedEndpoints = endpointChecks.filter((endpoint) => endpoint.status === 'skipped').length;
425
+ onCheck({
426
+ kind: 'mcp',
427
+ ok: mcpPending.length === 0,
428
+ pending: mcpPending.length > 0 || skippedEndpoints > 0,
429
+ detail: `${workspaces.length} workspace(s) checked — ${connectedEndpoints} connected${pendingEndpoints ? `, ${pendingEndpoints} pending` : ''}${skippedEndpoints ? `, ${skippedEndpoints} waiting for Internet` : ''}`,
430
+ context: {
431
+ endpoints: endpointChecks,
432
+ command: `/use ${mcpPending[0]?.workspace.name ?? workspaces[0].name}, then /mcp status`,
433
+ },
434
+ });
129
435
  return gaps;
130
436
  }
437
+
438
+ export async function runPreflightChecks(options = {}) {
439
+ const checks = [];
440
+ const externalOnCheck = options.onCheck;
441
+ const gaps = await runChecks({
442
+ ...options,
443
+ onCheck: (check) => {
444
+ checks.push(check);
445
+ externalOnCheck?.(check);
446
+ },
447
+ });
448
+ return { gaps, checks, status: preflightStatus(gaps, checks) };
449
+ }
450
+
451
+ export function withRuntimePreflight(preflight, runtime) {
452
+ const runtimeCheck = runtime?.url
453
+ ? { kind: 'runtime', ok: true, detail: runtime.started ? 'Started' : 'Connected' }
454
+ : { kind: 'runtime', ok: false, detail: runtime?.error ?? 'Unavailable' };
455
+ const checks = [...(preflight?.checks ?? []).filter((check) => check.kind !== 'runtime'), runtimeCheck];
456
+ return {
457
+ ...(preflight ?? { gaps: [] }),
458
+ checks,
459
+ status: preflight?.status === 'setup_required'
460
+ ? 'setup_required'
461
+ : checks.every((check) => check.ok || check.skipped) ? 'ready' : 'degraded',
462
+ };
463
+ }
@@ -4,7 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
- import { runChecks } from './startupCheck.js';
7
+ import { checkInternetConnectivity, checkMcpConnections, runChecks, runPreflightChecks, withRuntimePreflight } from './startupCheck.js';
8
8
 
9
9
  async function withWorkspace(wikircLines, fn) {
10
10
  const root = await mkdtemp(join(tmpdir(), 'wiki-manager-startup-check-'));
@@ -44,7 +44,13 @@ test('runChecks treats default LLM config without baseUrl as incomplete', async
44
44
  ' vector:',
45
45
  ' enabled: true',
46
46
  ], async () => {
47
- const gaps = await runChecks();
47
+ const gaps = await runChecks({
48
+ dockerCheck: async () => ({ ok: true }),
49
+ internetCheck: async () => ({ ok: true }),
50
+ agentsCheck: async () => null,
51
+ workspaceContainersCheck: async () => ({ ok: true, detail: 'running' }),
52
+ mcpCheck: async () => ({ ok: true, detail: 'connected' }),
53
+ });
48
54
  assert.equal(hasGap(gaps, 'llm'), true);
49
55
  });
50
56
  });
@@ -60,7 +66,188 @@ test('runChecks treats default LLM config with provider, baseUrl, apiKey and mod
60
66
  ' vector:',
61
67
  ' enabled: true',
62
68
  ], async () => {
63
- const gaps = await runChecks();
69
+ const gaps = await runChecks({
70
+ dockerCheck: async () => ({ ok: true }),
71
+ internetCheck: async () => ({ ok: true }),
72
+ agentsCheck: async () => null,
73
+ workspaceContainersCheck: async () => ({ ok: true, detail: 'running' }),
74
+ mcpCheck: async () => ({ ok: true, detail: 'connected' }),
75
+ });
64
76
  assert.equal(hasGap(gaps, 'llm'), false);
65
77
  });
66
78
  });
79
+
80
+ test('runChecks reports and probes every registered workspace', async () => {
81
+ const root = await mkdtemp(join(tmpdir(), 'wiki-manager-multi-startup-'));
82
+ const registryRoot = join(root, 'registry');
83
+ for (const name of ['alpha', 'beta']) {
84
+ const registryPath = join(registryRoot, name);
85
+ const workspacePath = join(root, name);
86
+ mkdirSync(registryPath, { recursive: true });
87
+ mkdirSync(workspacePath, { recursive: true });
88
+ writeFileSync(join(registryPath, '.env'), `WORKSPACE_NAME=${name}\nWIKI_WORKSPACE_PATH=${workspacePath}\n`, 'utf8');
89
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), [
90
+ 'llm:',
91
+ ' provider: openai-compatible',
92
+ ' baseUrl: http://localhost:8000/v1',
93
+ ' apiKey: key',
94
+ ' model: chat-model',
95
+ '',
96
+ ].join('\n'), 'utf8');
97
+ }
98
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
99
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
100
+ try {
101
+ const containerCalls = [];
102
+ const mcpCalls = [];
103
+ const reports = [];
104
+ await runChecks({
105
+ dockerCheck: async () => ({ ok: true }),
106
+ internetCheck: async () => ({ ok: true }),
107
+ agentsCheck: async () => null,
108
+ workspaceContainersCheck: async (workspace) => {
109
+ containerCalls.push(workspace.name);
110
+ return {
111
+ ok: false,
112
+ pending: true,
113
+ detail: 'to start',
114
+ context: { unavailable: ['serve', 'mcp-http', 'production-mcp'], command: `wiki-workspace up ${workspace.name}` },
115
+ };
116
+ },
117
+ mcpCheck: async (workspace) => {
118
+ mcpCalls.push(workspace.name);
119
+ return { ok: true, detail: 'connected', context: { endpoints: [{ name: 'wiki', status: 'connected' }] } };
120
+ },
121
+ onCheck: (check) => reports.push(check),
122
+ });
123
+ assert.deepEqual(containerCalls, ['alpha', 'beta']);
124
+ assert.deepEqual(mcpCalls, ['alpha', 'beta']);
125
+ const workspaceReport = reports.find((check) => check.kind === 'workspace');
126
+ assert.equal(workspaceReport.detail, '2 registered: alpha, beta');
127
+ assert.equal(
128
+ reports.find((check) => check.kind === 'containers').detail,
129
+ '2/2 workspaces to start: alpha, beta — services: serve, mcp-http, production-mcp',
130
+ );
131
+ assert.match(reports.find((check) => check.kind === 'mcp').detail, /2 workspace\(s\) checked/);
132
+ } finally {
133
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
134
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
135
+ }
136
+ });
137
+
138
+ test('runChecks executes Docker before Internet and reports both failures in order', async () => {
139
+ const calls = [];
140
+ const reports = [];
141
+ const gaps = await runChecks({
142
+ dockerCheck: async () => { calls.push('docker'); return { ok: false, context: { dockerUnavailable: true } }; },
143
+ internetCheck: async () => { calls.push('internet'); return { ok: false, context: { error: 'offline' } }; },
144
+ agentsCheck: async () => { calls.push('agents'); return null; },
145
+ onCheck: (check) => reports.push(check.kind),
146
+ });
147
+ assert.deepEqual(calls, ['docker', 'internet']);
148
+ assert.deepEqual(reports.slice(0, 3), ['docker', 'internet', 'agents']);
149
+ assert.deepEqual(gaps.slice(0, 2).map((gap) => gap.kind), ['agents', 'network']);
150
+ });
151
+
152
+ test('MCP preflight distinguishes authentication failure and skips remote endpoints while offline', async () => {
153
+ const workspace = { name: 'demo', workspacePath: '/tmp/demo', envFile: '/tmp/demo.env', env: {} };
154
+ const result = await checkMcpConnections(workspace, {
155
+ internetAvailable: false,
156
+ buildStatus: () => ({
157
+ local: { status: 'configured', url: 'http://127.0.0.1:3333/mcp' },
158
+ remote: { status: 'configured', url: 'https://mcp.example.test', external: true },
159
+ }),
160
+ discover: async (endpoints) => ({
161
+ local: { ...endpoints.local, status: 'configured', tools: [], toolError: '401 Unauthorized' },
162
+ }),
163
+ });
164
+ assert.equal(result.ok, false);
165
+ assert.deepEqual(result.context.endpoints.map(({ name, status, reason }) => ({ name, status, reason })), [
166
+ { name: 'local', status: 'failed', reason: 'authentication' },
167
+ { name: 'remote', status: 'skipped', reason: 'internet unavailable' },
168
+ ]);
169
+ });
170
+
171
+ test('MCP preflight reports invalid endpoint configuration without aborting startup', async () => {
172
+ const result = await checkMcpConnections({ name: 'demo', workspacePath: '/tmp/demo', env: {} }, {
173
+ buildStatus: () => { throw new Error('Unexpected token in mcp.endpoints.json'); },
174
+ });
175
+ assert.equal(result.ok, false);
176
+ assert.equal(result.detail, 'MCP configuration invalid');
177
+ assert.match(result.context.error, /mcp\.endpoints\.json/);
178
+ });
179
+
180
+ test('preflight status is setup_required without a valid workspace and runtime failure only degrades', async () => {
181
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
182
+ const root = await mkdtemp(join(tmpdir(), 'wiki-manager-empty-startup-'));
183
+ process.env.WIKI_WORKSPACES_DIR = root;
184
+ try {
185
+ const preflight = await runPreflightChecks({
186
+ dockerCheck: async () => ({ ok: true }),
187
+ internetCheck: async () => ({ ok: true }),
188
+ agentsCheck: async () => null,
189
+ });
190
+ assert.equal(preflight.status, 'setup_required');
191
+ assert.equal(withRuntimePreflight(preflight, { error: 'offline' }).status, 'setup_required');
192
+ const ready = { gaps: [], status: 'ready', checks: [{ kind: 'docker', ok: true }] };
193
+ assert.equal(withRuntimePreflight(ready, { error: 'offline' }).status, 'degraded');
194
+ } finally {
195
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
196
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
197
+ }
198
+ });
199
+
200
+ test('runChecks presents Internet failure before stopped agents after Docker succeeds', async () => {
201
+ const gaps = await runChecks({
202
+ dockerCheck: async () => ({ ok: true }),
203
+ internetCheck: async () => ({ ok: false, context: { error: 'offline' } }),
204
+ agentsCheck: async () => ({ kind: 'agents', context: { downServices: ['documents'] } }),
205
+ workspaceContainersCheck: async () => ({ ok: false, detail: 'stopped' }),
206
+ mcpCheck: async () => ({ ok: false, detail: 'unavailable' }),
207
+ });
208
+ assert.deepEqual(gaps.slice(0, 2).map((gap) => gap.kind), ['network', 'agents']);
209
+ });
210
+
211
+ test('runChecks still probes MCP endpoints when Docker is unavailable', async () => {
212
+ await withWorkspace([
213
+ 'llm:',
214
+ ' provider: openai-compatible',
215
+ ' baseUrl: http://localhost:8000/v1',
216
+ ' apiKey: key',
217
+ ' model: chat-model',
218
+ ], async () => {
219
+ const calls = [];
220
+ await runChecks({
221
+ dockerCheck: async () => ({ ok: false, context: { dockerUnavailable: true } }),
222
+ internetCheck: async () => ({ ok: true }),
223
+ agentsCheck: async () => { calls.push('agents'); return null; },
224
+ workspaceContainersCheck: async () => { calls.push('containers'); return { ok: true }; },
225
+ mcpCheck: async () => { calls.push('mcp'); return { ok: true, detail: 'remote connected' }; },
226
+ });
227
+ assert.deepEqual(calls, ['mcp']);
228
+ });
229
+ });
230
+
231
+ test('checkInternetConnectivity uses a fresh Node process with proxy and CA environment', async () => {
232
+ const calls = [];
233
+ const previousProxy = process.env.HTTPS_PROXY;
234
+ const previousProxyFlag = process.env.NODE_USE_ENV_PROXY;
235
+ process.env.HTTPS_PROXY = 'http://proxy.test:8080';
236
+ process.env.NODE_USE_ENV_PROXY = '1';
237
+ try {
238
+ const result = await checkInternetConnectivity({
239
+ url: 'https://connectivity.test/ping',
240
+ exec: async (...args) => { calls.push(args); return { stdout: '' }; },
241
+ });
242
+ assert.equal(result.ok, true);
243
+ assert.equal(calls.length, 1);
244
+ assert.equal(calls[0][1].at(-1), 'https://connectivity.test/ping');
245
+ assert.equal(calls[0][2].env.HTTPS_PROXY, 'http://proxy.test:8080');
246
+ assert.equal(result.context.proxyEnabled, true);
247
+ } finally {
248
+ if (previousProxy === undefined) delete process.env.HTTPS_PROXY;
249
+ else process.env.HTTPS_PROXY = previousProxy;
250
+ if (previousProxyFlag === undefined) delete process.env.NODE_USE_ENV_PROXY;
251
+ else process.env.NODE_USE_ENV_PROXY = previousProxyFlag;
252
+ }
253
+ });
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
6
6
  import YAML from 'yaml';
7
7
  import { checkMissingDockerImages } from './dockerImages.js';
8
8
  import { patchWikircProfile } from './wikirc.js';
9
- import { managerEnvFile, managerMcpEndpointsFile } from './env.js';
9
+ import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
10
10
  import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
11
11
 
12
12
  const execFileAsync = promisify(execFile);
@@ -68,6 +68,12 @@ export async function startAgents(options = {}) {
68
68
  // user's .env stayed empty on a fresh install.
69
69
  WIKI_MANAGER_ENV_FILE: managerEnvFile(),
70
70
  WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
71
+ // Same pinning for .agents-data: without it the script defaults to
72
+ // $PWD/.agents-data (the PACKAGE directory), while the serve stack
73
+ // mounts the workspace-root .agents-data — the documents container
74
+ // then reads an empty input dir and every conversion fails with
75
+ // "Input file does not exist" on a clean install.
76
+ AGENTS_DATA_DIR: resolveAgentsDataDir(),
71
77
  },
72
78
  timeout: options.timeout ?? 180_000,
73
79
  maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
@@ -88,6 +94,7 @@ export async function stopAgents(options = {}) {
88
94
  env: {
89
95
  ...process.env,
90
96
  WIKI_WORKSPACES_DIR: workspacesDir(),
97
+ AGENTS_DATA_DIR: resolveAgentsDataDir(),
91
98
  },
92
99
  timeout: options.timeout ?? 120_000,
93
100
  maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
@@ -22,3 +22,12 @@ test('wiki-workspace checks runtime pid command before killing', async () => {
22
22
  assert.match(script, /if ! runtime_pid_matches; then\n printf 'refusing to stop pid/);
23
23
  assert.match(script, /kill "\$\(cat "\$pid_file"\)"/);
24
24
  });
25
+
26
+ test('wiki-workspace regenerates CA compose overrides instead of retaining removed services', async () => {
27
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
28
+
29
+ assert.doesNotMatch(script, /if \[\[ ! -f "\$override_path" \]\]; then/);
30
+ assert.match(script, /local tmp_override="\$override_path\.tmp\.\$\$"/);
31
+ assert.match(script, /mv "\$tmp_override" "\$override_path"/);
32
+ assert.match(script, /Changes are overwritten on the next compose command/);
33
+ });
@@ -83,7 +83,7 @@ export async function ensureRuntime({
83
83
  if (!forceRestart && !stale && actualCacertPath === expectedCacertPath) {
84
84
  return { url, started: false, health: existing, token: auth.token, tokenPath: auth.tokenPath };
85
85
  }
86
- if (stale) console.error('runtime: source changed since start — restarting for fresh code.');
86
+ if (stale) console.log('\x1b[32mruntime: source changed since start — restarting for fresh code.\x1b[0m');
87
87
  await postRuntimeShutdown({ url, token: auth.token });
88
88
  await waitForRuntimeShutdown(url, auth.token, 2500);
89
89
  }
@@ -607,8 +607,25 @@ function failedTaskActivities(session) {
607
607
  return failedKeys.size > 0 ? all.filter((activity) => failedKeys.has(activity.key)) : all;
608
608
  }
609
609
 
610
- function ensurePlanProjection(session, runId) {
611
- if (!session.headlessPlan || session.agentProjection?.plan) return;
610
+ // Identity of a plan as displayed: the ordered list of task ids. Two plans
611
+ // with the same tasks in the same order are the same plan for projection
612
+ // purposes (statuses update through plan_step_updated, not a re-projection).
613
+ function planIdentitySignature(plan) {
614
+ return Array.isArray(plan) ? plan.map(planTaskId).join('|') : '';
615
+ }
616
+
617
+ export function ensurePlanProjection(session, runId) {
618
+ if (!session.headlessPlan) return;
619
+ // Re-emit plan_set not only when the projection has no plan yet, but also
620
+ // when the session plan has CHANGED shape since the last projection. A
621
+ // chained run (e.g. /skill pipeline) sets a fresh headlessPlan for step 2
622
+ // while the projection still holds step 1's plan; the old guard
623
+ // (`agentProjection?.plan` truthy → skip) then left the UI stuck on the
624
+ // step 1 plan. Compare task identities and re-project when they differ.
625
+ const projected = session.agentProjection?.plan ?? null;
626
+ if (projected && planIdentitySignature(projected) === planIdentitySignature(session.headlessPlan)) {
627
+ return;
628
+ }
612
629
  dispatchAgentEvent(session, createAgentEvent('plan_set', {
613
630
  origin: 'runtime',
614
631
  runId,