@dotdrelle/wiki-manager 0.15.66 → 0.15.71

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 (72) hide show
  1. package/.env.example +10 -3
  2. package/README.md +57 -0
  3. package/agent-runtimes.example.json +68 -0
  4. package/agents.docker-compose.yml +39 -1
  5. package/docker-compose.yml +3 -3
  6. package/package.json +3 -2
  7. package/src/activity/activityAggregator.test.js +2 -2
  8. package/src/agent/graph.js +13 -11
  9. package/src/agent/skillRecursion.test.js +13 -12
  10. package/src/cli/wiki-manager.js +125 -37
  11. package/src/cli/wiki-manager.test.js +16 -16
  12. package/src/commands/slash.js +59 -5
  13. package/src/contracts/schemas.js +67 -0
  14. package/src/core/activity.js +5 -0
  15. package/src/core/agentEvents.js +139 -25
  16. package/src/core/agentEvents.test.js +26 -1
  17. package/src/core/buildInfo.json +2 -2
  18. package/src/core/commandFailure.test.js +2 -2
  19. package/src/core/currentArtifact.test.js +5 -5
  20. package/src/core/dockerCompose.test.js +8 -40
  21. package/src/core/env.js +14 -0
  22. package/src/core/env.test.js +19 -0
  23. package/src/core/googleGrants.test.js +1 -1
  24. package/src/core/mcp.js +1 -1
  25. package/src/core/mcp.test.js +1 -1
  26. package/src/core/otherWorkspacesRunning.test.js +6 -6
  27. package/src/core/runtimeEventAdapter.js +81 -0
  28. package/src/core/runtimeEventAdapter.test.js +61 -0
  29. package/src/core/runtimeLog.js +35 -1
  30. package/src/core/runtimeLog.test.js +27 -2
  31. package/src/core/skillChainView.test.js +2 -2
  32. package/src/core/skillCompiler.test.js +1 -1
  33. package/src/core/skillInvocation.js +13 -8
  34. package/src/core/skillInvocation.test.js +1 -1
  35. package/src/core/startupCheck.js +58 -0
  36. package/src/core/startupCheck.test.js +29 -1
  37. package/src/core/wikiSetup.js +25 -0
  38. package/src/core/wikiSetup.test.js +35 -0
  39. package/src/core/wikirc.test.js +6 -6
  40. package/src/core/workspaceInherit.test.js +14 -14
  41. package/src/orchestrator/agentRegistry.js +1 -22
  42. package/src/orchestrator/agentRegistry.test.js +6 -6
  43. package/src/orchestrator/assignmentManager.js +16 -4
  44. package/src/orchestrator/capabilityRegistry.js +8 -1
  45. package/src/orchestrator/dispatcher.js +405 -2
  46. package/src/orchestrator/dispatcher.test.js +158 -4
  47. package/src/orchestrator/objectiveResolver.js +10 -6
  48. package/src/orchestrator/objectiveResolver.test.js +26 -27
  49. package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
  50. package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
  51. package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
  52. package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
  53. package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
  54. package/src/orchestrator/providers/runtimeProvider.js +101 -0
  55. package/src/orchestrator/providers/runtimeProviders.js +378 -0
  56. package/src/orchestrator/providers/runtimeProviders.test.js +384 -0
  57. package/src/orchestrator/resultAggregator.js +35 -2
  58. package/src/orchestrator/resultAggregator.test.js +62 -0
  59. package/src/orchestrator/scheduler.test.js +4 -4
  60. package/src/runtime/delegation.test.js +11 -11
  61. package/src/runtime/recoveryManager.js +70 -5
  62. package/src/runtime/runner.test.js +1 -1
  63. package/src/runtime/server.test.js +2 -2
  64. package/src/runtime/skillChain.e2e.test.js +2 -2
  65. package/src/runtime/store.test.js +8 -5
  66. package/src/runtime/supervisor.js +5 -10
  67. package/src/runtime/workspaceIsolation.test.js +26 -26
  68. package/src/shell/RightPane.tsx +23 -3
  69. package/src/shell/StartupScreen.tsx +44 -7
  70. package/src/shell/repl.js +24 -2
  71. package/src/shell/repl.test.js +13 -0
  72. package/wiki-workspace +53 -3
