@dotdrelle/wiki-manager 0.12.1 → 0.12.8

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 (43) hide show
  1. package/README.md +49 -8
  2. package/bin/wiki-manager +23 -12
  3. package/docker-compose.yml +0 -1
  4. package/mcp.endpoints.example.json +0 -6
  5. package/package.json +3 -2
  6. package/src/activity/activityAggregator.js +34 -3
  7. package/src/activity/activityAggregator.test.js +32 -0
  8. package/src/agent/graph.js +272 -22
  9. package/src/agent/graph.test.js +357 -1
  10. package/src/cli/wiki-manager.js +33 -2
  11. package/src/commands/slash.js +74 -1
  12. package/src/commands/slash.test.js +36 -0
  13. package/src/core/activity.js +4 -0
  14. package/src/core/activity.test.js +9 -1
  15. package/src/core/agentEvents.js +30 -0
  16. package/src/core/agentEvents.test.js +42 -1
  17. package/src/core/buildInfo.js +58 -0
  18. package/src/core/buildInfo.json +4 -0
  19. package/src/core/buildInfo.test.js +14 -0
  20. package/src/core/env.js +30 -1
  21. package/src/core/mcp.js +21 -1
  22. package/src/core/mcp.test.js +25 -0
  23. package/src/core/runtimeLog.js +6 -1
  24. package/src/core/runtimeLog.test.js +3 -1
  25. package/src/runtime/client.js +22 -0
  26. package/src/runtime/controlMessages.js +2 -2
  27. package/src/runtime/recoveryManager.js +54 -0
  28. package/src/runtime/recoveryManager.test.js +73 -0
  29. package/src/runtime/runner.js +49 -13
  30. package/src/runtime/runner.test.js +196 -0
  31. package/src/runtime/server.js +67 -8
  32. package/src/runtime/server.test.js +223 -6
  33. package/src/runtime/store.js +48 -3
  34. package/src/runtime/store.test.js +32 -0
  35. package/src/runtime/supervisor.js +77 -1
  36. package/src/shell/RightPane.tsx +124 -31
  37. package/src/shell/SetupWizard.tsx +13 -1
  38. package/src/shell/repl.js +78 -9
  39. package/src/shell/repl.test.js +114 -1
  40. package/src/shell/tui.tsx +4 -1
  41. package/src/shell/useAgent.ts +32 -3
  42. package/src/shell/useSession.ts +14 -1
  43. package/wiki-workspace +33 -0
@@ -1,7 +1,7 @@
1
1
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
2
- import { sessionActivities, terminalFailures } from '../core/activity.js';
2
+ import { isCancelledStatus, sessionActivities, terminalFailures } from '../core/activity.js';
3
3
  import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
4
- import { formatPlanStatus } from '../core/plan.js';
4
+ import { formatPlanStatus, formatPlanStep } from '../core/plan.js';
5
5
  import { readyPlanTasks, sanitizePlanForExecution } from '../core/planPatch.js';
6
6
  import { createAssignmentManager } from '../orchestrator/assignmentManager.js';
7
7
  import { createAttemptManager } from '../orchestrator/attemptManager.js';
@@ -134,6 +134,19 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
134
134
  continue;
135
135
  }
136
136
  }
