@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.
Files changed (48) hide show
  1. package/.env.example +20 -0
  2. package/README.md +35 -1
  3. package/bin/wiki-manager.js +2 -2
  4. package/docker-compose.yml +2 -0
  5. package/mcp.endpoints.example.json +13 -0
  6. package/package.json +2 -2
  7. package/src/agent/graph.js +101 -15
  8. package/src/agent/graph.test.js +145 -0
  9. package/src/cli/wiki-manager.js +459 -131
  10. package/src/commands/slash.js +1 -1
  11. package/src/core/activity.js +14 -0
  12. package/src/core/agentEvents.js +148 -6
  13. package/src/core/agentEvents.test.js +145 -4
  14. package/src/core/agentLoop.js +167 -0
  15. package/src/core/agentLoop.test.js +85 -0
  16. package/src/core/cacert.js +4 -3
  17. package/src/core/dockerCompose.test.js +14 -0
  18. package/src/core/documentIntake.test.js +7 -7
  19. package/src/core/env.js +6 -0
  20. package/src/core/jobQueue.js +47 -16
  21. package/src/core/mcp.js +120 -10
  22. package/src/core/mcp.test.js +121 -1
  23. package/src/core/plan.js +33 -0
  24. package/src/core/queueStore.js +27 -0
  25. package/src/core/queueStore.test.js +32 -0
  26. package/src/core/wikiWorkspace.test.js +24 -0
  27. package/src/core/workspaces.js +8 -1
  28. package/src/runtime/approvals.js +113 -0
  29. package/src/runtime/auth.js +35 -0
  30. package/src/runtime/auth.test.js +29 -0
  31. package/src/runtime/client.js +154 -0
  32. package/src/runtime/lifecycle.js +84 -0
  33. package/src/runtime/queueStore.js +6 -0
  34. package/src/runtime/runner.js +413 -0
  35. package/src/runtime/runner.test.js +270 -0
  36. package/src/runtime/server.js +216 -0
  37. package/src/runtime/server.test.js +308 -0
  38. package/src/runtime/store.js +431 -0
  39. package/src/runtime/store.test.js +437 -0
  40. package/src/runtime/supervisor.js +104 -0
  41. package/src/runtime/supervisor.test.js +240 -0
  42. package/src/shell/RightPane.tsx +1 -1
  43. package/src/shell/repl.js +148 -18
  44. package/src/shell/repl.test.js +38 -0
  45. package/src/shell/tui.tsx +4 -1
  46. package/src/shell/useAgent.ts +20 -2
  47. package/src/shell/useSession.ts +153 -13
  48. package/wiki-workspace +221 -22
