@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
@@ -31,6 +31,7 @@ test('runtime server accepts only one active run', async (t) => {
31
31
 
32
32
  try {
33
33
  const url = `http://127.0.0.1:${handle.port}/run`;
34
+ let acceptedRun = null;
34
35
  const [first, second] = await Promise.all([
35
36
  fetch(url, {
36
37
  method: 'POST',
@@ -45,9 +46,585 @@ test('runtime server accepts only one active run', async (t) => {
45
46
  ]);
46
47
 
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}$/);
48
53
  assert.equal(runCount, 1);
49
54
  } finally {
50
55
  releaseRun?.();
51
56
  await handle.close();
52
57
  }
53
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
+ });
309
+
310
+ test('runtime server exposes control status and explanation', async (t) => {
311
+ const session = {
312
+ workspace: 'juno',
313
+ controlQueue: [{ id: 'control-1', workspace: 'juno', status: 'queued', input: 'later' }],
314
+ };
315
+ let handle;
316
+ try {
317
+ handle = await startRuntimeServer({
318
+ host: '127.0.0.1',
319
+ port: 0,
320
+ store: {
321
+ dbPath: ':memory:',
322
+ getState: () => ({
323
+ status: 'idle',
324
+ plan: [{ step: 1, description: 'Check status', status: 'pending' }],
325
+ queue: [],
326
+ controlQueue: session.controlQueue,
327
+ approvals: [],
328
+ summary: null,
329
+ }),
330
+ listEvents: () => [],
331
+ },
332
+ getContext: async () => ({
333
+ workspace: 'juno',
334
+ session,
335
+ running: false,
336
+ currentAbortController: null,
337
+ }),
338
+ run: async () => {},
339
+ });
340
+ } catch (err) {
341
+ if (err?.code === 'EPERM') {
342
+ t.skip('network listen is not permitted in this sandbox');
343
+ return;
344
+ }
345
+ throw err;
346
+ }
347
+
348
+ try {
349
+ const status = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`);
350
+ assert.equal(status.status, 200);
351
+ const statusBody = await status.json();
352
+ assert.equal(statusBody.status, 'idle');
353
+ assert.equal(statusBody.running, false);
354
+ assert.equal(statusBody.controlQueue[0].id, 'control-1');
355
+
356
+ const explain = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
357
+ method: 'POST',
358
+ headers: { 'Content-Type': 'application/json' },
359
+ body: JSON.stringify({ action: 'explain' }),
360
+ });
361
+ assert.equal(explain.status, 200);
362
+ assert.match((await explain.json()).explanation, /control request/);
363
+ } finally {
364
+ await handle.close();
365
+ }
366
+ });
367
+
368
+ test('runtime server control enqueue emits events but does not patch an active plan or start a run', async (t) => {
369
+ const session = {
370
+ workspace: 'juno',
371
+ controlQueue: [],
372
+ };
373
+ const events = [];
374
+ session._onAgentEvent = (event) => events.push(event);
375
+ let runCount = 0;
376
+ let handle;
377
+ try {
378
+ handle = await startRuntimeServer({
379
+ host: '127.0.0.1',
380
+ port: 0,
381
+ store: {
382
+ dbPath: ':memory:',
383
+ getState: () => ({
384
+ status: 'running',
385
+ plan: [{ step: 1, description: 'Active step', status: 'running' }],
386
+ queue: [],
387
+ controlQueue: session.controlQueue,
388
+ approvals: [],
389
+ summary: null,
390
+ }),
391
+ listEvents: () => [],
392
+ },
393
+ getContext: async () => ({
394
+ workspace: 'juno',
395
+ session,
396
+ running: true,
397
+ currentAbortController: new AbortController(),
398
+ }),
399
+ run: async () => { runCount += 1; },
400
+ });
401
+ } catch (err) {
402
+ if (err?.code === 'EPERM') {
403
+ t.skip('network listen is not permitted in this sandbox');
404
+ return;
405
+ }
406
+ throw err;
407
+ }
408
+
409
+ try {
410
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
411
+ method: 'POST',
412
+ headers: { 'Content-Type': 'application/json' },
413
+ body: JSON.stringify({ action: 'enqueue', input: 'run this after current work' }),
414
+ });
415
+ assert.equal(response.status, 202);
416
+ const body = await response.json();
417
+ assert.equal(body.accepted, true);
418
+ assert.equal(body.item.status, 'queued');
419
+ assert.equal(body.controlQueue.length, 1);
420
+ assert.equal(body.plan[0].description, 'Active step');
421
+ assert.equal(session.controlQueue[0].input, 'run this after current work');
422
+ assert.equal(events.filter((event) => event.type === 'control_enqueued').length, 1);
423
+ assert.equal(runCount, 0);
424
+ } finally {
425
+ await handle.close();
426
+ }
427
+ });
428
+
429
+ test('runtime server drains queued control requests when idle', async (t) => {
430
+ const session = {
431
+ workspace: 'juno',
432
+ controlQueue: [],
433
+ };
434
+ const events = [];
435
+ session._onAgentEvent = (event) => events.push(event);
436
+ let receivedBody = null;
437
+ let handle;
438
+ try {
439
+ handle = await startRuntimeServer({
440
+ host: '127.0.0.1',
441
+ port: 0,
442
+ store: {
443
+ dbPath: ':memory:',
444
+ getState: () => ({
445
+ status: 'idle',
446
+ plan: [],
447
+ queue: [],
448
+ controlQueue: session.controlQueue,
449
+ approvals: [],
450
+ summary: null,
451
+ }),
452
+ listEvents: () => [],
453
+ },
454
+ getContext: async () => ({
455
+ workspace: 'juno',
456
+ session,
457
+ running: false,
458
+ currentAbortController: null,
459
+ }),
460
+ run: async (_context, body) => {
461
+ receivedBody = body;
462
+ },
463
+ });
464
+ } catch (err) {
465
+ if (err?.code === 'EPERM') {
466
+ t.skip('network listen is not permitted in this sandbox');
467
+ return;
468
+ }
469
+ throw err;
470
+ }
471
+
472
+ try {
473
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
474
+ method: 'POST',
475
+ headers: { 'Content-Type': 'application/json' },
476
+ body: JSON.stringify({ action: 'enqueue', input: 'run from control queue' }),
477
+ });
478
+ assert.equal(response.status, 202);
479
+ await new Promise((resolve) => setImmediate(resolve));
480
+ assert.equal(receivedBody.input, 'run from control queue');
481
+ assert.equal(receivedBody.workspace, 'juno');
482
+ assert.match(receivedBody.runId, /^[0-9a-f-]{36}$/);
483
+ assert.equal(session.controlQueue[0].status, 'running');
484
+ assert.equal(session.controlQueue[0].runId, receivedBody.runId);
485
+ assert.deepEqual(events.map((event) => event.type), ['control_enqueued', 'control_started']);
486
+ } finally {
487
+ await handle.close();
488
+ }
489
+ });
490
+
491
+ test('runtime server handle drains a pre-existing hydrated control request', async (t) => {
492
+ const session = {
493
+ workspace: 'juno',
494
+ controlQueue: [{ id: 'control-existing', workspace: 'juno', status: 'queued', input: 'resume queued control' }],
495
+ };
496
+ const events = [];
497
+ session._onAgentEvent = (event) => events.push(event);
498
+ const context = {
499
+ workspace: 'juno',
500
+ session,
501
+ running: false,
502
+ currentAbortController: null,
503
+ };
504
+ let receivedBody = null;
505
+ let handle;
506
+ try {
507
+ handle = await startRuntimeServer({
508
+ host: '127.0.0.1',
509
+ port: 0,
510
+ store: {
511
+ dbPath: ':memory:',
512
+ getState: () => ({ status: 'idle' }),
513
+ listEvents: () => [],
514
+ },
515
+ getContext: async () => context,
516
+ run: async (_context, body) => {
517
+ receivedBody = body;
518
+ },
519
+ });
520
+ } catch (err) {
521
+ if (err?.code === 'EPERM') {
522
+ t.skip('network listen is not permitted in this sandbox');
523
+ return;
524
+ }
525
+ throw err;
526
+ }
527
+
528
+ try {
529
+ assert.equal(handle.drainControl(context), true);
530
+ await new Promise((resolve) => setImmediate(resolve));
531
+ assert.equal(receivedBody.input, 'resume queued control');
532
+ assert.equal(session.controlQueue[0].status, 'running');
533
+ assert.equal(events[0].type, 'control_started');
534
+ } finally {
535
+ await handle.close();
536
+ }
537
+ });
538
+
539
+ test('runtime server exposes config profile list and switch endpoints', async (t) => {
540
+ const context = {
541
+ workspace: 'juno',
542
+ session: { workspace: 'juno', wikirc: { profile: 'default' } },
543
+ running: false,
544
+ currentAbortController: null,
545
+ };
546
+ let switchedProfile = null;
547
+ let handle;
548
+ try {
549
+ handle = await startRuntimeServer({
550
+ host: '127.0.0.1',
551
+ port: 0,
552
+ store: {
553
+ dbPath: ':memory:',
554
+ getState: () => ({ status: 'idle' }),
555
+ listEvents: () => [],
556
+ },
557
+ getContext: async () => context,
558
+ run: async () => {},
559
+ configProfiles: async () => ({ profiles: ['default', 'vpn'], active: context.session.wikirc.profile }),
560
+ useConfigProfile: async (_context, profile) => {
561
+ switchedProfile = profile;
562
+ context.session.wikirc.profile = profile;
563
+ return { ok: true, active: profile, config: { llm: { model: 'model-vpn' } } };
564
+ },
565
+ });
566
+ } catch (err) {
567
+ if (err?.code === 'EPERM') {
568
+ t.skip('network listen is not permitted in this sandbox');
569
+ return;
570
+ }
571
+ throw err;
572
+ }
573
+
574
+ try {
575
+ const profiles = await fetch(`http://127.0.0.1:${handle.port}/config/profiles?workspace=juno`);
576
+ assert.equal(profiles.status, 200);
577
+ assert.deepEqual(await profiles.json(), { profiles: ['default', 'vpn'], active: 'default' });
578
+
579
+ const use = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
580
+ method: 'POST',
581
+ headers: { 'Content-Type': 'application/json' },
582
+ body: JSON.stringify({ profile: 'vpn' }),
583
+ });
584
+ assert.equal(use.status, 200);
585
+ assert.equal(switchedProfile, 'vpn');
586
+ assert.deepEqual(await use.json(), { ok: true, active: 'vpn', config: { llm: { model: 'model-vpn' } } });
587
+ } finally {
588
+ await handle.close();
589
+ }
590
+ });
591
+
592
+ test('runtime server rejects config switching while a run is active', async (t) => {
593
+ let handle;
594
+ try {
595
+ handle = await startRuntimeServer({
596
+ host: '127.0.0.1',
597
+ port: 0,
598
+ store: {
599
+ dbPath: ':memory:',
600
+ getState: () => ({ status: 'running' }),
601
+ listEvents: () => [],
602
+ },
603
+ getContext: async () => ({
604
+ workspace: 'juno',
605
+ session: { workspace: 'juno' },
606
+ running: true,
607
+ currentAbortController: null,
608
+ }),
609
+ run: async () => {},
610
+ useConfigProfile: async () => ({ ok: true }),
611
+ });
612
+ } catch (err) {
613
+ if (err?.code === 'EPERM') {
614
+ t.skip('network listen is not permitted in this sandbox');
615
+ return;
616
+ }
617
+ throw err;
618
+ }
619
+
620
+ try {
621
+ const response = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
622
+ method: 'POST',
623
+ headers: { 'Content-Type': 'application/json' },
624
+ body: JSON.stringify({ profile: 'vpn' }),
625
+ });
626
+ assert.equal(response.status, 409);
627
+ } finally {
628
+ await handle.close();
629
+ }
630
+ });