@dotdrelle/wiki-manager 0.7.3 → 0.9.3
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 +50 -1
- package/docker-compose.yml +1 -23
- 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 +306 -53
- package/src/commands/slash.js +4 -24
- package/src/core/agentEvents.js +169 -4
- package/src/core/agentEvents.test.js +176 -4
- package/src/core/agentLoop.js +3 -0
- package/src/core/compose.js +1 -2
- package/src/core/dockerCompose.test.js +5 -5
- package/src/core/jobQueue.js +29 -12
- 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.test.js +1 -0
- package/src/core/sessionConfig.js +24 -0
- package/src/core/wikiWorkspace.test.js +24 -0
- package/src/runtime/approvals.js +113 -0
- package/src/runtime/auth.test.js +8 -0
- package/src/runtime/client.js +52 -6
- package/src/runtime/lifecycle.js +27 -3
- package/src/runtime/queueStore.js +3 -3
- package/src/runtime/runner.js +340 -0
- package/src/runtime/runner.test.js +270 -0
- package/src/runtime/server.js +252 -33
- package/src/runtime/server.test.js +577 -0
- package/src/runtime/store.js +181 -39
- package/src/runtime/store.test.js +363 -4
- package/src/runtime/supervisor.js +6 -0
- package/src/runtime/supervisor.test.js +141 -0
- package/src/shell/RightPane.tsx +1 -1
- package/src/shell/repl.js +22 -6
- package/src/shell/useAgent.ts +1 -1
- package/src/shell/useSession.ts +10 -5
- package/wiki-workspace +198 -4
package/src/runtime/server.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
4
|
import { runtimeTokenFromEnv } from './auth.js';
|
|
3
5
|
|
|
4
6
|
export function startRuntimeServer({
|
|
@@ -6,18 +8,24 @@ export function startRuntimeServer({
|
|
|
6
8
|
port = 7788,
|
|
7
9
|
token = runtimeTokenFromEnv(),
|
|
8
10
|
store,
|
|
9
|
-
session,
|
|
11
|
+
session = null,
|
|
12
|
+
getContext,
|
|
10
13
|
run,
|
|
11
14
|
cancel,
|
|
15
|
+
resume,
|
|
16
|
+
approve,
|
|
17
|
+
configProfiles,
|
|
18
|
+
useConfigProfile,
|
|
12
19
|
} = {}) {
|
|
13
20
|
const clients = new Set();
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
const defaultContext = { workspace: null, session, running: false, currentAbortController: null };
|
|
22
|
+
const resolvedGetContext = getContext ?? (() => defaultContext);
|
|
16
23
|
|
|
17
24
|
function publish(event) {
|
|
18
25
|
const payload = `event: agent_event\ndata: ${JSON.stringify(event)}\n\n`;
|
|
19
|
-
for (const
|
|
20
|
-
|
|
26
|
+
for (const client of clients) {
|
|
27
|
+
if (client.workspace && event.workspace !== client.workspace) continue;
|
|
28
|
+
client.response.write(payload);
|
|
21
29
|
}
|
|
22
30
|
}
|
|
23
31
|
|
|
@@ -30,69 +38,157 @@ export function startRuntimeServer({
|
|
|
30
38
|
|
|
31
39
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
|
32
40
|
if (request.method === 'GET' && url.pathname === '/health') {
|
|
33
|
-
|
|
41
|
+
const workspace = workspaceFromUrl(url);
|
|
42
|
+
const context = workspace ? await resolveContext({ workspace }) : null;
|
|
43
|
+
sendJson(response, 200, {
|
|
44
|
+
ok: true,
|
|
45
|
+
status: context?.running ? 'running' : 'idle',
|
|
46
|
+
workspace: context?.workspace ?? workspace ?? null,
|
|
47
|
+
dbPath: store.dbPath,
|
|
48
|
+
});
|
|
34
49
|
return;
|
|
35
50
|
}
|
|
36
51
|
if (request.method === 'GET' && url.pathname === '/state') {
|
|
37
|
-
|
|
52
|
+
const workspace = workspaceFromUrl(url);
|
|
53
|
+
const context = workspace ? await resolveContext({ workspace }) : null;
|
|
54
|
+
sendJson(response, 200, store.getState(context?.session ?? session, { workspace }));
|
|
38
55
|
return;
|
|
39
56
|
}
|
|
40
57
|
if (request.method === 'GET' && url.pathname === '/events') {
|
|
41
|
-
|
|
58
|
+
const workspace = workspaceFromUrl(url);
|
|
59
|
+
sendJson(response, 200, { events: store.listEvents({ workspace }) });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (request.method === 'GET' && url.pathname === '/control') {
|
|
63
|
+
const workspace = workspaceFromUrl(url);
|
|
64
|
+
const context = await resolveContext({ workspace });
|
|
65
|
+
sendJson(response, 200, controlStatus(context, store));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (request.method === 'POST' && url.pathname === '/control') {
|
|
69
|
+
const { body, context } = await resolveBodyContext(request, url);
|
|
70
|
+
const action = String(body.action ?? 'status').trim().toLowerCase();
|
|
71
|
+
if (action === 'status') {
|
|
72
|
+
sendJson(response, 200, controlStatus(context, store));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (action === 'explain') {
|
|
76
|
+
const status = controlStatus(context, store);
|
|
77
|
+
sendJson(response, 200, { ...status, explanation: explainControlState(status) });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (action === 'enqueue') {
|
|
81
|
+
const input = String(body.input ?? body.prompt ?? body.request ?? '').trim();
|
|
82
|
+
if (!input) {
|
|
83
|
+
sendJson(response, 400, { error: 'Missing input.' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const item = enqueueControlRequest(context, input);
|
|
87
|
+
void startNextControlRequest(context);
|
|
88
|
+
sendJson(response, 202, {
|
|
89
|
+
accepted: true,
|
|
90
|
+
item,
|
|
91
|
+
...controlStatus(context, store),
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
sendJson(response, 400, { error: 'Unsupported control action.' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (request.method === 'GET' && url.pathname === '/config/profiles') {
|
|
99
|
+
if (typeof configProfiles !== 'function') {
|
|
100
|
+
sendJson(response, 501, { error: 'Config profiles are not supported.' });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const workspace = workspaceFromUrl(url);
|
|
104
|
+
const context = await resolveContext({ workspace });
|
|
105
|
+
const result = await configProfiles(context);
|
|
106
|
+
sendJson(response, 200, result);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (request.method === 'POST' && url.pathname === '/config/use') {
|
|
110
|
+
if (typeof useConfigProfile !== 'function') {
|
|
111
|
+
sendJson(response, 501, { error: 'Config profile switching is not supported.' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const { body, context } = await resolveBodyContext(request, url);
|
|
115
|
+
if (context.running) {
|
|
116
|
+
sendJson(response, 409, { error: 'Cannot switch config while a runtime run is active.' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const profile = String(body.profile ?? '').trim();
|
|
120
|
+
if (!profile) {
|
|
121
|
+
sendJson(response, 400, { error: 'Missing profile.' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const result = await useConfigProfile(context, profile);
|
|
125
|
+
sendJson(response, 200, result);
|
|
42
126
|
return;
|
|
43
127
|
}
|
|
44
128
|
if (request.method === 'GET' && url.pathname === '/events/stream') {
|
|
129
|
+
const workspace = workspaceFromUrl(url);
|
|
130
|
+
const context = workspace ? await resolveContext({ workspace }) : null;
|
|
45
131
|
response.writeHead(200, {
|
|
46
132
|
'Content-Type': 'text/event-stream',
|
|
47
133
|
'Cache-Control': 'no-cache',
|
|
48
134
|
Connection: 'keep-alive',
|
|
49
135
|
'X-Accel-Buffering': 'no',
|
|
50
136
|
});
|
|
51
|
-
response.write(`event: state\ndata: ${JSON.stringify(store.getState(session))}\n\n`);
|
|
52
|
-
|
|
53
|
-
|
|
137
|
+
response.write(`event: state\ndata: ${JSON.stringify(store.getState(context?.session ?? session, { workspace }))}\n\n`);
|
|
138
|
+
const client = { response, workspace };
|
|
139
|
+
clients.add(client);
|
|
140
|
+
request.on('close', () => clients.delete(client));
|
|
54
141
|
return;
|
|
55
142
|
}
|
|
56
143
|
if (request.method === 'POST' && url.pathname === '/run') {
|
|
57
|
-
|
|
144
|
+
const { body, context } = await resolveBodyContext(request, url);
|
|
145
|
+
if (context.running) {
|
|
58
146
|
sendJson(response, 409, { error: 'A runtime run is already active.' });
|
|
59
147
|
return;
|
|
60
148
|
}
|
|
61
|
-
running = true;
|
|
62
|
-
currentAbortController = new AbortController();
|
|
63
149
|
try {
|
|
64
|
-
const body = await readJson(request);
|
|
65
150
|
const input = String(body.input ?? body.prompt ?? '').trim();
|
|
66
151
|
if (!input) {
|
|
67
|
-
running = false;
|
|
68
|
-
currentAbortController = null;
|
|
69
152
|
sendJson(response, 400, { error: 'Missing input.' });
|
|
70
153
|
return;
|
|
71
154
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
session._onRuntimeError?.(err);
|
|
75
|
-
})
|
|
76
|
-
.finally(() => {
|
|
77
|
-
running = false;
|
|
78
|
-
currentAbortController = null;
|
|
79
|
-
});
|
|
80
|
-
sendJson(response, 202, { accepted: true });
|
|
155
|
+
const accepted = startRuntimeRun(context, body);
|
|
156
|
+
sendJson(response, 202, accepted);
|
|
81
157
|
} catch (err) {
|
|
82
|
-
running = false;
|
|
83
|
-
currentAbortController = null;
|
|
158
|
+
context.running = false;
|
|
159
|
+
context.currentAbortController = null;
|
|
84
160
|
throw err;
|
|
85
161
|
}
|
|
86
162
|
return;
|
|
87
163
|
}
|
|
88
164
|
if (request.method === 'POST' && url.pathname === '/cancel') {
|
|
89
|
-
|
|
165
|
+
const workspace = workspaceFromUrl(url);
|
|
166
|
+
const context = await resolveContext({ workspace });
|
|
167
|
+
if (!context.running || !context.currentAbortController) {
|
|
90
168
|
sendJson(response, 200, { cancelled: false, reason: 'no active run' });
|
|
91
169
|
return;
|
|
92
170
|
}
|
|
93
|
-
currentAbortController.abort();
|
|
94
|
-
await cancel?.();
|
|
95
|
-
sendJson(response, 202, { cancelled: true });
|
|
171
|
+
context.currentAbortController.abort();
|
|
172
|
+
await cancel?.(context);
|
|
173
|
+
sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (request.method === 'POST' && url.pathname === '/resume') {
|
|
177
|
+
const workspace = workspaceFromUrl(url);
|
|
178
|
+
const result = await resume?.({ workspace });
|
|
179
|
+
sendJson(response, 202, result ?? { resumed: false, workspace: workspace ?? null });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (request.method === 'POST' && url.pathname === '/approve') {
|
|
183
|
+
const body = await readJson(request);
|
|
184
|
+
const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
|
|
185
|
+
const result = await approve?.({
|
|
186
|
+
workspace,
|
|
187
|
+
runId: url.searchParams.get('runId') ?? body.runId ?? null,
|
|
188
|
+
itemId: url.searchParams.get('itemId') ?? body.itemId ?? null,
|
|
189
|
+
approvalId: url.searchParams.get('approvalId') ?? body.approvalId ?? null,
|
|
190
|
+
});
|
|
191
|
+
sendJson(response, result?.approved ? 202 : 404, result ?? { approved: false });
|
|
96
192
|
return;
|
|
97
193
|
}
|
|
98
194
|
|
|
@@ -111,14 +207,137 @@ export function startRuntimeServer({
|
|
|
111
207
|
host,
|
|
112
208
|
port: typeof address === 'object' && address ? address.port : port,
|
|
113
209
|
publish,
|
|
210
|
+
drainControl: (context) => startNextControlRequest(context),
|
|
114
211
|
close: () => new Promise((closeResolve, closeReject) => {
|
|
115
|
-
for (const
|
|
212
|
+
for (const client of clients) client.response.end();
|
|
116
213
|
clients.clear();
|
|
117
214
|
server.close((err) => (err ? closeReject(err) : closeResolve()));
|
|
118
215
|
}),
|
|
119
216
|
});
|
|
120
217
|
});
|
|
121
218
|
});
|
|
219
|
+
|
|
220
|
+
async function resolveContext({ workspace = null } = {}) {
|
|
221
|
+
return resolvedGetContext(workspace);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Shared by POST handlers that take a JSON body carrying an optional
|
|
225
|
+
// `workspace` field: read the body, resolve the target workspace (body
|
|
226
|
+
// wins over the `?workspace=` query param), then resolve its context.
|
|
227
|
+
async function resolveBodyContext(request, url) {
|
|
228
|
+
const body = await readJson(request);
|
|
229
|
+
const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
|
|
230
|
+
const context = await resolveContext({ workspace });
|
|
231
|
+
return { body, workspace, context };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function startRuntimeRun(context, body, { controlItemId = null } = {}) {
|
|
235
|
+
const runId = randomUUID();
|
|
236
|
+
const runWorkspace = context.workspace ?? body.workspace ?? null;
|
|
237
|
+
context.running = true;
|
|
238
|
+
context.currentAbortController = new AbortController();
|
|
239
|
+
const runBody = { ...body, workspace: runWorkspace, runId };
|
|
240
|
+
if (controlItemId) {
|
|
241
|
+
dispatchAgentEvent(context.session, createAgentEvent('control_started', {
|
|
242
|
+
origin: 'runtime',
|
|
243
|
+
runId,
|
|
244
|
+
workspace: runWorkspace,
|
|
245
|
+
payload: { id: controlItemId, runId },
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
|
|
249
|
+
runPromise
|
|
250
|
+
.catch((err) => {
|
|
251
|
+
context.session?._onRuntimeError?.(err);
|
|
252
|
+
})
|
|
253
|
+
.finally(() => {
|
|
254
|
+
context.running = false;
|
|
255
|
+
context.currentAbortController = null;
|
|
256
|
+
void startNextControlRequest(context);
|
|
257
|
+
});
|
|
258
|
+
return { accepted: true, runId, workspace: runWorkspace };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function startNextControlRequest(context) {
|
|
262
|
+
if (!context?.session || context.running) return false;
|
|
263
|
+
const item = controlQueueFor(context.session).find((entry) => entry.status === 'queued');
|
|
264
|
+
if (!item) return false;
|
|
265
|
+
startRuntimeRun(context, {
|
|
266
|
+
input: item.input,
|
|
267
|
+
workspace: item.workspace ?? context.workspace ?? null,
|
|
268
|
+
}, { controlItemId: item.id });
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function workspaceFromUrl(url) {
|
|
274
|
+
const workspace = url.searchParams.get('workspace');
|
|
275
|
+
return workspace ? workspace.trim() || null : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function workspaceFromBody(body) {
|
|
279
|
+
const workspace = body?.workspace;
|
|
280
|
+
return workspace == null ? null : String(workspace).trim() || null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function controlStatus(context, store) {
|
|
284
|
+
const workspace = context?.workspace ?? context?.session?.workspace ?? null;
|
|
285
|
+
const state = store.getState(context?.session ?? null, { workspace });
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
workspace,
|
|
289
|
+
status: context?.running ? 'running' : state.status ?? 'idle',
|
|
290
|
+
running: Boolean(context?.running),
|
|
291
|
+
plan: Array.isArray(state.plan) ? state.plan : [],
|
|
292
|
+
queue: Array.isArray(state.queue) ? state.queue : [],
|
|
293
|
+
controlQueue: controlQueueFor(context?.session),
|
|
294
|
+
approvals: Array.isArray(state.approvals) ? state.approvals : [],
|
|
295
|
+
summary: state.summary ?? null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function explainControlState(status) {
|
|
300
|
+
if (status.running) {
|
|
301
|
+
const runningStep = status.plan.find((step) => step.status === 'running');
|
|
302
|
+
return runningStep
|
|
303
|
+
? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.`
|
|
304
|
+
: 'Runtime run is active. No current plan step is available yet.';
|
|
305
|
+
}
|
|
306
|
+
const pendingApproval = status.approvals.find((approval) => approval.status === 'pending_approval');
|
|
307
|
+
if (pendingApproval) {
|
|
308
|
+
return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
|
|
309
|
+
}
|
|
310
|
+
const queued = status.controlQueue.filter((item) => item.status === 'queued');
|
|
311
|
+
if (queued.length > 0) {
|
|
312
|
+
return `${queued.length} control request${queued.length === 1 ? '' : 's'} queued. They are not applied to the active plan automatically.`;
|
|
313
|
+
}
|
|
314
|
+
if (status.plan.some((step) => step.status === 'pending')) {
|
|
315
|
+
return 'Runtime is idle with pending plan steps visible from the last run.';
|
|
316
|
+
}
|
|
317
|
+
return 'Runtime is idle.';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function controlQueueFor(session) {
|
|
321
|
+
return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function enqueueControlRequest(context, input) {
|
|
325
|
+
const now = new Date().toISOString();
|
|
326
|
+
const item = {
|
|
327
|
+
id: `control-${randomUUID()}`,
|
|
328
|
+
workspace: context?.workspace ?? context?.session?.workspace ?? null,
|
|
329
|
+
type: 'run_request',
|
|
330
|
+
input,
|
|
331
|
+
status: 'queued',
|
|
332
|
+
createdAt: now,
|
|
333
|
+
updatedAt: now,
|
|
334
|
+
};
|
|
335
|
+
dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
|
|
336
|
+
origin: 'runtime',
|
|
337
|
+
workspace: item.workspace,
|
|
338
|
+
payload: item,
|
|
339
|
+
}));
|
|
340
|
+
return item;
|
|
122
341
|
}
|
|
123
342
|
|
|
124
343
|
function isAuthorized(request, token) {
|