@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/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 (!existsSync(endpointsFile) && existsSync(endpointsExample)) {
55
- copyFileSync(endpointsExample, endpointsFile);
56
- created.push('mcp.endpoints.json');
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.9';
4
+ const WIKI_MANAGER_VERSION = '0.14.12';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -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
+ }