@dotdrelle/wiki-manager 0.11.9 → 0.12.0

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 (81) hide show
  1. package/README.md +32 -7
  2. package/agents.docker-compose.yml +3 -0
  3. package/docker-compose.yml +1 -0
  4. package/package.json +3 -2
  5. package/src/activity/activityAggregator.js +109 -0
  6. package/src/activity/activityAggregator.test.js +60 -0
  7. package/src/activity/activityDeduplicator.js +50 -0
  8. package/src/activity/progressCalculator.js +61 -0
  9. package/src/activity/runSynthesis.js +15 -0
  10. package/src/agent/graph.js +65 -30
  11. package/src/agent/graph.test.js +28 -5
  12. package/src/cli/wiki-manager.js +22 -0
  13. package/src/contracts/schemas.js +249 -13
  14. package/src/contracts/schemas.test.js +145 -0
  15. package/src/core/activity.js +8 -5
  16. package/src/core/agentEvents.js +260 -23
  17. package/src/core/agentEvents.test.js +53 -0
  18. package/src/core/compose.js +6 -1
  19. package/src/core/dockerCompose.test.js +12 -0
  20. package/src/core/documentIntake.js +33 -38
  21. package/src/core/documentIntake.test.js +25 -0
  22. package/src/core/env.js +11 -1
  23. package/src/core/jobQueue.js +26 -2
  24. package/src/core/mcp.js +1 -1
  25. package/src/core/plan.js +46 -4
  26. package/src/core/plan.test.js +16 -1
  27. package/src/core/planPatch.js +64 -3
  28. package/src/core/planPatch.test.js +110 -1
  29. package/src/core/queueStore.test.js +21 -0
  30. package/src/core/runtimeLog.js +119 -0
  31. package/src/core/runtimeLog.test.js +84 -0
  32. package/src/core/wikirc.js +22 -0
  33. package/src/core/wikirc.test.js +49 -1
  34. package/src/core/workflow.js +14 -3
  35. package/src/graph/graphAggregator.js +5 -0
  36. package/src/graph/graphPatch.js +8 -0
  37. package/src/graph/graphSnapshot.js +14 -0
  38. package/src/graph/graphVisibilityPolicy.js +40 -0
  39. package/src/graph/runGraphProjector.js +87 -0
  40. package/src/graph/runGraphProjector.test.js +111 -0
  41. package/src/orchestrator/agentRegistry.js +172 -0
  42. package/src/orchestrator/agentRegistry.test.js +114 -0
  43. package/src/orchestrator/approvalPolicy.js +126 -0
  44. package/src/orchestrator/approvalPolicy.test.js +78 -0
  45. package/src/orchestrator/assignmentManager.js +80 -0
  46. package/src/orchestrator/attemptManager.js +168 -0
  47. package/src/orchestrator/attemptManager.test.js +160 -0
  48. package/src/orchestrator/budgetManager.js +127 -0
  49. package/src/orchestrator/capabilityRegistry.js +64 -0
  50. package/src/orchestrator/capabilityRegistry.test.js +59 -0
  51. package/src/orchestrator/capabilityResolver.js +151 -0
  52. package/src/orchestrator/capabilityResolver.test.js +127 -0
  53. package/src/orchestrator/dependencyResolver.js +112 -0
  54. package/src/orchestrator/dispatcher.js +302 -0
  55. package/src/orchestrator/lockManager.js +51 -0
  56. package/src/orchestrator/planIntegrator.js +268 -0
  57. package/src/orchestrator/planIntegrator.test.js +213 -0
  58. package/src/orchestrator/planValidator.js +534 -0
  59. package/src/orchestrator/planValidator.test.js +262 -0
  60. package/src/orchestrator/resultAggregator.js +248 -0
  61. package/src/orchestrator/resultAggregator.test.js +211 -0
  62. package/src/orchestrator/scheduler.js +103 -0
  63. package/src/orchestrator/scheduler.test.js +138 -0
  64. package/src/runtime/approvals.js +130 -1
  65. package/src/runtime/donna-contract.test.js +293 -30
  66. package/src/runtime/recoveryManager.js +176 -0
  67. package/src/runtime/recoveryManager.test.js +162 -0
  68. package/src/runtime/runner.e2e.test.js +98 -12
  69. package/src/runtime/runner.js +261 -227
  70. package/src/runtime/runner.test.js +249 -452
  71. package/src/runtime/server.js +137 -20
  72. package/src/runtime/server.test.js +137 -7
  73. package/src/runtime/store.js +837 -2
  74. package/src/runtime/store.test.js +245 -4
  75. package/src/runtime/supervisor.js +33 -1
  76. package/src/runtime/supervisor.test.js +47 -1
  77. package/src/shell/RightPane.tsx +8 -5
  78. package/src/shell/repl.js +4 -9
  79. package/src/shell/repl.test.js +25 -4
  80. package/src/shell/tui.tsx +1 -0
  81. package/src/shell/useSession.ts +38 -10
