@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/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 { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
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 <connector>.',
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 serviceNames() {
260
- return composeServiceNames();
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
- if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceNames()];
305
- if (command === '/stop' && tokenIndex === 1) return ['agents', ...serviceNames()];
306
- if (command === '/logs' && tokenIndex === 1) return serviceNames();
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 'Start the workspace services AND the external agents.';
537
- if (value === 'agents' || value === 'agent') return 'Start the external agents only.';
538
- if (value === 'services') return 'Start the workspace services only.';
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') return serviceDescription(value) ?? 'Stop this Docker Compose service.';
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 >= 4, 'expected the scaffold to declare endpoints and secrets');
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
  }
@@ -48,7 +61,7 @@ test('preloading a profile filters placeholders out of every prefilled field', (
48
61
  // With the vector URL filtered to null, this is what makes the embeddings
49
62
  // step offer the base URL the operator just typed.
50
63
  assert.match(source, /const baseUrl = vector\(\)\.baseUrl \|\| llm\(\)\.baseUrl;/);
51
- assert.match(source, /baseUrl: old\.baseUrl \|\| llm\(\)\.baseUrl \|\| defaultBaseUrl\(llm\(\)\.provider\)/);
64
+ assert.match(source, /baseUrl: old\.baseUrl \|\| llm\(\)\.baseUrl \|\| defaultBaseUrl\(llm\(\)\.provider, llm\(\)\.engine\)/);
52
65
  });
53
66
 
54
67
  test('a real configuration is never mistaken for a placeholder', () => {
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ const source = readFileSync(new URL('./SetupWizard.tsx', import.meta.url), 'utf8');
6
+
7
+ test('the model steps feed the discovered catalog into the filter', () => {
8
+ // Trois étapes doivent porter des suggestions : modèle LLM, embeddings,
9
+ // reranker. Sans elles, le filtre n'a rien a filtrer.
10
+ const occurrences = source.match(/\.\.\.suggestionField\(/g) ?? [];
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;/);
35
+ });
36
+
37
+ test('the typed value always wins over the suggestion list', () => {
38
+ // Garde-fou : la liste ne doit jamais devenir un select. Si ce message
39
+ // disparait, c'est que la saisie libre a ete perdue.
40
+ assert.match(source, /the typed value wins/);
41
+ assert.match(source, /the typed value is used as-is/);
42
+ });
43
+
44
+ test('suggestions are filtered by the current input, not shown raw', () => {
45
+ assert.match(source, /all\.filter\(\(item\) => item\.toLowerCase\(\)\.includes\(needle\)\)/);
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
+ );
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
- # Local-iteration build only: the packaged connectors service carries a
266
- # `build:` block pointing at the sibling agent-connectors repo, whose OAuth
267
- # credentials live in a gitignored .env.build.local. Compose resolves the
268
- # value-less build args from *this* process environment, not from
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
- [[ $# -eq 0 ]] || die "agents up accepts only --pull"
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
- [[ $# -eq 0 ]] || die "agents down does not take arguments"
345
- _agents_dc down
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
- [[ "$command" == *"/bin/wiki-manager.js runtime"* || "$command" == *" bin/wiki-manager.js runtime"* ]]
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
- printf 'agent-runtime is not running from %s\n' "$pid_file"
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
 
@@ -1074,6 +1157,7 @@ cacert_compose_args() {
1074
1157
  run_wiki() {
1075
1158
  local workspace="$1"
1076
1159
  shift
1160
+ need_workspace_env "$workspace"
1077
1161
  local ws_path
1078
1162
  ws_path="$(normalize_path "$(workspace_value "$workspace" WIKI_WORKSPACE_PATH)")"
1079
1163
  ensure_workspace_dirs "$ws_path"