@dotdrelle/wiki-manager 0.15.34 → 0.15.38

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.
Files changed (41) hide show
  1. package/.env.example +8 -5
  2. package/README.md +42 -0
  3. package/agents.docker-compose.yml +11 -16
  4. package/docker-compose.yml +9 -8
  5. package/package.json +2 -2
  6. package/src/agent/graph.js +20 -0
  7. package/src/cli/wiki-manager.js +20 -0
  8. package/src/commands/slash.js +85 -13
  9. package/src/commands/slash.test.js +85 -3
  10. package/src/core/agentsCompose.js +86 -1
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/compose.js +24 -2
  13. package/src/core/dockerCompose.test.js +18 -0
  14. package/src/core/env.js +2 -1
  15. package/src/core/env.test.js +14 -0
  16. package/src/core/googleGrants.js +38 -0
  17. package/src/core/googleGrants.test.js +59 -0
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/mcp.test.js +1 -0
  20. package/src/core/mcpEndpoints.js +96 -0
  21. package/src/core/mcpEndpoints.test.js +126 -0
  22. package/src/core/modelFetch.js +241 -27
  23. package/src/core/modelFetch.test.js +213 -0
  24. package/src/core/profileServiceStatus.test.js +146 -0
  25. package/src/core/wikiSetup.js +11 -2
  26. package/src/core/wikiWorkspace.test.js +16 -3
  27. package/src/orchestrator/agentRegistry.js +13 -1
  28. package/src/orchestrator/agentRegistry.test.js +20 -0
  29. package/src/runtime/lifecycle.js +105 -3
  30. package/src/runtime/lifecycle.test.js +68 -0
  31. package/src/runtime/server.js +28 -0
  32. package/src/shell/LeftPane.tsx +55 -41
  33. package/src/shell/SetupWizard.tsx +416 -116
  34. package/src/shell/repl.js +61 -12
  35. package/src/shell/repl.test.js +48 -19
  36. package/src/shell/setupWizardDiscovery.test.js +48 -0
  37. package/src/shell/setupWizardPlaceholders.test.js +15 -2
  38. package/src/shell/setupWizardSuggestions.test.js +36 -2
  39. package/src/shell/wrapText.js +57 -0
  40. package/src/shell/wrapText.test.js +48 -0
  41. package/wiki-workspace +122 -32
