@dotdrelle/wiki-manager 0.15.27 → 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.
- package/README.md +5 -4
- package/agents.docker-compose.override.example.yml +1 -1
- package/docker-compose.override.example.yml +2 -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 +148 -4
- package/src/cli/wiki-manager.js +48 -8
- package/src/cli/wiki-manager.test.js +35 -0
- package/src/commands/slash.js +159 -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 +1 -1
- 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/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +27 -0
- package/src/shell/LeftPane.tsx +49 -24
- package/src/shell/repl.js +114 -21
- package/src/shell/repl.test.js +102 -15
- package/src/shell/tui.tsx +0 -1
- package/src/shell/useSession.ts +21 -4
- package/wiki-workspace +29 -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,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(
|
|
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',
|
|
529
|
-
const
|
|
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
|
|
534
|
-
|
|
535
|
-
// Leading/trailing
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
// so
|
|
539
|
-
|
|
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|
|
|
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
|
|
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
|
-
|
|
950
|
-
|
|
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:
|
|
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:
|
|
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
|
+
});
|
package/src/core/buildInfo.json
CHANGED
|
@@ -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="\$
|
|
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\[@\]\}"\}/,
|