@@ -85,6 +85,35 @@ export function startRuntimeServer({
85
85
  });
86
86
  return;
87
87
  }
88
+ if (request.method === 'GET') {
89
+ const taskRead = readTaskEndpoint(url);
90
+ if (taskRead) {
91
+ if (taskRead.kind === 'run_tasks') {
92
+ sendJson(response, 200, {
93
+ ok: true,
94
+ runId: taskRead.runId,
95
+ tasks: typeof store.listTasks === 'function' ? store.listTasks({ runId: taskRead.runId }) : [],
96
+ });
97
+ return;
98
+ }
99
+ if (taskRead.kind === 'task_attempts') {
100
+ sendJson(response, 200, {
101
+ ok: true,
102
+ taskId: taskRead.taskId,
103
+ attempts: typeof store.listTaskAttempts === 'function' ? store.listTaskAttempts({ taskId: taskRead.taskId }) : [],
104
+ });
105
+ return;
106
+ }
107
+ if (taskRead.kind === 'task_result') {
108
+ sendJson(response, 200, {
109
+ ok: true,
110
+ taskId: taskRead.taskId,
111
+ result: typeof store.getTaskResult === 'function' ? store.getTaskResult({ taskId: taskRead.taskId }) : null,
112
+ });
113
+ return;
114
+ }
115
+ }
116
+ }
88
117
  if (request.method === 'GET' && url.pathname === '/control') {
89
118
  const workspace = workspaceFromUrl(url);
90
119
  const context = await resolveContext({ workspace });
@@ -112,9 +141,11 @@ export function startRuntimeServer({
112
141
  return;
113
142
  }
114
143
  validateContractInDev('controlMessage', { ...body, action, input });
115
- const result = handleControlMessage(context, store, input, {
144
+ const result = await handleControlMessage(context, store, input, {
116
145
  intent: body.intent,
117
146
  startNextControlRequest,
147
+ cancel,
148
+ approve,
118
149
  });
119
150
  sendJson(response, result.statusCode, result.body);
120
151
  return;
@@ -202,10 +233,6 @@ export function startRuntimeServer({
202
233
  }
203
234
  if (request.method === 'POST' && url.pathname === '/run') {
204
235
  const { body, context } = await resolveBodyContext(request, url);
205
- if (context.running) {
206
- sendJson(response, 409, { error: 'A runtime run is already active.' });
207
- return;
208
- }
209
236
  try {
210
237
  const input = String(body.input ?? body.prompt ?? '').trim();
211
238
  if (!input) {
@@ -213,6 +240,18 @@ export function startRuntimeServer({
213
240
  return;
214
241
  }
215
242
  validateContractInDev('runRequest', { ...body, input });
243
+ if (context.running) {
244
+ const item = enqueueControlRequest(context, input);
245
+ sendJson(response, 202, {
246
+ accepted: true,
247
+ queued: true,
248
+ kind: 'enqueue_run',
249
+ item,
250
+ ...controlStatus(context, store),
251
+ explanation: 'Request queued for a future run.',
252
+ });
253
+ return;
254
+ }
216
255
  const accepted = startRuntimeRun(context, body);
217
256
  sendJson(response, 202, accepted);
218
257
  } catch (err) {
@@ -262,9 +301,15 @@ export function startRuntimeServer({
262
301
  const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
263
302
  const result = await approve?.({
264
303
  workspace,
304
+ workspaceId: workspace,
265
305
  runId: url.searchParams.get('runId') ?? body.runId ?? null,
266
306
  itemId: url.searchParams.get('itemId') ?? body.itemId ?? null,
267
307
  approvalId: url.searchParams.get('approvalId') ?? body.approvalId ?? null,
308
+ scope: url.searchParams.get('scope') ?? body.scope ?? null,
309
+ taskId: url.searchParams.get('taskId') ?? body.taskId ?? null,
310
+ groupId: url.searchParams.get('groupId') ?? body.groupId ?? null,
311
+ planRevision: readOptionalNumber(url.searchParams.get('planRevision') ?? body.planRevision),
312
+ approvalClasses: readOptionalList(body.approvalClasses ?? body.approvalClass ?? url.searchParams.get('approvalClass')),
268
313
  });
269
314
  sendJson(response, result?.approved ? 202 : 404, result ?? { approved: false });
270
315
  return;
@@ -363,6 +408,20 @@ function workspaceFromBody(body) {
363
408
  return workspace == null ? null : String(workspace).trim() || null;
364
409
  }
365
410
 
411
+ function readTaskEndpoint(url) {
412
+ const parts = url.pathname.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
413
+ if (parts.length === 3 && parts[0] === 'runs' && parts[2] === 'tasks') {
414
+ return { kind: 'run_tasks', runId: parts[1] };
415
+ }
416
+ if (parts.length === 3 && parts[0] === 'tasks' && parts[2] === 'attempts') {
417
+ return { kind: 'task_attempts', taskId: parts[1] };
418
+ }
419
+ if (parts.length === 3 && parts[0] === 'tasks' && parts[2] === 'result') {
420
+ return { kind: 'task_result', taskId: parts[1] };
421
+ }
422
+ return null;
423
+ }
424
+
366
425
  function controlStatus(context, store) {
367
426
  const workspace = context?.workspace ?? context?.session?.workspace ?? null;
368
427
  const state = runtimeState(context, store, { workspace });
@@ -419,19 +478,47 @@ function readOnlyControlResponse(kind, classification, status, explanation, { ac
419
478
  };
420
479
  }
421
480
 
422
- function handleControlMessage(context, store, input, { intent = null, startNextControlRequest = () => false } = {}) {
481
+ function approvalRequestFromStatus(status) {
482
+ const runId = status.runId ?? status.runs?.find((run) => run.status === 'running' || run.status === 'pending_approval')?.id ?? null;
483
+ const pending = (status.approvals ?? []).filter((approval) => approval.status === 'pending_approval');
484
+ const classes = [...new Set(pending.flatMap((approval) => readOptionalList(approval.approvalClasses ?? approval.approvalClass)))];
485
+ return {
486
+ workspace: status.workspace ?? null,
487
+ workspaceId: status.workspace ?? null,
488
+ runId,
489
+ scope: 'run',
490
+ planRevision: status.planRevision ?? null,
491
+ approvalClasses: classes.length > 0 ? classes : ['default'],
492
+ };
493
+ }
494
+
495
+ async function handleControlMessage(context, store, input, { intent = null, startNextControlRequest = () => false, cancel = null, approve = null } = {}) {
423
496
  const status = controlStatus(context, store);
424
497
  const classification = classifyControlMessage(input, status, intent);
425
498
  if (classification.kind === 'observe') {
426
499
  return readOnlyControlResponse('observe', classification, status, explainControlState(status));
427
500
  }
428
- if (classification.kind === 'mutate') {
501
+ if (classification.kind === 'cancel') {
502
+ if (context.running && context.currentAbortController) {
503
+ context.currentAbortController.abort();
504
+ await cancel?.(context);
505
+ return readOnlyControlResponse('cancel', classification, controlStatus(context, store), 'Runtime cancellation requested.', { accepted: true });
506
+ }
507
+ return readOnlyControlResponse('cancel', classification, status, 'No active run to cancel.', { accepted: false });
508
+ }
509
+ if (classification.kind === 'approve') {
510
+ const result = await approve?.(approvalRequestFromStatus(status));
511
+ return readOnlyControlResponse('approve', classification, controlStatus(context, store), result?.approved
512
+ ? 'Approval grant recorded for the current run revision.'
513
+ : 'Approval intent recorded; no pending approval was available to grant.', { accepted: result?.approved === true, extra: { approval: result ?? null } });
514
+ }
515
+ if (classification.kind === 'modify_run') {
429
516
  const proposal = storeControlProposal(context, input, classification, status);
430
517
  return {
431
518
  statusCode: 202,
432
519
  body: {
433
520
  accepted: true,
434
- kind: 'mutate',
521
+ kind: 'modify_run',
435
522
  classification,
436
523
  proposal,
437
524
  ...controlStatus(context, store),
@@ -439,9 +526,9 @@ function handleControlMessage(context, store, input, { intent = null, startNextC
439
526
  },
440
527
  };
441
528
  }
442
- if (classification.kind === 'enqueue') {
529
+ if (classification.kind === 'enqueue_run') {
443
530
  const item = enqueueControlRequest(context, input);
444
- // Unlike `mutate`, this may synchronously start a queued run (see
531
+ // Unlike `modify_run`, this may synchronously start a queued run (see
445
532
  // startNextControlRequest), which can change running/plan/status — a full
446
533
  // controlStatus() recompute is required here, not just controlQueue.
447
534
  void startNextControlRequest(context);
@@ -449,10 +536,11 @@ function handleControlMessage(context, store, input, { intent = null, startNextC
449
536
  statusCode: 202,
450
537
  body: {
451
538
  accepted: true,
452
- kind: 'enqueue',
539
+ kind: 'enqueue_run',
453
540
  classification,
454
541
  item,
455
542
  ...controlStatus(context, store),
543
+ explanation: 'Request queued for a future run.',
456
544
  },
457
545
  };
458
546
  }
@@ -462,8 +550,8 @@ function handleControlMessage(context, store, input, { intent = null, startNextC
462
550
  extra: {
463
551
  choices: [
464
552
  { action: 'message', intent: 'observe', label: 'Ask about this run' },
465
- { action: 'message', intent: 'mutate', label: 'Propose a change to this run' },
466
- { action: 'enqueue', intent: 'enqueue', label: 'Queue as a future run' },
553
+ { action: 'message', intent: 'modify_run', label: 'Propose a change to this run' },
554
+ { action: 'enqueue', intent: 'enqueue_run', label: 'Queue as a future run' },
467
555
  ],
468
556
  },
469
557
  });
@@ -510,7 +598,7 @@ function storeControlProposal(context, input, classification, status) {
510
598
  origin: 'runtime',
511
599
  runId: context.currentRunId ?? status.runId ?? null,
512
600
  workspace: proposal.workspace,
513
- payload: { input, intent: 'mutate', classification },
601
+ payload: { input, intent: 'modify_run', classification },
514
602
  }));
515
603
  dispatchAgentEvent(context.session, createAgentEvent('plan_patch_proposed', {
516
604
  origin: 'runtime',
@@ -636,17 +724,34 @@ function classifyControlMessage(input, status, forcedIntent = null) {
636
724
  // Caller (the /control message route) already trims and rejects empty input.
637
725
  const lower = String(input ?? '').toLowerCase();
638
726
  const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
639
- if (['observe', 'converse', 'mutate', 'enqueue'].includes(intent)) {
640
- return { kind: intent, confidence: 1, reason: 'explicit_intent' };
727
+ const explicit = {
728
+ observe: 'observe',
729
+ converse: 'converse',
730
+ approve: 'approve',
731
+ modify_run: 'modify_run',
732
+ mutate: 'modify_run',
733
+ enqueue_run: 'enqueue_run',
734
+ enqueue: 'enqueue_run',
735
+ cancel: 'cancel',
736
+ ambiguous: 'ambiguous',
737
+ }[intent];
738
+ if (explicit) {
739
+ return { kind: explicit, confidence: 1, reason: 'explicit_intent' };
740
+ }
741
+ if (/\b(valide tout|approve all|approve|approuve|valid[eé]|ok pour tout|go pour tout)\b/i.test(lower)) {
742
+ return { kind: 'approve', confidence: 0.86, reason: 'approval_request' };
743
+ }
744
+ if (/\b(cancel|annule|stop|arr[eê]te|interromps|abort)\b/i.test(lower)) {
745
+ return { kind: 'cancel', confidence: 0.86, reason: 'cancel_request' };
746
+ }
747
+ if (/\b(plus tard|later|ensuite|apr[eè]s ce run|enqueue|mets en file|met en file|futur|next run|future run)\b/i.test(lower)) {
748
+ return { kind: 'enqueue_run', confidence: 0.8, reason: 'future_run_request' };
641
749
  }
642
750
  if (/\b(o[uù] en es[t-]|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
643
751
  return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
644
752
  }
645
753
  if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
646
- return { kind: 'mutate', confidence: 0.78, reason: 'active_run_change_request' };
647
- }
648
- if (/\b(plus tard|later|ensuite|apr[eè]s ce run|apr[eè]s|enqueue|queue|mets en file|met en file|futur|next run|future run)\b/i.test(lower)) {
649
- return { kind: 'enqueue', confidence: 0.8, reason: 'future_run_request' };
754
+ return { kind: 'modify_run', confidence: 0.78, reason: 'active_run_change_request' };
650
755
  }
651
756
  if (status.running && /\b(lance|run|g[eé]n[eè]re|build|export|cr[eé]e|create|send|envoie|ingest|convert|importe|import)\b/i.test(lower)) {
652
757
  return { kind: 'ambiguous', confidence: 0.45, reason: 'active_run_action_is_ambiguous' };
@@ -688,6 +793,18 @@ function readRequiredPatchId(body, response) {
688
793
  return patchId;
689
794
  }
690
795
 
796
+ function readOptionalNumber(value) {
797
+ if (value == null || value === '') return null;
798
+ const number = Number(value);
799
+ return Number.isFinite(number) ? number : null;
800
+ }
801
+
802
+ function readOptionalList(value) {
803
+ if (value == null || value === '') return [];
804
+ if (Array.isArray(value)) return value.map(String).map((item) => item.trim()).filter(Boolean);
805
+ return String(value).split(',').map((item) => item.trim()).filter(Boolean);
806
+ }
807
+
691
808
  function readJson(request) {
692
809
  return new Promise((resolve, reject) => {
693
810
  let raw = '';
@@ -128,6 +128,64 @@ test('runtime server shutdown endpoint closes the listener', async (t) => {
128
128
  }));
129
129
  });
130
130
 
131
+ test('runtime server exposes task assignment attempt and result read endpoints', async (t) => {
132
+ const taskId = 'run-1:task-a';
133
+ let handle;
134
+ try {
135
+ handle = await startRuntimeServer({
136
+ host: '127.0.0.1',
137
+ port: 0,
138
+ token: 'runtime-secret',
139
+ store: {
140
+ dbPath: ':memory:',
141
+ getState: () => ({ status: 'idle' }),
142
+ listEvents: () => [],
143
+ listTasks: ({ runId }) => runId === 'run-1' ? [{ id: taskId, runId, status: 'done' }] : [],
144
+ listTaskAttempts: ({ taskId: requested }) => requested === taskId ? [{
145
+ attemptId: 'attempt-1',
146
+ taskId,
147
+ runId: 'run-1',
148
+ status: 'done',
149
+ jobId: 'job-1',
150
+ }] : [],
151
+ getTaskResult: ({ taskId: requested }) => requested === taskId ? {
152
+ attemptId: 'attempt-1',
153
+ taskId,
154
+ status: 'succeeded',
155
+ outputRefs: [{ type: 'file', ref: 'deliverables/a.md' }],
156
+ metrics: { durationMs: 7 },
157
+ } : null,
158
+ },
159
+ session: {},
160
+ run: async () => {},
161
+ });
162
+ } catch (err) {
163
+ if (err?.code === 'EPERM') {
164
+ t.skip('network listen is not permitted in this sandbox');
165
+ return;
166
+ }
167
+ throw err;
168
+ }
169
+
170
+ try {
171
+ const headers = { authorization: 'Bearer runtime-secret' };
172
+ const tasksResponse = await fetch(`http://127.0.0.1:${handle.port}/runs/run-1/tasks`, { headers });
173
+ assert.equal(tasksResponse.status, 200);
174
+ assert.deepEqual((await tasksResponse.json()).tasks, [{ id: taskId, runId: 'run-1', status: 'done' }]);
175
+
176
+ const encodedTaskId = encodeURIComponent(taskId);
177
+ const attemptsResponse = await fetch(`http://127.0.0.1:${handle.port}/tasks/${encodedTaskId}/attempts`, { headers });
178
+ assert.equal(attemptsResponse.status, 200);
179
+ assert.equal((await attemptsResponse.json()).attempts[0].attemptId, 'attempt-1');
180
+
181
+ const resultResponse = await fetch(`http://127.0.0.1:${handle.port}/tasks/${encodedTaskId}/result`, { headers });
182
+ assert.equal(resultResponse.status, 200);
183
+ assert.equal((await resultResponse.json()).result.metrics.durationMs, 7);
184
+ } finally {
185
+ await handle.close();
186
+ }
187
+ });
188
+
131
189
  test('runtime server accepts only one active run', async (t) => {
132
190
  let releaseRun;
133
191
  let runCount = 0;
@@ -157,7 +215,6 @@ test('runtime server accepts only one active run', async (t) => {
157
215
 
158
216
  try {
159
217
  const url = `http://127.0.0.1:${handle.port}/run`;
160
- let acceptedRun = null;
161
218
  const [first, second] = await Promise.all([
162
219
  fetch(url, {
163
220
  method: 'POST',
@@ -171,9 +228,11 @@ test('runtime server accepts only one active run', async (t) => {
171
228
  }),
172
229
  ]);
173
230
 
174
- assert.deepEqual([first.status, second.status].sort(), [202, 409]);
175
- const accepted = first.status === 202 ? first : second;
176
- acceptedRun = await accepted.json();
231
+ assert.deepEqual([first.status, second.status], [202, 202]);
232
+ 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');
177
236
  assert.equal(acceptedRun.accepted, true);
178
237
  assert.match(acceptedRun.runId, /^[0-9a-f-]{36}$/);
179
238
  assert.equal(runCount, 1);
@@ -336,12 +395,13 @@ test('runtime server isolates active runs by workspace', async (t) => {
336
395
  ]);
337
396
  assert.deepEqual([juno.status, docs.status], [202, 202]);
338
397
 
339
- const conflict = await fetch(`${url}?workspace=juno`, {
398
+ const queued = await fetch(`${url}?workspace=juno`, {
340
399
  method: 'POST',
341
400
  headers: { 'Content-Type': 'application/json' },
342
401
  body: JSON.stringify({ input: 'third' }),
343
402
  });
344
- assert.equal(conflict.status, 409);
403
+ assert.equal(queued.status, 202);
404
+ assert.equal((await queued.json()).queued, true);
345
405
  assert.deepEqual(runWorkspaces.sort(), ['docs', 'juno']);
346
406
  } finally {
347
407
  releases.get('juno')?.();
@@ -521,9 +581,15 @@ test('runtime server exposes approval endpoint', async (t) => {
521
581
  assert.equal(response.status, 202);
522
582
  assert.deepEqual(approved, {
523
583
  workspace: 'juno',
584
+ workspaceId: 'juno',
524
585
  runId: 'run-1',
525
586
  itemId: 'item-1',
526
587
  approvalId: null,
588
+ scope: null,
589
+ taskId: null,
590
+ groupId: null,
591
+ planRevision: null,
592
+ approvalClasses: [],
527
593
  });
528
594
  assert.deepEqual(await response.json(), { approved: true, runId: 'run-1', itemId: 'item-1' });
529
595
  } finally {
@@ -705,6 +771,70 @@ test('runtime server control message observes an active run without enqueueing',
705
771
  }
706
772
  });
707
773
 
774
+ test('runtime server control message handles approve and cancel intents during an active run', async (t) => {
775
+ const session = {
776
+ workspace: 'juno',
777
+ controlQueue: [],
778
+ };
779
+ const abortController = new AbortController();
780
+ let cancelled = false;
781
+ let handle;
782
+ try {
783
+ handle = await startRuntimeServer({
784
+ host: '127.0.0.1',
785
+ port: 0,
786
+ store: {
787
+ dbPath: ':memory:',
788
+ getState: () => ({
789
+ status: 'running',
790
+ plan: [{ step: 1, description: 'Build documents', status: 'running' }],
791
+ queue: [],
792
+ approvals: [],
793
+ summary: null,
794
+ }),
795
+ listEvents: () => [],
796
+ },
797
+ getContext: async () => ({
798
+ workspace: 'juno',
799
+ session,
800
+ running: true,
801
+ currentAbortController: abortController,
802
+ }),
803
+ cancel: async () => { cancelled = true; },
804
+ run: async () => {},
805
+ });
806
+ } catch (err) {
807
+ if (err?.code === 'EPERM') {
808
+ t.skip('network listen is not permitted in this sandbox');
809
+ return;
810
+ }
811
+ throw err;
812
+ }
813
+
814
+ try {
815
+ const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
816
+ method: 'POST',
817
+ headers: { 'Content-Type': 'application/json' },
818
+ body: JSON.stringify({ action: 'message', input: 'valide tout' }),
819
+ });
820
+ assert.equal(approveResponse.status, 200);
821
+ assert.equal((await approveResponse.json()).kind, 'approve');
822
+
823
+ const cancelResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
824
+ method: 'POST',
825
+ headers: { 'Content-Type': 'application/json' },
826
+ body: JSON.stringify({ action: 'message', input: 'annule le run' }),
827
+ });
828
+ assert.equal(cancelResponse.status, 200);
829
+ assert.equal((await cancelResponse.json()).kind, 'cancel');
830
+ assert.equal(cancelled, true);
831
+ assert.equal(abortController.signal.aborted, true);
832
+ assert.equal(session.controlQueue.length, 0);
833
+ } finally {
834
+ await handle.close();
835
+ }
836
+ });
837
+
708
838
  test('runtime server control message records active plan mutation as a proposal', async (t) => {
709
839
  const session = {
710
840
  workspace: 'juno',
@@ -765,7 +895,7 @@ test('runtime server control message records active plan mutation as a proposal'
765
895
  });
766
896
  assert.equal(response.status, 202);
767
897
  const body = await response.json();
768
- assert.equal(body.kind, 'mutate');
898
+ assert.equal(body.kind, 'modify_run');
769
899
  assert.equal(body.proposal.status, 'proposed');
770
900
  assert.equal(body.proposal.input, 'Ajoute un envoi après chaque génération');
771
901
  assert.equal(session.agentProjection.planPatches[0].status, 'proposed');