137
+ if (!trigger && isCancelledOnlyLoopResult(result)) {
138
+ dispatchAgentEvent(session, createAgentEvent('run_cancelled', {
139
+ origin: 'runtime',
140
+ runId,
141
+ payload: {
142
+ runId,
143
+ cancelled: true,
144
+ message: 'Runtime run cancelled by user.',
145
+ },
146
+ }));
147
+ emitRuntimeLog(session, 'runtime: run ended by user cancellation (no replan)');
148
+ return { ok: false, result, cancelled: true };
149
+ }
137
150
  dispatchAgentEvent(session, createAgentEvent('run_error', {
138
151
  origin: 'runtime',
139
152
  runId,
@@ -800,6 +813,7 @@ export async function replanRuntimeRun(session, input, trigger, {
800
813
  'Given the original objective, current plan, and failure reason, return only the remaining steps required.',
801
814
  'Do not include steps that are already done.',
802
815
  'Return only JSON with this exact shape: {"steps":["..."]}.',
816
+ 'Each step MUST be a plain string, not an object.',
803
817
  ].join('\n'),
804
818
  tools: [],
805
819
  messages: [{ role: 'user', content: buildReplanPrompt(input, session, trigger) }],
@@ -825,10 +839,12 @@ export async function replanRuntimeRun(session, input, trigger, {
825
839
  steps: mergedSteps,
826
840
  },
827
841
  }));
828
- if (hasMutatingReplanStep(steps)) {
829
- session._runApprovalRequired = true;
830
- session._runApprovalResolved = false;
831
- }
842
+ // Every replan requires approval: deciding "is this step mutating?" with
843
+ // a verb regex was a safety judgement made by pattern-matching — a
844
+ // missed verb silently skipped the approval gate. Replans are rare
845
+ // (technical failures only) and re-executing work deserves a human OK.
846
+ session._runApprovalRequired = true;
847
+ session._runApprovalResolved = false;
832
848
  return { ok: true, steps };
833
849
  } catch (err) {
834
850
  return { ok: false, reason: err instanceof Error ? err.message : String(err) };
@@ -847,7 +863,12 @@ function replanTriggerFromLoopResult(result) {
847
863
  };
848
864
  }
849
865
  const failures = terminalFailures(result.completed ?? []);
850
- const failure = failures[0];
866
+ const technical = failures.filter((failure) => !isCancelledStatus(failure.status));
867
+ const cancelled = failures.filter((failure) => isCancelledStatus(failure.status));
868
+ if (cancelled.length > 0 && technical.length === 0 && (result.failures ?? []).every(isCancelledTaskFailure)) {
869
+ return null;
870
+ }
871
+ const failure = technical[0];
851
872
  if (failure) {
852
873
  return {
853
874
  kind: 'activity_error',
@@ -859,7 +880,7 @@ function replanTriggerFromLoopResult(result) {
859
880
  // The parallel scheduler can fail a task before it ever produces an
860
881
  // _activity (e.g. a thrown error on the first turn) — that failure lives
861
882
  // in result.failures, not in any activity, so it must be checked too.
862
- const taskFailure = (result.failures ?? [])[0];
883
+ const taskFailure = (result.failures ?? []).find((failure) => !isCancelledTaskFailure(failure));
863
884
  if (!taskFailure) return null;
864
885
  return {
865
886
  kind: 'task_error',
@@ -869,6 +890,25 @@ function replanTriggerFromLoopResult(result) {
869
890
  };
870
891
  }
871
892
 
893
+ function isCancelledTaskFailure(failure) {
894
+ return failure?.cancelled === true
895
+ || failure?.result?.cancelled === true
896
+ || isCancelledStatus(failure?.status)
897
+ || isCancelledStatus(failure?.result?.status);
898
+ }
899
+
900
+ function isCancelledOnlyLoopResult(result) {
901
+ const failures = terminalFailures(result.completed ?? []);
902
+ const technicalActivities = failures.filter((failure) => !isCancelledStatus(failure.status));
903
+ const cancelledActivities = failures.filter((failure) => isCancelledStatus(failure.status));
904
+ const taskFailures = result.failures ?? [];
905
+ const technicalTasks = taskFailures.filter((failure) => !isCancelledTaskFailure(failure));
906
+ const cancelledTasks = taskFailures.filter(isCancelledTaskFailure);
907
+ return technicalActivities.length === 0
908
+ && technicalTasks.length === 0
909
+ && (cancelledActivities.length > 0 || cancelledTasks.length > 0);
910
+ }
911
+
872
912
  function runtimeLoopErrorMessage(result) {
873
913
  if (result.timedOut) return 'Runtime agentic loop timed out.';
874
914
  if (result.maxTurns) return 'Runtime agentic loop reached max turns.';
@@ -912,15 +952,11 @@ function buildReplannedRunPrompt(input, trigger, steps) {
912
952
  function normalizeReplan(steps) {
913
953
  if (!Array.isArray(steps)) return [];
914
954
  return steps
915
- .map((step) => String(step ?? '').trim())
955
+ .map((step) => formatPlanStep(step).trim())
916
956
  .filter(Boolean)
917
957
  .slice(0, 12);
918
958
  }
919
959
 
920
- function hasMutatingReplanStep(steps) {
921
- return steps.some((step) => /\b(build|copy|ingest|import|export|polish|pipeline|write|create|delete|update|send|deploy|publish|generate|construire|copier|importer|exporter|publier|envoyer|supprimer|modifier|creer|générer|generer)\b/i.test(step));
922
- }
923
-
924
960
  function mergeReplanWithCompleted(plan, steps) {
925
961
  const completed = (plan ?? [])
926
962
  .filter((step) => step.status === 'done')
@@ -259,6 +259,30 @@ test('replanRuntimeRun preserves completed outputs when replacing remaining work
259
259
  assert.deepEqual(session.headlessPlan[1].dependsOn, ['export']);
260
260
  });
261
261
 
262
+ test('replanRuntimeRun normalizes object steps without leaking object strings', async () => {
263
+ const session = {
264
+ activities: {},
265
+ headlessPlan: [{ step: 1, id: 'done', description: 'Done', status: 'done' }],
266
+ llm: {
267
+ async completeWithTools({ system }) {
268
+ assert.match(system, /Each step MUST be a plain string, not an object\./);
269
+ return { content: '{"steps":["a",{"description":"b"},{"id":"c"},{},null]}' };
270
+ },
271
+ },
272
+ };
273
+
274
+ const result = await replanRuntimeRun(session, 'Build deliverable', {
275
+ kind: 'activity_error',
276
+ reason: 'Build failed.',
277
+ }, { runId: 'run-replan-object-steps', replansLeft: 1 });
278
+
279
+ assert.equal(result.ok, true);
280
+ assert.deepEqual(result.steps, ['a', 'b', 'c']);
281
+ assert.deepEqual(session.agentProjection.replans[0].plan, ['a', 'b', 'c']);
282
+ assert.deepEqual(session.headlessPlan.slice(1).map((step) => step.description), ['a', 'b', 'c']);
283
+ assert.equal(JSON.stringify(session.headlessPlan).includes('[object Object]'), false);
284
+ });
285
+
262
286
  test('replanRuntimeRun requires runtime approval for mutating replanned work', async () => {
263
287
  const session = {
264
288
  activities: {},
@@ -370,6 +394,154 @@ test('runRuntimeAgenticWorkflow replans after terminal activity error', async ()
370
394
  }
371
395
  });
372
396
 
397
+ test('runRuntimeAgenticWorkflow treats user-cancelled activity as terminal cancellation without replan', async () => {
398
+ const originalFetch = globalThis.fetch;
399
+ let pollAttempts = 0;
400
+ const events = [];
401
+ globalThis.fetch = async () => {
402
+ pollAttempts += 1;
403
+ return mcpActivityResponse({
404
+ id: 'job-cancelled',
405
+ source: 'production',
406
+ label: 'Production build',
407
+ status: 'cancelled',
408
+ terminal: true,
409
+ });
410
+ };
411
+ const session = {
412
+ mcp: {
413
+ production: {
414
+ status: 'connected',
415
+ url: 'http://127.0.0.1:3000/mcp/',
416
+ retry: { maxAttempts: 1, backoffMs: 0 },
417
+ },
418
+ },
419
+ activities: {},
420
+ headlessPlan: null,
421
+ llm: {
422
+ async completeWithTools() {
423
+ assert.fail('cancelled activity must not call evaluator or replanner');
424
+ },
425
+ },
426
+ _onAgentEvent: (event) => events.push(event),
427
+ };
428
+ const agent = {
429
+ async invoke({ session: turnSession }) {
430
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
431
+ payload: {
432
+ activity: runningPollableActivity('job-cancelled', 'Production build'),
433
+ },
434
+ }));
435
+ return { response: 'Started build.' };
436
+ },
437
+ };
438
+
439
+ try {
440
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'Build workspace', {
441
+ runId: 'run-cancelled-activity',
442
+ timeoutMs: 1000,
443
+ maxTurns: 1,
444
+ maxReplans: 1,
445
+ });
446
+
447
+ assert.equal(result.ok, false);
448
+ assert.equal(result.cancelled, true);
449
+ assert.equal(pollAttempts, 1);
450
+ assert.equal(events.some((event) => event.type === 'run_replanned'), false);
451
+ assert.ok(events.some((event) => event.type === 'run_cancelled' && event.payload?.cancelled === true));
452
+ assert.equal(session.agentProjection.status, 'cancelled');
453
+ assert.match(session.agentProjection.logs.join('\n'), /run ended by user cancellation \(no replan\)/);
454
+ } finally {
455
+ globalThis.fetch = originalFetch;
456
+ }
457
+ });
458
+
459
+ test('runRuntimeAgenticWorkflow replans from failed activity when another activity is cancelled', async () => {
460
+ const originalFetch = globalThis.fetch;
461
+ const events = [];
462
+ const pollStatuses = new Map([
463
+ ['job-failed', {
464
+ id: 'job-failed',
465
+ source: 'production',
466
+ label: 'Production build',
467
+ status: 'failed',
468
+ terminal: true,
469
+ error: 'build failed',
470
+ }],
471
+ ['job-cancelled', {
472
+ id: 'job-cancelled',
473
+ source: 'production',
474
+ label: 'Production cleanup',
475
+ status: 'cancelled',
476
+ terminal: true,
477
+ }],
478
+ ]);
479
+ globalThis.fetch = async (_url, options = {}) => {
480
+ const body = JSON.parse(String(options.body ?? '{}'));
481
+ return mcpActivityResponse(pollStatuses.get(body.params?.arguments?.jobId));
482
+ };
483
+ const session = {
484
+ mcp: {
485
+ production: {
486
+ status: 'connected',
487
+ url: 'http://127.0.0.1:3000/mcp/',
488
+ retry: { maxAttempts: 1, backoffMs: 0 },
489
+ },
490
+ },
491
+ activities: {},
492
+ headlessPlan: null,
493
+ llm: {
494
+ async completeWithTools({ system }) {
495
+ if (/replanner/.test(system)) return { content: '{"steps":["Retry build"]}' };
496
+ return { content: '{"ok":true,"reason":"Build complete.","suggestedAction":null}' };
497
+ },
498
+ },
499
+ _onAgentEvent: (event) => events.push(event),
500
+ };
501
+ let turns = 0;
502
+ const agent = {
503
+ async invoke({ session: turnSession }) {
504
+ turns += 1;
505
+ if (turns === 1) {
506
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
507
+ payload: { activity: runningPollableActivity('job-failed', 'Production build') },
508
+ }));
509
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
510
+ payload: { activity: runningPollableActivity('job-cancelled', 'Production cleanup') },
511
+ }));
512
+ return { response: 'Started build.' };
513
+ }
514
+ const pending = turnSession.headlessPlan?.find((step) => step.status === 'pending');
515
+ if (pending) {
516
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
517
+ origin: 'tool',
518
+ payload: { taskId: pending.id, status: 'done' },
519
+ }));
520
+ }
521
+ return { response: 'Retry done.' };
522
+ },
523
+ };
524
+
525
+ try {
526
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'Build workspace', {
527
+ runId: 'run-failed-and-cancelled',
528
+ timeoutMs: 1000,
529
+ maxTurns: 3,
530
+ maxReplans: 1,
531
+ });
532
+
533
+ assert.equal(result.ok, true);
534
+ assert.equal(turns, 2);
535
+ assert.equal(events.some((event) => event.type === 'run_cancelled'), false);
536
+ assert.ok(events.some((event) => event.type === 'run_replanned'));
537
+ assert.equal(session.agentProjection.replans[0].reason, 'Production build ended with failed: build failed');
538
+ assert.deepEqual(session.agentProjection.replans[0].plan, ['Retry build']);
539
+ assert.equal(session.agentProjection.status, 'done');
540
+ } finally {
541
+ globalThis.fetch = originalFetch;
542
+ }
543
+ });
544
+
373
545
  test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async () => {
374
546
  const session = {
375
547
  activities: {},
@@ -707,6 +879,30 @@ function productionAgent() {
707
879
  };
708
880
  }
709
881
 
882
+ function runningPollableActivity(id, label) {
883
+ return {
884
+ id,
885
+ source: 'production',
886
+ label,
887
+ status: 'running',
888
+ terminal: false,
889
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: id }, intervalMs: 0 },
890
+ };
891
+ }
892
+
893
+ function mcpActivityResponse(activity) {
894
+ return {
895
+ ok: true,
896
+ status: 200,
897
+ headers: { get: () => null },
898
+ text: async () => JSON.stringify({
899
+ result: {
900
+ content: [{ type: 'text', text: JSON.stringify({ _activity: activity }) }],
901
+ },
902
+ }),
903
+ };
904
+ }
905
+
710
906
  function toolResult(payload) {
711
907
  return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
712
908
  }
