@dotdrelle/wiki-manager 0.12.0 → 0.12.7

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 (40) hide show
  1. package/package.json +2 -2
  2. package/src/activity/activityAggregator.js +34 -3
  3. package/src/activity/activityAggregator.test.js +32 -0
  4. package/src/agent/graph.js +307 -29
  5. package/src/agent/graph.test.js +404 -1
  6. package/src/cli/wiki-manager.js +24 -1
  7. package/src/commands/slash.js +74 -1
  8. package/src/commands/slash.test.js +36 -0
  9. package/src/contracts/schemas.test.js +2 -2
  10. package/src/core/activity.js +4 -0
  11. package/src/core/activity.test.js +9 -1
  12. package/src/core/agentEvents.js +30 -0
  13. package/src/core/agentEvents.test.js +42 -1
  14. package/src/core/buildInfo.js +58 -0
  15. package/src/core/buildInfo.json +4 -0
  16. package/src/core/buildInfo.test.js +14 -0
  17. package/src/core/mcp.js +50 -3
  18. package/src/core/mcp.test.js +94 -1
  19. package/src/core/runtimeLog.js +6 -1
  20. package/src/core/runtimeLog.test.js +3 -1
  21. package/src/runtime/client.js +22 -0
  22. package/src/runtime/controlMessages.js +43 -0
  23. package/src/runtime/controlMessages.test.js +21 -0
  24. package/src/runtime/donna-contract.test.js +3 -3
  25. package/src/runtime/recoveryManager.js +54 -0
  26. package/src/runtime/recoveryManager.test.js +73 -0
  27. package/src/runtime/runner.js +49 -13
  28. package/src/runtime/runner.test.js +196 -0
  29. package/src/runtime/server.js +73 -13
  30. package/src/runtime/server.test.js +284 -67
  31. package/src/runtime/store.js +48 -3
  32. package/src/runtime/store.test.js +56 -24
  33. package/src/runtime/supervisor.js +77 -1
  34. package/src/shell/RightPane.tsx +124 -31
  35. package/src/shell/SetupWizard.tsx +13 -1
  36. package/src/shell/repl.js +81 -11
  37. package/src/shell/repl.test.js +115 -2
  38. package/src/shell/tui.tsx +4 -1
  39. package/src/shell/useAgent.ts +37 -4
  40. package/src/shell/useSession.ts +51 -10
@@ -224,15 +224,18 @@ test('runtime server accepts only one active run', async (t) => {
224
224
  fetch(url, {
225
225
  method: 'POST',
226
226
  headers: { 'Content-Type': 'application/json' },
227
- body: JSON.stringify({ input: 'second' }),
227
+ // Messages during an active run are now CLASSIFIED (observe answers
228
+ // immediately, cancel aborts…) — enqueueing a future run is the
229
+ // explicit intent.
230
+ body: JSON.stringify({ input: 'second', intent: 'enqueue' }),
228
231
  }),
229
232
  ]);
230
233
 
231
234
  assert.deepEqual([first.status, second.status], [202, 202]);
232
235
  const bodies = [await first.json(), await second.json()];
233
- const acceptedRun = bodies.find((body) => body.accepted && !body.queued);
234
- const queuedRun = bodies.find((body) => body.queued);
235
- assert.equal(queuedRun.kind, 'enqueue_run');
236
+ const acceptedRun = bodies.find((body) => body.runId && body.accepted);
237
+ const queuedRun = bodies.find((body) => body.kind === 'enqueue_run');
238
+ assert.equal(queuedRun.accepted, true);
236
239
  assert.equal(acceptedRun.accepted, true);
237
240
  assert.match(acceptedRun.runId, /^[0-9a-f-]{36}$/);
238
241
  assert.equal(runCount, 1);
