@dotdrelle/wiki-manager 0.15.28 → 0.15.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.env.example +5 -2
  2. package/README.md +5 -4
  3. package/agents.docker-compose.override.example.yml +1 -1
  4. package/agents.docker-compose.yml +10 -0
  5. package/bin/wiki-manager.js +1 -1
  6. package/docker-compose.override.example.yml +3 -2
  7. package/mcp.endpoints.example.json +1 -1
  8. package/package.json +2 -2
  9. package/src/agent/graph.js +77 -13
  10. package/src/agent/graph.test.js +165 -8
  11. package/src/cli/runtimeStartup.test.js +14 -0
  12. package/src/cli/wiki-manager.js +118 -19
  13. package/src/cli/wiki-manager.test.js +192 -0
  14. package/src/commands/slash.js +168 -39
  15. package/src/commands/slash.test.js +100 -1
  16. package/src/core/agentsCompose.js +79 -0
  17. package/src/core/agentsCompose.test.js +99 -0
  18. package/src/core/buildInfo.json +2 -2
  19. package/src/core/composeOverrides.test.js +17 -3
  20. package/src/core/env.js +27 -10
  21. package/src/core/mcp.js +113 -38
  22. package/src/core/mcp.test.js +181 -20
  23. package/src/core/startupCheck.js +64 -27
  24. package/src/core/startupCheck.test.js +49 -1
  25. package/src/core/wikiSetup.js +57 -22
  26. package/src/core/wikiSetup.test.js +87 -0
  27. package/src/core/wikiWorkspace.test.js +16 -0
  28. package/src/orchestrator/agentRegistry.js +35 -7
  29. package/src/orchestrator/agentRegistry.test.js +73 -0
  30. package/src/orchestrator/dispatcher.js +6 -1
  31. package/src/orchestrator/dispatcher.test.js +24 -1
  32. package/src/orchestrator/objectiveResolver.js +24 -0
  33. package/src/orchestrator/objectiveResolver.test.js +27 -0
  34. package/src/runtime/auth.test.js +1 -65
  35. package/src/runtime/donna-contract.test.js +2 -1
  36. package/src/runtime/lifecycle.js +0 -33
  37. package/src/runtime/runner.test.js +10 -1
  38. package/src/runtime/supervisor.test.js +11 -1
  39. package/src/shell/LeftPane.tsx +49 -24
  40. package/src/shell/repl.js +114 -21
  41. package/src/shell/repl.test.js +88 -17
  42. package/src/shell/tui.tsx +10 -19
  43. package/wiki-workspace +98 -12
@@ -30,6 +30,8 @@ import {
30
30
  summarizeWikircConfig,
31
31
  } from '../core/wikirc.js';
32
32
  import { applySessionWikircProfile } from '../core/sessionConfig.js';