@@ -242,15 +242,19 @@ export function startRuntimeServer({
242
242
  }
243
243
  validateContractInDev('runRequest', { ...body, input });
244
244
  if (context.running) {
245
- const item = enqueueControlRequest(context, input);
246
- sendJson(response, 202, {
247
- accepted: true,
248
- queued: true,
249
- kind: 'enqueue_run',
250
- item,
251
- ...controlStatus(context, store),
252
- explanation: controlMessage(context?.session, 'queued_for_future_run'),
245
+ // Blind enqueueing made every message typed during a run
246
+ // invisible until the run ended (serve UI symptom: "no result
247
+ // until the job finished or was stopped"). Classify instead:
248
+ // observe → immediate status explanation, cancel → abort,
249
+ // ambiguous action → explicit choices, explicit "later" intent
250
+ // (body.intent = 'enqueue') → enqueue as before.
251
+ const result = await handleControlMessage(context, store, input, {
252
+ intent: body.intent,
253
+ startNextControlRequest,
254
+ cancel,
255
+ approve,
253
256
  });
257
+ sendJson(response, result.statusCode, result.body);
254
258
  return;
255
259
  }
256
260
  const accepted = startRuntimeRun(context, body);
@@ -274,6 +278,16 @@ export function startRuntimeServer({
274
278
  sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
275
279
  return;
276
280
  }
281
+ if (request.method === 'POST' && url.pathname === '/kill') {
282
+ const body = await readJson(request);
283
+ const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
284
+ const runId = url.searchParams.get('runId') ?? body.runId ?? null;
285
+ const context = await resolveContext({ workspace });
286
+ const result = await killRuntimeRuns(context, { workspace, runId });
287
+ publishState(context.workspace ?? workspace ?? null, context);
288
+ sendJson(response, 202, result);
289
+ return;
290
+ }
277
291
  if (request.method === 'POST' && url.pathname === '/shutdown') {
278
292
  const workspace = workspaceFromUrl(url);
279
293
  const context = await resolveContext({ workspace });
@@ -355,6 +369,23 @@ export function startRuntimeServer({
355
369
  return { body, workspace, context };
356
370
  }
357
371
 
372
+ async function killRuntimeRuns(context, { workspace = null, runId = null } = {}) {
373
+ const targetWorkspace = context?.workspace ?? workspace ?? null;
374
+ const targetRunId = runId ? String(runId) : null;
375
+ if (!targetRunId || targetRunId === context?.currentRunId) {
376
+ context?.currentAbortController?.abort();
377
+ await cancel?.(context);
378
+ }
379
+ const runs = typeof store.interruptRuns === 'function'
380
+ ? store.interruptRuns({ workspace: targetWorkspace, runId: targetRunId, reason: 'Runtime run killed by user.' })
381
+ : 0;
382
+ const tasks = typeof store.cancelActiveTasksForInterruptedRuns === 'function'
383
+ ? store.cancelActiveTasksForInterruptedRuns({ workspace: targetWorkspace, runId: targetRunId })
384
+ : 0;
385
+ const queued = cancelQueuedControlItems(context?.session, targetWorkspace);
386
+ return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued };
387
+ }
388
+
358
389
  function startRuntimeRun(context, body, { controlItemId = null } = {}) {
359
390
  const runId = randomUUID();
360
391
  const runWorkspace = context.workspace ?? body.workspace ?? null;
@@ -472,6 +503,34 @@ function controlQueueFor(session) {
472
503
  return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
473
504
  }
474
505
 
506
+ function cancelQueuedControlItems(session, workspace = null) {
507
+ const now = new Date().toISOString();
508
+ const counted = new Set();
509
+ let count = 0;
510
+ for (const queue of [controlQueueFor(session), session?.agentProjection?.controlQueue].filter(Array.isArray)) {
511
+ for (const item of queue) {
512
+ if (item.status !== 'queued') continue;
513
+ if (workspace && item.workspace && item.workspace !== workspace) continue;
514
+ item.status = 'cancelled';
515
+ item.finishedAt = now;
516
+ item.updatedAt = now;
517
+ const id = item.id ?? item.runId ?? `${item.input}:${item.createdAt}`;
518
+ if (!counted.has(id)) {
519
+ counted.add(id);
520
+ if (session) {
521
+ dispatchAgentEvent(session, createAgentEvent('control_cancelled', {
522
+ origin: 'runtime',
523
+ workspace: item.workspace ?? workspace ?? null,
524
+ payload: { id, finishedAt: now },
525
+ }));
526
+ }
527
+ count += 1;
528
+ }
529
+ }
530
+ }
531
+ return count;
532
+ }
533
+
475
534
  function readOnlyControlResponse(kind, classification, status, explanation, { accepted = true, extra = {} } = {}) {
476
535
  return {
477
536
  statusCode: 200,