@dotdrelle/wiki-manager 0.15.29 → 0.15.34
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 +5 -2
- package/agents.docker-compose.yml +10 -0
- package/bin/wiki-manager.js +1 -1
- package/docker-compose.override.example.yml +1 -0
- package/package.json +2 -2
- package/src/agent/graph.test.js +17 -4
- package/src/cli/runtimeStartup.test.js +14 -0
- package/src/cli/wiki-manager.js +79 -13
- package/src/cli/wiki-manager.test.js +157 -0
- package/src/commands/slash.js +9 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +113 -38
- package/src/core/mcp.test.js +181 -20
- package/src/core/modelFetch.js +229 -41
- package/src/core/modelFetch.test.js +77 -2
- package/src/core/wikiSetup.js +32 -2
- package/src/core/wikiWorkspace.test.js +16 -0
- package/src/core/wikirc.test.js +59 -5
- package/src/orchestrator/agentRegistry.js +35 -7
- package/src/orchestrator/agentRegistry.test.js +73 -0
- package/src/orchestrator/dispatcher.js +6 -1
- package/src/orchestrator/dispatcher.test.js +24 -1
- package/src/runtime/auth.test.js +1 -65
- package/src/runtime/donna-contract.test.js +2 -1
- package/src/runtime/lifecycle.js +0 -33
- package/src/runtime/runner.test.js +10 -1
- package/src/runtime/supervisor.test.js +11 -1
- package/src/shell/SetupWizard.tsx +282 -31
- package/src/shell/setupWizardPlaceholders.test.js +1 -1
- package/src/shell/setupWizardSuggestions.test.js +24 -0
- package/src/shell/tui.tsx +10 -18
- package/wiki-workspace +70 -0
|
@@ -112,3 +112,76 @@ test('agentRegistry marks unavailable boot agents and emits health changes on re
|
|
|
112
112
|
assert.equal(events[1].payload.health, 'available');
|
|
113
113
|
assert.equal(registry.snapshot()[0].health, 'available');
|
|
114
114
|
});
|
|
115
|
+
|
|
116
|
+
test('discovery sends the workspace only to agents whose schema declares it', async () => {
|
|
117
|
+
const seen = {};
|
|
118
|
+
const registry = createAgentRegistry({
|
|
119
|
+
callTool: async (_mcp, server, _tool, args) => {
|
|
120
|
+
seen[server] = args;
|
|
121
|
+
return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await registry.discover({
|
|
126
|
+
workspace: 'acpi',
|
|
127
|
+
mcp: {
|
|
128
|
+
// Declares workspace: gets it, and can scope its vocabulary.
|
|
129
|
+
cme: {
|
|
130
|
+
status: 'connected',
|
|
131
|
+
tools: [{
|
|
132
|
+
name: 'agent_describe',
|
|
133
|
+
inputSchema: { type: 'object', properties: { workspace: { type: 'string' } }, additionalProperties: false },
|
|
134
|
+
}],
|
|
135
|
+
},
|
|
136
|
+
// Strict schema without workspace: sending it would be REJECTED, the
|
|
137
|
+
// agent would drop out of the registry, and its capabilities would
|
|
138
|
+
// silently vanish from objective resolution.
|
|
139
|
+
production: {
|
|
140
|
+
status: 'connected',
|
|
141
|
+
tools: [{
|
|
142
|
+
name: 'agent_describe',
|
|
143
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
144
|
+
}],
|
|
145
|
+
},
|
|
146
|
+
// Open schema: tolerant, so the workspace is worth sending.
|
|
147
|
+
connectors: {
|
|
148
|
+
status: 'connected',
|
|
149
|
+
tools: [{ name: 'agent_describe', inputSchema: { type: 'object', properties: {} } }],
|
|
150
|
+
},
|
|
151
|
+
// No schema published at all: assume nothing.
|
|
152
|
+
legacyish: {
|
|
153
|
+
status: 'connected',
|
|
154
|
+
tools: [{ name: 'agent_describe' }],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(seen.cme, { workspace: 'acpi' });
|
|
160
|
+
assert.deepEqual(seen.production, {});
|
|
161
|
+
assert.deepEqual(seen.connectors, { workspace: 'acpi' });
|
|
162
|
+
assert.deepEqual(seen.legacyish, {});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('discovery sends no workspace argument when no workspace is active', async () => {
|
|
166
|
+
const seen = [];
|
|
167
|
+
const registry = createAgentRegistry({
|
|
168
|
+
callTool: async (_mcp, _server, _tool, args) => {
|
|
169
|
+
seen.push(args);
|
|
170
|
+
return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await registry.discover({
|
|
175
|
+
mcp: {
|
|
176
|
+
cme: {
|
|
177
|
+
status: 'connected',
|
|
178
|
+
tools: [{
|
|
179
|
+
name: 'agent_describe',
|
|
180
|
+
inputSchema: { type: 'object', properties: { workspace: { type: 'string' } } },
|
|
181
|
+
}],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
assert.deepEqual(seen, [{}]);
|
|
187
|
+
});
|
|
@@ -243,10 +243,15 @@ export function normalizeTaskError(rawError, { fallbackCode = null, fallbackMess
|
|
|
243
243
|
retryable: transientError(fallbackCode),
|
|
244
244
|
};
|
|
245
245
|
}
|
|
246
|
+
// The agent gave a real reason. `fallbackMessage` describes only WHERE the
|
|
247
|
+
// failure was observed ("agent_execute rejected task"), so letting it win
|
|
248
|
+
// here replaced the one actionable sentence we have with a generic one —
|
|
249
|
+
// and Donna, left with nothing to explain, invented a cause. The fallback
|
|
250
|
+
// is a last resort, never an override.
|
|
246
251
|
const code = String(rawError);
|
|
247
252
|
return {
|
|
248
253
|
code,
|
|
249
|
-
message:
|
|
254
|
+
message: code,
|
|
250
255
|
retryable: transientError(code),
|
|
251
256
|
};
|
|
252
257
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { createDispatcher } from './dispatcher.js';
|
|
3
|
+
import { createDispatcher, normalizeTaskError } from './dispatcher.js';
|
|
4
4
|
|
|
5
5
|
test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
|
|
6
6
|
const session = {
|
|
@@ -159,3 +159,26 @@ test('dispatcher keeps a null error when a terminal status reports none', async
|
|
|
159
159
|
assert.equal(result.ok, true);
|
|
160
160
|
assert.equal(result.error, null);
|
|
161
161
|
});
|
|
162
|
+
|
|
163
|
+
test('normalizeTaskError keeps the agent reason as the message, never the fallback', () => {
|
|
164
|
+
// Regression: the fallback describes only WHERE the failure was seen
|
|
165
|
+
// ("agent_execute rejected task"). Letting it win discarded the one
|
|
166
|
+
// actionable sentence — and left Donna to invent a cause.
|
|
167
|
+
const error = normalizeTaskError("Error: source 'acpi' not found", {
|
|
168
|
+
fallbackCode: 'execution_rejected',
|
|
169
|
+
fallbackMessage: 'agent_execute rejected task',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
assert.equal(error.code, "Error: source 'acpi' not found");
|
|
173
|
+
assert.equal(error.message, "Error: source 'acpi' not found");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('normalizeTaskError falls back only when the agent reports no reason at all', () => {
|
|
177
|
+
const error = normalizeTaskError('', {
|
|
178
|
+
fallbackCode: 'execution_rejected',
|
|
179
|
+
fallbackMessage: 'agent_execute rejected task',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
assert.equal(error.code, 'execution_rejected');
|
|
183
|
+
assert.equal(error.message, 'agent_execute rejected task');
|
|
184
|
+
});
|
package/src/runtime/auth.test.js
CHANGED
|
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import { resolveRuntimeAuthToken } from './auth.js';
|
|
7
|
-
import { assertRuntimeNode, runtimeNodeExecutable
|
|
7
|
+
import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
|
|
8
8
|
|
|
9
9
|
test('resolveRuntimeAuthToken: loopback host does not require token', () => {
|
|
10
10
|
const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
|
|
@@ -27,67 +27,3 @@ test('runtime lifecycle uses a Node executable with node:sqlite support', async
|
|
|
27
27
|
assert.ok(runtimeNode.executable);
|
|
28
28
|
assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
|
|
29
29
|
});
|
|
30
|
-
|
|
31
|
-
test('shutdownOwnedRuntime reports progress and stops an idle owned runtime', async () => {
|
|
32
|
-
const originalFetch = globalThis.fetch;
|
|
33
|
-
const calls = [];
|
|
34
|
-
const logs = [];
|
|
35
|
-
globalThis.fetch = async (url, options = {}) => {
|
|
36
|
-
calls.push({ url: String(url), method: options.method ?? 'GET' });
|
|
37
|
-
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
38
|
-
status: calls.length === 1 ? 200 : 202,
|
|
39
|
-
headers: { 'content-type': 'application/json' },
|
|
40
|
-
});
|
|
41
|
-
};
|
|
42
|
-
try {
|
|
43
|
-
const result = await shutdownOwnedRuntime(
|
|
44
|
-
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
45
|
-
{ log: (message) => logs.push(message), timeoutMs: 100 },
|
|
46
|
-
);
|
|
47
|
-
assert.equal(result.action, 'shutdown');
|
|
48
|
-
assert.deepEqual(calls.map((call) => call.method), ['GET', 'POST']);
|
|
49
|
-
assert.match(logs.join('\n'), /runtime arrêté/);
|
|
50
|
-
} finally {
|
|
51
|
-
globalThis.fetch = originalFetch;
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('shutdownOwnedRuntime also stops an idle reused runtime', async () => {
|
|
56
|
-
const originalFetch = globalThis.fetch;
|
|
57
|
-
const calls = [];
|
|
58
|
-
globalThis.fetch = async (_url, options = {}) => {
|
|
59
|
-
calls.push(options.method ?? 'GET');
|
|
60
|
-
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
61
|
-
status: calls.length === 1 ? 200 : 202,
|
|
62
|
-
headers: { 'content-type': 'application/json' },
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
try {
|
|
66
|
-
const result = await shutdownOwnedRuntime(
|
|
67
|
-
{ url: 'http://127.0.0.1:7788', started: false, token: null },
|
|
68
|
-
{ timeoutMs: 100 },
|
|
69
|
-
);
|
|
70
|
-
assert.equal(result.action, 'shutdown');
|
|
71
|
-
assert.deepEqual(calls, ['GET', 'POST']);
|
|
72
|
-
} finally {
|
|
73
|
-
globalThis.fetch = originalFetch;
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('shutdownOwnedRuntime bounds an unresponsive shutdown', async () => {
|
|
78
|
-
const originalFetch = globalThis.fetch;
|
|
79
|
-
const logs = [];
|
|
80
|
-
globalThis.fetch = async (_url, { signal } = {}) => new Promise((_resolve, reject) => {
|
|
81
|
-
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
|
|
82
|
-
});
|
|
83
|
-
try {
|
|
84
|
-
const result = await shutdownOwnedRuntime(
|
|
85
|
-
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
86
|
-
{ log: (message) => logs.push(message), timeoutMs: 5 },
|
|
87
|
-
);
|
|
88
|
-
assert.equal(result.action, 'timeout');
|
|
89
|
-
assert.match(logs.join('\n'), /délai de fermeture/);
|
|
90
|
-
} finally {
|
|
91
|
-
globalThis.fetch = originalFetch;
|
|
92
|
-
}
|
|
93
|
-
});
|
|
@@ -146,7 +146,8 @@ test('CME setup and source configuration stay outside export orchestration', asy
|
|
|
146
146
|
const calledTools = [];
|
|
147
147
|
globalThis.fetch = async (_url, options = {}) => {
|
|
148
148
|
const body = JSON.parse(String(options.body ?? '{}'));
|
|
149
|
-
|
|
149
|
+
// Record tool calls only — the MCP session handshake is not a tool call.
|
|
150
|
+
if (body.method === 'tools/call') calledTools.push(body.params?.name);
|
|
150
151
|
return {
|
|
151
152
|
ok: true,
|
|
152
153
|
status: 200,
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -139,36 +139,3 @@ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = run
|
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
|
|
143
|
-
// Close the runtime attached to the shell when it is idle, including a process
|
|
144
|
-
// reused at startup. Restricting cleanup to `started: true` left ownerless
|
|
145
|
-
// runtimes occupying port 7788 with another manager directory/token.
|
|
146
|
-
// An active run still survives the shell.
|
|
147
|
-
export async function shutdownOwnedRuntime(runtime, { log = (_message) => {}, timeoutMs = 3000 } = {}) {
|
|
148
|
-
if (!runtime?.url) return { action: 'kept', reason: 'unavailable' };
|
|
149
|
-
const controller = new AbortController();
|
|
150
|
-
const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 3000));
|
|
151
|
-
try {
|
|
152
|
-
const health = await runtimeHealthOrNull(runtime.url, runtime.token, controller.signal);
|
|
153
|
-
if (!health) return { action: 'kept', reason: 'unreachable' };
|
|
154
|
-
const activeRuns = Array.isArray(health.activeRuns) ? health.activeRuns : [];
|
|
155
|
-
if (activeRuns.length > 0) {
|
|
156
|
-
const labels = activeRuns
|
|
157
|
-
.map((run) => [run.workspace, run.runId ? String(run.runId).slice(0, 8) : null].filter(Boolean).join('/'))
|
|
158
|
-
.join(', ');
|
|
159
|
-
log(`runtime laissé actif : run en cours (${labels}) — il survivra à ce shell ; relance wiki-manager pour le retrouver.`);
|
|
160
|
-
return { action: 'kept', reason: 'run_active', activeRuns };
|
|
161
|
-
}
|
|
162
|
-
await postRuntimeShutdown({ url: runtime.url, token: runtime.token, signal: controller.signal });
|
|
163
|
-
log('runtime arrêté (aucun run en cours).');
|
|
164
|
-
return { action: 'shutdown' };
|
|
165
|
-
} catch (err) {
|
|
166
|
-
if (controller.signal.aborted) {
|
|
167
|
-
log('délai de fermeture du runtime dépassé — le shell termine sans attendre davantage.');
|
|
168
|
-
return { action: 'timeout', reason: 'shutdown_timeout' };
|
|
169
|
-
}
|
|
170
|
-
return { action: 'error', reason: err instanceof Error ? err.message : String(err) };
|
|
171
|
-
} finally {
|
|
172
|
-
clearTimeout(timeout);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
@@ -401,7 +401,16 @@ test('replanRuntimeRun requires runtime approval for mutating replanned work', a
|
|
|
401
401
|
test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
|
|
402
402
|
const originalFetch = globalThis.fetch;
|
|
403
403
|
let pollAttempts = 0;
|
|
404
|
-
globalThis.fetch = async () => {
|
|
404
|
+
globalThis.fetch = async (_url, init) => {
|
|
405
|
+
// Count polls, not the one-off MCP session handshake.
|
|
406
|
+
if (JSON.parse(init.body).method === 'initialize') {
|
|
407
|
+
return {
|
|
408
|
+
ok: true,
|
|
409
|
+
status: 200,
|
|
410
|
+
headers: { get: () => null },
|
|
411
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
412
|
+
};
|
|
413
|
+
}
|
|
405
414
|
pollAttempts += 1;
|
|
406
415
|
return {
|
|
407
416
|
ok: true,
|
|
@@ -93,7 +93,17 @@ test('pollActivitiesOnce does not repeat an unchanged retry state when retryAt m
|
|
|
93
93
|
test('pollActivitiesOnce retries transient MCP poll failures', async () => {
|
|
94
94
|
const originalFetch = globalThis.fetch;
|
|
95
95
|
let attempts = 0;
|
|
96
|
-
globalThis.fetch = async () => {
|
|
96
|
+
globalThis.fetch = async (_url, init) => {
|
|
97
|
+
// The session handshake is not the thing under test: answer it, and make
|
|
98
|
+
// the first *poll* the transient failure.
|
|
99
|
+
if (JSON.parse(init.body).method === 'initialize') {
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
status: 200,
|
|
103
|
+
headers: { get: () => null },
|
|
104
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
97
107
|
attempts += 1;
|
|
98
108
|
if (attempts === 1) {
|
|
99
109
|
return {
|