@@ -271,7 +274,7 @@ test('runtime server returns the accepted run id and passes it to the runner', a
271
274
  const response = await fetch(`http://127.0.0.1:${handle.port}/run`, {
272
275
  method: 'POST',
273
276
  headers: { 'Content-Type': 'application/json' },
274
- body: JSON.stringify({ input: 'build', workspace: 'juno', evaluate: false, replans: 1 }),
277
+ body: JSON.stringify({ input: 'build', workspace: 'acme', evaluate: false, replans: 1 }),
275
278
  });
276
279
  assert.equal(response.status, 202);
277
280
  const body = await response.json();
@@ -279,7 +282,7 @@ test('runtime server returns the accepted run id and passes it to the runner', a
279
282
  assert.match(body.runId, /^[0-9a-f-]{36}$/);
280
283
  await new Promise((resolve) => setImmediate(resolve));
281
284
  assert.equal(receivedBody.runId, body.runId);
282
- assert.equal(receivedBody.workspace, 'juno');
285
+ assert.equal(receivedBody.workspace, 'acme');
283
286
  assert.equal(receivedBody.evaluate, false);
284
287
  assert.equal(receivedBody.replans, 1);
285
288
  } finally {
@@ -287,11 +290,163 @@ test('runtime server returns the accepted run id and passes it to the runner', a
287
290
  }
288
291
  });
289
292
 