33
+ import { loadManagerEnv } from '../core/env.js';
34
+ import { resolveSchedulerConcurrency } from '../orchestrator/scheduler.js';
33
35
  import {
34
36
  deleteWorkspaceAndFiles,
35
37
  finalizeCreatedWorkspace,
@@ -83,6 +85,18 @@ function twoColumns(left, right) {
83
85
  return out.join('\n');
84
86
  }
85
87
 
88
+ export function compactBaseUrl(value) {
89
+ const raw = String(value ?? '').trim();
90
+ if (!raw) return '-';
91
+ try {
92
+ const parsed = new URL(raw);
93
+ const label = parsed.host || parsed.hostname;
94
+ return label ? `[${label}](${raw})` : raw;
95
+ } catch {
96
+ return raw.replace(/^https?:\/\//i, '').replace(/\/+$/, '') || '-';
97
+ }
98
+ }
99
+
86
100
  function commandLabel(value) {
87
101
  return `${styles.bold}${styles.cyan}${value}${styles.reset}`;
88
102
  }
@@ -222,20 +236,68 @@ function statLine(label, stat) {
222
236
  return `${label}: ${stat.count} (${formatBytes(stat.totalBytes)})`;
223
237
  }
224
238
 
225
- function workspaceStatsColumns(stats) {
239
+ function positiveConcurrency(value, fallback) {
240
+ const parsed = Number(value);
241
+ return Number.isFinite(parsed) && parsed > 0 ? Math.max(1, Math.floor(parsed)) : fallback;
242
+ }
243
+
244
+ function discoveredAgentLimits(session, agentType) {
245
+ const agents = session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? [];
246
+ const agent = agents.find((entry) =>
247
+ String(entry?.description?.agentType ?? '').toLowerCase() === agentType
248
+ || String(entry?.serverName ?? '').toLowerCase().includes(agentType));
249
+ return agent?.description?.limits ?? {};
250
+ }
251
+
252
+ export function agentConcurrencySections(session, env = process.env) {
253
+ const managerCeiling = positiveConcurrency(env.WIKI_MANAGER_CAPABILITY_CONCURRENCY, null);
254
+ const productionLimits = discoveredAgentLimits(session, 'production');
255
+ const productionRecommended = positiveConcurrency(
256
+ productionLimits.recommendedConcurrency ?? env.PRODUCTION_RECOMMENDED_CONCURRENCY,
257
+ 4,
258
+ );
259
+ const productionMaximum = positiveConcurrency(
260
+ productionLimits.maxConcurrency ?? env.PRODUCTION_MAX_CONCURRENCY,
261
+ 8,
262
+ );
263
+ const productionEffective = Math.min(
264
+ productionRecommended,
265
+ productionMaximum,
266
+ managerCeiling ?? Number.POSITIVE_INFINITY,
267
+ );
268
+
269
+ const connectorLimits = discoveredAgentLimits(session, 'connectors');
270
+ const collectionRecommended = positiveConcurrency(
271
+ connectorLimits.recommendedConcurrency ?? env.CONNECTORS_RECOMMENDED_CONCURRENCY,
272
+ 2,
273
+ );
274
+ const collectionMaximum = positiveConcurrency(
275
+ connectorLimits.maxConcurrency ?? env.CONNECTORS_MAX_CONCURRENCY,
276
+ 4,
277
+ );
278
+ const collectionEffective = Math.min(
279
+ collectionRecommended,
280
+ collectionMaximum,
281
+ managerCeiling ?? Number.POSITIVE_INFINITY,
282
+ );
283
+
284
+ return {
285
+ production: sectionBlock('Parallelism & throughput', [
286
+ `effective: ${productionEffective}`,
287
+ `recommended: ${productionRecommended}`,
288
+ `maximum: ${productionMaximum}`,
289
+ `scheduler workers: ${resolveSchedulerConcurrency(env.WIKI_MANAGER_SCHEDULER_CONCURRENCY)}`,
290
+ ]),
291
+ collection: sectionBlock('Collection concurrency', [
292
+ `effective: ${collectionEffective}`,
293
+ `recommended: ${collectionRecommended}`,
294
+ `maximum: ${collectionMaximum}`,
295
+ ]),
296
+ };
297
+ }
298
+
299
+ function workspaceStatsColumns(stats, session) {
226
300
  if (!stats) return { left: 'No workspace loaded.', right: '' };
227
- const hints = [];
228
- if (stats.untracked.count > 0) {
229
- hints.push(`${stats.untracked.count} raw/untracked document(s) are waiting for ingest.`);
230
- }
231
- if (!stats.index.exists) {
232
- hints.push('wiki/index.md is missing.');
233
- } else if (stats.index.links === 0) {
234
- hints.push('wiki/index.md exists but has no markdown/wiki links.');
235
- }
236
- if (stats.wiki.count === 0) {
237
- hints.push('The wiki has no markdown pages yet.');
238
- }
239
301
 
240
302
  const wikiLatest = formatDate(Math.max(
241
303
  stats.wiki.latest?.mtimeMs ?? 0,
@@ -266,14 +328,11 @@ function workspaceStatsColumns(stats) {
266
328
  statLine('templates', stats.templates),
267
329
  statLine('deliverables', stats.deliverables),
268
330
  ]);
269
- const internalColumn = sectionBlock('Internal', [
270
- `logs: ${stats.logs.count} (${formatBytes(stats.logs.totalBytes)})`,
271
- ]);
272
- const hintsColumn = sectionBlock('Hints', hints.length > 0 ? hints : ['No immediate content action detected.']);
331
+ const concurrency = agentConcurrencySections(session);
273
332
 
274
333
  return {
275
334
  left: [wikiColumn, deliveryColumn].join('\n\n'),
276
- right: [rawColumn, internalColumn, hintsColumn].join('\n\n'),
335
+ right: [rawColumn, concurrency.production, concurrency.collection].join('\n\n'),
277
336
  };
278
337
  }
279
338
 
@@ -353,6 +412,44 @@ function mcpEndpointsText(mcpStatus) {
353
412
  .join('\n');
354
413
  }
355
414
 
415
+ function mcpPort(endpoint) {
416
+ const value = String(endpoint?.url ?? endpoint?.configuredUrl ?? '');
417
+ try {
418
+ const parsed = new URL(value);
419
+ if (parsed.port) return parsed.port;
420
+ if (parsed.protocol === 'https:') return '443';
421
+ if (parsed.protocol === 'http:') return '80';
422
+ } catch {
423
+ // Placeholders in configured URLs are not valid URL syntax; use the
424
+ // resolved trailing numeric port when one is present.
425
+ }
426
+ return value.match(/:(\d+)(?:\/|$)/)?.[1] ?? '-';
427
+ }
428
+
429
+ export function compactMcpStatus(mcpStatus) {
430
+ const entries = Object.entries(mcpStatus ?? {});
431
+ if (entries.length === 0) return '○ none';
432
+ return entries
433
+ .sort(([left], [right]) => left.localeCompare(right))
434
+ .map(([name, endpoint]) => {
435
+ // `configured` covers two very different situations: an endpoint we
436
+ // never probed (service stopped) and one whose probe failed. Reporting
437
+ // both as "configured" is what makes a live agent look unconfigured —
438
+ // surface the failure as its own state, with the cause one command away
439
+ // (`/mcp status` prints the full toolsError).
440
+ if (endpoint.status !== 'connected' && endpoint.toolError) {
441
+ return `✕ ${name} :${mcpPort(endpoint)} unreachable (/mcp status)`;
442
+ }
443
+ const marker = endpoint.status === 'connected'
444
+ ? '●'
445
+ : endpoint.status === 'configured'
446
+ ? '◐'
447
+ : '○';
448
+ return `${marker} ${name} :${mcpPort(endpoint)} ${endpoint.status ?? 'unknown'}`;
449
+ })
450
+ .join('\n');
451
+ }
452
+
356
453
  function skillsText(session) {
357
454
  const skills = listSkills(session);
358
455
  if (skills.length === 0) return 'No skills discovered.';
@@ -511,8 +608,7 @@ export async function refreshMcpRuntimeStatus(session) {
511
608
  async function statusText(session) {
512
609
  const states = await refreshMcpRuntimeStatus(session);
513
610
  const workspaceStats = collectWorkspaceStats(session);
514
- const workspaceColumn = sectionBlock('Workspace', [
515
- `workspace: ${session.workspace ?? '-'}`,
611
+ const workspaceColumn = sectionBlock(`Workspace · ${session.workspace ?? '-'}`, [
516
612
  `path: ${compactPath(session.workspacePath ?? '-')}`,
517
613
  `env: ${compactPath(session.workspaceEnvFile ?? '-')}`,
518
614
  ]);
@@ -522,22 +618,20 @@ async function statusText(session) {
522
618
  `llm: ${session.llm ? 'configured' : 'missing'}`,
523
619
  `provider: ${session.wikircConfig?.llm?.provider ?? '-'}`,
524
620
  `model: ${session.wikircConfig?.llm?.model ?? '-'}`,
525
- `baseUrl: ${session.wikircConfig?.llm?.baseUrl ?? '-'}`,
621
+ `baseUrl: ${compactBaseUrl(session.wikircConfig?.llm?.baseUrl)}`,
526
622
  ]);
527
623
  const runtimeColumn = sectionBlock('Runtime', (states ? serviceStatesText(states) : 'Docker runtime not available or no workspace loaded.').split('\n'));
528
- const mcpColumn = sectionBlock('MCP', formatMcpStatus(session.mcp).split('\n'));
529
- const mcpToolsColumn = sectionBlock('MCP tool summary', formatMcpToolSummary(session.mcp).split('\n'));
530
- const stats = workspaceStatsColumns(workspaceStats);
624
+ const mcpColumn = sectionBlock('MCP', compactMcpStatus(session.mcp).split('\n'));
625
+ const stats = workspaceStatsColumns(workspaceStats, session);
531
626
 
532
627
  const leftColumn = [workspaceColumn, stats.left, runtimeColumn, mcpColumn].filter(Boolean).join('\n\n');
533
- const rightColumn = [configColumn, stats.right, mcpToolsColumn].filter(Boolean).join('\n\n');
534
-
535
- // Leading/trailing padding row on *both* columns so the boxed pair doesn't
536
- // butt directly against the pane border when the view is scrolled to show
537
- // the tail. A single space on each side (not '') keeps the row tab-joined,
538
- // so both the left and right box render — an empty string on either side
539
- // of the tab makes twoColumns drop the pairing and only the left box shows.
540
- const pad = ' \t ';
628
+ const rightColumn = [configColumn, stats.right].filter(Boolean).join('\n\n');
629
+
630
+ // Leading/trailing blank row so the boxed pair doesn't butt directly against
631
+ // the pane border when the view is scrolled to show the tail. It is padding,
632
+ // not data: LeftPane renders a row that is blank on both sides as a plain
633
+ // spacer, so no empty bordered box is drawn past the last real line.
634
+ const pad = ' ';
541
635
  return [pad, twoColumns(leftColumn, rightColumn), pad].join('\n');
542
636
  }
543
637
 
@@ -598,6 +692,7 @@ Usage:
598
692
  Options:
599
693
  -v, --version Print version
600
694
  -h, --help Print help
695
+ --refresh Stop runtime and project containers; remove project images
601
696
  --cacert <path> Trust a local CA; Docker must be able to read this host path
602
697
  --once <prompt> Run one agent turn and exit
603
698
  --headless Run a workspace task non-interactively
@@ -616,7 +711,7 @@ ${helpPair('/workspace list', 'Workspaces', '/new <n> [path]', 'New workspace')}
616
711
  ${helpPair('/use <workspace>', 'Use workspace', '/status', 'Session status')}
617
712
  ${helpPair('/config list', 'Config profiles', '/config use <n>', 'Use config')}
618
713
  ${helpPair('/config edit <n>', 'Edit config', '/workspace delete <n>', 'Delete workspace')}
619
- ${helpPair('/services', 'Services', '/start [all|service|agents]', 'Start service(s)')}
714
+ ${helpPair('/services', 'Services', '/start [all|agents|services]', 'all = services + agents')}
620
715
  ${helpPair('/stop [all|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
621
716
  ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
622
717
  ${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
@@ -656,11 +751,12 @@ export function printHelp(packageJson) {
656
751
  console.log(helpText(packageJson));
657
752
  }
658
753
 
659
- function rawCommandAgentPrompt(command, output) {
754
+ export function rawCommandAgentPrompt(command, output) {
660
755
  return [
661
756
  `L'utilisateur a lancé la commande shell ${command}.`,
662
- 'Voici la sortie brute collectée par la commande déterministe. Ne relance pas la commande et ne modifie pas les données.',
757
+ 'Voici la sortie brute collectée par la commande déterministe. Ne relance pas la commande, ne modifie pas les données et n’appelle aucun outil.',
663
758
  'Réponds à l’utilisateur à partir de ces faits, en appliquant le profil workspace et les préférences de présentation déjà chargés dans ton prompt système.',
759
+ 'Ne reproduis pas les détails techniques, identifiants internes, chemins, noms de conteneurs ou sorties brutes. Donne seulement le résultat utile en langage naturel.',
664
760
  '',
665
761
  'Sortie brute:',
666
762
  '```text',
@@ -731,6 +827,10 @@ export function localizedOperationFailure({ operation, target, error }) {
731
827
  return {
732
828
  output: facts,
733
829
  rawOutput: true,
830
+ // The status only lived inside the serialized facts, so a caller chaining
831
+ // two operations (/start all = agents then services) had to re-parse JSON
832
+ // to know whether to continue. Surface it as a plain flag.
833
+ failed: true,
734
834
  agentTrigger: [
735
835
  "Formule l'échec structuré suivant dans la langue et le ton demandés par le profil du workspace.",
736
836
  "Réponds en une ou deux phrases humaines: ce qui a échoué, et l'action concrète que la personne peut faire.",
@@ -779,6 +879,19 @@ export async function handleSlashCommand(line, context) {
779
879
  try {
780
880
  step(`Agents: ${verb}ing external agents…`);
781
881
  const missingImages = await collectMissingImages(step, fn);
882
+ if (verb === 'start') {
883
+ // `wiki-workspace agents up` generates the agent tokens into the
884
+ // manager .env and adds the profiled entries to mcp.endpoints.json.
885
+ // Both were read at boot, so without re-reading them the endpoint the
886
+ // script just wrote resolves with an empty ${..._AUTH_TOKEN} and the
887
+ // freshly started agent stays "credential not set" until a restart.
888
+ step('Agents: reloading manager environment and MCP endpoints…');
889
+ // The script may have replaced a blank/generated token that was
890
+ // already loaded at boot. A non-overriding dotenv reload would keep
891
+ // the stale process value and defeat the refresh.
892
+ loadManagerEnv({ override: true });
893
+ await refreshMcpRuntimeStatus(context.session);
894
+ }
782
895
  return localizedOperationResult({
783
896
  operation: verb,
784
897
  target: 'agents',
@@ -945,21 +1058,37 @@ export async function handleSlashCommand(line, context) {
945
1058
  // falls back to the hardcoded COMPOSE_SERVICES constant instead.
946
1059
  const service = args[1];
947
1060
  if (service === 'agents' || service === 'agent') return runAgentCommand(startAgents, 'start');
1061
+ // "all" used to mean "the workspace services", which left the external
1062
+ // agents down and looked like nothing had happened. It now means what an
1063
+ // operator reads into it: the whole stack. `/start services` keeps the
1064
+ // workspace-only behaviour.
1065
+ const startsAgents = service === 'all';
1066
+ const target = service === 'services' ? undefined : service;
948
1067
  try {
949
- step(`Services: starting ${service ?? 'workspace services'}…`);
950
- const missingImages = await collectMissingImages(step, (opts) => startService(context.session, service, opts));
1068
+ if (startsAgents) {
1069
+ // Validate the workspace half before mutating the global agents
1070
+ // stack. Otherwise `/start all` with no active workspace starts the
1071
+ // agents successfully and only then fails, leaving a partial start.
1072
+ if (!context.session.workspace || !context.session.workspacePath || !context.session.workspaceEnv?.WORKSPACE_NAME) {
1073
+ throw new Error('No workspace loaded. Use /use <workspace>.');
1074
+ }
1075
+ const agentsResult = await runAgentCommand(startAgents, 'start');
1076
+ if (agentsResult?.failed) return agentsResult;
1077
+ }
1078
+ step(`Services: starting ${target ?? 'workspace services'}…`);
1079
+ const missingImages = await collectMissingImages(step, (opts) => startService(context.session, target, opts));
951
1080
  step('Services: refreshing MCP runtime…');
952
1081
  await refreshMcpRuntimeStatus(context.session);
953
1082
  return localizedOperationResult({
954
1083
  operation: 'start',
955
- target: service || 'workspace-services',
1084
+ target: startsAgents ? 'all-services-and-agents' : (target || 'workspace-services'),
956
1085
  componentAction: componentInstallAction(missingImages),
957
1086
  images: missingImages,
958
1087
  });
959
1088
  } catch (err) {
960
1089
  step(formatActivityError('services', 'start', err));
961
1090
  step(`Services: docker output — ${rawFailureText(err)}`);
962
- return localizedOperationFailure({ operation: 'start', target: service || 'workspace-services', error: err });
1091
+ return localizedOperationFailure({ operation: 'start', target: target || 'workspace-services', error: err });
963
1092
  }
964
1093
  }
965
1094
  case 'stop': {
@@ -4,7 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
- import { handleSlashCommand, localizedOperationResult } from './slash.js';
7
+ import { agentConcurrencySections, compactBaseUrl, compactMcpStatus, handleSlashCommand, localizedOperationResult } from './slash.js';
8
8
  import { completionContext } from '../shell/repl.js';
9
9
 
10
10
  test('deterministic operation results ask Donna to localize compact facts without leaking commands', () => {
@@ -15,6 +15,68 @@ test('deterministic operation results ask Donna to localize compact facts withou
15
15
  assert.doesNotMatch(result.agentTrigger, /\/start|Docker|compose/);
16
16
  });
17
17
 
18
+ test('/status MCP overview contains only connector name, port and status', () => {
19
+ const output = compactMcpStatus({
20
+ connectors: {
21
+ url: 'http://localhost:3338/mcp/',
22
+ status: 'connected',
23
+ tools: Array.from({ length: 20 }, (_, index) => ({ name: `long_tool_${index}` })),
24
+ toolError: 'a very long technical error that must stay out of the overview',
25
+ },
26
+ cme: {
27
+ configuredUrl: 'http://host.docker.internal:3336/mcp/',
28
+ status: 'configured',
29
+ detail: 'some long runtime detail',
30
+ },
31
+ });
32
+
33
+ assert.equal(output, '◐ cme :3336 configured\n● connectors :3338 connected');
34
+ assert.doesNotMatch(output, /tools|error|detail|http/i);
35
+ });
36
+
37
+ test('/status base URL displays only its domain while retaining the full link', () => {
38
+ assert.equal(
39
+ compactBaseUrl('https://albert.api.etalab.gouv.fr/v1'),
40
+ '[albert.api.etalab.gouv.fr](https://albert.api.etalab.gouv.fr/v1)',
41
+ );
42
+ assert.equal(compactBaseUrl('http://localhost:11434/v1'), '[localhost:11434](http://localhost:11434/v1)');
43
+ assert.equal(compactBaseUrl(undefined), '-');
44
+ });
45
+
46
+ test('/status replaces Internal and Hints with effective agent concurrency', () => {
47
+ const sections = agentConcurrencySections({
48
+ agentRegistrySnapshot: [
49
+ {
50
+ serverName: 'production',
51
+ description: {
52
+ agentType: 'production',
53
+ limits: { recommendedConcurrency: 6, maxConcurrency: 10 },
54
+ },
55
+ },
56
+ {
57
+ serverName: 'connectors',
58
+ description: {
59
+ agentType: 'connectors',
60
+ limits: { recommendedConcurrency: 3, maxConcurrency: 5 },
61
+ },
62
+ },
63
+ ],
64
+ }, {
65
+ WIKI_MANAGER_CAPABILITY_CONCURRENCY: '4',
66
+ WIKI_MANAGER_SCHEDULER_CONCURRENCY: '7',
67
+ });
68
+
69
+ assert.match(sections.production, /Parallelism & throughput/);
70
+ assert.match(sections.production, /effective: 4/);
71
+ assert.match(sections.production, /recommended: 6/);
72
+ assert.match(sections.production, /maximum: 10/);
73
+ assert.doesNotMatch(sections.production, /manager ceiling/);
74
+ assert.match(sections.production, /scheduler workers: 7/);
75
+ assert.match(sections.collection, /Collection concurrency/);
76
+ assert.match(sections.collection, /effective: 3/);
77
+ assert.doesNotMatch(`${sections.production}\n${sections.collection}`, /Internal|Hints/);
78
+ });
79
+
18
80
  test('start result tells Donna when missing container components were installed', () => {
19
81
  const result = localizedOperationResult({
20
82
  operation: 'start',
@@ -32,6 +94,43 @@ test('start result tells Donna when missing container components were installed'
32
94
  assert.match(result.agentTrigger, /downloaded-and-installed-missing-components/);
33
95
  });
34
96
 
97
+ test('/start all covers the agents as well as the workspace services', async () => {
98
+ const { readFile } = await import('node:fs/promises');
99
+ const source = await readFile(new URL('./slash.js', import.meta.url), 'utf8');
100
+ const start = source.slice(source.indexOf("case 'start': {"), source.indexOf("case 'stop': {"));
101
+
102
+ // "all" used to start the workspace services only, which left every external
103
+ // agent down while the wording promised the opposite.
104
+ assert.match(start, /const startsAgents = service === 'all';/);
105
+ assert.match(start, /const target = service === 'services' \? undefined : service;/);
106
+ assert.match(start, /await runAgentCommand\(startAgents, 'start'\)/);
107
+ assert.match(start, /if \(agentsResult\?\.failed\) return agentsResult;/);
108
+ assert.match(start, /if \(!context\.session\.workspace \|\| !context\.session\.workspacePath/);
109
+ assert.match(start, /service === 'agents' \|\| service === 'agent'/);
110
+ });
111
+
112
+ test('a started agent stack reloads the env and the MCP endpoints the script rewrote', async () => {
113
+ const { readFile } = await import('node:fs/promises');
114
+ const source = await readFile(new URL('./slash.js', import.meta.url), 'utf8');
115
+ const runAgent = source.slice(source.indexOf('const runAgentCommand ='), source.indexOf('switch (command)'));
116
+
117
+ assert.match(runAgent, /if \(verb === 'start'\)/);
118
+ assert.match(runAgent, /loadManagerEnv\(\{ override: true \}\);/);
119
+ assert.match(runAgent, /await refreshMcpRuntimeStatus\(context\.session\)/);
120
+ });
121
+
122
+ test('/start completes to the three documented targets', async () => {
123
+ const { completionDescription } = await import('../shell/repl.js');
124
+ const { matches } = completionContext('/start ', {});
125
+
126
+ for (const expected of ['all', 'agents', 'services']) {
127
+ assert.ok(matches.includes(expected), `${expected} missing from ${JSON.stringify(matches)}`);
128
+ }
129
+ assert.match(completionDescription('all', ['/start']), /services AND the external agents/);
130
+ assert.match(completionDescription('agents', ['/start']), /agents only/);
131
+ assert.match(completionDescription('services', ['/start']), /workspace services only/);
132
+ });
133
+
35
134
  test('/workspace delete removes files and clears current session context after confirmation', async () => {
36
135
  const root = await mkdtemp(join(tmpdir(), 'wiki-manager-delete-workspace-'));
37
136
  const registryRoot = join(root, 'registry');
@@ -0,0 +1,79 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { managerComposeOverrideFile, managerEnvFile, readEnvFile } from './env.js';
5
+ import { managerRoot } from './workspaces.js';
6
+
7
+ export const AGENTS_COMPOSE_PROJECT = 'wiki-agents';
8
+ export const AGENTS_COMPOSE_FILE = 'agents.docker-compose.yml';
9
+ export const AGENTS_COMPOSE_OVERRIDE = 'agents.docker-compose.override.yml';
10
+
11
+ // Services behind a compose profile, and the manager flag that activates each.
12
+ // A profiled service is invisible to every compose command — `ps` included —
13
+ // unless its profile is active, which is why an inactive profile used to look
14
+ // exactly like "the flag was ignored".
15
+ export const PROFILE_FLAGS = Object.freeze({
16
+ connectors: 'CONNECTORS_ENABLED',
17
+ });
18
+
19
+ export function isEnabled(value) {
20
+ return /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim());
21
+ }
22
+
23
+ /**
24
+ * The manager `.env` wins over the ambient process environment.
25
+ *
26
+ * Compose normally gives an already-exported process variable precedence over
27
+ * `--env-file`. The manager deliberately defines the opposite policy for its
28
+ * owned configuration, then passes this resolved environment explicitly to
29
+ * `wiki-workspace`, so stale values loaded at boot cannot shadow a later .env
30
+ * update.
31
+ */
32
+ export function resolvedManagerEnv() {
33
+ let fileEnv = {};
34
+ try {
35
+ fileEnv = readEnvFile(managerEnvFile());
36
+ } catch {
37
+ // No manager .env yet: valid during first-run setup.
38
+ }
39
+ return { ...process.env, ...fileEnv };
40
+ }
41
+
42
+ /**
43
+ * Single source of truth for how the agents stack is addressed: the compose
44
+ * files, the user-owned override, the active profiles and the project name.
45
+ * `checkAgents`, the image detection and the post-start verification all go
46
+ * through this, so they can never observe a different set of services than the
47
+ * one `wiki-workspace agents up` actually started.
48
+ */
49
+ export function resolveAgentsComposeContext({ env = resolvedManagerEnv() } = {}) {
50
+ const composeFile = join(managerRoot(), AGENTS_COMPOSE_FILE);
51
+ const envFile = managerEnvFile();
52
+ const overrideFile = managerComposeOverrideFile(AGENTS_COMPOSE_OVERRIDE);
53
+ const profiles = Object.entries(PROFILE_FLAGS)
54
+ .filter(([, flag]) => isEnabled(env[flag]))
55
+ .map(([profile]) => profile)
56
+ .sort();
57
+
58
+ const composeFiles = [composeFile, ...(existsSync(overrideFile) ? [overrideFile] : [])];
59
+ const args = ['compose', '--project-directory', managerRoot()];
60
+ if (existsSync(envFile)) args.push('--env-file', envFile);
61
+ for (const file of composeFiles) args.push('-f', file);
62
+ for (const profile of profiles) args.push('--profile', profile);
63
+ args.push('-p', AGENTS_COMPOSE_PROJECT);
64
+
65
+ return {
66
+ args,
67
+ profiles,
68
+ env,
69
+ composeFile,
70
+ composeFiles,
71
+ envFile,
72
+ overrideFile,
73
+ exists: existsSync(composeFile),
74
+ // Services the operator asked for that a `ps` MUST therefore report as
75
+ // running — an enabled profile whose container is absent is a failure, not
76
+ // an opt-out.
77
+ expectedProfileServices: profiles,
78
+ };
79
+ }
@@ -0,0 +1,99 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import test from 'node:test';
7
+
8
+ import { loadManagerEnv } from './env.js';
9
+ import { isEnabled, resolveAgentsComposeContext, resolvedManagerEnv } from './agentsCompose.js';
10
+
11
+ async function withManagerState(files, fn) {
12
+ const root = await mkdtemp(join(tmpdir(), 'wiki-manager-agents-compose-'));
13
+ mkdirSync(root, { recursive: true });
14
+ for (const [name, content] of Object.entries(files)) {
15
+ const file = join(root, name);
16
+ mkdirSync(dirname(file), { recursive: true });
17
+ writeFileSync(file, content, 'utf8');
18
+ }
19
+ const previousEnvFile = process.env.WIKI_MANAGER_ENV_FILE;
20
+ const previousFlag = process.env.CONNECTORS_ENABLED;
21
+ process.env.WIKI_MANAGER_ENV_FILE = join(root, '.env');
22
+ try {
23
+ await fn({ root });
24
+ } finally {
25
+ if (previousEnvFile === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
26
+ else process.env.WIKI_MANAGER_ENV_FILE = previousEnvFile;
27
+ if (previousFlag === undefined) delete process.env.CONNECTORS_ENABLED;
28
+ else process.env.CONNECTORS_ENABLED = previousFlag;
29
+ }
30
+ }
31
+
32
+ test('isEnabled accepts every documented truthy spelling', () => {
33
+ for (const value of ['1', 'true', 'TRUE', 'yes', 'on', ' true ']) {
34
+ assert.equal(isEnabled(value), true, value);
35
+ }
36
+ for (const value of ['0', 'false', 'no', 'off', '', undefined, null, 'maybe']) {
37
+ assert.equal(isEnabled(value), false, String(value));
38
+ }
39
+ });
40
+
41
+ test('the connectors profile activates from the manager .env alone', async () => {
42
+ await withManagerState({ '.env': 'CONNECTORS_ENABLED=true\n' }, () => {
43
+ delete process.env.CONNECTORS_ENABLED;
44
+ assert.deepEqual(resolveAgentsComposeContext().profiles, ['connectors']);
45
+ });
46
+ });
47
+
48
+ test('the connectors profile activates from process.env when the .env is silent', async () => {
49
+ await withManagerState({ '.env': 'WORKSPACES_ROOT=/tmp/ws\n' }, () => {
50
+ process.env.CONNECTORS_ENABLED = 'true';
51
+ assert.deepEqual(resolveAgentsComposeContext().profiles, ['connectors']);
52
+ });
53
+ });
54
+
55
+ test('the manager .env wins over the ambient process environment', async () => {
56
+ // The manager enforces this policy before invoking Compose; otherwise an
57
+ // exported empty/stale value would beat --env-file.
58
+ await withManagerState({ '.env': 'CONNECTORS_ENABLED=false\n' }, () => {
59
+ process.env.CONNECTORS_ENABLED = 'true';
60
+ assert.equal(resolvedManagerEnv().CONNECTORS_ENABLED, 'false');
61
+ assert.deepEqual(resolveAgentsComposeContext().profiles, []);
62
+ });
63
+ });
64
+
65
+ test('an explicit manager env reload replaces values generated after boot', async () => {
66
+ await withManagerState({ '.env': 'CONNECTORS_MCP_AUTH_TOKEN=fresh-token\n' }, () => {
67
+ process.env.CONNECTORS_MCP_AUTH_TOKEN = '';
68
+ loadManagerEnv();
69
+ assert.equal(process.env.CONNECTORS_MCP_AUTH_TOKEN, '');
70
+ loadManagerEnv({ override: true });
71
+ assert.equal(process.env.CONNECTORS_MCP_AUTH_TOKEN, 'fresh-token');
72
+ delete process.env.CONNECTORS_MCP_AUTH_TOKEN;
73
+ });
74
+ });
75
+
76
+ test('the user-owned override is passed whenever it exists', async () => {
77
+ await withManagerState({
78
+ '.env': 'CONNECTORS_ENABLED=true\n',
79
+ '.wiki/compose/agents.docker-compose.override.yml': 'services: {}\n',
80
+ }, ({ root }) => {
81
+ const { args, composeFiles, overrideFile } = resolveAgentsComposeContext();
82
+ assert.equal(overrideFile, join(root, '.wiki', 'compose', 'agents.docker-compose.override.yml'));
83
+ assert.equal(composeFiles.length, 2);
84
+ assert.ok(args.includes(overrideFile));
85
+ assert.deepEqual(args.slice(args.indexOf('--profile'), args.indexOf('--profile') + 2), ['--profile', 'connectors']);
86
+ assert.deepEqual(args.slice(-2), ['-p', 'wiki-agents']);
87
+ assert.ok(args.includes('--env-file'));
88
+ });
89
+ });
90
+
91
+ test('a missing override and a missing .env are both valid', async () => {
92
+ await withManagerState({}, () => {
93
+ delete process.env.CONNECTORS_ENABLED;
94
+ const { args, composeFiles, profiles } = resolveAgentsComposeContext();
95
+ assert.equal(composeFiles.length, 1);
96
+ assert.deepEqual(profiles, []);
97
+ assert.equal(args.includes('--env-file'), false);
98
+ });
99
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.28",
3
- "commit": "11b99e6"
2
+ "version": "0.15.32",
3
+ "commit": "8b6b1aa"
4
4
  }