@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.
Files changed (46) hide show
  1. package/.env.example +46 -4
  2. package/README.md +116 -11
  3. package/agents.docker-compose.yml +41 -0
  4. package/docker-compose.yml +16 -1
  5. package/mcp.endpoints.example.json +3 -3
  6. package/package.json +1 -2
  7. package/src/activity/activityAggregator.js +7 -3
  8. package/src/activity/activityAggregator.test.js +16 -2
  9. package/src/agent/graph.js +91 -11
  10. package/src/agent/graph.test.js +108 -6
  11. package/src/cli/runtimeStartup.test.js +14 -0
  12. package/src/cli/wiki-manager.js +220 -37
  13. package/src/cli/wiki-manager.test.js +131 -1
  14. package/src/commands/slash.js +58 -0
  15. package/src/commands/slash.test.js +18 -0
  16. package/src/core/activity.js +1 -1
  17. package/src/core/activity.test.js +5 -0
  18. package/src/core/buildInfo.json +2 -2
  19. package/src/core/compose.js +7 -1
  20. package/src/core/dockerCompose.test.js +27 -3
  21. package/src/core/env.js +16 -3
  22. package/src/core/env.test.js +8 -3
  23. package/src/core/mcp.js +7 -2
  24. package/src/core/mcp.test.js +55 -0
  25. package/src/core/wikiSetup.js +26 -2
  26. package/src/core/wikiWorkspace.test.js +25 -0
  27. package/src/core/workflow.js +72 -0
  28. package/src/core/workflow.test.js +57 -0
  29. package/src/orchestrator/dispatcher.js +1 -1
  30. package/src/orchestrator/dispatcher.test.js +48 -0
  31. package/src/orchestrator/scheduler.js +27 -7
  32. package/src/orchestrator/scheduler.test.js +23 -0
  33. package/src/runtime/client.js +23 -0
  34. package/src/runtime/runner.js +30 -3
  35. package/src/runtime/store.js +54 -0
  36. package/src/runtime/store.test.js +42 -0
  37. package/src/shell/LeftPane.tsx +20 -1
  38. package/src/shell/RightPane.tsx +22 -3
  39. package/src/shell/repl.js +86 -9
  40. package/src/shell/repl.test.js +103 -2
  41. package/src/shell/tui.tsx +6 -1
  42. package/src/shell/useAgent.ts +8 -12
  43. package/src/shell/useSession.ts +47 -0
  44. package/tsconfig.json +2 -1
  45. package/wiki-workspace +110 -18
  46. package/agents.docker-compose.mailer.example.yml +0 -36
@@ -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');
@@ -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);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.16",
3
- "commit": "a603298"
2
+ "version": "0.14.23",
3
+ "commit": "f0519ef"
4
4
  }
@@ -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
- // MailerSend was an external-connector experiment — removed from the
33
- // default stack (the agent repo still exists for ad-hoc use).
34
- assert.equal(agentsCompose.services.mailer, undefined);
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, mailer,
46
- // documents) silently never connect — /status shows no agents and Donna has
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
- created.push(`mcp.endpoints.json keys: ${missing.join(', ')}`);
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 {
@@ -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 servers untouched; new key added from the example.
49
- assert.deepEqual(merged.mcpServers, { custom: { url: 'http://localhost:9999/mcp/' } });
50
- assert.ok(merged.chatAccess?.servers?.wiki);
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.16';
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 ID/i.test(text)) {
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
  };
@@ -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 () => ({
@@ -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 Object.values(config.services ?? {}).map((service) => service?.image).filter(Boolean);
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
 
@@ -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
+ });