293
+ test('runtime server kill aborts active run and interrupts workspace work', async (t) => {
294
+ let abortSeen = false;
295
+ let cancelCalled = false;
296
+ let interruptArgs = null;
297
+ let taskCancelArgs = null;
298
+ let handle;
299
+ try {
300
+ handle = await startRuntimeServer({
301
+ host: '127.0.0.1',
302
+ port: 0,
303
+ store: {
304
+ dbPath: ':memory:',
305
+ getState: () => ({ status: 'running' }),
306
+ listEvents: () => [],
307
+ interruptRuns: (args) => {
308
+ interruptArgs = args;
309
+ return 1;
310
+ },
311
+ cancelActiveTasksForInterruptedRuns: (args) => {
312
+ taskCancelArgs = args;
313
+ return 3;
314
+ },
315
+ },
316
+ session: { controlQueue: [{ id: 'control-1', workspace: 'docs', status: 'queued' }] },
317
+ run: async (_context, _body, { signal }) => {
318
+ signal.addEventListener('abort', () => { abortSeen = true; }, { once: true });
319
+ await new Promise(() => {});
320
+ },
321
+ cancel: async () => {
322
+ cancelCalled = true;
323
+ },
324
+ });
325
+ } catch (err) {
326
+ if (err?.code === 'EPERM') {
327
+ t.skip('network listen is not permitted in this sandbox');
328
+ return;
329
+ }
330
+ throw err;
331
+ }
332
+
333
+ try {
334
+ const base = `http://127.0.0.1:${handle.port}`;
335
+ const runResponse = await fetch(`${base}/run`, {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({ input: 'build', workspace: 'docs' }),
339
+ });
340
+ assert.equal(runResponse.status, 202);
341
+
342
+ const killResponse = await fetch(`${base}/kill?workspace=docs`, { method: 'POST' });
343
+ assert.equal(killResponse.status, 202);
344
+ assert.deepEqual(await killResponse.json(), {
345
+ killed: true,
346
+ workspace: 'docs',
347
+ runId: null,
348
+ runs: 1,
349
+ tasks: 3,
350
+ queued: 1,
351
+ });
352
+ assert.equal(abortSeen, true);
353
+ assert.equal(cancelCalled, true);
354
+ assert.equal(interruptArgs.workspace, 'docs');
355
+ assert.equal(interruptArgs.runId, null);
356
+ assert.deepEqual(taskCancelArgs, { workspace: 'docs', runId: null });
357
+ } finally {
358
+ await handle.close();
359
+ }
360
+ });
361
+
362
+ test('runtime server kill succeeds without an active run', async (t) => {
363
+ let handle;
364
+ try {
365
+ handle = await startRuntimeServer({
366
+ host: '127.0.0.1',
367
+ port: 0,
368
+ store: {
369
+ dbPath: ':memory:',
370
+ getState: () => ({ status: 'idle' }),
371
+ listEvents: () => [],
372
+ interruptRuns: () => 0,
373
+ cancelActiveTasksForInterruptedRuns: () => 0,
374
+ },
375
+ session: {},
376
+ run: async () => {},
377
+ });
378
+ } catch (err) {
379
+ if (err?.code === 'EPERM') {
380
+ t.skip('network listen is not permitted in this sandbox');
381
+ return;
382
+ }
383
+ throw err;
384
+ }
385
+
386
+ try {
387
+ const response = await fetch(`http://127.0.0.1:${handle.port}/kill?workspace=docs`, { method: 'POST' });
388
+ assert.equal(response.status, 202);
389
+ assert.deepEqual(await response.json(), {
390
+ killed: true,
391
+ workspace: 'docs',
392
+ runId: null,
393
+ runs: 0,
394
+ tasks: 0,
395
+ queued: 0,
396
+ });
397
+ } finally {
398
+ await handle.close();
399
+ }
400
+ });
401
+
402
+ test('runtime server kill can target a specific run id', async (t) => {
403
+ let interruptArgs = null;
404
+ let taskCancelArgs = null;
405
+ let handle;
406
+ try {
407
+ handle = await startRuntimeServer({
408
+ host: '127.0.0.1',
409
+ port: 0,
410
+ store: {
411
+ dbPath: ':memory:',
412
+ getState: () => ({ status: 'idle' }),
413
+ listEvents: () => [],
414
+ interruptRuns: (args) => {
415
+ interruptArgs = args;
416
+ return 1;
417
+ },
418
+ cancelActiveTasksForInterruptedRuns: (args) => {
419
+ taskCancelArgs = args;
420
+ return 2;
421
+ },
422
+ },
423
+ session: {},
424
+ run: async () => {},
425
+ });
426
+ } catch (err) {
427
+ if (err?.code === 'EPERM') {
428
+ t.skip('network listen is not permitted in this sandbox');
429
+ return;
430
+ }
431
+ throw err;
432
+ }
433
+
434
+ try {
435
+ const response = await fetch(`http://127.0.0.1:${handle.port}/kill?workspace=docs&runId=run-1`, { method: 'POST' });
436
+ assert.equal(response.status, 202);
437
+ assert.equal((await response.json()).runId, 'run-1');
438
+ assert.equal(interruptArgs.runId, 'run-1');
439
+ assert.deepEqual(taskCancelArgs, { workspace: 'docs', runId: 'run-1' });
440
+ } finally {
441
+ await handle.close();
442
+ }
443
+ });
444
+
290
445
  test('runtime server state exposes active run identity while running', async (t) => {
291
446
  let releaseRun;
292
447
  const context = {
293
- workspace: 'juno',
294
- session: { workspace: 'juno' },
448
+ workspace: 'acme',
449
+ session: { workspace: 'acme' },
295
450
  running: false,
296
451
  currentAbortController: null,
297
452
  currentRunId: null,
@@ -321,18 +476,18 @@ test('runtime server state exposes active run identity while running', async (t)
321
476
  }
322
477
 
323
478
  try {
324
- const runResponse = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=juno`, {
479
+ const runResponse = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
325
480
  method: 'POST',
326
481
  headers: { 'Content-Type': 'application/json' },
327
482
  body: JSON.stringify({ input: 'build' }),
328
483
  });
329
484
  acceptedRun = await runResponse.json();
330
- const stateResponse = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=juno`);
485
+ const stateResponse = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=acme`);
331
486
  const state = await stateResponse.json();
332
487
  assert.equal(state.status, 'running');
333
488
  assert.equal(state.running, true);
334
489
  assert.equal(state.runId, acceptedRun.runId);
335
- assert.equal(state.workspace, 'juno');
490
+ assert.equal(state.workspace, 'acme');
336
491
  } finally {
337
492
  releaseRun?.();
338
493
  await handle.close();
@@ -381,8 +536,8 @@ test('runtime server isolates active runs by workspace', async (t) => {
381
536
 
382
537
  try {
383
538
  const url = `http://127.0.0.1:${handle.port}/run`;
384
- const [juno, docs] = await Promise.all([
385
- fetch(`${url}?workspace=juno`, {
539
+ const [acme, docs] = await Promise.all([
540
+ fetch(`${url}?workspace=acme`, {
386
541
  method: 'POST',
387
542
  headers: { 'Content-Type': 'application/json' },
388
543
  body: JSON.stringify({ input: 'first' }),
@@ -393,18 +548,18 @@ test('runtime server isolates active runs by workspace', async (t) => {
393
548
  body: JSON.stringify({ input: 'second' }),
394
549
  }),
395
550
  ]);
396
- assert.deepEqual([juno.status, docs.status], [202, 202]);
551
+ assert.deepEqual([acme.status, docs.status], [202, 202]);
397
552
 
398
- const queued = await fetch(`${url}?workspace=juno`, {
553
+ const queued = await fetch(`${url}?workspace=acme`, {
399
554
  method: 'POST',
400
555
  headers: { 'Content-Type': 'application/json' },
401
- body: JSON.stringify({ input: 'third' }),
556
+ body: JSON.stringify({ input: 'third', intent: 'enqueue' }),
402
557
  });
403
558
  assert.equal(queued.status, 202);
404
- assert.equal((await queued.json()).queued, true);
405
- assert.deepEqual(runWorkspaces.sort(), ['docs', 'juno']);
559
+ assert.equal((await queued.json()).kind, 'enqueue_run');
560
+ assert.deepEqual(runWorkspaces.sort(), ['acme', 'docs']);
406
561
  } finally {
407
- releases.get('juno')?.();
562
+ releases.get('acme')?.();
408
563
  releases.get('docs')?.();
409
564
  await handle.close();
410
565
  }
@@ -446,10 +601,10 @@ test('runtime server filters state and events by workspace', async (t) => {
446
601
  }
447
602
 
448
603
  try {
449
- const state = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=juno`);
604
+ const state = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=acme`);
450
605
  assert.equal(state.status, 200);
451
- assert.equal((await state.json()).workspace, 'juno');
452
- assert.equal(stateWorkspace, 'juno');
606
+ assert.equal((await state.json()).workspace, 'acme');
607
+ assert.equal(stateWorkspace, 'acme');
453
608
 
454
609
  const events = await fetch(`http://127.0.0.1:${handle.port}/events?workspace=docs`);
455
610
  assert.equal(events.status, 200);
@@ -533,15 +688,15 @@ test('runtime server exposes manual resume endpoint', async (t) => {
533
688
  }
534
689
 
535
690
  try {
536
- const response = await fetch(`http://127.0.0.1:${handle.port}/resume?workspace=juno`, {
691
+ const response = await fetch(`http://127.0.0.1:${handle.port}/resume?workspace=acme`, {
537
692
  method: 'POST',
538
693
  });
539
694
  assert.equal(response.status, 202);
540
- assert.equal(resumedWorkspace, 'juno');
695
+ assert.equal(resumedWorkspace, 'acme');
541
696
  assert.deepEqual(await response.json(), {
542
697
  resumed: 1,
543
698
  interrupted: 0,
544
- workspaces: [{ workspace: 'juno', resumed: true }],
699
+ workspaces: [{ workspace: 'acme', resumed: true }],
545
700
  });
546
701
  } finally {
547
702
  await handle.close();
@@ -575,13 +730,13 @@ test('runtime server exposes approval endpoint', async (t) => {
575
730
  }
576
731
 
577
732
  try {
578
- const response = await fetch(`http://127.0.0.1:${handle.port}/approve?workspace=juno&runId=run-1&itemId=item-1`, {
733
+ const response = await fetch(`http://127.0.0.1:${handle.port}/approve?workspace=acme&runId=run-1&itemId=item-1`, {
579
734
  method: 'POST',
580
735
  });
581
736
  assert.equal(response.status, 202);
582
737
  assert.deepEqual(approved, {
583
- workspace: 'juno',
584
- workspaceId: 'juno',
738
+ workspace: 'acme',
739
+ workspaceId: 'acme',
585
740
  runId: 'run-1',
586
741
  itemId: 'item-1',
587
742
  approvalId: null,
@@ -599,8 +754,8 @@ test('runtime server exposes approval endpoint', async (t) => {
599
754
 
600
755
  test('runtime server exposes control status and explanation', async (t) => {
601
756
  const session = {
602
- workspace: 'juno',
603
- controlQueue: [{ id: 'control-1', workspace: 'juno', status: 'queued', input: 'later' }],
757
+ workspace: 'acme',
758
+ controlQueue: [{ id: 'control-1', workspace: 'acme', status: 'queued', input: 'later' }],
604
759
  };
605
760
  let handle;
606
761
  try {
@@ -620,7 +775,7 @@ test('runtime server exposes control status and explanation', async (t) => {
620
775
  listEvents: () => [],
621
776
  },
622
777
  getContext: async () => ({
623
- workspace: 'juno',
778
+ workspace: 'acme',
624
779
  session,
625
780
  running: false,
626
781
  currentAbortController: null,
@@ -636,14 +791,14 @@ test('runtime server exposes control status and explanation', async (t) => {
636
791
  }
637
792
 
638
793
  try {
639
- const status = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`);
794
+ const status = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`);
640
795
  assert.equal(status.status, 200);
641
796
  const statusBody = await status.json();
642
797
  assert.equal(statusBody.status, 'idle');
643
798
  assert.equal(statusBody.running, false);
644
799
  assert.equal(statusBody.controlQueue[0].id, 'control-1');
645
800
 
646
- const explain = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
801
+ const explain = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
647
802
  method: 'POST',
648
803
  headers: { 'Content-Type': 'application/json' },
649
804
  body: JSON.stringify({ action: 'explain' }),
@@ -657,7 +812,7 @@ test('runtime server exposes control status and explanation', async (t) => {
657
812
 
658
813
  test('runtime server control enqueue emits events but does not patch an active plan or start a run', async (t) => {
659
814
  const session = {
660
- workspace: 'juno',
815
+ workspace: 'acme',
661
816
  controlQueue: [],
662
817
  };
663
818
  const events = [];
@@ -681,7 +836,7 @@ test('runtime server control enqueue emits events but does not patch an active p
681
836
  listEvents: () => [],
682
837
  },
683
838
  getContext: async () => ({
684
- workspace: 'juno',
839
+ workspace: 'acme',
685
840
  session,
686
841
  running: true,
687
842
  currentAbortController: new AbortController(),
@@ -697,7 +852,7 @@ test('runtime server control enqueue emits events but does not patch an active p
697
852
  }
698
853
 
699
854
  try {
700
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
855
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
701
856
  method: 'POST',
702
857
  headers: { 'Content-Type': 'application/json' },
703
858
  body: JSON.stringify({ action: 'enqueue', input: 'run this after current work' }),
@@ -718,7 +873,7 @@ test('runtime server control enqueue emits events but does not patch an active p
718
873
 
719
874
  test('runtime server control message observes an active run without enqueueing', async (t) => {
720
875
  const session = {
721
- workspace: 'juno',
876
+ workspace: 'acme',
722
877
  controlQueue: [],
723
878
  };
724
879
  let runCount = 0;
@@ -739,7 +894,7 @@ test('runtime server control message observes an active run without enqueueing',
739
894
  listEvents: () => [],
740
895
  },
741
896
  getContext: async () => ({
742
- workspace: 'juno',
897
+ workspace: 'acme',
743
898
  session,
744
899
  running: true,
745
900
  currentAbortController: new AbortController(),
@@ -755,7 +910,7 @@ test('runtime server control message observes an active run without enqueueing',
755
910
  }
756
911
 
757
912
  try {
758
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
913
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
759
914
  method: 'POST',
760
915
  headers: { 'Content-Type': 'application/json' },
761
916
  body: JSON.stringify({ action: 'message', input: 'Où en est le build ?' }),
@@ -773,7 +928,7 @@ test('runtime server control message observes an active run without enqueueing',
773
928
 
774
929
  test('runtime server control message handles approve and cancel intents during an active run', async (t) => {
775
930
  const session = {
776
- workspace: 'juno',
931
+ workspace: 'acme',
777
932
  controlQueue: [],
778
933
  };
779
934
  const abortController = new AbortController();
@@ -795,7 +950,7 @@ test('runtime server control message handles approve and cancel intents during a
795
950
  listEvents: () => [],
796
951
  },
797
952
  getContext: async () => ({
798
- workspace: 'juno',
953
+ workspace: 'acme',
799
954
  session,
800
955
  running: true,
801
956
  currentAbortController: abortController,
@@ -812,7 +967,7 @@ test('runtime server control message handles approve and cancel intents during a
812
967
  }
813
968
 
814
969
  try {
815
- const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
970
+ const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
816
971
  method: 'POST',
817
972
  headers: { 'Content-Type': 'application/json' },
818
973
  body: JSON.stringify({ action: 'message', input: 'valide tout' }),
@@ -820,7 +975,7 @@ test('runtime server control message handles approve and cancel intents during a
820
975
  assert.equal(approveResponse.status, 200);
821
976
  assert.equal((await approveResponse.json()).kind, 'approve');
822
977
 
823
- const cancelResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
978
+ const cancelResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
824
979
  method: 'POST',
825
980
  headers: { 'Content-Type': 'application/json' },
826
981
  body: JSON.stringify({ action: 'message', input: 'annule le run' }),
@@ -837,18 +992,18 @@ test('runtime server control message handles approve and cancel intents during a
837
992
 
838
993
  test('runtime server control message records active plan mutation as a proposal', async (t) => {
839
994
  const session = {
840
- workspace: 'juno',
995
+ workspace: 'acme',
841
996
  controlQueue: [],
842
997
  };
843
998
  dispatchAgentEvent(session, createAgentEvent('run_started', {
844
999
  origin: 'runtime',
845
1000
  runId: 'run-mutate',
846
- workspace: 'juno',
1001
+ workspace: 'acme',
847
1002
  }));
848
1003
  dispatchAgentEvent(session, createAgentEvent('plan_set', {
849
1004
  origin: 'tool',
850
1005
  runId: 'run-mutate',
851
- workspace: 'juno',
1006
+ workspace: 'acme',
852
1007
  payload: {
853
1008
  steps: [{ step: 1, id: 'generate', description: 'Generate', status: 'running' }],
854
1009
  },
@@ -865,13 +1020,13 @@ test('runtime server control message records active plan mutation as a proposal'
865
1020
  ...session.agentProjection,
866
1021
  status: session.agentProjection?.status ?? 'running',
867
1022
  queue: [],
868
- runs: [{ id: 'run-mutate', workspace: 'juno', status: 'running' }],
1023
+ runs: [{ id: 'run-mutate', workspace: 'acme', status: 'running' }],
869
1024
  runId: 'run-mutate',
870
1025
  }),
871
1026
  listEvents: () => [],
872
1027
  },
873
1028
  getContext: async () => ({
874
- workspace: 'juno',
1029
+ workspace: 'acme',
875
1030
  session,
876
1031
  running: true,
877
1032
  currentAbortController: new AbortController(),
@@ -888,7 +1043,7 @@ test('runtime server control message records active plan mutation as a proposal'
888
1043
  }
889
1044
 
890
1045
  try {
891
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
1046
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
892
1047
  method: 'POST',
893
1048
  headers: { 'Content-Type': 'application/json' },
894
1049
  body: JSON.stringify({ action: 'message', input: 'Ajoute un envoi après chaque génération' }),
@@ -901,7 +1056,7 @@ test('runtime server control message records active plan mutation as a proposal'
901
1056
  assert.equal(session.agentProjection.planPatches[0].status, 'proposed');
902
1057
  assert.ok(session.agentEvents.some((event) => event.type === 'control_message_received'));
903
1058
  assert.ok(session.agentEvents.some((event) => event.type === 'plan_patch_proposed'));
904
- const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
1059
+ const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
905
1060
  method: 'POST',
906
1061
  headers: { 'Content-Type': 'application/json' },
907
1062
  body: JSON.stringify({ action: 'approve_patch', patchId: body.proposal.id }),
@@ -924,7 +1079,7 @@ test('runtime server control message records active plan mutation as a proposal'
924
1079
 
925
1080
  test('runtime server control message reports ambiguity without starting a run', async (t) => {
926
1081
  const session = {
927
- workspace: 'juno',
1082
+ workspace: 'acme',
928
1083
  controlQueue: [],
929
1084
  };
930
1085
  let runCount = 0;
@@ -945,7 +1100,7 @@ test('runtime server control message reports ambiguity without starting a run',
945
1100
  listEvents: () => [],
946
1101
  },
947
1102
  getContext: async () => ({
948
- workspace: 'juno',
1103
+ workspace: 'acme',
949
1104
  session,
950
1105
  running: true,
951
1106
  currentAbortController: new AbortController(),
@@ -961,7 +1116,7 @@ test('runtime server control message reports ambiguity without starting a run',
961
1116
  }
962
1117
 
963
1118
  try {
964
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
1119
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
965
1120
  method: 'POST',
966
1121
  headers: { 'Content-Type': 'application/json' },
967
1122
  body: JSON.stringify({ action: 'message', input: 'Lance aussi la publication' }),
@@ -979,7 +1134,7 @@ test('runtime server control message reports ambiguity without starting a run',
979
1134
 
980
1135
  test('runtime server drains queued control requests when idle', async (t) => {
981
1136
  const session = {
982
- workspace: 'juno',
1137
+ workspace: 'acme',
983
1138
  controlQueue: [],
984
1139
  };
985
1140
  const events = [];
@@ -1003,7 +1158,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
1003
1158
  listEvents: () => [],
1004
1159
  },
1005
1160
  getContext: async () => ({
1006
- workspace: 'juno',
1161
+ workspace: 'acme',
1007
1162
  session,
1008
1163
  running: false,
1009
1164
  currentAbortController: null,
@@ -1021,7 +1176,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
1021
1176
  }
1022
1177
 
1023
1178
  try {
1024
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
1179
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
1025
1180
  method: 'POST',
1026
1181
  headers: { 'Content-Type': 'application/json' },
1027
1182
  body: JSON.stringify({ action: 'enqueue', input: 'run from control queue' }),
@@ -1029,7 +1184,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
1029
1184
  assert.equal(response.status, 202);
1030
1185
  await new Promise((resolve) => setImmediate(resolve));
1031
1186
  assert.equal(receivedBody.input, 'run from control queue');
1032
- assert.equal(receivedBody.workspace, 'juno');
1187
+ assert.equal(receivedBody.workspace, 'acme');
1033
1188
  assert.match(receivedBody.runId, /^[0-9a-f-]{36}$/);
1034
1189
  assert.equal(session.controlQueue[0].status, 'running');
1035
1190
  assert.equal(session.controlQueue[0].runId, receivedBody.runId);
@@ -1041,13 +1196,13 @@ test('runtime server drains queued control requests when idle', async (t) => {
1041
1196
 
1042
1197
  test('runtime server handle drains a pre-existing hydrated control request', async (t) => {
1043
1198
  const session = {
1044
- workspace: 'juno',
1045
- controlQueue: [{ id: 'control-existing', workspace: 'juno', status: 'queued', input: 'resume queued control' }],
1199
+ workspace: 'acme',
1200
+ controlQueue: [{ id: 'control-existing', workspace: 'acme', status: 'queued', input: 'resume queued control' }],
1046
1201
  };
1047
1202
  const events = [];
1048
1203
  session._onAgentEvent = (event) => events.push(event);
1049
1204
  const context = {
1050
- workspace: 'juno',
1205
+ workspace: 'acme',
1051
1206
  session,
1052
1207
  running: false,
1053
1208
  currentAbortController: null,
@@ -1089,8 +1244,8 @@ test('runtime server handle drains a pre-existing hydrated control request', asy
1089
1244
 
1090
1245
  test('runtime server exposes config profile list and switch endpoints', async (t) => {
1091
1246
  const context = {
1092
- workspace: 'juno',
1093
- session: { workspace: 'juno', wikirc: { profile: 'default' } },
1247
+ workspace: 'acme',
1248
+ session: { workspace: 'acme', wikirc: { profile: 'default' } },
1094
1249
  running: false,
1095
1250
  currentAbortController: null,
1096
1251
  };
@@ -1123,11 +1278,11 @@ test('runtime server exposes config profile list and switch endpoints', async (t
1123
1278
  }
1124
1279
 
1125
1280
  try {
1126
- const profiles = await fetch(`http://127.0.0.1:${handle.port}/config/profiles?workspace=juno`);
1281
+ const profiles = await fetch(`http://127.0.0.1:${handle.port}/config/profiles?workspace=acme`);
1127
1282
  assert.equal(profiles.status, 200);
1128
1283
  assert.deepEqual(await profiles.json(), { profiles: ['default', 'vpn'], active: 'default' });
1129
1284
 
1130
- const use = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
1285
+ const use = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=acme`, {
1131
1286
  method: 'POST',
1132
1287
  headers: { 'Content-Type': 'application/json' },
1133
1288
  body: JSON.stringify({ profile: 'vpn' }),
@@ -1152,8 +1307,8 @@ test('runtime server rejects config switching while a run is active', async (t)
1152
1307
  listEvents: () => [],
1153
1308
  },
1154
1309
  getContext: async () => ({
1155
- workspace: 'juno',
1156
- session: { workspace: 'juno' },
1310
+ workspace: 'acme',
1311
+ session: { workspace: 'acme' },
1157
1312
  running: true,
1158
1313
  currentAbortController: null,
1159
1314
  }),
@@ -1169,7 +1324,7 @@ test('runtime server rejects config switching while a run is active', async (t)
1169
1324
  }
1170
1325
 
1171
1326
  try {
1172
- const response = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
1327
+ const response = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=acme`, {
1173
1328
  method: 'POST',
1174
1329
  headers: { 'Content-Type': 'application/json' },
1175
1330
  body: JSON.stringify({ profile: 'vpn' }),
@@ -1179,3 +1334,65 @@ test('runtime server rejects config switching while a run is active', async (t)
1179
1334
  await handle.close();
1180
1335
  }
1181
1336
  });
1337
+
1338
+ test('runtime server answers control messages posted to /run during an active run', async (t) => {
1339
+ let releaseRun;
1340
+ let handle;
1341
+ try {
1342
+ handle = await startRuntimeServer({
1343
+ host: '127.0.0.1',
1344
+ port: 0,
1345
+ store: {
1346
+ dbPath: ':memory:',
1347
+ getState: () => ({ status: 'running' }),
1348
+ listEvents: () => [],
1349
+ },
1350
+ session: {},
1351
+ run: async () => {
1352
+ await new Promise((resolve) => { releaseRun = resolve; });
1353
+ },
1354
+ });
1355
+ } catch (err) {
1356
+ if (err?.code === 'EPERM') {
1357
+ t.skip('network listen is not permitted in this sandbox');
1358
+ return;
1359
+ }
1360
+ throw err;
1361
+ }
1362
+
1363
+ try {
1364
+ const url = `http://127.0.0.1:${handle.port}/run`;
1365
+ const started = await fetch(url, {
1366
+ method: 'POST',
1367
+ headers: { 'Content-Type': 'application/json' },
1368
+ body: JSON.stringify({ input: 'lance le pipeline' }),
1369
+ });
1370
+ assert.equal(started.status, 202);
1371
+
1372
+ // A status question must be answered NOW, not parked until the run ends
1373
+ // (regression: serve UI got no result until the job finished/stopped).
1374
+ const observe = await fetch(url, {
1375
+ method: 'POST',
1376
+ headers: { 'Content-Type': 'application/json' },
1377
+ body: JSON.stringify({ input: 'où en est le run ?' }),
1378
+ });
1379
+ assert.equal(observe.status, 200);
1380
+ const observeBody = await observe.json();
1381
+ assert.equal(observeBody.kind, 'observe');
1382
+ assert.ok(String(observeBody.explanation ?? '').length > 0, 'observe must carry an explanation');
1383
+
1384
+ // A cancel must abort the active run instead of being enqueued.
1385
+ const cancel = await fetch(url, {
1386
+ method: 'POST',
1387
+ headers: { 'Content-Type': 'application/json' },
1388
+ body: JSON.stringify({ input: 'annule le run' }),
1389
+ });
1390
+ assert.equal(cancel.status, 200);
1391
+ const cancelBody = await cancel.json();
1392
+ assert.equal(cancelBody.kind, 'cancel');
1393
+ assert.equal(cancelBody.accepted, true);
1394
+ } finally {
1395
+ releaseRun?.();
1396
+ await handle.close();
1397
+ }
1398
+ });