@dotdrelle/wiki-manager 0.14.9 → 0.14.12
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/.env.example +15 -1
- package/README.md +42 -2
- package/agents.docker-compose.yml +4 -5
- package/mcp.endpoints.example.json +2 -2
- package/package.json +2 -2
- package/src/agent/graph.js +8 -5
- package/src/agent/graph.test.js +4 -1
- package/src/cli/wiki-manager.js +73 -23
- package/src/commands/slash.js +33 -6
- package/src/commands/slash.test.js +17 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/cacert.js +11 -8
- package/src/core/cacert.test.js +35 -0
- package/src/core/compose.js +21 -3
- package/src/core/dockerCompose.test.js +3 -0
- package/src/core/dockerImages.js +22 -0
- package/src/core/env.js +27 -3
- package/src/core/env.test.js +72 -0
- package/src/core/mcp.js +1 -1
- package/src/core/startupCheck.js +340 -7
- package/src/core/startupCheck.test.js +190 -3
- package/src/core/wikiSetup.js +26 -2
- package/src/core/wikiWorkspace.test.js +9 -0
- package/src/runtime/lifecycle.js +1 -1
- package/src/shell/LeftPane.tsx +17 -2
- package/src/shell/RightPane.tsx +34 -17
- package/src/shell/SetupWizard.tsx +53 -10
- package/src/shell/StartupScreen.tsx +42 -9
- package/src/shell/externalLinks.js +67 -0
- package/src/shell/repl.js +149 -29
- package/src/shell/repl.test.js +187 -0
- package/src/shell/tui.tsx +60 -3
- package/wiki-workspace +20 -17
package/src/core/env.js
CHANGED
|
@@ -51,9 +51,33 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
|
|
|
51
51
|
const created = [];
|
|
52
52
|
const endpointsFile = managerMcpEndpointsFile();
|
|
53
53
|
const endpointsExample = join(packageRoot, 'mcp.endpoints.example.json');
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
if (existsSync(endpointsExample)) {
|
|
55
|
+
if (!existsSync(endpointsFile)) {
|
|
56
|
+
copyFileSync(endpointsExample, endpointsFile);
|
|
57
|
+
created.push('mcp.endpoints.json');
|
|
58
|
+
} else {
|
|
59
|
+
// Additive migration: the scaffold only copies the example on first run,
|
|
60
|
+
// so installs that predate a new top-level key (e.g. chatAccess) never
|
|
61
|
+
// receive it and the feature stays silently disabled. Merge ONLY the
|
|
62
|
+
// top-level keys missing from the operator's file; existing keys —
|
|
63
|
+
// including a hand-edited chatAccess — are never touched. To disable a
|
|
64
|
+
// feature key permanently, set it to null instead of deleting it: null is
|
|
65
|
+
// "present", so the merge preserves it and readers treat it as absent.
|
|
66
|
+
try {
|
|
67
|
+
const current = JSON.parse(readFileSync(endpointsFile, 'utf8'));
|
|
68
|
+
const example = JSON.parse(readFileSync(endpointsExample, 'utf8'));
|
|
69
|
+
if (current && typeof current === 'object' && !Array.isArray(current)) {
|
|
70
|
+
const missing = Object.keys(example).filter((key) => !(key in current));
|
|
71
|
+
if (missing.length > 0) {
|
|
72
|
+
for (const key of missing) current[key] = example[key];
|
|
73
|
+
writeFileSync(endpointsFile, `${JSON.stringify(current, null, 2)}\n`);
|
|
74
|
+
created.push(`mcp.endpoints.json keys: ${missing.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Unreadable or invalid JSON: leave the operator's file strictly alone.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
57
81
|
}
|
|
58
82
|
const envFile = managerEnvFile();
|
|
59
83
|
const envExample = join(packageRoot, '.env.example');
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { ensureManagerScaffold } from './env.js';
|
|
8
|
+
|
|
9
|
+
// ensureManagerScaffold resolves the manager state dir from
|
|
10
|
+
// WIKI_MANAGER_ENV_FILE — point it at a temp dir for each test.
|
|
11
|
+
function withTempManagerDir(fn) {
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), 'wiki-manager-env-test-'));
|
|
13
|
+
const previous = process.env.WIKI_MANAGER_ENV_FILE;
|
|
14
|
+
process.env.WIKI_MANAGER_ENV_FILE = join(dir, '.env');
|
|
15
|
+
try {
|
|
16
|
+
return fn(dir);
|
|
17
|
+
} finally {
|
|
18
|
+
if (previous === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
|
|
19
|
+
else process.env.WIKI_MANAGER_ENV_FILE = previous;
|
|
20
|
+
rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('scaffold copies the packaged examples into a fresh directory', () => {
|
|
25
|
+
withTempManagerDir((dir) => {
|
|
26
|
+
const created = ensureManagerScaffold();
|
|
27
|
+
assert.ok(created.includes('mcp.endpoints.json'));
|
|
28
|
+
assert.ok(created.includes('.env'));
|
|
29
|
+
const endpoints = JSON.parse(readFileSync(join(dir, 'mcp.endpoints.json'), 'utf8'));
|
|
30
|
+
assert.ok(endpoints.mcpServers);
|
|
31
|
+
assert.ok(endpoints.chatAccess);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('scaffold merges missing top-level keys into an existing endpoints file', () => {
|
|
36
|
+
withTempManagerDir((dir) => {
|
|
37
|
+
const endpointsFile = join(dir, 'mcp.endpoints.json');
|
|
38
|
+
// Pre-chatAccess install: only mcpServers, with operator edits.
|
|
39
|
+
writeFileSync(endpointsFile, JSON.stringify({
|
|
40
|
+
mcpServers: { custom: { url: 'http://localhost:9999/mcp/' } },
|
|
41
|
+
}, null, 2));
|
|
42
|
+
const created = ensureManagerScaffold();
|
|
43
|
+
assert.ok(created.some((item) => item.includes('chatAccess')));
|
|
44
|
+
const merged = JSON.parse(readFileSync(endpointsFile, 'utf8'));
|
|
45
|
+
// Operator's servers untouched; new key added from the example.
|
|
46
|
+
assert.deepEqual(merged.mcpServers, { custom: { url: 'http://localhost:9999/mcp/' } });
|
|
47
|
+
assert.ok(merged.chatAccess?.servers?.wiki);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('scaffold never overwrites an existing chatAccess, including explicit null', () => {
|
|
52
|
+
withTempManagerDir((dir) => {
|
|
53
|
+
const endpointsFile = join(dir, 'mcp.endpoints.json');
|
|
54
|
+
writeFileSync(endpointsFile, JSON.stringify({
|
|
55
|
+
mcpServers: {},
|
|
56
|
+
chatAccess: null,
|
|
57
|
+
}, null, 2));
|
|
58
|
+
ensureManagerScaffold();
|
|
59
|
+
const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
|
|
60
|
+
// null means "deliberately disabled" — the merge must preserve it.
|
|
61
|
+
assert.equal(after.chatAccess, null);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('scaffold leaves an invalid endpoints file strictly alone', () => {
|
|
66
|
+
withTempManagerDir((dir) => {
|
|
67
|
+
const endpointsFile = join(dir, 'mcp.endpoints.json');
|
|
68
|
+
writeFileSync(endpointsFile, '{ not json');
|
|
69
|
+
ensureManagerScaffold();
|
|
70
|
+
assert.equal(readFileSync(endpointsFile, 'utf8'), '{ not json');
|
|
71
|
+
});
|
|
72
|
+
});
|
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.14.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.14.12';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/startupCheck.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
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
|
-
|
|
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
|
+
}
|