@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.
- package/.env.example +10 -3
- package/README.md +57 -0
- package/agent-runtimes.example.json +68 -0
- package/agents.docker-compose.yml +39 -1
- package/docker-compose.yml +3 -3
- package/package.json +3 -2
- package/src/activity/activityAggregator.test.js +2 -2
- package/src/agent/graph.js +13 -11
- package/src/agent/skillRecursion.test.js +13 -12
- package/src/cli/wiki-manager.js +125 -37
- package/src/cli/wiki-manager.test.js +16 -16
- package/src/commands/slash.js +59 -5
- package/src/contracts/schemas.js +67 -0
- package/src/core/activity.js +5 -0
- package/src/core/agentEvents.js +139 -25
- package/src/core/agentEvents.test.js +26 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/commandFailure.test.js +2 -2
- package/src/core/currentArtifact.test.js +5 -5
- package/src/core/dockerCompose.test.js +8 -40
- package/src/core/env.js +14 -0
- package/src/core/env.test.js +19 -0
- package/src/core/googleGrants.test.js +1 -1
- package/src/core/mcp.js +1 -1
- package/src/core/mcp.test.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +6 -6
- package/src/core/runtimeEventAdapter.js +81 -0
- package/src/core/runtimeEventAdapter.test.js +61 -0
- package/src/core/runtimeLog.js +35 -1
- package/src/core/runtimeLog.test.js +27 -2
- package/src/core/skillChainView.test.js +2 -2
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/skillInvocation.js +13 -8
- package/src/core/skillInvocation.test.js +1 -1
- package/src/core/startupCheck.js +58 -0
- package/src/core/startupCheck.test.js +29 -1
- package/src/core/wikiSetup.js +25 -0
- package/src/core/wikiSetup.test.js +35 -0
- package/src/core/wikirc.test.js +6 -6
- package/src/core/workspaceInherit.test.js +14 -14
- package/src/orchestrator/agentRegistry.js +1 -22
- package/src/orchestrator/agentRegistry.test.js +6 -6
- package/src/orchestrator/assignmentManager.js +16 -4
- package/src/orchestrator/capabilityRegistry.js +8 -1
- package/src/orchestrator/dispatcher.js +405 -2
- package/src/orchestrator/dispatcher.test.js +158 -4
- package/src/orchestrator/objectiveResolver.js +10 -6
- package/src/orchestrator/objectiveResolver.test.js +26 -27
- package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
- package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
- package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
- package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
- package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
- package/src/orchestrator/providers/runtimeProvider.js +101 -0
- package/src/orchestrator/providers/runtimeProviders.js +378 -0
- package/src/orchestrator/providers/runtimeProviders.test.js +384 -0
- package/src/orchestrator/resultAggregator.js +35 -2
- package/src/orchestrator/resultAggregator.test.js +62 -0
- package/src/orchestrator/scheduler.test.js +4 -4
- package/src/runtime/delegation.test.js +11 -11
- package/src/runtime/recoveryManager.js +70 -5
- package/src/runtime/runner.test.js +1 -1
- package/src/runtime/server.test.js +2 -2
- package/src/runtime/skillChain.e2e.test.js +2 -2
- package/src/runtime/store.test.js +8 -5
- package/src/runtime/supervisor.js +5 -10
- package/src/runtime/workspaceIsolation.test.js +26 -26
- package/src/shell/RightPane.tsx +23 -3
- package/src/shell/StartupScreen.tsx +44 -7
- package/src/shell/repl.js +24 -2
- package/src/shell/repl.test.js +13 -0
- 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
|
+
});
|
package/src/core/runtimeLog.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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':
|
|
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 :
|
|
28
|
-
`/wiki-ingest`
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
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-
|
|
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', () => {
|
package/src/core/startupCheck.js
CHANGED
|
@@ -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
|
+
});
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -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
|
+
});
|
package/src/core/wikirc.test.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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', '
|
|
304
|
-
writeFileSync(join(agentsData, 'cme', '
|
|
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('
|
|
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', '
|
|
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', '
|
|
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', '
|
|
153
|
+
writeFileSync(join(root, 'cme', 'acme', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
|
|
154
154
|
|
|
155
|
-
const copied = await copyCmeCredentials(root, '
|
|
155
|
+
const copied = await copyCmeCredentials(root, 'acme', 'fresh');
|
|
156
156
|
|
|
157
|
-
assert.equal(copied, cmeCredentialsPath(root, '
|
|
157
|
+
assert.equal(copied, cmeCredentialsPath(root, 'fresh'));
|
|
158
158
|
assert.equal(readFileSync(copied, 'utf8'), '{"auth":{"pat":"secret"}}');
|
|
159
|
-
assert.equal(existsSync(join(root, 'cme', '
|
|
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', '
|
|
165
|
-
mkdirSync(join(root, 'cme', '
|
|
166
|
-
writeFileSync(join(root, 'cme', '
|
|
167
|
-
writeFileSync(join(root, 'cme', '
|
|
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, '
|
|
169
|
+
assert.equal(await copyCmeCredentials(root, 'acme', 'fresh'), null);
|
|
170
170
|
assert.equal(
|
|
171
|
-
readFileSync(cmeCredentialsPath(root, '
|
|
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', '
|
|
179
|
-
assert.equal(await copyCmeCredentials(root, null, '
|
|
180
|
-
assert.equal(await copyCmeCredentials(root, '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
296
|
+
assert.deepEqual(seen.cme, { workspace: 'acme' });
|
|
297
297
|
assert.deepEqual(seen.production, {});
|
|
298
|
-
assert.deepEqual(seen.connectors, { workspace: '
|
|
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: '
|
|
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: {
|