@@ -0,0 +1,146 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { PROFILE_FLAGS, profileServiceStatus } from './agentsCompose.js';
6
+
7
+ test('a disabled service says which flag, in which file, and what to do', () => {
8
+ const status = profileServiceStatus('connectors', { env: {} });
9
+ assert.equal(status.enabled, false);
10
+ assert.equal(status.flag, 'CONNECTORS_ENABLED');
11
+ // Le message d'origine — « unavailable or disabled » — ne disait ni lequel
12
+ // des deux, ni où regarder : Donna, faute de fait à citer, inventait un
13
+ // `cme.yaml` et un « manifeste des services actifs ».
14
+ assert.match(status.message, /CONNECTORS_ENABLED is not set/);
15
+ assert.match(status.message, /set CONNECTORS_ENABLED=true/);
16
+ assert.match(status.message, /\/start agents/);
17
+ assert.match(status.message, /\.env/);
18
+ // Le piège du profil Compose : le conteneur n'existe pas du tout, ce qui
19
+ // ressemble à un service planté.
20
+ assert.match(status.message, /does not exist at all/);
21
+ });
22
+
23
+ test('a disabled service quotes the value actually found', () => {
24
+ const status = profileServiceStatus('connectors', { env: { CONNECTORS_ENABLED: 'false' } });
25
+ assert.equal(status.enabled, false);
26
+ assert.match(status.message, /CONNECTORS_ENABLED is "false"/);
27
+ });
28
+
29
+ test('an enabled but unreachable service is a different diagnosis', () => {
30
+ const status = profileServiceStatus('connectors', { env: { CONNECTORS_ENABLED: 'true' } });
31
+ assert.equal(status.enabled, true);
32
+ assert.match(status.message, /enabled .* but not reachable/);
33
+ assert.doesNotMatch(status.message, /set CONNECTORS_ENABLED=true/);
34
+ });
35
+
36
+ test('an unknown service is not described as disabled', () => {
37
+ const status = profileServiceStatus('nope', { env: {} });
38
+ assert.equal(status.flag, null);
39
+ assert.match(status.message, /no "nope" service/);
40
+ });
41
+
42
+ test('/connector reports the reason instead of a bare "unavailable or disabled"', () => {
43
+ const source = readFileSync(
44
+ fileURLToPath(new URL('../commands/slash.js', import.meta.url)),
45
+ 'utf8',
46
+ );
47
+ assert.match(source, /profileServiceStatus\('connectors'\)\.message/);
48
+ assert.doesNotMatch(source, /The connectors service is unavailable or disabled\./);
49
+ });
50
+
51
+ test('every profile flag is documented where the operator will look', () => {
52
+ const doc = readFileSync(
53
+ fileURLToPath(new URL('../../../llm-wiki/help-doc/06-troubleshooting.md', import.meta.url)),
54
+ 'utf8',
55
+ );
56
+ // Donna répond aux questions produit depuis cette documentation : un drapeau
57
+ // absent d'ici est un drapeau qu'elle ne peut que deviner.
58
+ for (const flag of Object.values(PROFILE_FLAGS)) {
59
+ assert.ok(doc.includes(flag), `${flag} must be documented in help-doc/06-troubleshooting.md`);
60
+ }
61
+ });
62
+
63
+ test('stopping the agents resolves the same manager files as starting them', () => {
64
+ const source = readFileSync(
65
+ fileURLToPath(new URL('./wikiSetup.js', import.meta.url)),
66
+ 'utf8',
67
+ );
68
+ // `stopAgents` n'épinglait pas WIKI_MANAGER_ENV_FILE : le script résolvait
69
+ // `.env` contre le répertoire du paquet, `connectors_enabled` y lisait un
70
+ // profil inactif, et `docker compose down` partait sans
71
+ // `--profile connectors`. Un service à profil inactif étant invisible à
72
+ // Compose, le conteneur restait debout — `/stop agents` annonçait un succès
73
+ // en laissant tourner ce qu'il prétendait arrêter.
74
+ const stopBlock = source.slice(source.indexOf('export async function stopAgents'));
75
+ for (const key of ['WIKI_MANAGER_ENV_FILE: managerEnvFile()', 'WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile()']) {
76
+ assert.ok(stopBlock.includes(key), `stopAgents must pin ${key}`);
77
+ }
78
+ });
79
+
80
+ test('"all" means the same thing to start and to stop', () => {
81
+ const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
82
+ // `/start all` démarrait les agents, `/stop all` ne les arrêtait pas : la
83
+ // séquence start/stop ne revenait donc pas à l'état de départ.
84
+ assert.match(slash, /const startsAgents = service === 'all';/);
85
+ assert.match(slash, /const stopsAgents = service === 'all';/);
86
+ assert.match(slash, /if \(stopsAgents\) \{\s*\n\s*const agentsResult = await runAgentCommand\(stopAgents, 'stop'\);/);
87
+ });
88
+
89
+ test('the addressable agents are read from the compose file, never hard-coded', async () => {
90
+ const { agentServiceNames, togglableAgentNames } = await import('./agentsCompose.js');
91
+ const declared = agentServiceNames();
92
+
93
+ // Une liste figée annonçait `mailer`, qui ne vit que dans l'exemple
94
+ // d'override : proposer un service absent, c'est promettre un
95
+ // « no such service ».
96
+ assert.ok(declared.includes('connectors'));
97
+ assert.ok(declared.includes('cme'));
98
+ assert.ok(!declared.includes('mailer'), 'mailer is not declared until the operator uncomments it');
99
+
100
+ // Et seuls les agents à drapeau se proposent un par un dans l'interface.
101
+ assert.deepEqual(togglableAgentNames(), ['connectors']);
102
+ for (const name of togglableAgentNames()) {
103
+ assert.ok(declared.includes(name), `${name} must actually exist in the agents stack`);
104
+ }
105
+ });
106
+
107
+ test('an agent can be started and stopped on its own', () => {
108
+ const setup = readFileSync(fileURLToPath(new URL('./wikiSetup.js', import.meta.url)), 'utf8');
109
+ const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
110
+ const script = readFileSync(fileURLToPath(new URL('../../wiki-workspace', import.meta.url)), 'utf8');
111
+
112
+ assert.match(setup, /'agents', 'up', \.\.\.\(options\.services \?\? \[\]\)/);
113
+ assert.match(setup, /'agents', 'down', \.\.\.\(options\.services \?\? \[\]\)/);
114
+ // La pile agents est un projet Compose distinct : sans ce routage, `/start
115
+ // connectors` allait chercher un service inexistant côté workspace.
116
+ assert.match(slash, /agentServiceNames\(\)\.includes\(service\)/);
117
+ // `down <service>` supprimerait le réseau commun : Compose impose stop + rm.
118
+ assert.match(script, /_agents_dc stop "\$@"/);
119
+ assert.match(script, /_agents_dc rm -f "\$@"/);
120
+ });
121
+
122
+ test('a connector read tool is not mistaken for the limit of its agent', () => {
123
+ const graph = readFileSync(fileURLToPath(new URL('../agent/graph.js', import.meta.url)), 'utf8');
124
+ // « récupère ce mail » et « envoie un mail » ont été refusés comme
125
+ // impossibles, alors que l'agent connectors déclare `external-source.collect`
126
+ // et `communication.send-email`. Donna raisonnait sur les outils de lecture
127
+ // exposés au chat — qui ne rendent que des métadonnées — au lieu de déléguer.
128
+ assert.match(graph, /direct read tools of a connector are a preview/);
129
+ assert.match(graph, /delegated through runtime__delegate, not answered from a read tool/);
130
+ // Et un droit manquant doit être nommé comme tel, pas présenté comme une
131
+ // fonctionnalité absente.
132
+ assert.match(graph, /lack of an authorization grant or scope/);
133
+ });
134
+
135
+ test('the system prompt points product questions at the documentation tools', () => {
136
+ const graph = readFileSync(
137
+ fileURLToPath(new URL('../agent/graph.js', import.meta.url)),
138
+ 'utf8',
139
+ );
140
+ // Les outils help_* sont exposés (allow-list du mode chat) mais le prompt ne
141
+ // disait nulle part qu'ils sont LA source des questions produit : Donna
142
+ // répondait de mémoire, et inventait.
143
+ assert.match(graph, /bundled product documentation is your source/);
144
+ assert.match(graph, /call the documentation search\/read tools FIRST/);
145
+ assert.match(graph, /Configuration facts are never answered from memory/);
146
+ });
@@ -72,7 +72,7 @@ export async function startAgents(options = {}) {
72
72
  : await missingAgentImages(composeContext);
73
73
  if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
74
74
  const exec = options.exec ?? execFileAsync;
75
- const { stdout, stderr } = await exec(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
75
+ const { stdout, stderr } = await exec(join(managerRoot(), 'wiki-workspace'), ['agents', 'up', ...(options.services ?? [])], {
76
76
  cwd: managerRoot(),
77
77
  env: {
78
78
  ...process.env,
@@ -148,11 +148,20 @@ function agentsStartFailure({ downServices = [], missingServices = [], profiles
148
148
 
149
149
  export async function stopAgents(options = {}) {
150
150
  try {
151
- const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'down'], {
151
+ const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'down', ...(options.services ?? [])], {
152
152
  cwd: managerRoot(),
153
153
  env: {
154
154
  ...process.env,
155
155
  WIKI_WORKSPACES_DIR: workspacesDir(),
156
+ // Même épinglage que startAgents, et pour la même raison : cwd est la
157
+ // racine du paquet, donc sans ces variables le script résout `.env`
158
+ // contre $PWD. `connectors_enabled` lisait alors le .env du PAQUET,
159
+ // concluait que le profil était inactif, et `docker compose down`
160
+ // partait sans `--profile connectors` — un service à profil inactif est
161
+ // invisible à Compose, donc le conteneur restait debout. `/stop agents`
162
+ // annonçait un succès en laissant tourner ce qu'il prétendait arrêter.
163
+ WIKI_MANAGER_ENV_FILE: managerEnvFile(),
164
+ WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
156
165
  AGENTS_DATA_DIR: resolveAgentsDataDir(),
157
166
  },
158
167
  timeout: options.timeout ?? 120_000,
@@ -66,11 +66,17 @@ test('wiki-workspace provisions connector secrets and persistent state', async (
66
66
  assert.match(script, /delete config\.chatAccess\.servers\.connectors/);
67
67
  });
68
68
 
69
- test('local connector builds require both public Desktop OAuth values', async () => {
69
+ test('the manager never builds an image, it only runs published ones', async () => {
70
70
  const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
71
71
 
72
- assert.match(script, /for build_key in WIKILLM_GOOGLE_OAUTH_CLIENT_ID WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET/);
73
- assert.match(script, /require_connectors_build_credentials/);
72
+ // Ce script est livré dans le paquet npm, où le dépôt agent-connectors
73
+ // n'existe pas : il ne peut ni construire l'image, ni lire le
74
+ // .env.build.local qui porte l'application OAuth. La construction appartient
75
+ // à build-and-push.sh. Pour utiliser sa propre application Google,
76
+ // l'opérateur surcharge GOOGLE_OAUTH_CLIENT_ID / _SECRET dans le .env.
77
+ assert.doesNotMatch(script, /--build\b/);
78
+ assert.doesNotMatch(script, /\.env\.build\.local/);
79
+ assert.doesNotMatch(script, /require_connectors_build_credentials/);
74
80
  });
75
81
 
76
82
  test('workspace creation keeps mutable manager files outside the installed package', async () => {
@@ -82,6 +88,13 @@ test('workspace creation keeps mutable manager files outside the installed packa
82
88
  assert.match(source, /WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile\(\)/);
83
89
  });
84
90
 
91
+ test('wiki-workspace rejects an MCP endpoints directory instead of copying into it', async () => {
92
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
93
+
94
+ assert.match(script, /\[\[ -e "\$MANAGER_ENDPOINTS_FILE" && ! -f "\$MANAGER_ENDPOINTS_FILE" \]\]/);
95
+ assert.match(script, /MCP endpoints path is not a file/);
96
+ });
97
+
85
98
  test('container refresh pulls and renews only services that are already running', async () => {
86
99
  const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
87
100
 
@@ -15,10 +15,22 @@ export function createAgentRegistry({
15
15
  return {
16
16
  async discover(session, { signal = null } = {}) {
17
17
  const discovered = [];
18
- for (const [serverName, endpoint] of Object.entries(session?.mcp ?? {})) {
18
+ const endpoints = Object.entries(session?.mcp ?? {});
19
+ const activeServers = new Set(endpoints.map(([serverName]) => serverName));
20
+ for (const [serverName, endpoint] of endpoints) {
19
21
  const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
20
22
  discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer }));
21
23
  }
24
+ for (const [serverName, instanceId] of instanceByServer) {
25
+ if (activeServers.has(serverName)) continue;
26
+ const previous = agentsByInstance.get(instanceId);
27
+ instanceByServer.delete(serverName);
28
+ agentsByInstance.delete(instanceId);
29
+ if (previous) dispatchRegistryEvent(session, 'agent.unregistered', {
30
+ agentInstanceId: instanceId,
31
+ serverName,
32
+ });
33
+ }
22
34
  session.agentRegistry = this;
23
35
  session.agentRegistrySnapshot = this.snapshot();
24
36
  return discovered;
@@ -85,6 +85,26 @@ test('agentRegistry records legacy visible agents when no contract tool exists',
85
85
  assert.equal(agent.health, 'available');
86
86
  });
87
87
 
88
+ test('agentRegistry removes a server that disappeared during MCP refresh', async () => {
89
+ const events = [];
90
+ const session = {
91
+ mcp: {
92
+ cme: { status: 'connected', tools: [{ name: 'cme_status' }] },
93
+ exa: { status: 'connected', tools: [{ name: 'web_search_exa' }] },
94
+ },
95
+ _onAgentEvent: (event) => events.push(event),
96
+ };
97
+ const registry = createAgentRegistry({ callTool: async () => assert.fail('no contract tool expected') });
98
+ await registry.discover(session);
99
+ assert.deepEqual(registry.snapshot().map((agent) => agent.serverName), ['cme', 'exa']);
100
+
101
+ delete session.mcp.cme;
102
+ await registry.discover(session);
103
+
104
+ assert.deepEqual(registry.snapshot().map((agent) => agent.serverName), ['exa']);
105
+ assert.ok(events.some((event) => event.type === 'agent.unregistered' && event.payload.serverName === 'cme'));
106
+ });
107
+
88
108
  test('agentRegistry marks unavailable boot agents and emits health changes on re-scan', async () => {
89
109
  const events = [];
90
110
  let health = 'unavailable';
@@ -1,5 +1,5 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
- import { readdirSync, statSync } from 'node:fs';
2
+ import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { activeCacertPath } from '../core/cacert.js';
@@ -93,6 +93,19 @@ export async function ensureRuntime({
93
93
  }
94
94
 
95
95
  const runtimeNode = await assertRuntimeNode();
96
+ // Le fils écrivait sur `stdio: 'ignore'`. Un runtime qui plantait au
97
+ // démarrage — port pris, état SQLite illisible, config invalide — ne laissait
98
+ // donc aucune trace, et le shell n'avait qu'un « did not become healthy » à
99
+ // afficher. On redirige vers le même `runtime.log` que
100
+ // `wiki-workspace runtime up`, pour que les deux chemins de démarrage se
101
+ // diagnostiquent de la même façon.
102
+ const logPath = runtimeLogPath(stateDir);
103
+ let logFd = null;
104
+ try {
105
+ mkdirSync(resolve(stateDir), { recursive: true });
106
+ logFd = openSync(logPath, 'a');
107
+ } catch { /* le log est un confort : ne jamais empêcher le démarrage */ }
108
+
96
109
  const child = spawn(runtimeNode.executable, [
97
110
  binPath,
98
111
  'runtime',
@@ -104,7 +117,7 @@ export async function ensureRuntime({
104
117
  stateDir,
105
118
  ], {
106
119
  detached: true,
107
- stdio: 'ignore',
120
+ stdio: logFd === null ? 'ignore' : ['ignore', logFd, logFd],
108
121
  env: {
109
122
  ...process.env,
110
123
  ...(auth.token ? { WIKI_MANAGER_RUNTIME_TOKEN: auth.token } : {}),
@@ -112,15 +125,104 @@ export async function ensureRuntime({
112
125
  },
113
126
  });
114
127
  child.unref();
128
+ if (logFd !== null) closeSync(logFd);
129
+
130
+ // Le pid manquait : `wiki-workspace runtime down` répondait « not running »
131
+ // sur un runtime démarré par le shell, et le processus restait sur le port.
132
+ writeRuntimePidFile(stateDir, child.pid);
133
+
134
+ // Un fils qui meurt aussitôt faisait quand même attendre le délai complet.
135
+ let exited = null;
136
+ child.once('exit', (code, signal) => { exited = { code, signal }; });
115
137
 
116
138
  const deadline = Date.now() + timeoutMs;
117
139
  let health = null;
118
140
  while (Date.now() < deadline) {
119
141
  health = await runtimeHealthOrNull(url, auth.token);
120
142
  if (health) return { url, started: true, health, pid: child.pid, token: auth.token, tokenPath: auth.tokenPath, node: runtimeNode };
143
+ if (exited) break;
121
144
  await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
122
145
  }
123
- throw new Error(`Runtime did not become healthy at ${url}`);
146
+ if (exited) removeRuntimePidFile(stateDir);
147
+ throw new Error(await describeRuntimeFailure({ url, token: auth.token, stateDir, exited, logPath }));
148
+ }
149
+
150
+ export function runtimeLogPath(stateDir = defaultRuntimeStateDir()) {
151
+ return join(resolve(stateDir), 'runtime.log');
152
+ }
153
+
154
+ export function runtimePidPath(stateDir = defaultRuntimeStateDir()) {
155
+ return join(resolve(stateDir), 'runtime.pid');
156
+ }
157
+
158
+ function writeRuntimePidFile(stateDir, pid) {
159
+ if (!pid) return;
160
+ try {
161
+ mkdirSync(resolve(stateDir), { recursive: true });
162
+ writeFileSync(runtimePidPath(stateDir), `${pid}\n`, 'utf8');
163
+ } catch { /* idem : informatif, jamais bloquant */ }
164
+ }
165
+
166
+ function removeRuntimePidFile(stateDir) {
167
+ try { unlinkSync(runtimePidPath(stateDir)); } catch { /* déjà absent */ }
168
+ }
169
+
170
+ /**
171
+ * Message d'échec du démarrage du runtime.
172
+ *
173
+ * `checkRuntimeHealth` rend `null` sur n'importe quelle réponse non-2xx : un
174
+ * 401 — jeton lu dans un autre `state-dir` que celui du runtime déjà en place —
175
+ * était donc rigoureusement indiscernable d'un port fermé. Ici on refait la
176
+ * requête pour distinguer les deux, et on joint la fin du journal.
177
+ */
178
+ async function describeRuntimeFailure({ url, token, stateDir, exited, logPath }) {
179
+ const lines = [`Runtime did not become healthy at ${url}`];
180
+
181
+ if (exited) {
182
+ lines.push(
183
+ `The runtime process exited immediately (${exited.signal ? `signal ${exited.signal}` : `code ${exited.code}`}).`,
184
+ );
185
+ }
186
+
187
+ const probe = await probeRuntimeHealth(url, token);
188
+ if (probe.status === 401 || probe.status === 403) {
189
+ lines.push(
190
+ `A runtime IS answering on this port but rejected our token (HTTP ${probe.status}).`,
191
+ `It was started from another state directory: this one is ${resolve(stateDir)}.`,
192
+ 'Stop it (`wiki-workspace runtime down` from that directory, or kill the process holding the port) or export WIKI_MANAGER_RUNTIME_TOKEN with its token.',
193
+ );
194
+ } else if (probe.status) {
195
+ lines.push(`Health endpoint answered HTTP ${probe.status}.`);
196
+ } else if (probe.error) {
197
+ lines.push(`Health endpoint unreachable: ${probe.error}`);
198
+ }
199
+
200
+ const tail = readLogTail(logPath, 12);
201
+ if (tail) lines.push(`Last lines of ${logPath}:`, tail);
202
+ else lines.push(`No runtime log at ${logPath}.`);
203
+
204
+ return lines.join('\n');
205
+ }
206
+
207
+ async function probeRuntimeHealth(url, token) {
208
+ try {
209
+ const response = await fetch(`${String(url).replace(/\/+$/, '')}/health`, {
210
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
211
+ signal: AbortSignal.timeout(2000),
212
+ });
213
+ return { status: response.status, error: null };
214
+ } catch (err) {
215
+ return { status: null, error: err instanceof Error ? err.message : String(err) };
216
+ }
217
+ }
218
+
219
+ function readLogTail(logPath, count) {
220
+ try {
221
+ const lines = readFileSync(logPath, 'utf8').split('\n').filter((line) => line.trim() !== '');
222
+ return lines.slice(-count).map((line) => ` ${line}`).join('\n') || null;
223
+ } catch {
224
+ return null;
225
+ }
124
226
  }
125
227
 
126
228
  async function waitForRuntimeShutdown(url, token, timeoutMs) {
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createServer } from 'node:http';
4
+ import { existsSync, mkdtempSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { ensureRuntime, runtimeLogPath, runtimePidPath } from './lifecycle.js';
8
+
9
+ function tempStateDir() {
10
+ return mkdtempSync(join(tmpdir(), 'wiki-runtime-state-'));
11
+ }
12
+
13
+ /**
14
+ * Occupe un port avec un serveur qui répond `status`.
15
+ *
16
+ * Deux effets, tous deux voulus : le runtime que `ensureRuntime` va lancer ne
17
+ * pourra pas se lier au port et sortira aussitôt — c'est le scénario réel du
18
+ * « port déjà pris » — et la sonde de santé recevra un statut choisi.
19
+ */
20
+ async function occupiedPort(status) {
21
+ const server = createServer((_req, res) => res.writeHead(status).end('nope'));
22
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
23
+ return { server, port: server.address().port };
24
+ }
25
+
26
+ async function failingEnsureRuntime(status) {
27
+ const stateDir = tempStateDir();
28
+ const { server, port } = await occupiedPort(status);
29
+ try {
30
+ const error = await ensureRuntime({
31
+ host: '127.0.0.1',
32
+ port,
33
+ stateDir,
34
+ url: `http://127.0.0.1:${port}`,
35
+ timeoutMs: 3000,
36
+ }).then(() => null, (err) => err);
37
+ assert.ok(error, 'ensureRuntime must fail when nothing healthy answers');
38
+ return { error, stateDir };
39
+ } finally {
40
+ server.close();
41
+ }
42
+ }
43
+
44
+ test('a failed start reports the captured log instead of a bare message', async () => {
45
+ const { error, stateDir } = await failingEnsureRuntime(500);
46
+ assert.match(error.message, /Runtime did not become healthy/);
47
+ // Le fils écrivait sur `stdio: 'ignore'` : sa sortie — ici l'échec de bind —
48
+ // était perdue, et l'opérateur n'avait aucune piste.
49
+ assert.match(error.message, /runtime\.log/);
50
+ assert.ok(existsSync(runtimeLogPath(stateDir)), 'the child output must be captured to a log file');
51
+ assert.match(error.message, /exited immediately|Health endpoint answered HTTP 500/);
52
+ });
53
+
54
+ test('a runtime answering 401 is not reported as unreachable', async () => {
55
+ // Cas réel : un runtime démarré depuis un autre state-dir répond, mais
56
+ // rejette notre jeton. `checkRuntimeHealth` rend `null` sur tout non-2xx,
57
+ // donc ce cas était rigoureusement indiscernable d'un port fermé.
58
+ const { error, stateDir } = await failingEnsureRuntime(401);
59
+ assert.match(error.message, /rejected our token \(HTTP 401\)/);
60
+ assert.match(error.message, /another state directory/);
61
+ assert.ok(error.message.includes(stateDir), 'the message must name the state dir in use');
62
+ });
63
+
64
+ test('the pid file path is the one `wiki-workspace runtime down` reads', () => {
65
+ const stateDir = tempStateDir();
66
+ assert.equal(runtimePidPath(stateDir), join(stateDir, 'runtime.pid'));
67
+ assert.equal(runtimeLogPath(stateDir), join(stateDir, 'runtime.log'));
68
+ });
@@ -24,6 +24,9 @@ export function startRuntimeServer({
24
24
  approve,
25
25
  configProfiles,
26
26
  useConfigProfile,
27
+ listMcpEndpoints,
28
+ upsertMcpEndpoint,
29
+ deleteMcpEndpoint,
27
30
  listActiveRuns = null,
28
31
  exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
29
32
  } = {}) {
@@ -230,6 +233,31 @@ export function startRuntimeServer({
230
233
  sendJson(response, 200, result);
231
234
  return;
232
235
  }
236
+ if (request.method === 'GET' && url.pathname === '/mcp/endpoints') {
237
+ const workspace = workspaceFromUrl(url);
238
+ const context = await resolveContext({ workspace });
239
+ const result = await listMcpEndpoints?.(context);
240
+ sendJson(response, 200, result ?? { endpoints: [] });
241
+ return;
242
+ }
243
+ if (request.method === 'POST' && url.pathname === '/mcp/endpoints') {
244
+ const activeRuns = typeof listActiveRuns === 'function' ? listActiveRuns() : [];
245
+ if (activeRuns.length > 0) {
246
+ sendJson(response, 409, { error: 'MCP connectors cannot be changed while a plan is running.' });
247
+ return;
248
+ }
249
+ const { body, context } = await resolveBodyContext(request, url);
250
+ const action = String(body.action ?? 'upsert').trim().toLowerCase();
251
+ const result = action === 'delete'
252
+ ? await deleteMcpEndpoint?.(context, body)
253
+ : await upsertMcpEndpoint?.(context, body);
254
+ if (!result) {
255
+ sendJson(response, 501, { error: 'MCP endpoint management is not supported.' });
256
+ return;
257
+ }
258
+ sendJson(response, 200, result);
259
+ return;
260
+ }
233
261
  if (request.method === 'GET' && url.pathname === '/events/stream') {
234
262
  const workspace = workspaceFromUrl(url);
235
263
  const context = workspace ? await resolveContext({ workspace }) : null;