@dotdrelle/wiki-manager 0.15.32 → 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 +449 -47
- package/src/core/modelFetch.test.js +290 -2
- package/src/core/profileServiceStatus.test.js +146 -0
- package/src/core/wikiSetup.js +43 -4
- package/src/core/wikiWorkspace.test.js +9 -3
- package/src/core/wikirc.test.js +59 -5
- package/src/runtime/lifecycle.js +105 -3
- package/src/runtime/lifecycle.test.js +68 -0
- package/src/shell/SetupWizard.tsx +617 -66
- package/src/shell/repl.js +37 -11
- package/src/shell/setupWizardDiscovery.test.js +48 -0
- package/src/shell/setupWizardPlaceholders.test.js +16 -3
- package/src/shell/setupWizardSuggestions.test.js +58 -0
- package/src/shell/wrapText.js +57 -0
- package/src/shell/wrapText.test.js +48 -0
- package/wiki-workspace +116 -32
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
|
+
});
|