@dotdrelle/wiki-manager 0.14.16 → 0.14.23
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 +46 -4
- package/README.md +116 -11
- package/agents.docker-compose.yml +41 -0
- package/docker-compose.yml +16 -1
- package/mcp.endpoints.example.json +3 -3
- package/package.json +1 -2
- package/src/activity/activityAggregator.js +7 -3
- package/src/activity/activityAggregator.test.js +16 -2
- package/src/agent/graph.js +91 -11
- package/src/agent/graph.test.js +108 -6
- package/src/cli/runtimeStartup.test.js +14 -0
- package/src/cli/wiki-manager.js +220 -37
- package/src/cli/wiki-manager.test.js +131 -1
- package/src/commands/slash.js +58 -0
- package/src/commands/slash.test.js +18 -0
- package/src/core/activity.js +1 -1
- package/src/core/activity.test.js +5 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +7 -1
- package/src/core/dockerCompose.test.js +27 -3
- package/src/core/env.js +16 -3
- package/src/core/env.test.js +8 -3
- package/src/core/mcp.js +7 -2
- package/src/core/mcp.test.js +55 -0
- package/src/core/wikiSetup.js +26 -2
- package/src/core/wikiWorkspace.test.js +25 -0
- package/src/core/workflow.js +72 -0
- package/src/core/workflow.test.js +57 -0
- package/src/orchestrator/dispatcher.js +1 -1
- package/src/orchestrator/dispatcher.test.js +48 -0
- package/src/orchestrator/scheduler.js +27 -7
- package/src/orchestrator/scheduler.test.js +23 -0
- package/src/runtime/client.js +23 -0
- package/src/runtime/runner.js +30 -3
- package/src/runtime/store.js +54 -0
- package/src/runtime/store.test.js +42 -0
- package/src/shell/LeftPane.tsx +20 -1
- package/src/shell/RightPane.tsx +22 -3
- package/src/shell/repl.js +86 -9
- package/src/shell/repl.test.js +103 -2
- package/src/shell/tui.tsx +6 -1
- package/src/shell/useAgent.ts +8 -12
- package/src/shell/useSession.ts +47 -0
- package/tsconfig.json +2 -1
- package/wiki-workspace +110 -18
- package/agents.docker-compose.mailer.example.yml +0 -36
package/src/commands/slash.js
CHANGED
|
@@ -631,6 +631,7 @@ ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
|
|
|
631
631
|
${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
|
|
632
632
|
${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
|
|
633
633
|
${helpPair('/mcp tools [mcp]', 'MCP tools', '/mcp call ...', 'Call MCP tool')}
|
|
634
|
+
${helpPair('/connector list', 'Connector auth status', '/connector auth <n>', 'Authorize connector')}
|
|
634
635
|
${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
|
|
635
636
|
${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
|
|
636
637
|
${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
|
|
@@ -1025,6 +1026,63 @@ export async function handleSlashCommand(line, context) {
|
|
|
1025
1026
|
}
|
|
1026
1027
|
return { output: 'Usage: /mcp <status|endpoints|tools|call> [mcp]' };
|
|
1027
1028
|
}
|
|
1029
|
+
case 'connector': {
|
|
1030
|
+
const subcommand = args[1] ?? 'list';
|
|
1031
|
+
const connectorResult = (output) => rawCommandResult(`/connector ${args.slice(1).join(' ')}`.trim(), output);
|
|
1032
|
+
if (!context.session.workspace) {
|
|
1033
|
+
return connectorResult('No workspace is currently loaded.');
|
|
1034
|
+
}
|
|
1035
|
+
await refreshMcpRuntimeStatus(context.session);
|
|
1036
|
+
const connectorMcp = context.session.mcp?.connectors;
|
|
1037
|
+
if (!connectorMcp || connectorMcp.status !== 'connected') {
|
|
1038
|
+
return connectorResult('The connectors service is unavailable or disabled.');
|
|
1039
|
+
}
|
|
1040
|
+
if (subcommand === 'list') {
|
|
1041
|
+
if (args[2]) return connectorResult('The connector list request has invalid extra arguments.');
|
|
1042
|
+
try {
|
|
1043
|
+
const result = await callMcpTool(
|
|
1044
|
+
context.session.mcp,
|
|
1045
|
+
'connectors',
|
|
1046
|
+
'connectors_google_status',
|
|
1047
|
+
{ workspace: context.session.workspace },
|
|
1048
|
+
);
|
|
1049
|
+
const payload = parseJsonText(formatMcpToolResult(result));
|
|
1050
|
+
const status = payload?.status === 'configured' ? 'authorized' : 'not authorized';
|
|
1051
|
+
return connectorResult(`google (Gmail read-only): ${status}`);
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
return connectorResult(`google (Gmail read-only): unavailable (${err instanceof Error ? err.message : String(err)})`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (subcommand === 'auth') {
|
|
1057
|
+
const connector = String(args[2] ?? '').toLowerCase();
|
|
1058
|
+
if (!['google', 'gmail'].includes(connector)) {
|
|
1059
|
+
return connectorResult('The requested connector is unsupported. The available connector is google (Gmail read-only).');
|
|
1060
|
+
}
|
|
1061
|
+
try {
|
|
1062
|
+
const result = await callMcpTool(
|
|
1063
|
+
context.session.mcp,
|
|
1064
|
+
'connectors',
|
|
1065
|
+
'connectors_google_oauth_start',
|
|
1066
|
+
{ workspace: context.session.workspace },
|
|
1067
|
+
);
|
|
1068
|
+
const payload = parseJsonText(formatMcpToolResult(result));
|
|
1069
|
+
const authorizationUrl = payload?.authorizationUrl;
|
|
1070
|
+
if (payload?.ok !== true || typeof authorizationUrl !== 'string') {
|
|
1071
|
+
return connectorResult(`Google authorization could not start (${payload?.error ?? 'missing authorization URL'}).`);
|
|
1072
|
+
}
|
|
1073
|
+
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
1074
|
+
try {
|
|
1075
|
+
execFileSync(opener, [authorizationUrl], { stdio: 'ignore' });
|
|
1076
|
+
return connectorResult('Google authorization opened successfully in the user browser.');
|
|
1077
|
+
} catch {
|
|
1078
|
+
return connectorResult(`Google authorization requires the user to open this URL: ${authorizationUrl}`);
|
|
1079
|
+
}
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
return connectorResult(`Google authorization could not start (${err instanceof Error ? err.message : String(err)}).`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return connectorResult('The requested connector action is unsupported. Available actions: list and auth google.');
|
|
1085
|
+
}
|
|
1028
1086
|
case 'cancel': {
|
|
1029
1087
|
// Alias of /run cancel — people type /cancel when they want out.
|
|
1030
1088
|
const runtime = context.runtime ?? {};
|
|
@@ -93,6 +93,24 @@ test('/new without a name shows usage', async () => {
|
|
|
93
93
|
assert.match(result.output ?? '', /Usage/i);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test('/connector completions expose list and Google authorization', () => {
|
|
97
|
+
const session = { commands: ['connector'] };
|
|
98
|
+
assert.deepEqual(completionContext('/connector ', session)?.matches, ['auth', 'list']);
|
|
99
|
+
assert.deepEqual(completionContext('/connector auth ', session)?.matches, ['google']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('/connector hands deterministic facts to Donna for localized natural-language output', async () => {
|
|
103
|
+
const result = await handleSlashCommand('/connector list', {
|
|
104
|
+
packageJson: { version: 'test' },
|
|
105
|
+
session: {},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
assert.equal(result.rawOutput, true);
|
|
109
|
+
assert.match(result.output ?? '', /No workspace is currently loaded/);
|
|
110
|
+
assert.match(result.agentTrigger ?? '', /profil workspace/);
|
|
111
|
+
assert.match(result.agentTrigger ?? '', /Ne relance pas la commande/);
|
|
112
|
+
});
|
|
113
|
+
|
|
96
114
|
test('/skills run sends the private skill body to Donna without rendering it as command output', async () => {
|
|
97
115
|
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-skill-run-'));
|
|
98
116
|
const skillDir = join(root, '.wiki', 'skills');
|
package/src/core/activity.js
CHANGED
|
@@ -13,7 +13,7 @@ function basename(value) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function terminalStatus(status) {
|
|
16
|
-
return ['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error'].includes(String(status ?? '').toLowerCase());
|
|
16
|
+
return ['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'succeeded', 'error'].includes(String(status ?? '').toLowerCase());
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function activityKey(activity) {
|
|
@@ -29,6 +29,11 @@ test('normalizeActivity: plan null when no plan field', () => {
|
|
|
29
29
|
assert.equal(a.plan, null);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
test('normalizeActivity: succeeded is terminal', () => {
|
|
33
|
+
const a = normalizeActivity({ id: 'job-connectors', status: 'succeeded' });
|
|
34
|
+
assert.equal(a.terminal, true);
|
|
35
|
+
});
|
|
36
|
+
|
|
32
37
|
test('isCancelledStatus recognizes both cancellation spellings', () => {
|
|
33
38
|
assert.equal(isCancelledStatus('cancelled'), true);
|
|
34
39
|
assert.equal(isCancelledStatus('canceled'), true);
|
package/src/core/buildInfo.json
CHANGED
package/src/core/compose.js
CHANGED
|
@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
import YAML from 'yaml';
|
|
6
6
|
import { cacertEnv, ensureCacertComposeOverride } from './cacert.js';
|
|
7
7
|
import { checkMissingDockerImages } from './dockerImages.js';
|
|
8
|
-
import { managerEnvFile, readEnvFile, resolveAgentsDataDir } from './env.js';
|
|
8
|
+
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, resolveAgentsDataDir } from './env.js';
|
|
9
9
|
import { managerRoot } from './workspaces.js';
|
|
10
10
|
|
|
11
11
|
const execFileAsync = promisify(execFile);
|
|
@@ -119,6 +119,12 @@ function composeEnv(session) {
|
|
|
119
119
|
AGENTS_DATA_DIR: resolveAgentsDataDir(session),
|
|
120
120
|
WORKSPACE_NAME: session.workspace,
|
|
121
121
|
WIKI_WORKSPACE_PATH: session.workspacePath,
|
|
122
|
+
// The serve container reads external connectors from /mcp.endpoints.json.
|
|
123
|
+
// Mount the SAME file the runtime reads (managerStateDir), not the package
|
|
124
|
+
// dir's copy — otherwise serve and the runtime diverge (different servers,
|
|
125
|
+
// unresolved tokens) depending on where wiki-manager was launched from.
|
|
126
|
+
WIKI_MANAGER_MCP_ENDPOINTS_FILE:
|
|
127
|
+
process.env.WIKI_MANAGER_MCP_ENDPOINTS_FILE ?? managerMcpEndpointsFile(),
|
|
122
128
|
...(session.wikirc?.fileName && { WIKI_CONFIG_PATH: session.wikirc.fileName }),
|
|
123
129
|
};
|
|
124
130
|
}
|
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { test } from 'node:test';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
|
+
import { configuredAgentImages } from './wikiSetup.js';
|
|
5
6
|
|
|
6
7
|
test('workspace compose does not start a per-workspace agent runtime', async () => {
|
|
7
8
|
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
@@ -26,10 +27,33 @@ test('agent compose services run as the host uid and gid', async () => {
|
|
|
26
27
|
const agentsCompose = YAML.parse(agentsRaw);
|
|
27
28
|
assert.equal(agentsCompose.services.cme.user, '${UID:-1000}:${GID:-1000}');
|
|
28
29
|
assert.equal(agentsCompose.services.documents.user, '${UID:-1000}:${GID:-1000}');
|
|
30
|
+
assert.equal(agentsCompose.services.connectors.user, '${UID:-1000}:${GID:-1000}');
|
|
31
|
+
assert.deepEqual(agentsCompose.services.connectors.profiles, ['connectors']);
|
|
32
|
+
assert.equal(agentsCompose.services.connectors.environment.includes('GOOGLE_OAUTH_CALLBACK_URL=${GOOGLE_OAUTH_CALLBACK_URL:-}'), true);
|
|
33
|
+
assert.equal(agentsCompose.services.connectors.volumes.includes('${AGENTS_DATA_DIR:-./.agents-data}/connectors:/data'), true);
|
|
29
34
|
assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://albert.api.etalab.gouv.fr/v1}'), true);
|
|
30
35
|
assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-lightonai/LightOnOCR-2-1B}'), true);
|
|
31
36
|
assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-}'), true);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('serve proxies connector OAuth through the host agent endpoint', async () => {
|
|
40
|
+
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
41
|
+
const compose = YAML.parse(raw);
|
|
42
|
+
assert.equal(compose.services.serve.environment.includes('CONNECTORS_AGENT_URL=http://host.docker.internal:${CONNECTORS_MCP_PORT:-3338}'), true);
|
|
43
|
+
assert.equal(compose.services.serve.environment.includes('CONNECTORS_OAUTH_START_TOKEN=${OAUTH_START_TOKEN:-}'), true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('missing-image checks ignore agents behind inactive Compose profiles', () => {
|
|
47
|
+
const compose = {
|
|
48
|
+
services: {
|
|
49
|
+
cme: { image: 'example/cme:latest' },
|
|
50
|
+
connectors: { image: 'example/connectors:latest', profiles: ['connectors'] },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(configuredAgentImages(compose), ['example/cme:latest']);
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
configuredAgentImages(compose, new Set(['connectors'])),
|
|
57
|
+
['example/cme:latest', 'example/connectors:latest'],
|
|
58
|
+
);
|
|
35
59
|
});
|
package/src/core/env.js
CHANGED
|
@@ -42,8 +42,8 @@ export function managerMcpEndpointsFile() {
|
|
|
42
42
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
43
43
|
|
|
44
44
|
// First-run scaffolding: a fresh install directory has neither
|
|
45
|
-
// mcp.endpoints.json nor .env, so the external agents (cme
|
|
46
|
-
//
|
|
45
|
+
// mcp.endpoints.json nor .env, so the external agents (cme and documents)
|
|
46
|
+
// silently never connect — /status shows no agents and Donna has
|
|
47
47
|
// no CME tools to configure anything with. Copy the packaged examples so a
|
|
48
48
|
// fresh directory works out of the box with the default agent ports. Optional
|
|
49
49
|
// credentials can be added later for the external services the user enables.
|
|
@@ -68,10 +68,23 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
|
|
|
68
68
|
const example = JSON.parse(readFileSync(endpointsExample, 'utf8'));
|
|
69
69
|
if (current && typeof current === 'object' && !Array.isArray(current)) {
|
|
70
70
|
const missing = Object.keys(example).filter((key) => !(key in current));
|
|
71
|
+
const currentServers = current.mcpServers;
|
|
72
|
+
const exampleServers = example.mcpServers;
|
|
73
|
+
const missingServers = currentServers && typeof currentServers === 'object' && !Array.isArray(currentServers)
|
|
74
|
+
&& exampleServers && typeof exampleServers === 'object' && !Array.isArray(exampleServers)
|
|
75
|
+
? Object.keys(exampleServers).filter((key) => !(key in currentServers))
|
|
76
|
+
: [];
|
|
71
77
|
if (missing.length > 0) {
|
|
72
78
|
for (const key of missing) current[key] = example[key];
|
|
79
|
+
}
|
|
80
|
+
for (const key of missingServers) currentServers[key] = exampleServers[key];
|
|
81
|
+
if (missing.length > 0 || missingServers.length > 0) {
|
|
73
82
|
writeFileSync(endpointsFile, `${JSON.stringify(current, null, 2)}\n`);
|
|
74
|
-
|
|
83
|
+
const changes = [
|
|
84
|
+
missing.length > 0 ? `keys: ${missing.join(', ')}` : '',
|
|
85
|
+
missingServers.length > 0 ? `servers: ${missingServers.join(', ')}` : '',
|
|
86
|
+
].filter(Boolean).join('; ');
|
|
87
|
+
created.push(`mcp.endpoints.json ${changes}`);
|
|
75
88
|
}
|
|
76
89
|
}
|
|
77
90
|
} catch {
|
package/src/core/env.test.js
CHANGED
|
@@ -45,9 +45,14 @@ test('scaffold merges missing top-level keys into an existing endpoints file', (
|
|
|
45
45
|
const created = ensureManagerScaffold();
|
|
46
46
|
assert.ok(created.some((item) => item.includes('chatAccess')));
|
|
47
47
|
const merged = JSON.parse(readFileSync(endpointsFile, 'utf8'));
|
|
48
|
-
// Operator's
|
|
49
|
-
|
|
50
|
-
assert.
|
|
48
|
+
// Operator's server is untouched; packaged servers are added without
|
|
49
|
+
// replacing any existing definitions.
|
|
50
|
+
assert.deepEqual(merged.mcpServers.custom, { url: 'http://localhost:9999/mcp/' });
|
|
51
|
+
assert.ok(merged.mcpServers.cme);
|
|
52
|
+
assert.ok(merged.mcpServers.documents);
|
|
53
|
+
// Server keys must match the connected MCP endpoint keys (the tool-call
|
|
54
|
+
// prefix): the wiki server is "llm-wiki", not "wiki".
|
|
55
|
+
assert.ok(merged.chatAccess?.servers?.['llm-wiki']);
|
|
51
56
|
});
|
|
52
57
|
});
|
|
53
58
|
|
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.14.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.14.23';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -86,6 +86,10 @@ export function readChatAccessConfig() {
|
|
|
86
86
|
} else if (Array.isArray(entry?.allow)) {
|
|
87
87
|
servers[name] = { allow: entry.allow.map(String).filter(Boolean) };
|
|
88
88
|
}
|
|
89
|
+
if (Array.isArray(entry?.allowActions)) {
|
|
90
|
+
servers[name] ??= { allow: [] };
|
|
91
|
+
servers[name].allowActions = entry.allowActions.map(String).filter(Boolean);
|
|
92
|
+
}
|
|
89
93
|
}
|
|
90
94
|
const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
|
|
91
95
|
? Math.floor(Number(chatAccess.maxToolIterations))
|
|
@@ -284,7 +288,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
|
|
|
284
288
|
let response = await doRequest(method, params);
|
|
285
289
|
let text = await response.text();
|
|
286
290
|
|
|
287
|
-
if (response.status === 400 && /session
|
|
291
|
+
if (response.status === 400 && /session(?:\s+ID)?/i.test(text)) {
|
|
288
292
|
endpoint._sessionId = null;
|
|
289
293
|
const initResponse = await fetch(endpoint.url, {
|
|
290
294
|
method: 'POST',
|
|
@@ -532,6 +536,7 @@ export async function discoverMcpTools(mcpStatus) {
|
|
|
532
536
|
name: tool.name,
|
|
533
537
|
description: tool.description ?? '',
|
|
534
538
|
inputSchema: tool.inputSchema,
|
|
539
|
+
annotations: tool.annotations,
|
|
535
540
|
})),
|
|
536
541
|
toolError: null,
|
|
537
542
|
};
|
package/src/core/mcp.test.js
CHANGED
|
@@ -523,6 +523,61 @@ test('discoverMcpTools downgrades connected endpoint when tool discovery fails',
|
|
|
523
523
|
}
|
|
524
524
|
});
|
|
525
525
|
|
|
526
|
+
test('discoverMcpTools initializes SDK servers that report no valid session', async () => {
|
|
527
|
+
const originalFetch = globalThis.fetch;
|
|
528
|
+
const methods = [];
|
|
529
|
+
globalThis.fetch = async (_url, init) => {
|
|
530
|
+
const body = JSON.parse(init.body);
|
|
531
|
+
methods.push(body.method);
|
|
532
|
+
if (body.method === 'tools/list' && !init.headers['mcp-session-id']) {
|
|
533
|
+
return {
|
|
534
|
+
ok: false,
|
|
535
|
+
status: 400,
|
|
536
|
+
headers: { get: () => null },
|
|
537
|
+
text: async () => '{"jsonrpc":"2.0","error":{"code":-32000,"message":"No valid session"},"id":null}',
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (body.method === 'initialize') {
|
|
541
|
+
return {
|
|
542
|
+
ok: true,
|
|
543
|
+
status: 200,
|
|
544
|
+
headers: { get: (name) => name === 'mcp-session-id' ? 'session-1' : null },
|
|
545
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
if (body.method === 'notifications/initialized') {
|
|
549
|
+
return {
|
|
550
|
+
ok: true,
|
|
551
|
+
status: 202,
|
|
552
|
+
headers: { get: () => null },
|
|
553
|
+
text: async () => '',
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
ok: true,
|
|
558
|
+
status: 200,
|
|
559
|
+
headers: { get: () => null },
|
|
560
|
+
text: async () => '{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"connectors_google_status"}]}}',
|
|
561
|
+
};
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
const status = await discoverMcpTools({
|
|
566
|
+
connectors: {
|
|
567
|
+
status: 'configured',
|
|
568
|
+
url: 'http://127.0.0.1:3338/mcp/',
|
|
569
|
+
headers: { authorization: 'Bearer token' },
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
assert.equal(status.connectors.status, 'connected');
|
|
574
|
+
assert.deepEqual(status.connectors.tools.map((tool) => tool.name), ['connectors_google_status']);
|
|
575
|
+
assert.deepEqual(methods, ['tools/list', 'initialize', 'notifications/initialized', 'tools/list']);
|
|
576
|
+
} finally {
|
|
577
|
+
globalThis.fetch = originalFetch;
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
526
581
|
test('callMcpTool parses SSE responses after keepalive comments', async () => {
|
|
527
582
|
const originalFetch = globalThis.fetch;
|
|
528
583
|
globalThis.fetch = async () => ({
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -6,12 +6,36 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import YAML from 'yaml';
|
|
7
7
|
import { checkMissingDockerImages } from './dockerImages.js';
|
|
8
8
|
import { patchWikircProfile } from './wikirc.js';
|
|
9
|
-
import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
|
|
9
|
+
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, resolveAgentsDataDir } from './env.js';
|
|
10
10
|
import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
11
11
|
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
13
|
|
|
14
|
+
function enabled(value) {
|
|
15
|
+
return /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function configuredAgentImages(config, activeProfiles = new Set()) {
|
|
19
|
+
return Object.values(config.services ?? {})
|
|
20
|
+
.filter((service) => {
|
|
21
|
+
const profiles = Array.isArray(service?.profiles) ? service.profiles : [];
|
|
22
|
+
return profiles.length === 0 || profiles.some((profile) => activeProfiles.has(profile));
|
|
23
|
+
})
|
|
24
|
+
.map((service) => service?.image)
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
async function missingAgentImages() {
|
|
29
|
+
let managerEnv = {};
|
|
30
|
+
try {
|
|
31
|
+
managerEnv = readEnvFile(managerEnvFile());
|
|
32
|
+
} catch {
|
|
33
|
+
// A missing manager .env is valid during first-run setup.
|
|
34
|
+
}
|
|
35
|
+
const activeProfiles = new Set();
|
|
36
|
+
if (enabled(managerEnv.CONNECTORS_ENABLED ?? process.env.CONNECTORS_ENABLED)) {
|
|
37
|
+
activeProfiles.add('connectors');
|
|
38
|
+
}
|
|
15
39
|
const composeFiles = [
|
|
16
40
|
join(managerRoot(), 'agents.docker-compose.yml'),
|
|
17
41
|
join(dirname(managerEnvFile()), 'agents.docker-compose.override.yml'),
|
|
@@ -19,7 +43,7 @@ async function missingAgentImages() {
|
|
|
19
43
|
const images = [...new Set(composeFiles.flatMap((filePath) => {
|
|
20
44
|
try {
|
|
21
45
|
const config = YAML.parse(readFileSync(filePath, 'utf8')) ?? {};
|
|
22
|
-
return
|
|
46
|
+
return configuredAgentImages(config, activeProfiles);
|
|
23
47
|
} catch {
|
|
24
48
|
return [];
|
|
25
49
|
}
|
|
@@ -12,6 +12,11 @@ test('wiki-workspace autostarts host runtime before workspace services', async (
|
|
|
12
12
|
assert.match(script, /start_workspace_services "\$workspace"\n\n local serve_port prod_port/);
|
|
13
13
|
assert.match(script, /start_workspace_services "\$workspace"\n local serve_port production_port/);
|
|
14
14
|
assert.match(script, /ensure_runtime_up\n printf 'Starting mcp-http/);
|
|
15
|
+
assert.match(script, /wait_for_runtime_health\(\) \{/);
|
|
16
|
+
assert.match(script, /if ! wait_for_runtime_health; then/);
|
|
17
|
+
assert.match(script, /runtime_port\(\) \{\n env_value "\$MANAGER_ENV_FILE" WIKI_MANAGER_RUNTIME_PORT/);
|
|
18
|
+
assert.match(script, /port="\$\(runtime_port\)"/);
|
|
19
|
+
assert.doesNotMatch(script, /sleep 0\.5\n printf 'agent-runtime started/);
|
|
15
20
|
});
|
|
16
21
|
|
|
17
22
|
test('wiki-workspace checks runtime pid command before killing', async () => {
|
|
@@ -32,6 +37,26 @@ test('wiki-workspace regenerates CA compose overrides instead of retaining remov
|
|
|
32
37
|
assert.match(script, /Changes are overwritten on the next compose command/);
|
|
33
38
|
});
|
|
34
39
|
|
|
40
|
+
test('wiki-workspace provisions connector secrets and persistent state', async () => {
|
|
41
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
42
|
+
|
|
43
|
+
assert.match(script, /connectors_enabled\(\) \{/);
|
|
44
|
+
assert.match(script, /CONNECTORS_ENABLED/);
|
|
45
|
+
assert.doesNotMatch(script, /\$\{enabled,,\}/);
|
|
46
|
+
assert.match(script, /\[Tt\]\[Rr\]\[Uu\]\[Ee\]/);
|
|
47
|
+
assert.match(script, /profile_args\+=\(--profile connectors\)/);
|
|
48
|
+
assert.match(script, /COMPOSE_PROFILES= \\\n docker compose/);
|
|
49
|
+
assert.match(script, /if connectors_enabled; then/);
|
|
50
|
+
assert.match(script, /CONNECTORS_MCP_AUTH_TOKEN OAUTH_STATE_SECRET OAUTH_START_TOKEN/);
|
|
51
|
+
assert.match(script, /http:\/\/127\.0\.0\.1:\$\{connectors_port\}\/oauth\/google\/callback/);
|
|
52
|
+
assert.match(script, /set_env_value "\$MANAGER_ENV_FILE" GOOGLE_OAUTH_CALLBACK_URL/);
|
|
53
|
+
assert.match(script, /"\$agents_data_dir\/connectors"/);
|
|
54
|
+
assert.match(script, /config\.mcpServers\.connectors \?\?=/);
|
|
55
|
+
assert.match(script, /config\.chatAccess\.servers\.connectors \?\?=/);
|
|
56
|
+
assert.match(script, /delete config\.mcpServers\.connectors/);
|
|
57
|
+
assert.match(script, /delete config\.chatAccess\.servers\.connectors/);
|
|
58
|
+
});
|
|
59
|
+
|
|
35
60
|
test('workspace creation keeps mutable manager files outside the installed package', async () => {
|
|
36
61
|
const source = await readFile(new URL('./workspaces.js', import.meta.url), 'utf8');
|
|
37
62
|
|
package/src/core/workflow.js
CHANGED
|
@@ -107,11 +107,83 @@ export function projectWorkflow(state = {}, events = []) {
|
|
|
107
107
|
activity,
|
|
108
108
|
waitingReasons,
|
|
109
109
|
warnings,
|
|
110
|
+
usage: summarizeTokenUsage(events),
|
|
111
|
+
timingByTask: summarizeTaskTiming(events),
|
|
110
112
|
};
|
|
111
113
|
projected.graph = aggregateGraph(projected, events);
|
|
112
114
|
return projected;
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
// Per-task wall-clock timing, derived from the lifecycle events. Start = the
|
|
118
|
+
// first assigned/started event, finish = the last terminal event. Display-only
|
|
119
|
+
// (the inspector shows duration + orders tasks into a temporal flow); never used
|
|
120
|
+
// for scheduling.
|
|
121
|
+
function summarizeTaskTiming(events = []) {
|
|
122
|
+
const byTask = {};
|
|
123
|
+
const START_EVENTS = new Set(['task.assigned', 'task.started']);
|
|
124
|
+
const END_EVENTS = new Set(['task.completed', 'task.failed', 'task.result_returned']);
|
|
125
|
+
for (const event of events) {
|
|
126
|
+
if (!START_EVENTS.has(event.type) && !END_EVENTS.has(event.type)) continue;
|
|
127
|
+
const taskId = String(event.taskId ?? event.payload?.taskId ?? '').replace(/^task:/, '');
|
|
128
|
+
if (!taskId) continue;
|
|
129
|
+
const ts = Number(new Date(event.ts).getTime());
|
|
130
|
+
if (!Number.isFinite(ts)) continue;
|
|
131
|
+
const entry = byTask[taskId] ?? (byTask[taskId] = { startedAt: null, finishedAt: null, durationMs: null });
|
|
132
|
+
if (START_EVENTS.has(event.type)) {
|
|
133
|
+
entry.startedAt = entry.startedAt == null ? ts : Math.min(entry.startedAt, ts);
|
|
134
|
+
} else {
|
|
135
|
+
entry.finishedAt = entry.finishedAt == null ? ts : Math.max(entry.finishedAt, ts);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const entry of Object.values(byTask)) {
|
|
139
|
+
if (entry.startedAt != null && entry.finishedAt != null && entry.finishedAt >= entry.startedAt) {
|
|
140
|
+
entry.durationMs = entry.finishedAt - entry.startedAt;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return byTask;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function summarizeTokenUsage(events = []) {
|
|
147
|
+
let inputTokens = 0;
|
|
148
|
+
let outputTokens = 0;
|
|
149
|
+
let totalTokens = 0;
|
|
150
|
+
let inputKnown = false;
|
|
151
|
+
let outputKnown = false;
|
|
152
|
+
let totalKnown = false;
|
|
153
|
+
const byTask = {};
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const event of events) {
|
|
156
|
+
if (!['task.result_returned', 'task.completed', 'task.failed'].includes(event.type)) continue;
|
|
157
|
+
const result = event.payload?.result ?? {};
|
|
158
|
+
const metrics = result.metrics ?? event.payload?.metrics ?? {};
|
|
159
|
+
const attemptId = result.attemptId ?? event.payload?.attemptId ?? event.id;
|
|
160
|
+
if (attemptId && seen.has(attemptId)) continue;
|
|
161
|
+
if (attemptId) seen.add(attemptId);
|
|
162
|
+
const input = metricNumber(metrics.inputTokens ?? metrics.promptTokens ?? metrics.prompt_tokens);
|
|
163
|
+
const output = metricNumber(metrics.outputTokens ?? metrics.completionTokens ?? metrics.completion_tokens);
|
|
164
|
+
const total = metricNumber(metrics.totalTokens ?? metrics.total_tokens ?? metrics.tokens);
|
|
165
|
+
if (input != null) { inputTokens += input; inputKnown = true; }
|
|
166
|
+
if (output != null) { outputTokens += output; outputKnown = true; }
|
|
167
|
+
if (total != null) { totalTokens += total; totalKnown = true; }
|
|
168
|
+
else if (input != null || output != null) { totalTokens += (input ?? 0) + (output ?? 0); totalKnown = true; }
|
|
169
|
+
const taskId = String(event.taskId ?? event.payload?.taskId ?? '');
|
|
170
|
+
if (taskId && (input != null || output != null || total != null)) {
|
|
171
|
+
const current = byTask[taskId] ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0, inputKnown: false, outputKnown: false, totalKnown: false };
|
|
172
|
+
if (input != null) { current.inputTokens += input; current.inputKnown = true; }
|
|
173
|
+
if (output != null) { current.outputTokens += output; current.outputKnown = true; }
|
|
174
|
+
current.totalTokens += total ?? ((input ?? 0) + (output ?? 0));
|
|
175
|
+
current.totalKnown = true;
|
|
176
|
+
byTask[taskId] = current;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { inputTokens, outputTokens, totalTokens, inputKnown, outputKnown, totalKnown, byTask };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function metricNumber(value) {
|
|
183
|
+
const number = Number(value);
|
|
184
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
115
187
|
function currentRun(state, events) {
|
|
116
188
|
const runId = state.runId ?? state.runs?.find((run) => isActiveStatus(run.status))?.id ?? events.findLast?.((event) => event.runId)?.runId ?? null;
|
|
117
189
|
if (!runId && !state.status) return null;
|
|
@@ -64,3 +64,60 @@ test('projectWorkflow reports approval and queue waiting reasons', () => {
|
|
|
64
64
|
assert.ok(workflow.waitingReasons.includes('approval:approval-1'));
|
|
65
65
|
assert.ok(workflow.waitingReasons.includes('queue:queued-1'));
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
test('projectWorkflow aggregates input and output tokens once per attempt', () => {
|
|
69
|
+
const state = {
|
|
70
|
+
status: 'done',
|
|
71
|
+
runId: 'run-usage',
|
|
72
|
+
plan: [{ id: 'build', description: 'Build', status: 'done' }],
|
|
73
|
+
activities: [],
|
|
74
|
+
queue: [],
|
|
75
|
+
approvals: [],
|
|
76
|
+
};
|
|
77
|
+
const result = {
|
|
78
|
+
attemptId: 'attempt-1',
|
|
79
|
+
metrics: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 },
|
|
80
|
+
};
|
|
81
|
+
const workflow = projectWorkflow(state, [
|
|
82
|
+
{ id: 'event-1', type: 'task.result_returned', runId: 'run-usage', taskId: 'build', payload: { result } },
|
|
83
|
+
{ id: 'event-2', type: 'task.completed', runId: 'run-usage', taskId: 'build', payload: { result } },
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(workflow.usage, {
|
|
87
|
+
inputTokens: 1200,
|
|
88
|
+
outputTokens: 300,
|
|
89
|
+
totalTokens: 1500,
|
|
90
|
+
inputKnown: true,
|
|
91
|
+
outputKnown: true,
|
|
92
|
+
totalKnown: true,
|
|
93
|
+
byTask: {
|
|
94
|
+
build: {
|
|
95
|
+
inputTokens: 1200,
|
|
96
|
+
outputTokens: 300,
|
|
97
|
+
totalTokens: 1500,
|
|
98
|
+
inputKnown: true,
|
|
99
|
+
outputKnown: true,
|
|
100
|
+
totalKnown: true,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('projectWorkflow derives per-task timing (start, finish, duration) from lifecycle events', () => {
|
|
107
|
+
const state = {
|
|
108
|
+
status: 'done',
|
|
109
|
+
runId: 'run-timing',
|
|
110
|
+
plan: [{ id: 'ingest', description: 'Ingest', status: 'done' }],
|
|
111
|
+
activities: [],
|
|
112
|
+
queue: [],
|
|
113
|
+
approvals: [],
|
|
114
|
+
};
|
|
115
|
+
const workflow = projectWorkflow(state, [
|
|
116
|
+
{ id: 'e1', type: 'task.started', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:00.000Z', payload: {} },
|
|
117
|
+
{ id: 'e2', type: 'task.completed', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:12.500Z', payload: {} },
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
assert.equal(workflow.timingByTask.ingest.startedAt, Date.parse('2026-07-23T10:00:00.000Z'));
|
|
121
|
+
assert.equal(workflow.timingByTask.ingest.finishedAt, Date.parse('2026-07-23T10:00:12.500Z'));
|
|
122
|
+
assert.equal(workflow.timingByTask.ingest.durationMs, 12500);
|
|
123
|
+
});
|
|
@@ -3,7 +3,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
3
3
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
4
4
|
import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
|
|
5
5
|
|
|
6
|
-
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error']);
|
|
6
|
+
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'succeeded', 'error']);
|
|
7
7
|
|
|
8
8
|
export function createDispatcher({
|
|
9
9
|
session = null,
|
|
@@ -32,3 +32,51 @@ test('dispatcher returns a retryable logical failure when agent_execute reports
|
|
|
32
32
|
assert.equal(result.error.code, 'workspace_busy');
|
|
33
33
|
assert.equal(result.error.retryable, true);
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
test('dispatcher completes when an executor-only agent reports succeeded', async () => {
|
|
37
|
+
const session = {
|
|
38
|
+
workspace: 'test',
|
|
39
|
+
mcp: {
|
|
40
|
+
connectors: {
|
|
41
|
+
status: 'connected',
|
|
42
|
+
tools: [
|
|
43
|
+
{ name: 'agent_execute' },
|
|
44
|
+
{ name: 'agent_status' },
|
|
45
|
+
{ name: 'agent_cancel' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
activities: {},
|
|
50
|
+
};
|
|
51
|
+
const dispatcher = createDispatcher({
|
|
52
|
+
session,
|
|
53
|
+
pollIntervalMs: 1,
|
|
54
|
+
callTool: async (_mcp, _server, tool) => {
|
|
55
|
+
if (tool === 'agent_execute') {
|
|
56
|
+
return { accepted: true, jobId: 'job-connectors', status: 'queued' };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
jobId: 'job-connectors',
|
|
60
|
+
status: 'succeeded',
|
|
61
|
+
terminal: true,
|
|
62
|
+
result: { status: 'succeeded', outputRefs: [] },
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await dispatcher.execute(
|
|
68
|
+
{
|
|
69
|
+
id: 'collect-mail',
|
|
70
|
+
label: 'Collect mail',
|
|
71
|
+
requiredCapability: 'external-source.collect',
|
|
72
|
+
operation: 'collect',
|
|
73
|
+
arguments: {},
|
|
74
|
+
},
|
|
75
|
+
{ serverName: 'connectors', agentInstanceId: 'connectors' },
|
|
76
|
+
{ attempt: { attemptId: 'collect-mail:attempt-1', locks: [], release() {} } },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
assert.equal(result.ok, true);
|
|
80
|
+
assert.equal(result.status, 'succeeded');
|
|
81
|
+
assert.equal(result.jobId, 'job-connectors');
|
|
82
|
+
});
|