@@ -0,0 +1,61 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { mapRuntimeEvent } from './runtimeEventAdapter.js';
4
+
5
+ test('message becomes an assistant_message', () => {
6
+ const mapped = mapRuntimeEvent({ type: 'message', content: 'analysis complete' });
7
+ assert.deepEqual(mapped, [{ type: 'assistant_message', payload: { content: 'analysis complete' } }]);
8
+ });
9
+
10
+ test('an empty message produces nothing', () => {
11
+ assert.deepEqual(mapRuntimeEvent({ type: 'message', content: ' ' }), []);
12
+ });
13
+
14
+ test('tool_started and tool_finished become structured log lines', () => {
15
+ const started = mapRuntimeEvent({ type: 'tool_started', tool: 'wiki_search' });
16
+ assert.equal(started.length, 1);
17
+ assert.equal(started[0].type, 'runtime_log');
18
+ assert.match(started[0].payload.message, /wiki_search started/);
19
+
20
+ const finished = mapRuntimeEvent({ type: 'tool_finished', tool: 'wiki_search', durationMs: 842, resultSummary: '17 documents found' });
21
+ assert.match(finished[0].payload.message, /wiki_search done \(842ms\) — 17 documents found/);
22
+ });
23
+
24
+ test('a failed tool is reported as such, not as a success', () => {
25
+ const mapped = mapRuntimeEvent({ type: 'tool_finished', tool: 'wiki_read', error: 'permission denied' });
26
+ assert.match(mapped[0].payload.message, /wiki_read failed: permission denied/);
27
+ });
28
+
29
+ test('subagent events surface as logs', () => {
30
+ assert.match(mapRuntimeEvent({ type: 'subagent_started', subagent: 'reviewer' })[0].payload.message, /subagent reviewer started/);
31
+ });
32
+
33
+ test('approval_required becomes an approval.requested with the proposal classes', () => {
34
+ const mapped = mapRuntimeEvent({
35
+ type: 'approval_required',
36
+ approvalId: 'prop-1',
37
+ reason: 'analysis complete',
38
+ proposal: {
39
+ summary: 'analyse',
40
+ mutations: [{ kind: 'send_email' }, { kind: 'plan_expansion' }],
41
+ },
42
+ });
43
+
44
+ assert.equal(mapped.length, 1);
45
+ assert.equal(mapped[0].type, 'approval.requested');
46
+ assert.equal(mapped[0].payload.approvalId, 'prop-1');
47
+ assert.equal(mapped[0].payload.scope, 'run');
48
+ assert.deepEqual(mapped[0].payload.approvalClasses, ['send_email', 'plan_expansion']);
49
+ });
50
+
51
+ test('private reasoning and terminal events are never re-emitted', () => {
52
+ assert.deepEqual(mapRuntimeEvent({ type: 'agent_thinking', content: 'secret chain of thought' }), []);
53
+ assert.deepEqual(mapRuntimeEvent({ type: 'run_started' }), []);
54
+ assert.deepEqual(mapRuntimeEvent({ type: 'run_completed' }), []);
55
+ assert.deepEqual(mapRuntimeEvent({ type: 'run_failed' }), []);
56
+ assert.deepEqual(mapRuntimeEvent({ type: 'run_cancelled' }), []);
57
+ });
58
+
59
+ test('an unknown event type produces nothing', () => {
60
+ assert.deepEqual(mapRuntimeEvent({ type: 'made_up' }), []);
61
+ });
@@ -70,7 +70,9 @@ export function formatRuntimeLogPayload(payload = {}, ts = null) {
70
70
  const time = timeLabel(ts);
71
71
  const event = eventLabel(payload.event);
72
72
  const fields = ORDERED_FIELDS
73
- .map((key) => formatField(FIELD_ALIASES[key], payload[key]))
73
+ .map((key) => (key === 'taskId'
74
+ ? formatField(FIELD_ALIASES[key], shortTaskLabel(payload[key]))
75
+ : formatField(FIELD_ALIASES[key], payload[key])))
74
76
  .filter(Boolean);
75
77
  if (payload.status != null) fields.push(formatField('status', payload.status));
76
78
  if (payload.percent != null) fields.push(formatField('percent', payload.percent));
@@ -90,11 +92,43 @@ function shortenUuids(text) {
90
92
  return String(text ?? '').replace(UUID_RE, (uuid) => `${uuid.slice(0, 8)}…`);
91
93
  }
92
94
 
95
+ // A structured taskId is `<runId-uuid>:<slug>-<hash8>`. The UUID and the hash
96
+ // carry no meaning for a reader; keep the human-readable slug in between so
97
+ // `task=…` in a runtime log line names the work instead of an opaque id. A
98
+ // plain id that is neither prefixed nor hash-suffixed (`task-build`, `a`, a
99
+ // legacy step number) is left exactly as it is — only the UUID is collapsed.
100
+ const TASK_HASH_SUFFIX = /-[0-9a-f]{8,}$/i;
101
+
102
+ export function shortTaskLabel(value) {
103
+ const raw = String(value ?? '').trim();
104
+ if (!raw) return raw;
105
+ const hasColon = raw.includes(':');
106
+ if (!hasColon && !TASK_HASH_SUFFIX.test(raw)) return shortenUuids(raw);
107
+ const tail = hasColon ? raw.slice(raw.indexOf(':') + 1) : raw;
108
+ const pretty = tail.replace(TASK_HASH_SUFFIX, '').replace(/[-_]+/g, ' ').trim();
109
+ return pretty || shortenUuids(raw);
110
+ }
111
+
93
112
  export function shortLogId(value, { maxLength = 40 } = {}) {
94
113
  const shortened = shortenUuids(value);
95
114
  return shortened.length > maxLength ? `${shortened.slice(0, maxLength - 1)}…` : shortened;
96
115
  }
97
116
 
117
+ // A line emitted by formatRuntimeLogPayload for a structured event: optional
118
+ // HH:MM:SS, then the ALL-CAPS event token eventLabel() produces from the LAST
119
+ // dotted segment ('job.accepted' → 'ACCEPTED', 'capability.resolving' →
120
+ // 'RESOLVING', 'agent_status' → 'AGENT_STATUS'). These are the dispatch
121
+ // plumbing. A business line the reducer writes starts with a ▸/✓/✗/↻ glyph or
122
+ // a capitalised word ("Plan received", "Run failed:") — never an all-caps
123
+ // token — so this one shape separates the two without an event-name list
124
+ // (which is what an earlier enumeration got wrong: it only ever matched the
125
+ // two underscore-form events and missed every dotted one).
126
+ const DISPATCH_PLUMBING_LINE = /^(?:\d{1,2}:\d{2}(?::\d{2})?\s+)?[A-Z][A-Z0-9_]{2,}(?:\s|$)/;
127
+
128
+ export function isDispatchPlumbingLine(line) {
129
+ return DISPATCH_PLUMBING_LINE.test(String(line ?? ''));
130
+ }
131
+
98
132
  export function runtimeLogMatchesFilter(line, filter = '') {
99
133
  const query = String(filter ?? '').trim();
100
134
  if (!query) return true;
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
5
- import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, shortLogId } from './runtimeLog.js';
5
+ import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, isDispatchPlumbingLine, shortLogId } from './runtimeLog.js';
6
6
  import { emitRuntimeLog } from '../runtime/supervisor.js';
