@dotdrelle/wiki-manager 0.15.28 → 0.15.29

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.
@@ -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,36 @@ 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
+ const marker = endpoint.status === 'connected'
436
+ ? '●'
437
+ : endpoint.status === 'configured'
438
+ ? '◐'
439
+ : '○';
440
+ return `${marker} ${name} :${mcpPort(endpoint)} ${endpoint.status ?? 'unknown'}`;
441
+ })
442
+ .join('\n');
443
+ }
444
+
356
445
  function skillsText(session) {
357
446
  const skills = listSkills(session);
358
447
  if (skills.length === 0) return 'No skills discovered.';
@@ -511,8 +600,7 @@ export async function refreshMcpRuntimeStatus(session) {
511
600
  async function statusText(session) {
512
601
  const states = await refreshMcpRuntimeStatus(session);
513
602
  const workspaceStats = collectWorkspaceStats(session);
514
- const workspaceColumn = sectionBlock('Workspace', [
515
- `workspace: ${session.workspace ?? '-'}`,
603
+ const workspaceColumn = sectionBlock(`Workspace · ${session.workspace ?? '-'}`, [
516
604
  `path: ${compactPath(session.workspacePath ?? '-')}`,
517
605
  `env: ${compactPath(session.workspaceEnvFile ?? '-')}`,
518
606
  ]);
@@ -522,22 +610,20 @@ async function statusText(session) {
522
610
  `llm: ${session.llm ? 'configured' : 'missing'}`,
523
611
  `provider: ${session.wikircConfig?.llm?.provider ?? '-'}`,
524
612
  `model: ${session.wikircConfig?.llm?.model ?? '-'}`,
525
- `baseUrl: ${session.wikircConfig?.llm?.baseUrl ?? '-'}`,
613
+ `baseUrl: ${compactBaseUrl(session.wikircConfig?.llm?.baseUrl)}`,
526
614
  ]);
527
615
  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);
616
+ const mcpColumn = sectionBlock('MCP', compactMcpStatus(session.mcp).split('\n'));
617
+ const stats = workspaceStatsColumns(workspaceStats, session);
531
618
 
532
619
  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 ';
620
+ const rightColumn = [configColumn, stats.right].filter(Boolean).join('\n\n');
621
+
622
+ // Leading/trailing blank row so the boxed pair doesn't butt directly against
623
+ // the pane border when the view is scrolled to show the tail. It is padding,
624
+ // not data: LeftPane renders a row that is blank on both sides as a plain
625
+ // spacer, so no empty bordered box is drawn past the last real line.
626
+ const pad = ' ';
541
627
  return [pad, twoColumns(leftColumn, rightColumn), pad].join('\n');
542
628
  }
543
629
 
@@ -616,7 +702,7 @@ ${helpPair('/workspace list', 'Workspaces', '/new <n> [path]', 'New workspace')}
616
702
  ${helpPair('/use <workspace>', 'Use workspace', '/status', 'Session status')}
617
703
  ${helpPair('/config list', 'Config profiles', '/config use <n>', 'Use config')}
618
704
  ${helpPair('/config edit <n>', 'Edit config', '/workspace delete <n>', 'Delete workspace')}
619
- ${helpPair('/services', 'Services', '/start [all|service|agents]', 'Start service(s)')}
705
+ ${helpPair('/services', 'Services', '/start [all|agents|services]', 'all = services + agents')}
620
706
  ${helpPair('/stop [all|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
621
707
  ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
622
708
  ${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
@@ -656,11 +742,12 @@ export function printHelp(packageJson) {
656
742
  console.log(helpText(packageJson));
657
743
  }
658
744
 
659
- function rawCommandAgentPrompt(command, output) {
745
+ export function rawCommandAgentPrompt(command, output) {
660
746
  return [
661
747
  `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.',
748
+ '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
749
  '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.',
750
+ '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
751
  '',
665
752
  'Sortie brute:',
666
753
  '```text',
@@ -731,6 +818,10 @@ export function localizedOperationFailure({ operation, target, error }) {
731
818
  return {
732
819
  output: facts,
733
820
  rawOutput: true,
821
+ // The status only lived inside the serialized facts, so a caller chaining
822
+ // two operations (/start all = agents then services) had to re-parse JSON
823
+ // to know whether to continue. Surface it as a plain flag.
824
+ failed: true,
734
825
  agentTrigger: [
735
826
  "Formule l'échec structuré suivant dans la langue et le ton demandés par le profil du workspace.",
736
827
  "Réponds en une ou deux phrases humaines: ce qui a échoué, et l'action concrète que la personne peut faire.",
@@ -779,6 +870,19 @@ export async function handleSlashCommand(line, context) {
779
870
  try {
780
871
  step(`Agents: ${verb}ing external agents…`);
781
872
  const missingImages = await collectMissingImages(step, fn);
873
+ if (verb === 'start') {
874
+ // `wiki-workspace agents up` generates the agent tokens into the
875
+ // manager .env and adds the profiled entries to mcp.endpoints.json.
876
+ // Both were read at boot, so without re-reading them the endpoint the
877
+ // script just wrote resolves with an empty ${..._AUTH_TOKEN} and the
878
+ // freshly started agent stays "credential not set" until a restart.
879
+ step('Agents: reloading manager environment and MCP endpoints…');
880
+ // The script may have replaced a blank/generated token that was
881
+ // already loaded at boot. A non-overriding dotenv reload would keep
882
+ // the stale process value and defeat the refresh.
883
+ loadManagerEnv({ override: true });
884
+ await refreshMcpRuntimeStatus(context.session);
885
+ }
782
886
  return localizedOperationResult({
783
887
  operation: verb,
784
888
  target: 'agents',
@@ -945,21 +1049,37 @@ export async function handleSlashCommand(line, context) {
945
1049
  // falls back to the hardcoded COMPOSE_SERVICES constant instead.
946
1050
  const service = args[1];
947
1051
  if (service === 'agents' || service === 'agent') return runAgentCommand(startAgents, 'start');
1052
+ // "all" used to mean "the workspace services", which left the external
1053
+ // agents down and looked like nothing had happened. It now means what an
1054
+ // operator reads into it: the whole stack. `/start services` keeps the
1055
+ // workspace-only behaviour.
1056
+ const startsAgents = service === 'all';
1057
+ const target = service === 'services' ? undefined : service;
948
1058
  try {
949
- step(`Services: starting ${service ?? 'workspace services'}…`);
950
- const missingImages = await collectMissingImages(step, (opts) => startService(context.session, service, opts));
1059
+ if (startsAgents) {
1060
+ // Validate the workspace half before mutating the global agents
1061
+ // stack. Otherwise `/start all` with no active workspace starts the
1062
+ // agents successfully and only then fails, leaving a partial start.
1063
+ if (!context.session.workspace || !context.session.workspacePath || !context.session.workspaceEnv?.WORKSPACE_NAME) {
1064
+ throw new Error('No workspace loaded. Use /use <workspace>.');
1065
+ }
1066
+ const agentsResult = await runAgentCommand(startAgents, 'start');
1067
+ if (agentsResult?.failed) return agentsResult;
1068
+ }
1069
+ step(`Services: starting ${target ?? 'workspace services'}…`);
1070
+ const missingImages = await collectMissingImages(step, (opts) => startService(context.session, target, opts));
951
1071
  step('Services: refreshing MCP runtime…');
952
1072
  await refreshMcpRuntimeStatus(context.session);
953
1073
  return localizedOperationResult({
954
1074
  operation: 'start',
955
- target: service || 'workspace-services',
1075
+ target: startsAgents ? 'all-services-and-agents' : (target || 'workspace-services'),
956
1076
  componentAction: componentInstallAction(missingImages),
957
1077
  images: missingImages,
958
1078
  });
959
1079
  } catch (err) {
960
1080
  step(formatActivityError('services', 'start', err));
961
1081
  step(`Services: docker output — ${rawFailureText(err)}`);
962
- return localizedOperationFailure({ operation: 'start', target: service || 'workspace-services', error: err });
1082
+ return localizedOperationFailure({ operation: 'start', target: target || 'workspace-services', error: err });
963
1083
  }
964
1084
  }
965
1085
  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.29",
3
+ "commit": "2be3fe0"
4
4
  }
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { tmpdir } from 'node:os';
@@ -34,7 +34,7 @@ test('scaffold seeds both compose overrides once and never rewrites them', () =>
34
34
  withStateDir(() => {
35
35
  const created = ensureManagerScaffold();
36
36
  for (const { target } of COMPOSE_OVERRIDES) {
37
- assert.ok(created.includes(target), `expected ${target} to be created`);
37
+ assert.ok(created.includes(`.wiki/compose/${target}`), `expected ${target} to be created`);
38
38
  }
39
39
 
40
40
  const edited = '# operator edit\nservices:\n serve:\n environment:\n - HTTP_PROXY=http://proxy:3128\n';
@@ -49,6 +49,20 @@ test('scaffold seeds both compose overrides once and never rewrites them', () =>
49
49
  });
50
50
  });
51
51
 
52
+ test('scaffold migrates legacy root overrides into .wiki/compose without rewriting them', () => {
53
+ withStateDir((root) => {
54
+ const legacy = join(root, 'docker-compose.override.yml');
55
+ const content = '# legacy operator edit\nservices: {}\n';
56
+ writeFileSync(legacy, content, 'utf8');
57
+
58
+ const created = ensureManagerScaffold();
59
+ const migrated = managerComposeOverrideFile('docker-compose.override.yml');
60
+ assert.equal(readFileSync(migrated, 'utf8'), content);
61
+ assert.equal(existsSync(legacy), false);
62
+ assert.ok(created.includes('.wiki/compose/docker-compose.override.yml migrated'));
63
+ });
64
+ });
65
+
52
66
  test('the Node compose path merges the user override before the generated CA override', async () => {
53
67
  const source = await readFile(new URL('./compose.js', import.meta.url), 'utf8');
54
68
  const userOverrideIndex = source.indexOf("args.push('-f', userOverride)");
@@ -66,7 +80,7 @@ test('wiki-workspace seeds the overrides once and merges them in both stacks', a
66
80
  assert.match(script, /\[\[ -f "\$target" \]\] && return 0/);
67
81
  assert.match(script, /ensure_compose_override 'agents\.docker-compose\.override\.example\.yml' 'agents\.docker-compose\.override\.yml'/);
68
82
  assert.match(script, /ensure_compose_override 'docker-compose\.override\.example\.yml' 'docker-compose\.override\.yml'/);
69
- assert.match(script, /local user_override="\$MANAGER_STATE_DIR\/docker-compose\.override\.yml"/);
83
+ assert.match(script, /local user_override="\$MANAGER_COMPOSE_DIR\/docker-compose\.override\.yml"/);
70
84
  assert.match(
71
85
  script,
72
86
  /-f "\$ROOT_DIR\/docker-compose\.yml" \$\{override_args\[@\]\+"\$\{override_args\[@\]\}"\} \$\{cacert_args\[@\]\+"\$\{cacert_args\[@\]\}"\}/,