@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.
Files changed (39) hide show
  1. package/.env.example +20 -0
  2. package/README.md +50 -1
  3. package/docker-compose.yml +1 -23
  4. package/mcp.endpoints.example.json +13 -0
  5. package/package.json +2 -2
  6. package/src/agent/graph.js +101 -15
  7. package/src/agent/graph.test.js +145 -0
  8. package/src/cli/wiki-manager.js +306 -53
  9. package/src/commands/slash.js +4 -24
  10. package/src/core/agentEvents.js +169 -4
  11. package/src/core/agentEvents.test.js +176 -4
  12. package/src/core/agentLoop.js +3 -0
  13. package/src/core/compose.js +1 -2
  14. package/src/core/dockerCompose.test.js +5 -5
  15. package/src/core/jobQueue.js +29 -12
  16. package/src/core/mcp.js +120 -10
  17. package/src/core/mcp.test.js +121 -1
  18. package/src/core/plan.js +33 -0
  19. package/src/core/queueStore.test.js +1 -0
  20. package/src/core/sessionConfig.js +24 -0
  21. package/src/core/wikiWorkspace.test.js +24 -0
  22. package/src/runtime/approvals.js +113 -0
  23. package/src/runtime/auth.test.js +8 -0
  24. package/src/runtime/client.js +52 -6
  25. package/src/runtime/lifecycle.js +27 -3
  26. package/src/runtime/queueStore.js +3 -3
  27. package/src/runtime/runner.js +340 -0
  28. package/src/runtime/runner.test.js +270 -0
  29. package/src/runtime/server.js +252 -33
  30. package/src/runtime/server.test.js +577 -0
  31. package/src/runtime/store.js +181 -39
  32. package/src/runtime/store.test.js +363 -4
  33. package/src/runtime/supervisor.js +6 -0
  34. package/src/runtime/supervisor.test.js +141 -0
  35. package/src/shell/RightPane.tsx +1 -1
  36. package/src/shell/repl.js +22 -6
  37. package/src/shell/useAgent.ts +1 -1
  38. package/src/shell/useSession.ts +10 -5
  39. package/wiki-workspace +198 -4
@@ -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
- let running = false;
15
- let currentAbortController = null;
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 response of clients) {
20
- response.write(payload);
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
- sendJson(response, 200, { ok: true, status: running ? 'running' : 'idle', dbPath: store.dbPath });
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
- sendJson(response, 200, store.getState(session));
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
- sendJson(response, 200, { events: store.listEvents() });
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
- clients.add(response);
53
- request.on('close', () => clients.delete(response));
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
- if (running) {
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
- run(body, { signal: currentAbortController.signal })
73
- .catch((err) => {
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
- if (!running || !currentAbortController) {
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 response of clients) response.end();
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) {