7
7
 
8
8
  const CYCLE_EVENTS = [
@@ -112,12 +112,37 @@ test('long UUIDs collapse to a short prefix so log lines stay on one line', () =
112
112
  }, '2026-07-08T14:42:18.000Z');
113
113
 
114
114
  assert.match(line, /run=7fadad27…/);
115
- assert.match(line, /task=7fadad27…:taxonomy-synthesis/);
115
+ // The taskId's UUID prefix and hash suffix are dropped entirely: the field
116
+ // names the work ("taxonomy synthesis"), not an opaque id.
117
+ assert.match(line, /task="taxonomy synthesis"/);
116
118
  assert.match(line, /attempt=attempt-7fadad27…/);
117
119
  assert.match(line, /agentInstance=production-7fadad27…/);
118
120
  assert.doesNotMatch(line, /7fadad27-0be6-4d08-96e5-664fe7ee841e/);
119
121
  });
120
122
 
123
+ test('isDispatchPlumbingLine recognises every formatted event token, dotted ones included', () => {
124
+ // eventLabel() keeps only the last dotted segment, so these are the tokens
125
+ // that actually reach the panel — an event-name enumeration missed them.
126
+ for (const event of ['job.accepted', 'agent.selected', 'capability.resolving', 'runtime.accepted', 'agent_status', 'agent_execute', 'task.result_returned']) {
127
+ const line = formatRuntimeLogPayload({ event, runId: 'r1', taskId: 't1' }, '2026-07-08T14:42:18.000Z');
128
+ assert.equal(isDispatchPlumbingLine(line), true, `expected plumbing: ${line}`);
129
+ }
130
+ });
131
+
132
+ test('isDispatchPlumbingLine leaves the business flow lines for the Runtime tab', () => {
133
+ for (const line of [
134
+ '14:42:18 ▸ Polish proposition — started (knowledge.polish → agent-production)',
135
+ '14:42:19 ✓ Polish proposition — done (1 output)',
136
+ '14:42:19 ✗ Ingest DSI — failed: dependency_failed',
137
+ '14:42:20 ↻ Build overview — retry 2/3 (rate_limit)',
138
+ '14:42:21 Run failed: No agent provides capability workspace.restore.',
139
+ '14:42:22 Plan validated for run r1',
140
+ '14:42:23 Control message: où en est le build ?',
141
+ ]) {
142
+ assert.equal(isDispatchPlumbingLine(line), false, `expected business flow: ${line}`);
143
+ }
144
+ });
145
+
121
146
  test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
122
147
  const long = `${'x'.repeat(48)}-deadbeef`;
123
148
  assert.match(shortLogId(long), /…$/);
@@ -54,9 +54,9 @@ test('the selection reason is humanized, not leaked as an audit enum', () => {
54
54
  assert.equal(selectionKindLabel('description_match'), 'description match');
55
55
  assert.equal(selectionKindLabel(null), null);
56
56
  const [chain] = projectSkillChains([
57
- { id: 'c0', chainId: 'k', chainSequence: 0, skillName: 'wiki-taxonomy', selectionKind: 'explicit_name', status: 'running', input: '/wiki-taxonomy' },
57
+ { id: 'c0', chainId: 'k', chainSequence: 0, skillName: 'wiki-build', selectionKind: 'explicit_name', status: 'running', input: '/wiki-build' },
58
58
  ]);
59
59
  assert.equal(chain.selectionKind, 'explicit_name');
60
60
  assert.equal(chain.selectionLabel, 'explicit name');
61
- assert.equal(renderSkillChain(chain).split('\n')[0], 'wiki-taxonomy · explicit name');
61
+ assert.equal(renderSkillChain(chain).split('\n')[0], 'wiki-build · explicit name');
62
62
  });