@@ -0,0 +1,216 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { runtimeTokenFromEnv } from './auth.js';
4
+
5
+ export function startRuntimeServer({
6
+ host = '0.0.0.0',
7
+ port = 7788,
8
+ token = runtimeTokenFromEnv(),
9
+ store,
10
+ session = null,
11
+ getContext,
12
+ run,
13
+ cancel,
14
+ resume,
15
+ approve,
16
+ } = {}) {
17
+ const clients = new Set();
18
+ const defaultContext = { workspace: null, session, running: false, currentAbortController: null };
19
+ const resolvedGetContext = getContext ?? (() => defaultContext);
20
+
21
+ function publish(event) {
22
+ const payload = `event: agent_event\ndata: ${JSON.stringify(event)}\n\n`;
23
+ for (const client of clients) {
24
+ if (client.workspace && event.workspace !== client.workspace) continue;
25
+ client.response.write(payload);
26
+ }
27
+ }
28
+
29
+ const server = createServer(async (request, response) => {
30
+ try {
31
+ if (!isAuthorized(request, token)) {
32
+ sendJson(response, 401, { error: 'Unauthorized' });
33
+ return;
34
+ }
35
+
36
+ const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
37
+ if (request.method === 'GET' && url.pathname === '/health') {
38
+ const workspace = workspaceFromUrl(url);
39
+ const context = workspace ? await resolveContext({ workspace }) : null;
40
+ sendJson(response, 200, {
41
+ ok: true,
42
+ status: context?.running ? 'running' : 'idle',
43
+ workspace: context?.workspace ?? workspace ?? null,
44
+ dbPath: store.dbPath,
45
+ });
46
+ return;
47
+ }
48
+ if (request.method === 'GET' && url.pathname === '/state') {
49
+ const workspace = workspaceFromUrl(url);
50
+ const context = workspace ? await resolveContext({ workspace }) : null;
51
+ sendJson(response, 200, store.getState(context?.session ?? session, { workspace }));
52
+ return;
53
+ }
54
+ if (request.method === 'GET' && url.pathname === '/events') {
55
+ const workspace = workspaceFromUrl(url);
56
+ sendJson(response, 200, { events: store.listEvents({ workspace }) });
57
+ return;
58
+ }
59
+ if (request.method === 'GET' && url.pathname === '/events/stream') {
60
+ const workspace = workspaceFromUrl(url);
61
+ const context = workspace ? await resolveContext({ workspace }) : null;
62
+ response.writeHead(200, {
63
+ 'Content-Type': 'text/event-stream',
64
+ 'Cache-Control': 'no-cache',
65
+ Connection: 'keep-alive',
66
+ 'X-Accel-Buffering': 'no',
67
+ });
68
+ response.write(`event: state\ndata: ${JSON.stringify(store.getState(context?.session ?? session, { workspace }))}\n\n`);
69
+ const client = { response, workspace };
70
+ clients.add(client);
71
+ request.on('close', () => clients.delete(client));
72
+ return;
73
+ }
74
+ if (request.method === 'POST' && url.pathname === '/run') {
75
+ const body = await readJson(request);
76
+ const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
77
+ const context = await resolveContext({ workspace });
78
+ if (context.running) {
79
+ sendJson(response, 409, { error: 'A runtime run is already active.' });
80
+ return;
81
+ }
82
+ try {
83
+ const input = String(body.input ?? body.prompt ?? '').trim();
84
+ if (!input) {
85
+ sendJson(response, 400, { error: 'Missing input.' });
86
+ return;
87
+ }
88
+ const runId = randomUUID();
89
+ const runWorkspace = context.workspace ?? workspace ?? null;
90
+ context.running = true;
91
+ context.currentAbortController = new AbortController();
92
+ const runBody = { ...body, workspace: runWorkspace, runId };
93
+ const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
94
+ runPromise
95
+ .catch((err) => {
96
+ context.session?._onRuntimeError?.(err);
97
+ })
98
+ .finally(() => {
99
+ context.running = false;
100
+ context.currentAbortController = null;
101
+ });
102
+ sendJson(response, 202, { accepted: true, runId, workspace: runWorkspace });
103
+ } catch (err) {
104
+ context.running = false;
105
+ context.currentAbortController = null;
106
+ throw err;
107
+ }
108
+ return;
109
+ }
110
+ if (request.method === 'POST' && url.pathname === '/cancel') {
111
+ const workspace = workspaceFromUrl(url);
112
+ const context = await resolveContext({ workspace });
113
+ if (!context.running || !context.currentAbortController) {
114
+ sendJson(response, 200, { cancelled: false, reason: 'no active run' });
115
+ return;
116
+ }
117
+ context.currentAbortController.abort();
118
+ await cancel?.(context);
119
+ sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
120
+ return;
121
+ }
122
+ if (request.method === 'POST' && url.pathname === '/resume') {
123
+ const workspace = workspaceFromUrl(url);
124
+ const result = await resume?.({ workspace });
125
+ sendJson(response, 202, result ?? { resumed: false, workspace: workspace ?? null });
126
+ return;
127
+ }
128
+ if (request.method === 'POST' && url.pathname === '/approve') {
129
+ const body = await readJson(request);
130
+ const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
131
+ const result = await approve?.({
132
+ workspace,
133
+ runId: url.searchParams.get('runId') ?? body.runId ?? null,
134
+ itemId: url.searchParams.get('itemId') ?? body.itemId ?? null,
135
+ approvalId: url.searchParams.get('approvalId') ?? body.approvalId ?? null,
136
+ });
137
+ sendJson(response, result?.approved ? 202 : 404, result ?? { approved: false });
138
+ return;
139
+ }
140
+
141
+ sendJson(response, 404, { error: 'Not found' });
142
+ } catch (err) {
143
+ sendJson(response, 500, { error: err instanceof Error ? err.message : String(err) });
144
+ }
145
+ });
146
+
147
+ return new Promise((resolve, reject) => {
148
+ server.once('error', reject);
149
+ server.listen(port, host, () => {
150
+ server.off('error', reject);
151
+ const address = server.address();
152
+ resolve({
153
+ host,
154
+ port: typeof address === 'object' && address ? address.port : port,
155
+ publish,
156
+ close: () => new Promise((closeResolve, closeReject) => {
157
+ for (const client of clients) client.response.end();
158
+ clients.clear();
159
+ server.close((err) => (err ? closeReject(err) : closeResolve()));
160
+ }),
161
+ });
162
+ });
163
+ });
164
+
165
+ async function resolveContext({ workspace = null } = {}) {
166
+ return resolvedGetContext(workspace);
167
+ }
168
+ }
169
+
170
+ function workspaceFromUrl(url) {
171
+ const workspace = url.searchParams.get('workspace');
172
+ return workspace ? workspace.trim() || null : null;
173
+ }
174
+
175
+ function workspaceFromBody(body) {
176
+ const workspace = body?.workspace;
177
+ return workspace == null ? null : String(workspace).trim() || null;
178
+ }
179
+
180
+ function isAuthorized(request, token) {
181
+ if (!token) return true;
182
+ const authorization = request.headers.authorization ?? '';
183
+ if (authorization === `Bearer ${token}`) return true;
184
+ return request.headers['x-runtime-token'] === token;
185
+ }
186
+
187
+ function sendJson(response, statusCode, value) {
188
+ response.writeHead(statusCode, { 'Content-Type': 'application/json' });
189
+ response.end(`${JSON.stringify(value)}\n`);
190
+ }
191
+
192
+ function readJson(request) {
193
+ return new Promise((resolve, reject) => {
194
+ let raw = '';
195
+ request.setEncoding('utf8');
196
+ request.on('data', (chunk) => {
197
+ raw += chunk;
198
+ if (raw.length > 1024 * 1024) {
199
+ reject(new Error('Request body too large.'));
200
+ request.destroy();
201
+ }
202
+ });
203
+ request.on('end', () => {
204
+ if (!raw.trim()) {
205
+ resolve({});
206
+ return;
207
+ }
208
+ try {
209
+ resolve(JSON.parse(raw));
210
+ } catch (err) {
211
+ reject(err);
212
+ }
213
+ });
214
+ request.on('error', reject);
215
+ });
216
+ }
@@ -0,0 +1,308 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { startRuntimeServer } from './server.js';
4
+
5
+ test('runtime server accepts only one active run', async (t) => {
6
+ let releaseRun;
7
+ let runCount = 0;
8
+ let handle;
9
+ try {
10
+ handle = await startRuntimeServer({
11
+ host: '127.0.0.1',
12
+ port: 0,
13
+ store: {
14
+ dbPath: ':memory:',
15
+ getState: () => ({ status: 'idle' }),
16
+ listEvents: () => [],
17
+ },
18
+ session: {},
19
+ run: async () => {
20
+ runCount += 1;
21
+ await new Promise((resolve) => { releaseRun = resolve; });
22
+ },
23
+ });
24
+ } catch (err) {
25
+ if (err?.code === 'EPERM') {
26
+ t.skip('network listen is not permitted in this sandbox');
27
+ return;
28
+ }
29
+ throw err;
30
+ }
31
+
32
+ try {
33
+ const url = `http://127.0.0.1:${handle.port}/run`;
34
+ let acceptedRun = null;
35
+ const [first, second] = await Promise.all([
36
+ fetch(url, {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ input: 'first' }),
40
+ }),
41
+ fetch(url, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ input: 'second' }),
45
+ }),
46
+ ]);
47
+
48
+ assert.deepEqual([first.status, second.status].sort(), [202, 409]);
49
+ const accepted = first.status === 202 ? first : second;
50
+ acceptedRun = await accepted.json();
51
+ assert.equal(acceptedRun.accepted, true);
52
+ assert.match(acceptedRun.runId, /^[0-9a-f-]{36}$/);
53
+ assert.equal(runCount, 1);
54
+ } finally {
55
+ releaseRun?.();
56
+ await handle.close();
57
+ }
58
+ });
59
+
60
+ test('runtime server returns the accepted run id and passes it to the runner', async (t) => {
61
+ let receivedBody = null;
62
+ let handle;
63
+ try {
64
+ handle = await startRuntimeServer({
65
+ host: '127.0.0.1',
66
+ port: 0,
67
+ store: {
68
+ dbPath: ':memory:',
69
+ getState: () => ({ status: 'idle' }),
70
+ listEvents: () => [],
71
+ },
72
+ session: {},
73
+ run: async (context, body) => {
74
+ receivedBody = body;
75
+ },
76
+ });
77
+ } catch (err) {
78
+ if (err?.code === 'EPERM') {
79
+ t.skip('network listen is not permitted in this sandbox');
80
+ return;
81
+ }
82
+ throw err;
83
+ }
84
+
85
+ try {
86
+ const response = await fetch(`http://127.0.0.1:${handle.port}/run`, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({ input: 'build', workspace: 'juno', evaluate: false, replans: 1 }),
90
+ });
91
+ assert.equal(response.status, 202);
92
+ const body = await response.json();
93
+ assert.equal(body.accepted, true);
94
+ assert.match(body.runId, /^[0-9a-f-]{36}$/);
95
+ await new Promise((resolve) => setImmediate(resolve));
96
+ assert.equal(receivedBody.runId, body.runId);
97
+ assert.equal(receivedBody.workspace, 'juno');
98
+ assert.equal(receivedBody.evaluate, false);
99
+ assert.equal(receivedBody.replans, 1);
100
+ } finally {
101
+ await handle.close();
102
+ }
103
+ });
104
+
105
+ test('runtime server isolates active runs by workspace', async (t) => {
106
+ const releases = new Map();
107
+ const runWorkspaces = [];
108
+ const contexts = new Map();
109
+ let handle;
110
+ try {
111
+ handle = await startRuntimeServer({
112
+ host: '127.0.0.1',
113
+ port: 0,
114
+ store: {
115
+ dbPath: ':memory:',
116
+ getState: (session) => ({ status: session?.running ? 'running' : 'idle' }),
117
+ listEvents: () => [],
118
+ },
119
+ getContext: async (workspace) => {
120
+ if (!contexts.has(workspace)) {
121
+ contexts.set(workspace, {
122
+ workspace,
123
+ session: { workspace },
124
+ running: false,
125
+ currentAbortController: null,
126
+ });
127
+ }
128
+ return contexts.get(workspace);
129
+ },
130
+ run: async (context) => {
131
+ runWorkspaces.push(context.workspace);
132
+ context.session.running = true;
133
+ await new Promise((resolve) => { releases.set(context.workspace, resolve); });
134
+ context.session.running = false;
135
+ },
136
+ });
137
+ } catch (err) {
138
+ if (err?.code === 'EPERM') {
139
+ t.skip('network listen is not permitted in this sandbox');
140
+ return;
141
+ }
142
+ throw err;
143
+ }
144
+
145
+ try {
146
+ const url = `http://127.0.0.1:${handle.port}/run`;
147
+ const [juno, docs] = await Promise.all([
148
+ fetch(`${url}?workspace=juno`, {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ body: JSON.stringify({ input: 'first' }),
152
+ }),
153
+ fetch(`${url}?workspace=docs`, {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json' },
156
+ body: JSON.stringify({ input: 'second' }),
157
+ }),
158
+ ]);
159
+ assert.deepEqual([juno.status, docs.status], [202, 202]);
160
+
161
+ const conflict = await fetch(`${url}?workspace=juno`, {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify({ input: 'third' }),
165
+ });
166
+ assert.equal(conflict.status, 409);
167
+ assert.deepEqual(runWorkspaces.sort(), ['docs', 'juno']);
168
+ } finally {
169
+ releases.get('juno')?.();
170
+ releases.get('docs')?.();
171
+ await handle.close();
172
+ }
173
+ });
174
+
175
+ test('runtime server filters state and events by workspace', async (t) => {
176
+ let stateWorkspace = null;
177
+ let eventWorkspace = null;
178
+ let handle;
179
+ try {
180
+ handle = await startRuntimeServer({
181
+ host: '127.0.0.1',
182
+ port: 0,
183
+ store: {
184
+ dbPath: ':memory:',
185
+ getState: (_session, options) => {
186
+ stateWorkspace = options.workspace;
187
+ return { status: 'idle', workspace: options.workspace };
188
+ },
189
+ listEvents: (options) => {
190
+ eventWorkspace = options.workspace;
191
+ return [{ id: 'e1', workspace: options.workspace }];
192
+ },
193
+ },
194
+ getContext: async (workspace) => ({
195
+ workspace,
196
+ session: { workspace },
197
+ running: false,
198
+ currentAbortController: null,
199
+ }),
200
+ run: async () => {},
201
+ });
202
+ } catch (err) {
203
+ if (err?.code === 'EPERM') {
204
+ t.skip('network listen is not permitted in this sandbox');
205
+ return;
206
+ }
207
+ throw err;
208
+ }
209
+
210
+ try {
211
+ const state = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=juno`);
212
+ assert.equal(state.status, 200);
213
+ assert.equal((await state.json()).workspace, 'juno');
214
+ assert.equal(stateWorkspace, 'juno');
215
+
216
+ const events = await fetch(`http://127.0.0.1:${handle.port}/events?workspace=docs`);
217
+ assert.equal(events.status, 200);
218
+ assert.deepEqual(await events.json(), { events: [{ id: 'e1', workspace: 'docs' }] });
219
+ assert.equal(eventWorkspace, 'docs');
220
+ } finally {
221
+ await handle.close();
222
+ }
223
+ });
224
+
225
+ test('runtime server exposes manual resume endpoint', async (t) => {
226
+ let resumedWorkspace = null;
227
+ let handle;
228
+ try {
229
+ handle = await startRuntimeServer({
230
+ host: '127.0.0.1',
231
+ port: 0,
232
+ store: {
233
+ dbPath: ':memory:',
234
+ getState: () => ({ status: 'idle' }),
235
+ listEvents: () => [],
236
+ },
237
+ run: async () => {},
238
+ resume: async ({ workspace }) => {
239
+ resumedWorkspace = workspace;
240
+ return { resumed: 1, interrupted: 0, workspaces: [{ workspace, resumed: true }] };
241
+ },
242
+ });
243
+ } catch (err) {
244
+ if (err?.code === 'EPERM') {
245
+ t.skip('network listen is not permitted in this sandbox');
246
+ return;
247
+ }
248
+ throw err;
249
+ }
250
+
251
+ try {
252
+ const response = await fetch(`http://127.0.0.1:${handle.port}/resume?workspace=juno`, {
253
+ method: 'POST',
254
+ });
255
+ assert.equal(response.status, 202);
256
+ assert.equal(resumedWorkspace, 'juno');
257
+ assert.deepEqual(await response.json(), {
258
+ resumed: 1,
259
+ interrupted: 0,
260
+ workspaces: [{ workspace: 'juno', resumed: true }],
261
+ });
262
+ } finally {
263
+ await handle.close();
264
+ }
265
+ });
266
+
267
+ test('runtime server exposes approval endpoint', async (t) => {
268
+ let approved = null;
269
+ let handle;
270
+ try {
271
+ handle = await startRuntimeServer({
272
+ host: '127.0.0.1',
273
+ port: 0,
274
+ store: {
275
+ dbPath: ':memory:',
276
+ getState: () => ({ status: 'idle' }),
277
+ listEvents: () => [],
278
+ },
279
+ run: async () => {},
280
+ approve: async (request) => {
281
+ approved = request;
282
+ return { approved: true, runId: request.runId, itemId: request.itemId };
283
+ },
284
+ });
285
+ } catch (err) {
286
+ if (err?.code === 'EPERM') {
287
+ t.skip('network listen is not permitted in this sandbox');
288
+ return;
289
+ }
290
+ throw err;
291
+ }
292
+
293
+ try {
294
+ const response = await fetch(`http://127.0.0.1:${handle.port}/approve?workspace=juno&runId=run-1&itemId=item-1`, {
295
+ method: 'POST',
296
+ });
297
+ assert.equal(response.status, 202);
298
+ assert.deepEqual(approved, {
299
+ workspace: 'juno',
300
+ runId: 'run-1',
301
+ itemId: 'item-1',
302
+ approvalId: null,
303
+ });
304
+ assert.deepEqual(await response.json(), { approved: true, runId: 'run-1', itemId: 'item-1' });
305
+ } finally {
306
+ await handle.close();
307
+ }
308
+ });