@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.
- package/.env.example +5 -2
- package/README.md +5 -4
- package/agents.docker-compose.override.example.yml +1 -1
- package/agents.docker-compose.yml +10 -0
- package/bin/wiki-manager.js +1 -1
- package/docker-compose.override.example.yml +3 -2
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +77 -13
- package/src/agent/graph.test.js +165 -8
- package/src/cli/runtimeStartup.test.js +14 -0
- package/src/cli/wiki-manager.js +118 -19
- package/src/cli/wiki-manager.test.js +192 -0
- package/src/commands/slash.js +168 -39
- package/src/commands/slash.test.js +100 -1
- package/src/core/agentsCompose.js +79 -0
- package/src/core/agentsCompose.test.js +99 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/composeOverrides.test.js +17 -3
- package/src/core/env.js +27 -10
- package/src/core/mcp.js +113 -38
- package/src/core/mcp.test.js +181 -20
- package/src/core/startupCheck.js +64 -27
- package/src/core/startupCheck.test.js +49 -1
- package/src/core/wikiSetup.js +57 -22
- package/src/core/wikiSetup.test.js +87 -0
- package/src/core/wikiWorkspace.test.js +16 -0
- package/src/orchestrator/agentRegistry.js +35 -7
- package/src/orchestrator/agentRegistry.test.js +73 -0
- package/src/orchestrator/dispatcher.js +6 -1
- package/src/orchestrator/dispatcher.test.js +24 -1
- package/src/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +27 -0
- package/src/runtime/auth.test.js +1 -65
- package/src/runtime/donna-contract.test.js +2 -1
- package/src/runtime/lifecycle.js +0 -33
- package/src/runtime/runner.test.js +10 -1
- package/src/runtime/supervisor.test.js +11 -1
- package/src/shell/LeftPane.tsx +49 -24
- package/src/shell/repl.js +114 -21
- package/src/shell/repl.test.js +88 -17
- package/src/shell/tui.tsx +10 -19
- package/wiki-workspace +98 -12
package/src/commands/slash.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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(
|
|
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',
|
|
529
|
-
const
|
|
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
|
|
534
|
-
|
|
535
|
-
// Leading/trailing
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
// so
|
|
539
|
-
|
|
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|
|
|
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
|
|
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
|
-
|
|
950
|
-
|
|
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:
|
|
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:
|
|
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
|
+
});
|
package/src/core/buildInfo.json
CHANGED