@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
package/src/shell/repl.js
CHANGED
|
@@ -9,12 +9,13 @@ import { marked } from 'marked';
|
|
|
9
9
|
import { markedTerminal } from 'marked-terminal';
|
|
10
10
|
import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
|
|
11
11
|
import { handleSlashCommand, rawCommandAgentPrompt } from '../commands/slash.js';
|
|
12
|
-
import {
|
|
12
|
+
import { serviceChoices as composeServiceChoices, serviceDescription } from '../core/compose.js';
|
|
13
13
|
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
14
14
|
import { syncActivitiesToPlan } from '../core/plan.js';
|
|
15
15
|
import { buildLlmTools, callMcpTool, formatMcpToolResult, parseToolCallName, resolveToolCallName } from '../core/mcp.js';
|
|
16
16
|
import { runBoundedToolLoop } from '../core/toolLoop.js';
|
|
17
17
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
18
|
+
import { togglableAgentNames } from '../core/agentsCompose.js';
|
|
18
19
|
import { listSkills } from '../core/skills.js';
|
|
19
20
|
import { listWikircProfiles } from '../core/wikirc.js';
|
|
20
21
|
import { listWorkspaces } from '../core/workspaces.js';
|
|
@@ -78,7 +79,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
|
|
|
78
79
|
'/stop': 'Stop one service or the workspace service set.',
|
|
79
80
|
'/logs': 'Show recent logs for a service.',
|
|
80
81
|
'/mcp': 'Inspect or call workspace MCP servers.',
|
|
81
|
-
'/connector': 'List connectors or authorize one — /connector auth
|
|
82
|
+
'/connector': 'List connectors, or authorize one with all its rights — /connector auth google.',
|
|
82
83
|
'/wiki': 'Run llm-wiki commands for the active workspace.',
|
|
83
84
|
'/skills': 'List workspace skills.',
|
|
84
85
|
'/upload': 'Upload a document — /upload <path>',
|
|
@@ -256,8 +257,19 @@ function wikircProfileNames(session) {
|
|
|
256
257
|
: [];
|
|
257
258
|
}
|
|
258
259
|
|
|
259
|
-
function
|
|
260
|
-
return
|
|
260
|
+
function serviceChoices() {
|
|
261
|
+
return composeServiceChoices();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Agents proposés un par un, en plus du raccourci `agents`.
|
|
266
|
+
*
|
|
267
|
+
* Seulement ceux derrière un drapeau de profil. Lister tous les agents avait
|
|
268
|
+
* rallongé la liste de quatre entrées, dont `mailer` qui n'existe même pas tant
|
|
269
|
+
* que l'opérateur ne l'a pas décommenté dans son override.
|
|
270
|
+
*/
|
|
271
|
+
function togglableAgents() {
|
|
272
|
+
return togglableAgentNames();
|
|
261
273
|
}
|
|
262
274
|
|
|
263
275
|
function skillNames(session) {
|
|
@@ -282,6 +294,11 @@ function completionValuesFor(parts, inputBuffer, session) {
|
|
|
282
294
|
if (command === '/mcp' && parts[1] === 'call' && tokenIndex === 3) return mcpToolNames(session, parts[2]);
|
|
283
295
|
if (command === '/connector' && tokenIndex === 1) return ['auth', 'list'];
|
|
284
296
|
if (command === '/connector' && previousToken === 'auth') return ['google'];
|
|
297
|
+
// Pas de complétion des droits après le connecteur. `/connector auth google`
|
|
298
|
+
// demande déjà tout ce que l'agent sait faire, en une autorisation : proposer
|
|
299
|
+
// une liste laissait croire qu'il fallait choisir, et transformait une
|
|
300
|
+
// commande à taper d'un trait en menu à trois entrées. Les droits restent
|
|
301
|
+
// saisissables un par un pour restreindre, et le libellé de /connector le dit.
|
|
285
302
|
if (command === '/upload' && tokenIndex === 1) return ['convert'];
|
|
286
303
|
if (command === '/upload' && parts[1] === 'convert' && tokenIndex === 2) return ['pending'];
|
|
287
304
|
if (command === '/uploads' && tokenIndex === 1) return ['clean', 'list'];
|
|
@@ -301,9 +318,11 @@ function completionValuesFor(parts, inputBuffer, session) {
|
|
|
301
318
|
if (command === '/wiki' && tokenIndex === 1) return ['run'];
|
|
302
319
|
if (command === '/skills' && tokenIndex === 1) return ['edit', 'list', 'run', 'show'];
|
|
303
320
|
if (command === '/skills' && ['edit', 'run', 'show'].includes(previousToken ?? '')) return skillNames(session);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (command === '/
|
|
321
|
+
// Vocabulaire de l'opérateur uniquement : `serviceChoices()` écarte les noms
|
|
322
|
+
// Compose bruts que les alias désignent déjà. Ils restent tapables.
|
|
323
|
+
if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
|
|
324
|
+
if (command === '/stop' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
|
|
325
|
+
if (command === '/logs' && tokenIndex === 1) return ['all', ...serviceChoices()];
|
|
307
326
|
return [];
|
|
308
327
|
}
|
|
309
328
|
|
|
@@ -533,12 +552,19 @@ export function completionDescription(value, parts) {
|
|
|
533
552
|
if (subcommand) return subcommand;
|
|
534
553
|
if (command === '/use') return 'Load this workspace.';
|
|
535
554
|
if (command === '/start') {
|
|
536
|
-
if (value === 'all') return '
|
|
537
|
-
if (value === 'agents' || value === 'agent') return '
|
|
538
|
-
if (value === 'services') return '
|
|
555
|
+
if (value === 'all') return 'Everything: workspace services AND external agents.';
|
|
556
|
+
if (value === 'agents' || value === 'agent') return 'External agents only (CME, documents, connectors…).';
|
|
557
|
+
if (value === 'services') return 'Workspace services only, without the external agents.';
|
|
558
|
+
if (togglableAgents().includes(value)) return `Start only the ${value} agent, leaving the other agents as they are.`;
|
|
539
559
|
return serviceDescription(value) ?? 'Start this Docker Compose service.';
|
|
540
560
|
}
|
|
541
|
-
if (command === '/stop')
|
|
561
|
+
if (command === '/stop') {
|
|
562
|
+
if (value === 'all') return 'Everything: workspace services AND external agents.';
|
|
563
|
+
if (value === 'agents' || value === 'agent') return 'External agents only.';
|
|
564
|
+
if (value === 'services') return 'Workspace services only.';
|
|
565
|
+
if (togglableAgents().includes(value)) return `Stop only the ${value} agent, leaving the other agents running.`;
|
|
566
|
+
return serviceDescription(value) ?? 'Stop this Docker Compose service.';
|
|
567
|
+
}
|
|
542
568
|
if (command === '/logs') return serviceDescription(value) ?? 'Show logs for this Docker Compose service.';
|
|
543
569
|
if (command === '/workspace') {
|
|
544
570
|
if (parts[1] === 'delete' && value === '--confirm') return 'Confirm workspace deletion.';
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
|
|
6
|
+
const source = readFileSync(
|
|
7
|
+
fileURLToPath(new URL('./SetupWizard.tsx', import.meta.url)),
|
|
8
|
+
'utf8',
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
test('model discovery never blocks a wizard step', () => {
|
|
12
|
+
// La régression d'origine : `await discoverModels()` sur l'étape clé API
|
|
13
|
+
// gelait l'écran pendant tout le délai réseau, sans rien afficher.
|
|
14
|
+
assert.doesNotMatch(source, /await\s+(start|discover)\w*[Dd]iscover\w*\(/);
|
|
15
|
+
assert.doesNotMatch(source, /await\s+fetch(Models|GatewayCatalog)\(/);
|
|
16
|
+
assert.match(source, /startDiscovery\(\{ \.\.\.llm\(\), apiKey: value \}\);\s*\n\s*return navigate\('llm-model'\)/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('a late answer cannot overwrite a newer discovery', () => {
|
|
20
|
+
assert.match(source, /discoveryRun \+= 1;/);
|
|
21
|
+
assert.match(source, /if \(run !== discoveryRun\) return;/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('the gateway flat list is shown before the typed catalog', () => {
|
|
25
|
+
assert.match(source, /onPartial:/);
|
|
26
|
+
assert.match(source, /Refining chat\/embedding\/rerank types/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('the discovery panel reports state, endpoint and transport', () => {
|
|
30
|
+
for (const fragment of [
|
|
31
|
+
'Reading the model catalog',
|
|
32
|
+
'Model catalog unavailable',
|
|
33
|
+
'Cause: ',
|
|
34
|
+
'Transport: ',
|
|
35
|
+
'transportSummary()',
|
|
36
|
+
'model(s) available',
|
|
37
|
+
]) {
|
|
38
|
+
assert.ok(source.includes(fragment), `discovery panel must show: ${fragment}`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the wizard fills the terminal instead of a fixed narrow box', () => {
|
|
43
|
+
const width = source.match(/const dialogWidth = \(\) => ([^;]+);/);
|
|
44
|
+
const height = source.match(/const dialogHeight = \(\) => ([^;]+);/);
|
|
45
|
+
assert.ok(width && height);
|
|
46
|
+
assert.match(width[1], /Math\.min\(1\d\d,/);
|
|
47
|
+
assert.match(height[1], /Math\.min\([34]\d,/);
|
|
48
|
+
});
|
|
@@ -22,11 +22,22 @@ test('the wizard treats scaffolded config values as unset', { skip: !existsSync(
|
|
|
22
22
|
// Cross-repo guard: if the scaffold ever ships a different fake endpoint or
|
|
23
23
|
// secret, the wizard would prefill it as a real answer and a skipped step
|
|
24
24
|
// would write it to the operator's config.
|
|
25
|
+
// `embeddingModel` et `rerankerModel` ont ete ajoutes ici apres coup : ils
|
|
26
|
+
// etaient les seuls champs du scaffold a porter une valeur d'apparence reelle
|
|
27
|
+
// (`BAAI/bge-m3`), que le wizard preremplissait donc comme une reponse
|
|
28
|
+
// choisie — et qui filtrait le catalogue sur zero resultat des que le serveur
|
|
29
|
+
// nommait le meme modele autrement.
|
|
25
30
|
const placeholders = [
|
|
26
|
-
...scaffold.matchAll(/^\s*(baseUrl|apiKey|model):\s*(\S+)\s*$/gm),
|
|
31
|
+
...scaffold.matchAll(/^\s*(baseUrl|apiKey|model|embeddingModel|rerankerModel):\s*(\S+)\s*$/gm),
|
|
27
32
|
].map(([, key, value]) => [key, value]);
|
|
28
33
|
|
|
29
|
-
assert.ok(placeholders.length >=
|
|
34
|
+
assert.ok(placeholders.length >= 6, 'expected the scaffold to declare endpoints, secrets and models');
|
|
35
|
+
for (const key of ['embeddingModel', 'rerankerModel']) {
|
|
36
|
+
assert.ok(
|
|
37
|
+
placeholders.some(([name]) => name === key),
|
|
38
|
+
`the scaffold must declare ${key} so this guard covers it`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
30
41
|
for (const [key, value] of placeholders) {
|
|
31
42
|
assert.match(value, regex, `scaffolded ${key}=${value} must be recognised as a placeholder`);
|
|
32
43
|
}
|
|
@@ -41,6 +52,8 @@ test('preloading a profile filters placeholders out of every prefilled field', (
|
|
|
41
52
|
'model: configuredValue(config.llm.model)',
|
|
42
53
|
'baseUrl: configuredValue(config.retrieval.vector.baseUrl)',
|
|
43
54
|
'apiKey: configuredValue(config.retrieval.vector.apiKey)',
|
|
55
|
+
'embeddingModel: configuredValue(config.retrieval.vector.embeddingModel)',
|
|
56
|
+
'rerankerModel: configuredValue(config.retrieval.vector.rerankerModel)',
|
|
44
57
|
]) {
|
|
45
58
|
assert.ok(source.includes(field), `preloadWikirc must filter ${field}`);
|
|
46
59
|
}
|
|
@@ -7,8 +7,31 @@ const source = readFileSync(new URL('./SetupWizard.tsx', import.meta.url), 'utf8
|
|
|
7
7
|
test('the model steps feed the discovered catalog into the filter', () => {
|
|
8
8
|
// Trois étapes doivent porter des suggestions : modèle LLM, embeddings,
|
|
9
9
|
// reranker. Sans elles, le filtre n'a rien a filtrer.
|
|
10
|
-
const occurrences = source.match(/
|
|
10
|
+
const occurrences = source.match(/\.\.\.suggestionField\(/g) ?? [];
|
|
11
11
|
assert.equal(occurrences.length, 3);
|
|
12
|
+
assert.match(source, /suggestions: discovered/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('no model is preselected when a catalog was discovered', () => {
|
|
16
|
+
// Preremplir avec le premier modele decouvert filtrait la liste sur
|
|
17
|
+
// lui-meme : neuf entrees sur dix devenaient invisibles.
|
|
18
|
+
assert.match(source, /prefill: usable \?\? \(discovered\.length === 0 \? example : ''\)/);
|
|
19
|
+
assert.doesNotMatch(source, /prefill: [^\n]*discovered\[0\]/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('a configured model absent from the catalog is not prefilled', () => {
|
|
23
|
+
// Le `BAAI/bge-m3` du scaffold face a un serveur qui nomme le meme modele
|
|
24
|
+
// `bge-m3` : preremplir filtrait la liste sur zero resultat, et l'ecran
|
|
25
|
+
// affichait « No match » devant dix modeles disponibles.
|
|
26
|
+
assert.match(source, /discovered\.length === 0 \|\| discovered\.includes\(configured\)/);
|
|
27
|
+
assert.match(source, /is not in this catalog — pick one below/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the list is navigable with the arrows and confirmed in two steps', () => {
|
|
31
|
+
assert.match(source, /keyName === 'down' \|\| keyName === 'up'/);
|
|
32
|
+
assert.match(source, /if \(hovered && \(isEnter \|\| keyName === 'tab'\)\)/);
|
|
33
|
+
// Le premier Enter depose la valeur, il ne valide pas l'etape.
|
|
34
|
+
assert.match(source, /setInput\(hovered\);\s*\n\s*setHighlight\(-1\);\s*\n\s*return;/);
|
|
12
35
|
});
|
|
13
36
|
|
|
14
37
|
test('the typed value always wins over the suggestion list', () => {
|
|
@@ -20,5 +43,16 @@ test('the typed value always wins over the suggestion list', () => {
|
|
|
20
43
|
|
|
21
44
|
test('suggestions are filtered by the current input, not shown raw', () => {
|
|
22
45
|
assert.match(source, /all\.filter\(\(item\) => item\.toLowerCase\(\)\.includes\(needle\)\)/);
|
|
23
|
-
|
|
46
|
+
// Fenetre glissante : la liste defile au lieu de s'arreter aux premieres
|
|
47
|
+
// entrees, sinon un catalogue de 200 modeles reste inatteignable.
|
|
48
|
+
assert.match(source, /matches\.slice\(offset, offset \+ SUGGESTION_ROWS\)/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('the language typed by the operator is the one displayed back', () => {
|
|
52
|
+
// Sans ce setLanguage, le recapitulatif affichait la langue lue dans le
|
|
53
|
+
// scaffold a la creation du workspace, pas celle qui vient d'etre saisie.
|
|
54
|
+
assert.match(
|
|
55
|
+
source,
|
|
56
|
+
/writeLanguageConfig\([^)]*\);\s*(\n\s*\/\/[^\n]*)*\n\s*setLanguage\(lang\);/,
|
|
57
|
+
);
|
|
24
58
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Découpe un texte en lignes tenant dans la largeur d'une boîte TUI.
|
|
3
|
+
*
|
|
4
|
+
* Le wizard rendait chaque libellé, note ou erreur dans un `<text height={1}>` :
|
|
5
|
+
* au delà de la largeur du dialogue le texte était coupé net, sans ellipse ni
|
|
6
|
+
* indice. Les phrases les plus utiles — la note d'une étape, la cause d'une
|
|
7
|
+
* erreur de connexion — sont les plus longues, donc les plus tronquées.
|
|
8
|
+
*
|
|
9
|
+
* Le module est volontairement en JavaScript pur, hors du composant : c'est ce
|
|
10
|
+
* qui le rend testable sans transpiler du JSX.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} text texte à découper ; les `\n` explicites sont respectés.
|
|
13
|
+
* @param {number} width largeur utile en colonnes.
|
|
14
|
+
* @param {number} maxLines nombre de lignes maximum ; la dernière est
|
|
15
|
+
* marquée d'une ellipse quand il reste du texte, pour que la troncature
|
|
16
|
+
* restante soit au moins visible.
|
|
17
|
+
* @returns {string[]}
|
|
18
|
+
*/
|
|
19
|
+
export function wrapText(text, width, maxLines = 6) {
|
|
20
|
+
const safeWidth = Math.max(8, Math.floor(width) || 8);
|
|
21
|
+
const lines = [];
|
|
22
|
+
for (const paragraph of String(text ?? '').split('\n')) {
|
|
23
|
+
if (paragraph.trim() === '') {
|
|
24
|
+
lines.push('');
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let current = '';
|
|
28
|
+
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
29
|
+
// Un mot plus long que la boîte — une URL, un chemin, un nom de modèle
|
|
30
|
+
// qualifié — est coupé plutôt que de déborder : mieux vaut le lire en
|
|
31
|
+
// deux morceaux que pas du tout.
|
|
32
|
+
if (word.length > safeWidth) {
|
|
33
|
+
if (current) {
|
|
34
|
+
lines.push(current);
|
|
35
|
+
current = '';
|
|
36
|
+
}
|
|
37
|
+
for (let i = 0; i < word.length; i += safeWidth) {
|
|
38
|
+
lines.push(word.slice(i, i + safeWidth));
|
|
39
|
+
}
|
|
40
|
+
current = lines.pop() ?? '';
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!current) current = word;
|
|
44
|
+
else if (current.length + 1 + word.length <= safeWidth) current += ` ${word}`;
|
|
45
|
+
else {
|
|
46
|
+
lines.push(current);
|
|
47
|
+
current = word;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (current) lines.push(current);
|
|
51
|
+
}
|
|
52
|
+
if (lines.length <= maxLines) return lines;
|
|
53
|
+
const kept = lines.slice(0, maxLines);
|
|
54
|
+
const last = kept[maxLines - 1] ?? '';
|
|
55
|
+
kept[maxLines - 1] = `${last.slice(0, Math.max(1, safeWidth - 1))}…`;
|
|
56
|
+
return kept;
|
|
57
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { wrapText } from './wrapText.js';
|
|
6
|
+
|
|
7
|
+
test('wraps on word boundaries within the given width', () => {
|
|
8
|
+
const lines = wrapText('Required: an agentic model with tool calling support.', 20);
|
|
9
|
+
assert.ok(lines.length > 1);
|
|
10
|
+
for (const line of lines) assert.ok(line.length <= 20, `too wide: ${line}`);
|
|
11
|
+
assert.equal(lines.join(' '), 'Required: an agentic model with tool calling support.');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('keeps explicit line breaks', () => {
|
|
15
|
+
assert.deepEqual(wrapText('one\ntwo', 40), ['one', 'two']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('splits a word longer than the box instead of letting it overflow', () => {
|
|
19
|
+
const lines = wrapText('https://gateway.internal.example.com/v1/models', 16);
|
|
20
|
+
for (const line of lines) assert.ok(line.length <= 16, `too wide: ${line}`);
|
|
21
|
+
assert.equal(lines.join(''), 'https://gateway.internal.example.com/v1/models');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('marks the truncation instead of cutting silently', () => {
|
|
25
|
+
const lines = wrapText('a b c d e f g h i j k l m n o p', 4, 2);
|
|
26
|
+
assert.equal(lines.length, 2);
|
|
27
|
+
assert.ok(lines[1].endsWith('…'));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a short text is returned untouched', () => {
|
|
31
|
+
assert.deepEqual(wrapText('API key', 40), ['API key']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('the wizard renders every prose field through wrapText', () => {
|
|
35
|
+
const source = readFileSync(
|
|
36
|
+
fileURLToPath(new URL('./SetupWizard.tsx', import.meta.url)),
|
|
37
|
+
'utf8',
|
|
38
|
+
);
|
|
39
|
+
// Garde-fou : un `<text height={1}>` autour d'un libellé, d'une note ou
|
|
40
|
+
// d'une erreur réintroduirait la troncature silencieuse.
|
|
41
|
+
for (const call of [
|
|
42
|
+
'wrapText((step() as any).message ?? (step() as any).label, textWidth(), 5)',
|
|
43
|
+
'wrapText((step() as any).note, textWidth(), 4)',
|
|
44
|
+
'wrapText(message(), textWidth(), 5)',
|
|
45
|
+
]) {
|
|
46
|
+
assert.ok(source.includes(call), `missing wrapped render: ${call}`);
|
|
47
|
+
}
|
|
48
|
+
});
|
package/wiki-workspace
CHANGED
|
@@ -262,30 +262,10 @@ agents_compose() {
|
|
|
262
262
|
local compose_env_args=()
|
|
263
263
|
[[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
|
|
264
264
|
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
# --env-file, so the variables must be exported here or the image ships with
|
|
270
|
-
# empty WIKILLM_GOOGLE_OAUTH_* and every Google connector stays unusable.
|
|
271
|
-
local connectors_build_env="$ROOT_DIR/../agent-external/agent-connectors/.env.build.local"
|
|
272
|
-
if [[ -f "$connectors_build_env" ]]; then
|
|
273
|
-
local build_key build_value
|
|
274
|
-
while IFS='=' read -r build_key build_value; do
|
|
275
|
-
case "$build_key" in
|
|
276
|
-
WIKILLM_GOOGLE_OAUTH_CLIENT_ID|WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET) ;;
|
|
277
|
-
*) continue ;;
|
|
278
|
-
esac
|
|
279
|
-
[[ -n "${!build_key:-}" ]] || export "$build_key=$build_value"
|
|
280
|
-
done < <(grep -v '^[[:space:]]*#' "$connectors_build_env" || true)
|
|
281
|
-
fi
|
|
282
|
-
|
|
283
|
-
require_connectors_build_credentials() {
|
|
284
|
-
local build_key
|
|
285
|
-
for build_key in WIKILLM_GOOGLE_OAUTH_CLIENT_ID WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET; do
|
|
286
|
-
[[ -n "${!build_key:-}" ]] || die "cannot build connectors: $build_key is missing from $connectors_build_env"
|
|
287
|
-
done
|
|
288
|
-
}
|
|
265
|
+
# Ce script ne construit aucune image. L'application OAuth Google est cuite
|
|
266
|
+
# dans l'image publiée par build-and-push.sh ; pour en utiliser une autre,
|
|
267
|
+
# l'opérateur pose GOOGLE_OAUTH_CLIENT_ID / _SECRET dans le .env du manager,
|
|
268
|
+
# que Compose transmet au conteneur et qui l'emporte sur la valeur embarquée.
|
|
289
269
|
|
|
290
270
|
_agents_dc() {
|
|
291
271
|
local cacert_args=()
|
|
@@ -312,12 +292,11 @@ agents_compose() {
|
|
|
312
292
|
do_pull=true
|
|
313
293
|
shift
|
|
314
294
|
fi
|
|
315
|
-
|
|
295
|
+
# Services nommés : démarrer `connectors` seul une fois le profil actif,
|
|
296
|
+
# sans relancer toute la pile. Sans argument, tout monte comme avant.
|
|
297
|
+
local only_services=("$@")
|
|
316
298
|
ensure_agent_tokens
|
|
317
299
|
ensure_endpoints_file
|
|
318
|
-
if connectors_enabled && [[ -f "$ROOT_DIR/../agent-external/agent-connectors/Dockerfile" ]]; then
|
|
319
|
-
require_connectors_build_credentials
|
|
320
|
-
fi
|
|
321
300
|
# The .env may have just been created above — recompute the env-file
|
|
322
301
|
# flag so freshly generated tokens reach the containers on first run.
|
|
323
302
|
compose_env_args=()
|
|
@@ -330,6 +309,7 @@ agents_compose() {
|
|
|
330
309
|
if [[ -n "$CACERT_PATH" ]]; then
|
|
331
310
|
up_args+=(--force-recreate)
|
|
332
311
|
fi
|
|
312
|
+
up_args+=(${only_services[@]+"${only_services[@]}"})
|
|
333
313
|
_agents_dc "${up_args[@]}"
|
|
334
314
|
printf 'Workspaces root: %s\n' "$workspaces_root"
|
|
335
315
|
printf 'Agents data: %s\n' "$agents_data_dir"
|
|
@@ -341,8 +321,15 @@ agents_compose() {
|
|
|
341
321
|
[[ ! -f "$MANAGER_COMPOSE_DIR/agents.docker-compose.override.yml" ]] || printf 'User override: %s\n' "$MANAGER_COMPOSE_DIR/agents.docker-compose.override.yml"
|
|
342
322
|
;;
|
|
343
323
|
down)
|
|
344
|
-
|
|
345
|
-
|
|
324
|
+
# `down` sans argument démonte la pile ; avec des services nommés, Compose
|
|
325
|
+
# n'accepte que `stop` + `rm` — `down <service>` supprime le réseau commun
|
|
326
|
+
# et couperait les autres agents.
|
|
327
|
+
if [[ $# -eq 0 ]]; then
|
|
328
|
+
_agents_dc down
|
|
329
|
+
else
|
|
330
|
+
_agents_dc stop "$@"
|
|
331
|
+
_agents_dc rm -f "$@"
|
|
332
|
+
fi
|
|
346
333
|
;;
|
|
347
334
|
purge)
|
|
348
335
|
[[ $# -eq 0 ]] || die "agents purge does not take arguments"
|
|
@@ -634,12 +621,65 @@ runtime_pid_command() {
|
|
|
634
621
|
ps -p "$pid" -o command= 2>/dev/null || ps -p "$pid" -o args= 2>/dev/null || true
|
|
635
622
|
}
|
|
636
623
|
|
|
624
|
+
runtime_command_looks_like_runtime() {
|
|
625
|
+
local command="$1"
|
|
626
|
+
[[ "$command" == *"/bin/wiki-manager.js runtime"* || "$command" == *" bin/wiki-manager.js runtime"* ]]
|
|
627
|
+
}
|
|
628
|
+
|
|
637
629
|
runtime_pid_matches() {
|
|
638
630
|
local pid command
|
|
639
631
|
pid="$(cat "$(runtime_pid_file)" 2>/dev/null || true)"
|
|
640
632
|
[[ "$pid" =~ ^[0-9]+$ ]] || return 1
|
|
641
633
|
command="$(runtime_pid_command "$pid")"
|
|
642
|
-
|
|
634
|
+
runtime_command_looks_like_runtime "$command"
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
# Pids qui écoutent sur le port du runtime, quel que soit leur parent.
|
|
638
|
+
#
|
|
639
|
+
# Un runtime démarré par une session de shell précédente survit à la mort de
|
|
640
|
+
# cette session : il ne répond plus à notre jeton (401, donc « injoignable »)
|
|
641
|
+
# et son fichier pid a disparu avec l'ancien répertoire d'état. Sans ce repli,
|
|
642
|
+
# `runtime down` répond « not running » pendant que le port reste occupé, et il
|
|
643
|
+
# ne reste que le `kill` manuel — inacceptable pour qui utilise l'application.
|
|
644
|
+
runtime_port_pids() {
|
|
645
|
+
local port="$1"
|
|
646
|
+
if command -v lsof >/dev/null 2>&1; then
|
|
647
|
+
lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | sort -u
|
|
648
|
+
elif command -v ss >/dev/null 2>&1; then
|
|
649
|
+
ss -lptnH "sport = :${port}" 2>/dev/null |
|
|
650
|
+
grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u
|
|
651
|
+
fi
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
# Arrête les runtimes orphelins tenant le port. Ne tue QUE des processus dont
|
|
655
|
+
# la ligne de commande est celle d'un `wiki-manager runtime` : un port occupé
|
|
656
|
+
# par autre chose est signalé, jamais tué.
|
|
657
|
+
runtime_kill_orphans() {
|
|
658
|
+
local port="$1"
|
|
659
|
+
local pid command stopped=0 waited
|
|
660
|
+
while read -r pid; do
|
|
661
|
+
[[ "$pid" =~ ^[0-9]+$ ]] || continue
|
|
662
|
+
[[ "$pid" == "$$" ]] && continue
|
|
663
|
+
command="$(runtime_pid_command "$pid")"
|
|
664
|
+
if ! runtime_command_looks_like_runtime "$command"; then
|
|
665
|
+
printf 'port %s is held by pid %s, which is not a wiki-manager runtime — leaving it alone\n' "$port" "$pid" >&2
|
|
666
|
+
printf 'command: %s\n' "$command" >&2
|
|
667
|
+
continue
|
|
668
|
+
fi
|
|
669
|
+
printf 'Stopping orphan agent-runtime (pid %s)\n' "$pid"
|
|
670
|
+
kill "$pid" 2>/dev/null || true
|
|
671
|
+
waited=0
|
|
672
|
+
while kill -0 "$pid" 2>/dev/null && (( waited < 50 )); do
|
|
673
|
+
sleep 0.1
|
|
674
|
+
(( waited++ )) || true
|
|
675
|
+
done
|
|
676
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
677
|
+
printf 'pid %s did not stop, sending SIGKILL\n' "$pid" >&2
|
|
678
|
+
kill -9 "$pid" 2>/dev/null || true
|
|
679
|
+
fi
|
|
680
|
+
stopped=1
|
|
681
|
+
done < <(runtime_port_pids "$port")
|
|
682
|
+
(( stopped == 1 ))
|
|
643
683
|
}
|
|
644
684
|
|
|
645
685
|
runtime_port() {
|
|
@@ -704,6 +744,14 @@ runtime_command() {
|
|
|
704
744
|
if runtime_pid_running; then
|
|
705
745
|
printf 'Waiting for agent-runtime process (pid %s)...\n' "$(cat "$pid_file")"
|
|
706
746
|
else
|
|
747
|
+
# Le port peut être tenu par un runtime d'une session précédente, qui
|
|
748
|
+
# répond 401 à notre jeton : la sonde de santé le croit absent, et le
|
|
749
|
+
# démarrage se solderait par un EADDRINUSE incompréhensible. On le
|
|
750
|
+
# remplace, puisque `runtime up` veut précisément un runtime ici.
|
|
751
|
+
if [[ -n "$(runtime_port_pids "$port")" ]]; then
|
|
752
|
+
printf 'Port %s is already held by an unreachable agent-runtime.\n' "$port"
|
|
753
|
+
runtime_kill_orphans "$port" || true
|
|
754
|
+
fi
|
|
707
755
|
WIKI_WORKSPACES_DIR="$WORKSPACES_DIR" \
|
|
708
756
|
WIKI_MANAGER_ENV_FILE="$MANAGER_ENV_FILE" \
|
|
709
757
|
WIKI_MANAGER_STATE_DIR="$MANAGER_RUNTIME_DIR" \
|
|
@@ -730,12 +778,24 @@ runtime_command() {
|
|
|
730
778
|
sleep 0.1
|
|
731
779
|
(( wait_count++ )) || true
|
|
732
780
|
done
|
|
781
|
+
# Un arrêt propre refusé laissait le port occupé sans le dire.
|
|
782
|
+
if runtime_health_check; then
|
|
783
|
+
printf 'agent-runtime ignored the shutdown request; stopping the process\n' >&2
|
|
784
|
+
runtime_kill_orphans "$port" || true
|
|
785
|
+
fi
|
|
733
786
|
rm -f "$pid_file"
|
|
734
787
|
printf 'agent-runtime stopped\n'
|
|
735
788
|
return
|
|
736
789
|
fi
|
|
737
790
|
if ! runtime_pid_running; then
|
|
738
|
-
|
|
791
|
+
# Ni sain, ni référencé par un pid : reste le port. C'est le cas d'un
|
|
792
|
+
# orphelin, qu'on ne peut pas demander à l'utilisateur de tuer.
|
|
793
|
+
if runtime_kill_orphans "$port"; then
|
|
794
|
+
rm -f "$pid_file"
|
|
795
|
+
printf 'agent-runtime stopped\n'
|
|
796
|
+
return
|
|
797
|
+
fi
|
|
798
|
+
printf 'agent-runtime is not running (no healthy endpoint, no pid in %s, nothing listening on port %s)\n' "$pid_file" "$port"
|
|
739
799
|
rm -f "$pid_file"
|
|
740
800
|
return
|
|
741
801
|
fi
|
|
@@ -757,6 +817,16 @@ runtime_command() {
|
|
|
757
817
|
printf 'agent-runtime process running (pid %s), health check failed\n' "$(cat "$pid_file")"
|
|
758
818
|
exit 1
|
|
759
819
|
else
|
|
820
|
+
local orphan_pids
|
|
821
|
+
orphan_pids="$(runtime_port_pids "$port")"
|
|
822
|
+
if [[ -n "$orphan_pids" ]]; then
|
|
823
|
+
# Le symptôme perçu comme « ça ne démarre pas » : quelqu'un tient le
|
|
824
|
+
# port et répond 401 à notre jeton, donc les sondes le voient absent.
|
|
825
|
+
printf 'agent-runtime is not reachable with this token, but pid(s) %s hold port %s\n' \
|
|
826
|
+
"$(printf '%s' "$orphan_pids" | tr '\n' ' ')" "$port"
|
|
827
|
+
printf 'run `%s runtime down` to stop it\n' "$(basename "$0")"
|
|
828
|
+
exit 1
|
|
829
|
+
fi
|
|
760
830
|
printf 'agent-runtime is not running\n'
|
|
761
831
|
exit 1
|
|
762
832
|
fi
|
|
@@ -792,6 +862,19 @@ refresh_project() {
|
|
|
792
862
|
|
|
793
863
|
printf 'Removing external-agent stack and images...\n'
|
|
794
864
|
agents_compose purge
|
|
865
|
+
|
|
866
|
+
# `refresh` promet un état propre : le vérifier plutôt que l'affirmer. Un
|
|
867
|
+
# port encore tenu ici signifie qu'il l'est par un processus étranger, que
|
|
868
|
+
# `runtime down` refuse de tuer à raison — mais le taire ferait échouer le
|
|
869
|
+
# prochain démarrage sans que personne ne fasse le lien.
|
|
870
|
+
local leftover
|
|
871
|
+
leftover="$(runtime_port_pids "$(runtime_port)")"
|
|
872
|
+
if [[ -n "$leftover" ]]; then
|
|
873
|
+
printf 'Refresh incomplete: port %s is still held by pid(s) %s.\n' \
|
|
874
|
+
"$(runtime_port)" "$(printf '%s' "$leftover" | tr '\n' ' ')" >&2
|
|
875
|
+
printf 'It is not a wiki-manager runtime, so it was left running. Free the port, or set WIKI_MANAGER_RUNTIME_PORT to another one.\n' >&2
|
|
876
|
+
return 1
|
|
877
|
+
fi
|
|
795
878
|
printf 'Refresh complete. Persistent workspace and agent data were preserved.\n'
|
|
796
879
|
}
|
|
797
880
|
|