@dotdrelle/wiki-manager 0.6.47 → 0.8.2
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 +20 -0
- package/README.md +35 -1
- package/bin/wiki-manager.js +2 -2
- package/docker-compose.yml +2 -0
- package/mcp.endpoints.example.json +13 -0
- package/package.json +2 -2
- package/src/agent/graph.js +101 -15
- package/src/agent/graph.test.js +145 -0
- package/src/cli/wiki-manager.js +459 -131
- package/src/commands/slash.js +1 -1
- package/src/core/activity.js +14 -0
- package/src/core/agentEvents.js +148 -6
- package/src/core/agentEvents.test.js +145 -4
- package/src/core/agentLoop.js +167 -0
- package/src/core/agentLoop.test.js +85 -0
- package/src/core/cacert.js +4 -3
- package/src/core/dockerCompose.test.js +14 -0
- package/src/core/documentIntake.test.js +7 -7
- package/src/core/env.js +6 -0
- package/src/core/jobQueue.js +47 -16
- package/src/core/mcp.js +120 -10
- package/src/core/mcp.test.js +121 -1
- package/src/core/plan.js +33 -0
- package/src/core/queueStore.js +27 -0
- package/src/core/queueStore.test.js +32 -0
- package/src/core/wikiWorkspace.test.js +24 -0
- package/src/core/workspaces.js +8 -1
- package/src/runtime/approvals.js +113 -0
- package/src/runtime/auth.js +35 -0
- package/src/runtime/auth.test.js +29 -0
- package/src/runtime/client.js +154 -0
- package/src/runtime/lifecycle.js +84 -0
- package/src/runtime/queueStore.js +6 -0
- package/src/runtime/runner.js +413 -0
- package/src/runtime/runner.test.js +270 -0
- package/src/runtime/server.js +216 -0
- package/src/runtime/server.test.js +308 -0
- package/src/runtime/store.js +431 -0
- package/src/runtime/store.test.js +437 -0
- package/src/runtime/supervisor.js +104 -0
- package/src/runtime/supervisor.test.js +240 -0
- package/src/shell/RightPane.tsx +1 -1
- package/src/shell/repl.js +148 -18
- package/src/shell/repl.test.js +38 -0
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +20 -2
- package/src/shell/useSession.ts +153 -13
- package/wiki-workspace +221 -22
package/src/core/cacert.js
CHANGED
|
@@ -40,8 +40,7 @@ function composeOverrideContent(cacertPath, services) {
|
|
|
40
40
|
services: serviceEntries,
|
|
41
41
|
};
|
|
42
42
|
return [
|
|
43
|
-
'# Generated by wiki-manager.
|
|
44
|
-
'# Rewritten when --cacert is used with a Docker Compose command.',
|
|
43
|
+
'# Generated by wiki-manager. Edit freely; delete to regenerate.',
|
|
45
44
|
YAML.stringify(doc).trimEnd(),
|
|
46
45
|
'',
|
|
47
46
|
].join('\n');
|
|
@@ -61,6 +60,8 @@ export function ensureCacertComposeOverride(composeFilePath, fileName = 'cacert.
|
|
|
61
60
|
const runtimeDir = managerRuntimeDir();
|
|
62
61
|
mkdirSync(runtimeDir, { recursive: true });
|
|
63
62
|
const overridePath = join(runtimeDir, fileName);
|
|
64
|
-
|
|
63
|
+
if (!existsSync(overridePath)) {
|
|
64
|
+
writeFileSync(overridePath, composeOverrideContent(cacertPath, services), 'utf8');
|
|
65
|
+
}
|
|
65
66
|
return (_cacertOverrideCache = overridePath);
|
|
66
67
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
|
|
6
|
+
test('workspace compose does not start a per-workspace agent runtime', async () => {
|
|
7
|
+
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
8
|
+
const compose = YAML.parse(raw);
|
|
9
|
+
const aliases = compose['x-wiki-manager']['service-aliases'];
|
|
10
|
+
|
|
11
|
+
assert.equal(compose.services['agent-runtime'], undefined);
|
|
12
|
+
assert.deepEqual(aliases.all.targets, ['serve', 'mcp-http', 'production-mcp']);
|
|
13
|
+
assert.equal(aliases.runtime, undefined);
|
|
14
|
+
});
|
|
@@ -16,16 +16,16 @@ test('document intake stores uploads without requiring documents MCP', async ()
|
|
|
16
16
|
const source = path.join(root, 'rapport.pdf');
|
|
17
17
|
await writeFile(source, 'fake pdf content');
|
|
18
18
|
const session = {
|
|
19
|
-
workspace: '
|
|
19
|
+
workspace: 'my-project',
|
|
20
20
|
mcp: {},
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
try {
|
|
24
24
|
const { record, converted } = await storeAndMaybeConvertDocument(session, source);
|
|
25
25
|
assert.equal(converted, false);
|
|
26
|
-
assert.equal(record.workspace, '
|
|
26
|
+
assert.equal(record.workspace, 'my-project');
|
|
27
27
|
assert.equal(record.status, 'stored');
|
|
28
|
-
assert.equal(record.agentPath.startsWith('/documents/input/
|
|
28
|
+
assert.equal(record.agentPath.startsWith('/documents/input/my-project/'), true);
|
|
29
29
|
assert.equal(await readFile(record.storedPath, 'utf8'), 'fake pdf content');
|
|
30
30
|
|
|
31
31
|
const uploads = await listDocumentUploads(session);
|
|
@@ -45,7 +45,7 @@ test('document intake accepts quoted absolute paths with spaces', async () => {
|
|
|
45
45
|
const source = path.join(root, 'Screenshot 2026-06-21 at 10.03.36.png');
|
|
46
46
|
await writeFile(source, 'fake png content');
|
|
47
47
|
const session = {
|
|
48
|
-
workspace: '
|
|
48
|
+
workspace: 'my-project',
|
|
49
49
|
mcp: {},
|
|
50
50
|
};
|
|
51
51
|
|
|
@@ -66,7 +66,7 @@ test('document intake accepts double-quoted absolute paths with spaces', async (
|
|
|
66
66
|
const source = path.join(root, 'scan avec espace.pdf');
|
|
67
67
|
await writeFile(source, 'fake pdf content');
|
|
68
68
|
const session = {
|
|
69
|
-
workspace: '
|
|
69
|
+
workspace: 'my-project',
|
|
70
70
|
mcp: {},
|
|
71
71
|
};
|
|
72
72
|
|
|
@@ -87,7 +87,7 @@ test('document intake replaces an existing upload with the same original filenam
|
|
|
87
87
|
process.env.AGENTS_DATA_DIR = agentsDataDir;
|
|
88
88
|
const source = path.join(root, 'rapport.pdf');
|
|
89
89
|
const session = {
|
|
90
|
-
workspace: '
|
|
90
|
+
workspace: 'my-project',
|
|
91
91
|
mcp: {},
|
|
92
92
|
};
|
|
93
93
|
|
|
@@ -98,7 +98,7 @@ test('document intake replaces an existing upload with the same original filenam
|
|
|
98
98
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
99
99
|
await writeFile(outputPath, 'old markdown');
|
|
100
100
|
|
|
101
|
-
const manifest = path.join(agentsDataDir, 'documents', 'uploads', '
|
|
101
|
+
const manifest = path.join(agentsDataDir, 'documents', 'uploads', 'my-project.jsonl');
|
|
102
102
|
const firstRecord = JSON.parse(await readFile(manifest, 'utf8'));
|
|
103
103
|
await writeFile(manifest, `${JSON.stringify({ ...firstRecord, outputPath })}\n`, 'utf8');
|
|
104
104
|
|
package/src/core/env.js
CHANGED
|
@@ -15,6 +15,12 @@ export function managerRuntimeDir() {
|
|
|
15
15
|
return join(managerStateDir(), '.wiki-manager');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export function defaultRuntimeStateDir() {
|
|
19
|
+
return process.env.WIKI_MANAGER_STATE_DIR
|
|
20
|
+
? resolve(process.env.WIKI_MANAGER_STATE_DIR)
|
|
21
|
+
: managerRuntimeDir();
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export function managerEnvFile() {
|
|
19
25
|
return process.env.WIKI_MANAGER_ENV_FILE
|
|
20
26
|
? resolve(process.env.WIKI_MANAGER_ENV_FILE)
|
package/src/core/jobQueue.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { extractActivity, parseJsonText, sessionActivities } from './activity.js';
|
|
2
3
|
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
3
4
|
import { callMcpTool, formatMcpToolResult } from './mcp.js';
|
|
5
|
+
import { queueStoreFor } from './queueStore.js';
|
|
4
6
|
|
|
5
7
|
const TERMINAL = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error']);
|
|
6
8
|
|
|
@@ -8,8 +10,12 @@ function now() {
|
|
|
8
10
|
return new Date().toISOString();
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
function notifyQueueUpdate(session) {
|
|
14
|
+
queueStoreFor(session).changed();
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
function shortId() {
|
|
12
|
-
return `q-${
|
|
18
|
+
return `q-${randomUUID()}`;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
function terminalStatus(status) {
|
|
@@ -17,8 +23,7 @@ function terminalStatus(status) {
|
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export function ensureJobQueue(session) {
|
|
20
|
-
session.
|
|
21
|
-
return session.jobQueue;
|
|
26
|
+
return queueStoreFor(session).list();
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function productionLockBusy(session) {
|
|
@@ -41,6 +46,7 @@ export function enqueueProductionJob(session, args = {}, reason = 'waiting') {
|
|
|
41
46
|
createdAt: now(),
|
|
42
47
|
};
|
|
43
48
|
ensureJobQueue(session).push(item);
|
|
49
|
+
notifyQueueUpdate(session);
|
|
44
50
|
return item;
|
|
45
51
|
}
|
|
46
52
|
|
|
@@ -56,17 +62,33 @@ export function queueSummary(args = {}) {
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
export function queueCounts(session) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
const queue = projectQueue(session.headlessPlan, ensureJobQueue(session), { workspace: session.workspace ?? null });
|
|
66
|
+
return {
|
|
67
|
+
active: queue.length,
|
|
68
|
+
current: queue.filter((item) => item.workspace === session.workspace || !item.workspace).length,
|
|
69
|
+
frozen: queue.filter((item) => item.workspace && item.workspace !== session.workspace).length,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function projectQueue(plan, queue, { workspace = null } = {}) {
|
|
74
|
+
const blockedJobs = (queue ?? [])
|
|
75
|
+
.filter((item) => ['waiting', 'blocked', 'pending_approval'].includes(String(item.status ?? '').toLowerCase()))
|
|
76
|
+
.map((item) => ({
|
|
77
|
+
...item,
|
|
78
|
+
queueType: 'blocked_job',
|
|
79
|
+
}));
|
|
80
|
+
const pendingSteps = (plan ?? [])
|
|
81
|
+
.filter((step) => step.status === 'pending')
|
|
82
|
+
.map((step) => ({
|
|
83
|
+
id: `plan-${step.step}`,
|
|
84
|
+
queueType: 'pending_step',
|
|
85
|
+
status: 'pending',
|
|
86
|
+
workspace,
|
|
87
|
+
step: step.step,
|
|
88
|
+
activityKey: step.activityKey ?? step.ownerActivityKey ?? undefined,
|
|
89
|
+
args: { type: step.description },
|
|
90
|
+
}));
|
|
91
|
+
return [...blockedJobs, ...pendingSteps];
|
|
70
92
|
}
|
|
71
93
|
|
|
72
94
|
export function formatQueue(session) {
|
|
@@ -85,10 +107,11 @@ export function formatQueue(session) {
|
|
|
85
107
|
export function clearFinishedQueueItems(session) {
|
|
86
108
|
const queue = ensureJobQueue(session);
|
|
87
109
|
const before = queue.length;
|
|
88
|
-
|
|
110
|
+
const next = queue.filter((item) =>
|
|
89
111
|
item.workspace !== session.workspace || !terminalStatus(item.status),
|
|
90
112
|
);
|
|
91
|
-
|
|
113
|
+
queueStoreFor(session).replace(next);
|
|
114
|
+
return before - next.length;
|
|
92
115
|
}
|
|
93
116
|
|
|
94
117
|
function findQueueItem(session, id) {
|
|
@@ -102,6 +125,7 @@ export async function cancelQueueItem(session, id) {
|
|
|
102
125
|
const label = item.status === 'starting' ? 'starting' : 'queued';
|
|
103
126
|
item.status = 'cancelled';
|
|
104
127
|
item.finishedAt = now();
|
|
128
|
+
notifyQueueUpdate(session);
|
|
105
129
|
return { ok: true, message: `Cancelled ${label} job ${id}.` };
|
|
106
130
|
}
|
|
107
131
|
if (item.status === 'running') {
|
|
@@ -109,6 +133,7 @@ export async function cancelQueueItem(session, id) {
|
|
|
109
133
|
await callMcpTool(session.mcp, 'production', 'production_cancel_job', { jobId: item.jobId });
|
|
110
134
|
item.status = 'cancelled';
|
|
111
135
|
item.finishedAt = now();
|
|
136
|
+
notifyQueueUpdate(session);
|
|
112
137
|
return { ok: true, message: `Cancellation requested for ${id} (${item.jobId}).` };
|
|
113
138
|
}
|
|
114
139
|
return { ok: false, message: `No cancel tool available for ${id}.` };
|
|
@@ -140,6 +165,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
140
165
|
|
|
141
166
|
item.status = 'starting';
|
|
142
167
|
item.startedAt = now();
|
|
168
|
+
notifyQueueUpdate(session);
|
|
143
169
|
hooks.refresh?.();
|
|
144
170
|
hooks.addLog?.(`queue: starting ${item.id} ${queueSummary(item.args)}`);
|
|
145
171
|
|
|
@@ -154,6 +180,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
154
180
|
if (payload?.ok === false && payload?.error === 'workspace_busy') {
|
|
155
181
|
item.status = 'waiting';
|
|
156
182
|
item.reason = 'workspace_busy';
|
|
183
|
+
notifyQueueUpdate(session);
|
|
157
184
|
hooks.addLog?.(`queue: ${item.id} still waiting, production lock busy`);
|
|
158
185
|
hooks.refresh?.();
|
|
159
186
|
return item;
|
|
@@ -164,6 +191,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
164
191
|
item.jobId = activity.id;
|
|
165
192
|
item.activityKey = activity.key;
|
|
166
193
|
item.finishedAt = activity.terminal ? now() : undefined;
|
|
194
|
+
notifyQueueUpdate(session);
|
|
167
195
|
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
168
196
|
origin: 'queue',
|
|
169
197
|
payload: { activity },
|
|
@@ -171,6 +199,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
171
199
|
} else {
|
|
172
200
|
item.status = 'done';
|
|
173
201
|
item.finishedAt = now();
|
|
202
|
+
notifyQueueUpdate(session);
|
|
174
203
|
}
|
|
175
204
|
hooks.addLog?.(`queue: ${item.id} ${item.status}${item.jobId ? ` job=${item.jobId}` : ''}`);
|
|
176
205
|
hooks.refresh?.();
|
|
@@ -179,6 +208,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
179
208
|
item.status = 'failed';
|
|
180
209
|
item.error = err instanceof Error ? err.message : String(err);
|
|
181
210
|
item.finishedAt = now();
|
|
211
|
+
notifyQueueUpdate(session);
|
|
182
212
|
hooks.addLog?.(`queue: ${item.id} failed · ${item.error}`);
|
|
183
213
|
hooks.refresh?.();
|
|
184
214
|
return item;
|
|
@@ -193,5 +223,6 @@ export function syncQueueWithActivity(session, activity) {
|
|
|
193
223
|
item.activityKey = activity.key;
|
|
194
224
|
item.finishedAt = activity.terminal ? now() : item.finishedAt;
|
|
195
225
|
item.error = activity.error ?? item.error;
|
|
226
|
+
notifyQueueUpdate(session);
|
|
196
227
|
return item;
|
|
197
228
|
}
|
package/src/core/mcp.js
CHANGED
|
@@ -70,11 +70,28 @@ function endpointStatus(configured, detail = '') {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function approvalToolsFor(serverName) {
|
|
74
|
+
const raw = envValue('WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS');
|
|
75
|
+
if (!raw) return undefined;
|
|
76
|
+
const tools = String(raw)
|
|
77
|
+
.split(',')
|
|
78
|
+
.map((item) => item.trim())
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.filter((item) => item === '*' || item.startsWith(`${serverName}.`) || !item.includes('.'))
|
|
81
|
+
.map((item) => item.startsWith(`${serverName}.`) ? item.slice(serverName.length + 1) : item);
|
|
82
|
+
return tools.length > 0 ? tools : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
const MCP_SERVICE_MAP = {
|
|
74
86
|
wiki: 'mcp-http',
|
|
75
87
|
production: 'production-mcp',
|
|
76
88
|
};
|
|
77
89
|
|
|
90
|
+
const DEFAULT_MCP_RETRY_POLICY = {
|
|
91
|
+
maxAttempts: 2,
|
|
92
|
+
backoffMs: 500,
|
|
93
|
+
};
|
|
94
|
+
|
|
78
95
|
export function buildMcpStatus(session) {
|
|
79
96
|
const workspaceEnv = session.workspaceEnv ?? {};
|
|
80
97
|
const wikiMcpToken = session.wikircConfig?.mcp?.accessKey;
|
|
@@ -91,6 +108,7 @@ export function buildMcpStatus(session) {
|
|
|
91
108
|
),
|
|
92
109
|
url: workspaceEnv.WIKI_MCP_PORT ? `http://127.0.0.1:${workspaceEnv.WIKI_MCP_PORT}/mcp` : null,
|
|
93
110
|
token: wikiMcpToken || null,
|
|
111
|
+
requireApproval: approvalToolsFor('wiki'),
|
|
94
112
|
},
|
|
95
113
|
production: {
|
|
96
114
|
...endpointStatus(
|
|
@@ -100,6 +118,7 @@ export function buildMcpStatus(session) {
|
|
|
100
118
|
url: workspaceEnv.PRODUCTION_MCP_PORT ? `http://127.0.0.1:${workspaceEnv.PRODUCTION_MCP_PORT}/mcp/` : null,
|
|
101
119
|
token: workspaceEnv.PRODUCTION_MCP_AUTH_TOKEN || null,
|
|
102
120
|
activeConfigPath: session.wikirc?.fileName || null,
|
|
121
|
+
requireApproval: approvalToolsFor('production'),
|
|
103
122
|
},
|
|
104
123
|
...external,
|
|
105
124
|
};
|
|
@@ -211,7 +230,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
|
|
|
211
230
|
params: {
|
|
212
231
|
protocolVersion: '2025-06-18',
|
|
213
232
|
capabilities: {},
|
|
214
|
-
clientInfo: { name: 'wiki-manager', version: '0.
|
|
233
|
+
clientInfo: { name: 'wiki-manager', version: '0.8.2' },
|
|
215
234
|
},
|
|
216
235
|
}),
|
|
217
236
|
});
|
|
@@ -240,7 +259,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
|
|
|
240
259
|
}
|
|
241
260
|
}
|
|
242
261
|
|
|
243
|
-
export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, signal) {
|
|
262
|
+
export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, signal, options = {}) {
|
|
244
263
|
const endpoint = mcpStatus?.[serverName];
|
|
245
264
|
if (!endpoint) throw new Error(`Unknown MCP: ${serverName}`);
|
|
246
265
|
if (endpoint.status !== 'connected') throw new Error(`MCP is not connected: ${serverName}`);
|
|
@@ -254,14 +273,17 @@ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, si
|
|
|
254
273
|
...(shouldInjectConfigPath ? { configPath: endpoint.activeConfigPath } : {}),
|
|
255
274
|
};
|
|
256
275
|
const timeoutMs = serverName === 'documents' && toolName === 'documents_convert_to_markdown' ? 600_000 : 8000;
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
276
|
+
const retry = resolveRetryPolicy(endpoint, toolName, options.retry);
|
|
277
|
+
return withRetry(async () => {
|
|
278
|
+
const payload = await mcpRequest(endpoint, 'tools/call', {
|
|
279
|
+
name: toolName,
|
|
280
|
+
arguments: toolArgs,
|
|
281
|
+
}, signal, { timeoutMs });
|
|
282
|
+
if (payload?.result?.isError) {
|
|
283
|
+
throw new Error(formatMcpToolResult(payload.result));
|
|
284
|
+
}
|
|
285
|
+
return payload?.result ?? null;
|
|
286
|
+
}, retry, { signal, onRetry: options.onRetry });
|
|
265
287
|
}
|
|
266
288
|
|
|
267
289
|
export function formatMcpToolResult(result) {
|
|
@@ -278,6 +300,94 @@ export function formatMcpToolResult(result) {
|
|
|
278
300
|
.trim() || 'No result.';
|
|
279
301
|
}
|
|
280
302
|
|
|
303
|
+
let _cachedEnvRetryPolicy = null;
|
|
304
|
+
function getEnvRetryPolicy() {
|
|
305
|
+
if (!_cachedEnvRetryPolicy) {
|
|
306
|
+
_cachedEnvRetryPolicy = {
|
|
307
|
+
maxAttempts: numberFromEnv('WIKI_MANAGER_MCP_RETRY_MAX_ATTEMPTS')
|
|
308
|
+
?? numberFromEnv('WIKI_MANAGER_MCP_RETRY_ATTEMPTS')
|
|
309
|
+
?? DEFAULT_MCP_RETRY_POLICY.maxAttempts,
|
|
310
|
+
backoffMs: numberFromEnv('WIKI_MANAGER_MCP_RETRY_BACKOFF_MS')
|
|
311
|
+
?? DEFAULT_MCP_RETRY_POLICY.backoffMs,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return _cachedEnvRetryPolicy;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function resolveRetryPolicy(endpoint = {}, toolName = null, override = null) {
|
|
318
|
+
const toolPolicy = toolName ? endpoint.toolRetries?.[toolName] : null;
|
|
319
|
+
return normalizeRetryPolicy(getEnvRetryPolicy(), endpoint.retry, toolPolicy, override);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function normalizeRetryPolicy(...policies) {
|
|
323
|
+
const merged = {};
|
|
324
|
+
for (const policy of policies) {
|
|
325
|
+
if (policy === false) {
|
|
326
|
+
merged.maxAttempts = 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!policy || typeof policy !== 'object') continue;
|
|
330
|
+
if (policy.maxAttempts != null) merged.maxAttempts = Number(policy.maxAttempts);
|
|
331
|
+
if (policy.backoffMs != null) merged.backoffMs = Number(policy.backoffMs);
|
|
332
|
+
}
|
|
333
|
+
const maxAttempts = Number.isFinite(merged.maxAttempts)
|
|
334
|
+
? Math.max(1, Math.floor(merged.maxAttempts))
|
|
335
|
+
: DEFAULT_MCP_RETRY_POLICY.maxAttempts;
|
|
336
|
+
const backoffMs = Number.isFinite(merged.backoffMs)
|
|
337
|
+
? Math.max(0, Math.floor(merged.backoffMs))
|
|
338
|
+
: DEFAULT_MCP_RETRY_POLICY.backoffMs;
|
|
339
|
+
return { maxAttempts, backoffMs };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function numberFromEnv(key) {
|
|
343
|
+
const raw = envValue(key);
|
|
344
|
+
if (raw == null || raw === '') return null;
|
|
345
|
+
const value = Number(raw);
|
|
346
|
+
return Number.isFinite(value) ? value : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function withRetry(operation, policy, { signal = null, onRetry = null } = {}) {
|
|
350
|
+
let lastError;
|
|
351
|
+
for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
|
|
352
|
+
try {
|
|
353
|
+
return await operation({ attempt });
|
|
354
|
+
} catch (err) {
|
|
355
|
+
lastError = err;
|
|
356
|
+
if (attempt >= policy.maxAttempts || signal?.aborted) throw err;
|
|
357
|
+
onRetry?.({ attempt, maxAttempts: policy.maxAttempts, error: err });
|
|
358
|
+
await retryDelay(policy.backoffMs * (2 ** (attempt - 1)), signal);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
throw lastError;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function retryDelay(ms, signal) {
|
|
365
|
+
if (ms <= 0) return Promise.resolve();
|
|
366
|
+
return new Promise((resolve, reject) => {
|
|
367
|
+
let timer = null;
|
|
368
|
+
const cleanup = () => {
|
|
369
|
+
if (timer) clearTimeout(timer);
|
|
370
|
+
signal?.removeEventListener('abort', onAbort);
|
|
371
|
+
};
|
|
372
|
+
const onAbort = () => {
|
|
373
|
+
cleanup();
|
|
374
|
+
const err = new Error('Operation aborted.');
|
|
375
|
+
err.name = 'AbortError';
|
|
376
|
+
reject(err);
|
|
377
|
+
};
|
|
378
|
+
timer = setTimeout(() => {
|
|
379
|
+
cleanup();
|
|
380
|
+
resolve();
|
|
381
|
+
}, ms);
|
|
382
|
+
if (!signal) return;
|
|
383
|
+
if (signal.aborted) {
|
|
384
|
+
onAbort();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
281
391
|
export async function discoverMcpTools(mcpStatus) {
|
|
282
392
|
const next = {};
|
|
283
393
|
await Promise.all(Object.entries(mcpStatus ?? {}).map(async ([name, value]) => {
|
package/src/core/mcp.test.js
CHANGED
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { buildMcpStatus, callMcpTool, discoverMcpTools } from './mcp.js';
|
|
6
|
+
import { buildMcpStatus, callMcpTool, discoverMcpTools, resolveRetryPolicy } from './mcp.js';
|
|
7
7
|
|
|
8
8
|
test('buildMcpStatus reads external MCP endpoints from mcp.endpoints.json', async () => {
|
|
9
9
|
const originalCwd = process.cwd();
|
|
@@ -163,6 +163,29 @@ test('buildMcpStatus uses active wikirc mcp.accessKey for wiki MCP', () => {
|
|
|
163
163
|
assert.equal(status.wiki.token, 'wikirc-wiki-token');
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
+
test('buildMcpStatus applies internal tool approval policy from env', () => {
|
|
167
|
+
const original = process.env.WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS;
|
|
168
|
+
process.env.WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS = 'production.production_start_job,wiki.wiki_search';
|
|
169
|
+
try {
|
|
170
|
+
const status = buildMcpStatus({
|
|
171
|
+
workspaceEnv: {
|
|
172
|
+
PRODUCTION_MCP_PORT: '3102',
|
|
173
|
+
PRODUCTION_MCP_AUTH_TOKEN: 'production-token',
|
|
174
|
+
WIKI_MCP_PORT: '3101',
|
|
175
|
+
},
|
|
176
|
+
wikircConfig: {
|
|
177
|
+
mcp: { accessKey: 'wiki-token' },
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
assert.deepEqual(status.production.requireApproval, ['production_start_job']);
|
|
182
|
+
assert.deepEqual(status.wiki.requireApproval, ['wiki_search']);
|
|
183
|
+
} finally {
|
|
184
|
+
if (original === undefined) delete process.env.WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS;
|
|
185
|
+
else process.env.WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS = original;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
166
189
|
test('callMcpTool injects active configPath for production_start_job', async () => {
|
|
167
190
|
const originalFetch = globalThis.fetch;
|
|
168
191
|
let requestBody = null;
|
|
@@ -269,6 +292,103 @@ test('callMcpTool sends configured endpoint headers', async () => {
|
|
|
269
292
|
}
|
|
270
293
|
});
|
|
271
294
|
|
|
295
|
+
test('callMcpTool retries transient MCP failures', async () => {
|
|
296
|
+
const originalFetch = globalThis.fetch;
|
|
297
|
+
let attempts = 0;
|
|
298
|
+
const retries = [];
|
|
299
|
+
globalThis.fetch = async () => {
|
|
300
|
+
attempts += 1;
|
|
301
|
+
if (attempts === 1) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
status: 503,
|
|
305
|
+
headers: { get: () => null },
|
|
306
|
+
text: async () => 'temporarily unavailable',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
status: 200,
|
|
312
|
+
headers: { get: () => null },
|
|
313
|
+
text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const result = await callMcpTool(
|
|
319
|
+
{
|
|
320
|
+
production: {
|
|
321
|
+
status: 'connected',
|
|
322
|
+
url: 'http://127.0.0.1:3000/mcp/',
|
|
323
|
+
retry: { maxAttempts: 2, backoffMs: 0 },
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
'production',
|
|
327
|
+
'production_start_job',
|
|
328
|
+
{ type: 'doctor' },
|
|
329
|
+
null,
|
|
330
|
+
{ onRetry: (event) => retries.push(event) },
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
assert.equal(attempts, 2);
|
|
334
|
+
assert.equal(retries.length, 1);
|
|
335
|
+
assert.match(retries[0].error.message, /503/);
|
|
336
|
+
assert.equal(result.content[0].text, '{"ok":true}');
|
|
337
|
+
} finally {
|
|
338
|
+
globalThis.fetch = originalFetch;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('callMcpTool retries tool result errors', async () => {
|
|
343
|
+
const originalFetch = globalThis.fetch;
|
|
344
|
+
let attempts = 0;
|
|
345
|
+
globalThis.fetch = async () => {
|
|
346
|
+
attempts += 1;
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
status: 200,
|
|
350
|
+
headers: { get: () => null },
|
|
351
|
+
text: async () => JSON.stringify({
|
|
352
|
+
result: attempts === 1
|
|
353
|
+
? { isError: true, content: [{ type: 'text', text: 'rate limited' }] }
|
|
354
|
+
: { content: [{ type: 'text', text: '{"ok":true}' }] },
|
|
355
|
+
}),
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const result = await callMcpTool(
|
|
361
|
+
{
|
|
362
|
+
production: {
|
|
363
|
+
status: 'connected',
|
|
364
|
+
url: 'http://127.0.0.1:3000/mcp/',
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
'production',
|
|
368
|
+
'production_start_job',
|
|
369
|
+
{ type: 'doctor' },
|
|
370
|
+
null,
|
|
371
|
+
{ retry: { maxAttempts: 2, backoffMs: 0 } },
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
assert.equal(attempts, 2);
|
|
375
|
+
assert.equal(result.content[0].text, '{"ok":true}');
|
|
376
|
+
} finally {
|
|
377
|
+
globalThis.fetch = originalFetch;
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('resolveRetryPolicy supports endpoint and tool overrides', () => {
|
|
382
|
+
const policy = resolveRetryPolicy({
|
|
383
|
+
retry: { maxAttempts: 2, backoffMs: 100 },
|
|
384
|
+
toolRetries: {
|
|
385
|
+
production_start_job: { maxAttempts: 4 },
|
|
386
|
+
},
|
|
387
|
+
}, 'production_start_job');
|
|
388
|
+
|
|
389
|
+
assert.deepEqual(policy, { maxAttempts: 4, backoffMs: 100 });
|
|
390
|
+
});
|
|
391
|
+
|
|
272
392
|
test('discoverMcpTools downgrades connected endpoint when tool discovery fails', async () => {
|
|
273
393
|
const originalFetch = globalThis.fetch;
|
|
274
394
|
globalThis.fetch = async () => ({
|
package/src/core/plan.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export function ensurePlanFromActivity(session, activity) {
|
|
2
2
|
if (!activity) return;
|
|
3
3
|
const actKey = activity.key ?? null;
|
|
4
|
+
if (session.headlessPlan?.some((step) => step.owner === 'orchestrator')) {
|
|
5
|
+
attachActivityToExistingPlan(session.headlessPlan, activity);
|
|
6
|
+
session._onPlanUpdate?.();
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
4
9
|
// Same activity still being tracked — preserve current plan state (polling update).
|
|
5
10
|
if (session.headlessPlan && actKey !== null && session.headlessPlan[0]?._activityKey === actKey) return;
|
|
6
11
|
const steps = activity.plan?.steps;
|
|
@@ -10,6 +15,8 @@ export function ensurePlanFromActivity(session, activity) {
|
|
|
10
15
|
id: s.id ?? null,
|
|
11
16
|
description: s.label,
|
|
12
17
|
status: 'pending',
|
|
18
|
+
owner: 'activity',
|
|
19
|
+
ownerActivityKey: activity.key,
|
|
13
20
|
_activityKey: activity.key,
|
|
14
21
|
}));
|
|
15
22
|
} else {
|
|
@@ -18,6 +25,8 @@ export function ensurePlanFromActivity(session, activity) {
|
|
|
18
25
|
id: null,
|
|
19
26
|
description: activity.label,
|
|
20
27
|
status: 'pending',
|
|
28
|
+
owner: 'activity',
|
|
29
|
+
ownerActivityKey: activity.key,
|
|
21
30
|
_activityKey: activity.key,
|
|
22
31
|
}];
|
|
23
32
|
}
|
|
@@ -179,3 +188,27 @@ function statusRank(status) {
|
|
|
179
188
|
if (status === 'done') return 2;
|
|
180
189
|
return 3;
|
|
181
190
|
}
|
|
191
|
+
|
|
192
|
+
export function attachActivityToExistingPlan(plan, activity) {
|
|
193
|
+
const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
|
|
194
|
+
if (!actKey) return;
|
|
195
|
+
const matched = plan.find((step) => step.activityKey === actKey)
|
|
196
|
+
?? plan.find((step) => step.ownerActivityKey === actKey)
|
|
197
|
+
?? plan.find((step) => step.status === 'pending')
|
|
198
|
+
?? plan.find((step) => step.status === 'running');
|
|
199
|
+
if (!matched) return;
|
|
200
|
+
matched.activityKey = actKey;
|
|
201
|
+
if (!matched.ownerActivityKey) matched.ownerActivityKey = actKey;
|
|
202
|
+
const failed = ['failed', 'error', 'cancelled', 'canceled'].includes(String(activity.status).toLowerCase());
|
|
203
|
+
if (activity.terminal) {
|
|
204
|
+
matched.status = failed ? 'failed' : 'done';
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!failed) {
|
|
208
|
+
for (const step of plan) {
|
|
209
|
+
if (step.status === 'failed') continue;
|
|
210
|
+
if (step.step < matched.step) step.status = 'done';
|
|
211
|
+
else if (step.step === matched.step) step.status = 'running';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function createQueueStore(session, { persist = () => {} } = {}) {
|
|
2
|
+
session.jobQueue ??= [];
|
|
3
|
+
return {
|
|
4
|
+
list() {
|
|
5
|
+
session.jobQueue ??= [];
|
|
6
|
+
return session.jobQueue;
|
|
7
|
+
},
|
|
8
|
+
replace(queue) {
|
|
9
|
+
session.jobQueue = Array.isArray(queue) ? queue : [];
|
|
10
|
+
persist();
|
|
11
|
+
return session.jobQueue;
|
|
12
|
+
},
|
|
13
|
+
changed() {
|
|
14
|
+
session.jobQueue ??= [];
|
|
15
|
+
persist();
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createMemoryQueueStore(session) {
|
|
21
|
+
return createQueueStore(session, { persist: () => session._onQueueUpdate?.(session.jobQueue) });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function queueStoreFor(session) {
|
|
25
|
+
session.queueStore ??= createMemoryQueueStore(session);
|
|
26
|
+
return session.queueStore;
|
|
27
|
+
}
|