@dotdrelle/wiki-manager 0.15.34 → 0.15.35
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 +8 -5
- package/agents.docker-compose.yml +11 -16
- package/docker-compose.yml +9 -8
- package/package.json +2 -2
- package/src/agent/graph.js +20 -0
- package/src/commands/slash.js +73 -10
- package/src/commands/slash.test.js +37 -3
- package/src/core/agentsCompose.js +86 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +24 -2
- package/src/core/dockerCompose.test.js +18 -0
- package/src/core/googleGrants.js +38 -0
- package/src/core/googleGrants.test.js +59 -0
- package/src/core/mcp.js +1 -1
- package/src/core/modelFetch.js +241 -27
- package/src/core/modelFetch.test.js +213 -0
- package/src/core/profileServiceStatus.test.js +146 -0
- package/src/core/wikiSetup.js +11 -2
- package/src/core/wikiWorkspace.test.js +9 -3
- package/src/runtime/lifecycle.js +105 -3
- package/src/runtime/lifecycle.test.js +68 -0
- package/src/shell/SetupWizard.tsx +416 -116
- package/src/shell/repl.js +37 -11
- package/src/shell/setupWizardDiscovery.test.js +48 -0
- package/src/shell/setupWizardPlaceholders.test.js +15 -2
- package/src/shell/setupWizardSuggestions.test.js +36 -2
- package/src/shell/wrapText.js +57 -0
- package/src/shell/wrapText.test.js +48 -0
- package/wiki-workspace +115 -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
|
+
});
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
73
|
-
|
|
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 () => {
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|