@@ -31,7 +31,7 @@ test('validation rejects technical routing details', () => {
31
31
  });
32
32
 
33
33
  test('scaffold skills preserve existing capabilities and split only wiki-sync', async () => {
34
- const expected = { pipeline: 1, 'wiki-ingest': 2, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
34
+ const expected = { pipeline: 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
35
35
  for (const [name, count] of Object.entries(expected)) {
36
36
  const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
37
37
  const { meta, body } = parseFrontmatter(raw);
@@ -24,11 +24,12 @@ export function explicitSkillReference(input, skillName, language = null) {
24
24
  elle ?
25
25
 
26
26
  Le corps d'une compétence est compilé en intentions métier, et une intention
27
- décrit forcément ce que fait une compétence voisine : la deuxième intention de
28
- `/wiki-ingest` est mot pour mot le corps de `/wiki-rebuild-concepts`. Le
29
- sélecteur par description la reconnaissait donc et relançait la compétence
30
- voisine, qui relançait la suivante — concepts et taxonomie produits plusieurs
31
- fois pour un seul `/wiki-ingest`.
27
+ décrit forcément ce que fait une compétence voisine : c'est ainsi qu'un seul
28
+ `/wiki-ingest` relançait autrefois la compétence sœur qui reconstruisait la
29
+ grille de concepts, qui relançait la suivante — concepts et taxonomie produits
30
+ plusieurs fois pour une seule demande. (Ces compétences sœurs ont disparu avec
31
+ la simplification 0.15.66 ; la garde contre la sélection par description
32
+ reste, c'est elle que ces règles verrouillent.)
32
33
 
33
34
  La composition volontaire reste possible : un corps qui écrit `/deliver` ou
34
35
  « the deliver skill » nomme sa cible, et se distingue ainsi d'une intention
@@ -44,8 +45,8 @@ export function objectiveNamesSkill(input, skillName) {
44
45
  if (text.toLowerCase() === raw.toLowerCase()) return true;
45
46
  /*
46
47
  Le nom seul ne suffit pas : plusieurs compétences du scaffold portent un nom
47
- qui est aussi un mot courant. « Run the production pipeline steps concepts,
48
- reclassify-concepts and taxonomy » nomme ainsi la compétence `pipeline`, qui
48
+ qui est aussi un mot courant. « Run the production pipeline steps ingest,
49
+ build, export and polish » nomme ainsi la compétence `pipeline`, qui
49
50
  relance ingest + build + export + polish — bien pire que la cascade qu'on
50
51
  corrige. Le nom doit donc être cité EN TANT QUE compétence : forme slash, ou
51
52
  tournure explicite. La borne droite est écrite à la main, `\b` ne bornant pas
@@ -61,7 +62,11 @@ export function objectiveNamesSkill(input, skillName) {
61
62
  const keyword = '(?:skill|workflow|compétence)';
62
63
  return [
63
64
  new RegExp(`(?:^|[^A-Za-z0-9_-])/${name}${slashEnd}`, 'i'),
64
- new RegExp(`\\b${keyword}\\s+/?${name}${end}`, 'i'),
65
+ // Same path-vs-invocation distinction as the slash form above: unlike
66
+ // the third pattern below, nothing after this one forces a following
67
+ // whitespace, so a trailing `/` here would otherwise satisfy `end` and
68
+ // let "the skill /wiki-build/export-context" match `wiki-build`.
69
+ new RegExp(`\\b${keyword}\\s+/?${name}${slashEnd}`, 'i'),
65
70
  new RegExp(`(?:^|[^A-Za-z0-9_-])/?${name}${end}\\s+${keyword}\\b`, 'i'),
66
71
  new RegExp(`/skills\\s+run\\s+${name}${end}`, 'i'),
67
72
  ].some((pattern) => pattern.test(text));
@@ -18,7 +18,7 @@ test('matchSkillInvocation resolves only a real workspace skill', () => {
18
18
 
19
19
  test('parseSkillArguments preserves one free-form argument and parses quoted multi params', () => {
20
20
  assert.deepEqual(parseSkillArguments({ params: ['files'] }, 'document A.md document B.md'), { files: 'document A.md document B.md' });
21
- assert.deepEqual(parseSkillArguments({ params: ['deliverable', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { deliverable: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
21
+ assert.deepEqual(parseSkillArguments({ params: ['deliverable', 'polish'] }, '"architecture-demo" "improve the network security"'), { deliverable: 'architecture-demo', polish: 'improve the network security' });
22
22
  });
23
23
 
24
24
  test('legacy placeholders remain supported and are reported', () => {
@@ -8,6 +8,7 @@ import { resolveAgentsComposeContext } from './agentsCompose.js';
8
8
  import { buildMcpStatus, discoverMcpTools } from './mcp.js';
9
9
  import { listWikircProfiles, loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
10
10
  import { listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
11
+ import { loadAgentRuntimesConfig, resolveRuntimeProviders } from '../orchestrator/providers/runtimeProviders.js';
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
  const DEFAULT_CONNECTIVITY_URL = 'https://registry.npmjs.org/-/ping';
@@ -326,6 +327,58 @@ export async function checkMcpConnections(workspace, {
326
327
  };
327
328
  }
328
329
 
330
+ // Agentic runtime gateway (GATEWAY_ENABLED / agent-runtimes.json). HTTP
331
+ // probe, independent of Docker: the gateway may be a host-native service.
332
+ // Nothing declared or enabled = skipped, never degraded — an optional engine
333
+ // must not gate the startup of the manager it complements.
334
+ export async function checkAgenticRuntimes({
335
+ loadConfig = loadAgentRuntimesConfig,
336
+ resolve = resolveRuntimeProviders,
337
+ env = process.env,
338
+ } = {}) {
339
+ let config;
340
+ try {
341
+ config = loadConfig({ env });
342
+ } catch (err) {
343
+ return {
344
+ ok: false,
345
+ detail: 'Agentic runtime configuration invalid',
346
+ context: { error: commandError(err), runtimes: [] },
347
+ };
348
+ }
349
+ const { providers, skipped } = resolve(config);
350
+ if (providers.length === 0 && skipped.length === 0) {
351
+ return { ok: true, skipped: true, detail: 'No agentic runtime enabled', context: { runtimes: [] } };
352
+ }
353
+ const runtimes = [];
354
+ for (const item of skipped) {
355
+ runtimes.push({ name: item.id || '(unnamed)', status: 'skipped', reason: item.reason });
356
+ }
357
+ for (const { id, provider } of providers) {
358
+ try {
359
+ const description = await provider.describe();
360
+ runtimes.push({
361
+ name: id,
362
+ status: description.health === 'unavailable' ? 'failed' : 'connected',
363
+ capabilities: (description.capabilities ?? [])
364
+ .map((capability) => capability?.name ?? capability?.id ?? '')
365
+ .filter(Boolean),
366
+ reason: description.health === 'unavailable' ? (description.error ?? 'unavailable') : null,
367
+ });
368
+ } catch (err) {
369
+ runtimes.push({ name: id, status: 'failed', reason: commandError(err) });
370
+ }
371
+ }
372
+ const connected = runtimes.filter((runtime) => runtime.status === 'connected');
373
+ const failed = runtimes.filter((runtime) => runtime.status === 'failed');
374
+ return {
375
+ ok: failed.length === 0,
376
+ pending: failed.length > 0,
377
+ detail: `${connected.length} connected${failed.length ? `, ${failed.length} pending` : ''}`,
378
+ context: { runtimes, command: '/status' },
379
+ };
380
+ }
381
+
329
382
  function preflightStatus(gaps, checks) {
330
383
  const setupRequired = gaps.some((gap) => gap.kind === 'workspace' || gap.kind === 'llm');
331
384
  if (setupRequired) return 'setup_required';
@@ -360,6 +413,7 @@ export async function runChecks({
360
413
  dockerCheck = checkDockerAvailability,
361
414
  internetCheck = checkInternetConnectivity,
362
415
  agentsCheck = checkAgents,
416
+ agenticRuntimesCheck = checkAgenticRuntimes,
363
417
  workspaceContainersCheck = checkWorkspaceContainers,
364
418
  mcpCheck = checkMcpConnections,
365
419
  onCheck = () => {},
@@ -398,6 +452,10 @@ export async function runChecks({
398
452
  : 'Running',
399
453
  context: { ...(agents?.context ?? {}), command: 'wiki-workspace agents up' },
400
454
  });
455
+ // The gateway is an HTTP probe, independent of Docker: it may be a
456
+ // host-native service. Nothing enabled = skipped, never degraded.
457
+ const agenticRuntimes = await agenticRuntimesCheck();
458
+ onCheck({ kind: 'agentic', ...agenticRuntimes });
401
459
  const workspaceGap = checkWorkspace(workspaces);
402
460
  if (workspaceGap) {
403
461
  gaps.push(workspaceGap);
@@ -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 { checkAgents, checkInternetConnectivity, checkMcpConnections, runChecks, runPreflightChecks, withRuntimePreflight } from './startupCheck.js';
7
+ import { checkAgents, checkAgenticRuntimes, checkInternetConnectivity, checkMcpConnections, runChecks, runPreflightChecks, withRuntimePreflight } from './startupCheck.js';
8
8
 
9
9
  async function withWorkspace(wikircLines, fn) {
10
10
  const root = await mkdtemp(join(tmpdir(), 'wiki-manager-startup-check-'));
@@ -299,3 +299,31 @@ test('checkInternetConnectivity uses a fresh Node process with proxy and CA envi
299
299
  else process.env.NODE_USE_ENV_PROXY = previousProxyFlag;
300
300
  }
301
301
  });
302
+
303
+ test('checkAgenticRuntimes skips when nothing is enabled', async () => {
304
+ const result = await checkAgenticRuntimes({ loadConfig: () => [], env: {} });
305
+ assert.equal(result.ok, true);
306
+ assert.equal(result.skipped, true);
307
+ assert.equal(result.context.runtimes.length, 0);
308
+ });
309
+
310
+ test('checkAgenticRuntimes reports a healthy runtime as connected', async () => {
311
+ const result = await checkAgenticRuntimes({
312
+ loadConfig: () => [{ id: 'fake', type: 'fake', enabled: true }],
313
+ env: {},
314
+ });
315
+ assert.equal(result.ok, true);
316
+ assert.equal(result.pending, false);
317
+ assert.equal(result.context.runtimes[0].status, 'connected');
318
+ });
319
+
320
+ test('checkAgenticRuntimes reports a down runtime as failed', async () => {
321
+ const result = await checkAgenticRuntimes({
322
+ loadConfig: () => [{ id: 'down', type: 'fake', available: false, enabled: true }],
323
+ env: {},
324
+ });
325
+ assert.equal(result.ok, false);
326
+ assert.equal(result.pending, true);
327
+ assert.equal(result.context.runtimes[0].status, 'failed');
328
+ assert.match(result.detail, /pending/);
329
+ });
@@ -115,6 +115,31 @@ export async function startAgents(options = {}) {
115
115
  profiles: composeContext.profiles,
116
116
  };
117
117
  } catch (err) {
118
+ // One optional agent failing to start (today: the agentic gateway, whose
119
+ // image is not pulled yet) makes `docker compose up` exit non-zero — but
120
+ // the base stack usually DID come up. Aborting here turned `/start all`
121
+ // into "gateway image missing ⇒ workspace never starts", which is the
122
+ // optional tail wagging the whole dog. If the base agents are up, degrade
123
+ // loudly instead of failing: the caller continues, and the absent agent
124
+ // announces itself through its own check (/status, preflight, discovery).
125
+ const verification = await verifyAgentsStarted({
126
+ context: composeContext,
127
+ agentsCheck: options.agentsCheck,
128
+ }).catch(() => null);
129
+ if (verification?.ok) {
130
+ return {
131
+ output: [
132
+ (options.services ?? []).length > 0
133
+ ? 'A requested agent failed to start — the rest of the stack is up.'
134
+ : 'The agents stack started degraded — one optional agent failed (see /status).',
135
+ err instanceof Error ? (err.message ?? String(err)) : String(err),
136
+ ].filter(Boolean).join('\n').trim(),
137
+ missingImages: [],
138
+ profiles: composeContext.profiles,
139
+ degraded: true,
140
+ degradedError: wrapDockerError(err),
141
+ };
142
+ }
118
143
  throw wrapDockerError(err);
119
144
  }
120
145
  }
@@ -85,3 +85,38 @@ test('the manager .env values override stale blank process values for agents up'
85
85
  }));
86
86
  assert.equal(childEnv.GOOGLE_OAUTH_CLIENT_SECRET, 'fresh-secret');
87
87
  });
88
+
89
+ test('an optional agent that refuses to start degrades instead of failing /start all', async () => {
90
+ // The gateway image not being pulled yet makes `docker compose up` exit
91
+ // non-zero while the base stack still comes up. Aborting there made
92
+ // `/start all` stop before the workspace services — the optional tail
93
+ // wagging the whole dog. With the base agents verified up, the start
94
+ // returns a degraded result the caller can continue from.
95
+ const result = await startAgents(startOptions({
96
+ exec: async () => {
97
+ const err = new Error('pull access denied for dotdrelle/wiki-agentic-gateway:latest');
98
+ err.code = 1;
99
+ throw err;
100
+ },
101
+ agentsCheck: async () => null,
102
+ }));
103
+
104
+ assert.equal(result.degraded, true);
105
+ assert.match(result.output, /started degraded/);
106
+ assert.match(result.output, /wiki-agentic-gateway/);
107
+ assert.deepEqual(result.profiles, ['connectors']);
108
+ });
109
+
110
+ test('a docker failure that leaves the base agents down still fails the start', async () => {
111
+ await assert.rejects(
112
+ startAgents(startOptions({
113
+ exec: async () => {
114
+ const err = new Error('Cannot connect to the Docker daemon');
115
+ err.code = 1;
116
+ throw err;
117
+ },
118
+ agentsCheck: async () => ({ kind: 'agents', context: { downServices: ['cme', 'documents'] } }),
119
+ })),
120
+ /Docker daemon is not running/,
121
+ );
122
+ });
@@ -273,7 +273,7 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
273
273
  return workspacePath;
274
274
  };
275
275
 
276
- makeWorkspace('acpi', [
276
+ makeWorkspace('acme', [
277
277
  'language: en',
278
278
  'llm:',
279
279
  ' provider: ai-gateway',
@@ -287,7 +287,7 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
287
287
  ].join('\n'));
288
288
 
289
289
  // Exactly what the scaffold writes: placeholders everywhere.
290
- const targetPath = makeWorkspace('nouveau', [
290
+ const targetPath = makeWorkspace('fresh', [
291
291
  'language: en',
292
292
  'llm:',
293
293
  ' provider: openai-compatible',
@@ -300,15 +300,15 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
300
300
  '',
301
301
  ].join('\n'));
302
302
 
303
- mkdirSync(join(agentsData, 'cme', 'acpi', 'cme'), { recursive: true });
304
- writeFileSync(join(agentsData, 'cme', 'acpi', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
303
+ mkdirSync(join(agentsData, 'cme', 'acme', 'cme'), { recursive: true });
304
+ writeFileSync(join(agentsData, 'cme', 'acme', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
305
305
 
306
306
  const previousDir = process.env.WIKI_WORKSPACES_DIR;
307
307
  const previousData = process.env.AGENTS_DATA_DIR;
308
308
  process.env.WIKI_WORKSPACES_DIR = registryRoot;
309
309
  process.env.AGENTS_DATA_DIR = agentsData;
310
310
  try {
311
- const { inherited } = await finalizeCreatedWorkspace('nouveau', { inheritFrom: 'acpi' });
311
+ const { inherited } = await finalizeCreatedWorkspace('fresh', { inheritFrom: 'acme' });
312
312
  const parsed = YAML.parse(readFileSync(join(targetPath, '.wikirc.yaml'), 'utf8'));
313
313
 
314
314
  assert.equal(parsed.llm.baseUrl, 'https://itsdonna.events/v1');
@@ -320,7 +320,7 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
320
320
  assert.ok(inherited.includes('llm.baseUrl'));
321
321
  assert.ok(inherited.includes('cme.app_data.json'));
322
322
  assert.equal(
323
- readFileSync(join(agentsData, 'cme', 'nouveau', 'cme', 'app_data.json'), 'utf8'),
323
+ readFileSync(join(agentsData, 'cme', 'fresh', 'cme', 'app_data.json'), 'utf8'),
324
324
  '{"pat":"x"}',
325
325
  );
326
326
  } finally {
@@ -146,36 +146,36 @@ test('nothing to inherit yields an empty patch', () => {
146
146
 
147
147
  test('CME credentials are copied, and the source manifest is not', async () => {
148
148
  const root = mkdtempSync(join(tmpdir(), 'cme-inherit-'));
149
- const sourceDir = join(root, 'cme', 'acpi', 'cme');
149
+ const sourceDir = join(root, 'cme', 'acme', 'cme');
150
150
  mkdirSync(sourceDir, { recursive: true });
151
151
  writeFileSync(join(sourceDir, 'app_data.json'), '{"auth":{"pat":"secret"}}', 'utf8');
152
152
  // Export scope is what makes a workspace different — it must NOT travel.
153
- writeFileSync(join(root, 'cme', 'acpi', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
153
+ writeFileSync(join(root, 'cme', 'acme', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
154
154
 
155
- const copied = await copyCmeCredentials(root, 'acpi', 'nouveau');
155
+ const copied = await copyCmeCredentials(root, 'acme', 'fresh');
156
156
 
157
- assert.equal(copied, cmeCredentialsPath(root, 'nouveau'));
157
+ assert.equal(copied, cmeCredentialsPath(root, 'fresh'));
158
158
  assert.equal(readFileSync(copied, 'utf8'), '{"auth":{"pat":"secret"}}');
159
- assert.equal(existsSync(join(root, 'cme', 'nouveau', 'sources-manifest.yaml')), false);
159
+ assert.equal(existsSync(join(root, 'cme', 'fresh', 'sources-manifest.yaml')), false);
160
160
  });
161
161
 
162
162
  test('existing CME credentials on the target are never clobbered', async () => {
163
163
  const root = mkdtempSync(join(tmpdir(), 'cme-inherit-keep-'));
164
- mkdirSync(join(root, 'cme', 'acpi', 'cme'), { recursive: true });
165
- mkdirSync(join(root, 'cme', 'nouveau', 'cme'), { recursive: true });
166
- writeFileSync(join(root, 'cme', 'acpi', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
167
- writeFileSync(join(root, 'cme', 'nouveau', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
164
+ mkdirSync(join(root, 'cme', 'acme', 'cme'), { recursive: true });
165
+ mkdirSync(join(root, 'cme', 'fresh', 'cme'), { recursive: true });
166
+ writeFileSync(join(root, 'cme', 'acme', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
167
+ writeFileSync(join(root, 'cme', 'fresh', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
168
168
 
169
- assert.equal(await copyCmeCredentials(root, 'acpi', 'nouveau'), null);
169
+ assert.equal(await copyCmeCredentials(root, 'acme', 'fresh'), null);
170
170
  assert.equal(
171
- readFileSync(cmeCredentialsPath(root, 'nouveau'), 'utf8'),
171
+ readFileSync(cmeCredentialsPath(root, 'fresh'), 'utf8'),
172
172
  '{"from":"target"}',
173
173
  );
174
174
  });
175
175
 
176
176
  test('copying is a no-op without a source, a target, or a source file', async () => {
177
177
  const root = mkdtempSync(join(tmpdir(), 'cme-inherit-noop-'));
178
- assert.equal(await copyCmeCredentials(root, 'absent', 'nouveau'), null);
179
- assert.equal(await copyCmeCredentials(root, null, 'nouveau'), null);
180
- assert.equal(await copyCmeCredentials(root, 'acpi', 'acpi'), null);
178
+ assert.equal(await copyCmeCredentials(root, 'absent', 'fresh'), null);
179
+ assert.equal(await copyCmeCredentials(root, null, 'fresh'), null);
180
+ assert.equal(await copyCmeCredentials(root, 'acme', 'acme'), null);
181
181
  });
@@ -1,6 +1,5 @@
1
- import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
1
+ import { createAgentEvent, dispatchAgentEvent, dispatchRuntimeLog } from '../core/agentEvents.js';
2
2
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
3
- import { normalizeRuntimeLog } from '../core/runtimeLog.js';
4
3
  import { assertContract } from '../contracts/schemas.js';
5
4
 
6
5
  const AVAILABLE = 'available';
@@ -220,26 +219,6 @@ function registerAgent(session, agent, { agentsByInstance, instanceByServer, las
220
219
  return cloneAgent(next);
221
220
  }
222
221
 
223
- /**
224
- * Runtime log line, emitted without importing the supervisor.
225
- *
226
- * `emitRuntimeLog` lives in `runtime/supervisor.js`, which already imports THIS
227
- * module: importing it back would close a cycle for one log line. The event
228
- * shape is the contract, not the helper, so we build it from the same
229
- * normalizer the supervisor uses.
230
- */
231
- function dispatchRuntimeLog(session, message) {
232
- if (!session) return;
233
- const payload = normalizeRuntimeLog(message, { session });
234
- dispatchAgentEvent(session, createAgentEvent('runtime_log', {
235
- origin: 'runtime',
236
- runId: payload.runId ?? null,
237
- taskId: payload.taskId ?? null,
238
- workspace: payload.workspaceId ?? null,
239
- payload,
240
- }));
241
- }
242
-
243
222
  function dispatchRegistryEvent(session, type, payload) {
244
223
  if (!session) return;
245
224
  dispatchAgentEvent(session, createAgentEvent(type, {
@@ -144,7 +144,7 @@ test('a failed re-discovery keeps the orchestrator agent, never erases its capab
144
144
  */
145
145
  const events = [];
146
146
  const session = {
147
- workspace: 'acpi',
147
+ workspace: 'acme',
148
148
  mcp: {
149
149
  production: { status: 'connected', tools: [{ name: 'agent_describe' }] },
150
150
  },
@@ -216,7 +216,7 @@ test('a stopped agent is reported once, not on every re-scan', async () => {
216
216
  // until it answers again.
217
217
  const events = [];
218
218
  const session = {
219
- workspace: 'acpi',
219
+ workspace: 'acme',
220
220
  mcp: { production: { status: 'connected', tools: [{ name: 'agent_describe' }] } },
221
221
  _onAgentEvent: (event) => events.push(event),
222
222
  };
@@ -260,7 +260,7 @@ test('discovery sends the workspace only to agents whose schema declares it', as
260
260
  });
261
261
 
262
262
  await registry.discover({
263
- workspace: 'acpi',
263
+ workspace: 'acme',
264
264
  mcp: {
265
265
  // Declares workspace: gets it, and can scope its vocabulary.
266
266
  cme: {
@@ -293,9 +293,9 @@ test('discovery sends the workspace only to agents whose schema declares it', as
293
293
  },
294
294
  });
295
295
 
296
- assert.deepEqual(seen.cme, { workspace: 'acpi' });
296
+ assert.deepEqual(seen.cme, { workspace: 'acme' });
297
297
  assert.deepEqual(seen.production, {});
298
- assert.deepEqual(seen.connectors, { workspace: 'acpi' });
298
+ assert.deepEqual(seen.connectors, { workspace: 'acme' });
299
299
  assert.deepEqual(seen.legacyish, {});
300
300
  });
301
301
 
@@ -374,7 +374,7 @@ test('markPersistedAgentsStale invalide aussi l’instantané routable', () => {
374
374
 
375
375
  test('un agent redevient sélectionnable après un agent_describe réussi', async () => {
376
376
  const session = {
377
- workspace: 'juno',
377
+ workspace: 'demo',
378
378
  agentEvents: [],
379
379
  agents: [{ agentInstanceId: 'production-main', serverName: 'production', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'knowledge.update', version: '1' }] } }],
380
380
  mcp: {