@dotdrelle/wiki-manager 0.14.13 → 0.14.20

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 (46) hide show
  1. package/.env.example +29 -4
  2. package/README.md +19 -0
  3. package/docker-compose.yml +6 -1
  4. package/package.json +1 -1
  5. package/src/activity/activityAggregator.js +50 -16
  6. package/src/activity/activityAggregator.test.js +67 -4
  7. package/src/agent/graph.js +79 -11
  8. package/src/agent/graph.test.js +43 -3
  9. package/src/cli/wiki-manager.js +74 -11
  10. package/src/cli/wiki-manager.test.js +40 -1
  11. package/src/commands/slash.js +10 -3
  12. package/src/commands/slash.test.js +24 -0
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/dockerCompose.test.js +4 -0
  15. package/src/core/env.test.js +3 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/wikiSetup.js +35 -0
  18. package/src/core/wikiWorkspace.test.js +20 -0
  19. package/src/core/workflow.js +72 -0
  20. package/src/core/workflow.test.js +57 -0
  21. package/src/core/workspaces.js +10 -2
  22. package/src/orchestrator/dependencyResolver.js +19 -1
  23. package/src/orchestrator/objectiveResolver.js +24 -0
  24. package/src/orchestrator/objectiveResolver.test.js +23 -1
  25. package/src/orchestrator/scheduler.js +27 -7
  26. package/src/orchestrator/scheduler.test.js +45 -1
  27. package/src/runtime/auth.test.js +65 -1
  28. package/src/runtime/client.js +4 -0
  29. package/src/runtime/donna-contract.test.js +2 -0
  30. package/src/runtime/lifecycle.js +21 -12
  31. package/src/runtime/runner.js +141 -18
  32. package/src/runtime/runner.test.js +30 -0
  33. package/src/runtime/server.js +13 -2
  34. package/src/runtime/server.test.js +30 -1
  35. package/src/runtime/store.js +54 -0
  36. package/src/runtime/store.test.js +42 -0
  37. package/src/shell/FileEditorDialog.tsx +2 -2
  38. package/src/shell/LeftPane.tsx +60 -15
  39. package/src/shell/RightPane.tsx +168 -56
  40. package/src/shell/StartupScreen.tsx +3 -7
  41. package/src/shell/renderer.ts +1 -0
  42. package/src/shell/repl.js +7 -81
  43. package/src/shell/repl.test.js +141 -38
  44. package/src/shell/tui.tsx +65 -65
  45. package/src/shell/useSession.ts +92 -6
  46. package/wiki-workspace +28 -0
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
4
  import { createInteractiveSession, ensureInteractiveAssistantMessage } from '../cli/wiki-manager.js';
5
- import { runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
5
+ import { approvalRequestFromStatus, runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
6
6
 
7
7
  // Most server tests exercise endpoint behavior rather than authentication. Keep
8
8
  // them independent from a developer's WIKI_MANAGER_RUNTIME_TOKEN environment;
@@ -11,6 +11,35 @@ function startRuntimeServer(options) {
11
11
  return startRuntimeServerImpl({ token: '', ...options });
12
12
  }
13
13
 
14
+ test('approval fallback derives classes from waiting tasks when the approval queue is missing', () => {
15
+ const request = approvalRequestFromStatus({
16
+ workspace: 'acme',
17
+ runId: 'run-1',
18
+ planRevision: 4,
19
+ approvals: [],
20
+ plan: [
21
+ { id: 'apply', status: 'waiting_approval', requiresApproval: true, approvalClass: 'mutation' },
22
+ { id: 'export', status: 'waiting_approval', requiresApproval: true, approvalClass: 'external-write' },
23
+ ],
24
+ });
25
+
26
+ assert.deepEqual(request.approvalClasses, ['mutation', 'external-write']);
27
+ assert.equal(request.scope, 'run');
28
+ assert.equal(request.planRevision, 4);
29
+ });
30
+
31
+ test('approval fallback excludes tasks blocked by failed dependencies', () => {
32
+ const request = approvalRequestFromStatus({
33
+ workspace: 'acme', runId: 'run-1', planRevision: 4, approvals: [],
34
+ plan: [
35
+ { id: 'failed-source', status: 'failed' },
36
+ { id: 'blocked-write', status: 'waiting_approval', requiresApproval: true, approvalClass: 'external-write', dependsOn: ['failed-source'] },
37
+ ],
38
+ });
39
+
40
+ assert.deepEqual(request.approvalClasses, ['default']);
41
+ });
42
+
14
43
  test('interactive runtime sessions isolate canonical run state', () => {
15
44
  const mcp = { wiki: { status: 'connected' } };
16
45
  const session = createInteractiveSession({ session: {
@@ -541,6 +541,12 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
541
541
  const listApprovalGrantsByRunStatement = db.prepare(`
542
542
  SELECT * FROM approval_grants WHERE run_id = ? ORDER BY created_at ASC, id ASC
543
543
  `);
544
+ const listPendingApprovalGrantsByRunStatement = db.prepare(`
545
+ SELECT * FROM approval_grants WHERE run_id = ? AND status = 'pending_approval'
546
+ `);
547
+ const markApprovalGrantApprovedStatement = db.prepare(`
548
+ UPDATE approval_grants SET status = 'approved', granted_at = ? WHERE id = ?
549
+ `);
544
550
 
545
551
  function persistEvent(event) {
546
552
  if (NON_PERSISTED_EVENT_TYPES.has(event.type)) return event;
@@ -687,10 +693,12 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
687
693
  const delResults = db.prepare('DELETE FROM task_results WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
688
694
  const delAssignments = db.prepare('DELETE FROM task_assignments WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
689
695
  const delAttempts = db.prepare('DELETE FROM task_attempts WHERE run_id = ?');
696
+ const delApprovals = db.prepare('DELETE FROM approval_grants WHERE run_id = ?');
690
697
  for (const runId of runIds) {
691
698
  delResults.run(runId);
692
699
  delAssignments.run(runId);
693
700
  delAttempts.run(runId);
701
+ delApprovals.run(runId);
694
702
  }
695
703
  const events = (workspace
696
704
  ? db.prepare('DELETE FROM events WHERE workspace = ?').run(workspace)
@@ -1020,6 +1028,29 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1020
1028
  status === 'approved' ? (payload.grantedAt ?? event.ts) : null,
1021
1029
  status === 'rejected' ? (payload.rejectedAt ?? event.ts) : null,
1022
1030
  );
1031
+ // Mirror the in-memory projection's markCoveredApprovalsApproved into the
1032
+ // persisted table: a broad (run/group) grant covers the per-task
1033
+ // pending_approval requests, so mark them approved here too. Without this,
1034
+ // the scheduler proceeds (the projection knows they're covered) but the
1035
+ // approval_grants table keeps them pending_approval forever — orphaned rows
1036
+ // that pile up (32 per run observed) and survive run purges.
1037
+ if (status === 'approved') {
1038
+ const grantedAt = payload.grantedAt ?? event.ts;
1039
+ const grant = {
1040
+ scope: String(payload.scope ?? 'run'),
1041
+ runId: String(runId),
1042
+ workspaceId: event.workspace ?? payload.workspaceId ?? payload.workspace ?? null,
1043
+ planRevision: payload.planRevision == null ? null : Number(payload.planRevision),
1044
+ groupId: payload.groupId ?? null,
1045
+ approvalClasses: normalizeApprovalClasses(payload.approvalClasses ?? payload.approvalClass),
1046
+ ids: new Set([payload.taskId, payload.itemId, payload.approvalId, payload.id, event.taskId]
1047
+ .filter(Boolean).map(String)),
1048
+ };
1049
+ for (const row of listPendingApprovalGrantsByRunStatement.all(String(runId))) {
1050
+ if (String(row.id) === String(id)) continue;
1051
+ if (persistedGrantCovers(grant, row)) markApprovalGrantApprovedStatement.run(grantedAt, row.id);
1052
+ }
1053
+ }
1023
1054
  }
1024
1055
 
1025
1056
  function lifecycleTaskId(event) {
@@ -1177,6 +1208,10 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1177
1208
  return {
1178
1209
  ...baseState,
1179
1210
  runs,
1211
+ // Resolved scheduler concurrency for the live run (display-only). Null for
1212
+ // replayed/historical state — the UIs then fall back to the plan-derived
1213
+ // value. Never consumed by scheduling.
1214
+ concurrency: session?._runConcurrency ?? null,
1180
1215
  workflow: projectWorkflow({ ...baseState, runs, workspace }, events ?? session?.agentEvents ?? []),
1181
1216
  };
1182
1217
  }
@@ -1281,9 +1316,11 @@ function purgeOldTerminalRuns(db, now = new Date()) {
1281
1316
  db.exec('BEGIN');
1282
1317
  try {
1283
1318
  const deleteEvents = db.prepare('DELETE FROM events WHERE run_id = ? OR json_extract(payload, \'$.runId\') = ?');
1319
+ const deleteApprovals = db.prepare('DELETE FROM approval_grants WHERE run_id = ?');
1284
1320
  const deleteRun = db.prepare('DELETE FROM runs WHERE id = ?');
1285
1321
  for (const runId of oldRuns) {
1286
1322
  deleteEvents.run(runId, runId);
1323
+ deleteApprovals.run(runId);
1287
1324
  deleteRun.run(runId);
1288
1325
  }
1289
1326
  db.exec('COMMIT');
@@ -1403,6 +1440,23 @@ function normalizeApprovalClasses(value) {
1403
1440
  return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))];
1404
1441
  }
1405
1442
 
1443
+ // Persisted-table twin of markCoveredApprovalsApproved (core/agentEvents.js):
1444
+ // does `grant` (already-approved) cover the pending approval_grants `row`?
1445
+ // Kept in sync with that reducer so the DB never disagrees with the projection.
1446
+ function persistedGrantCovers(grant, row) {
1447
+ if (grant.runId != null && row.run_id != null && String(grant.runId) !== String(row.run_id)) return false;
1448
+ if (grant.workspaceId != null && row.workspace_id != null && String(grant.workspaceId) !== String(row.workspace_id)) return false;
1449
+ if (grant.planRevision != null && row.plan_revision != null && Number(grant.planRevision) !== Number(row.plan_revision)) return false;
1450
+ const rowClasses = row.approval_classes ? normalizeApprovalClasses(parseJsonMaybe(row.approval_classes)) : [];
1451
+ if (grant.approvalClasses.length > 0 && rowClasses.length > 0 && !rowClasses.some((item) => grant.approvalClasses.includes(item))) return false;
1452
+ if (grant.scope === 'group' && String(grant.groupId ?? '') !== String(row.group_id ?? '')) return false;
1453
+ if (grant.scope === 'task' || grant.scope === 'tool') {
1454
+ const rowIds = [row.task_id, row.id].filter(Boolean).map(String);
1455
+ if (!rowIds.some((id) => grant.ids.has(id))) return false;
1456
+ }
1457
+ return true;
1458
+ }
1459
+
1406
1460
  function parseJsonMaybe(value) {
1407
1461
  try {
1408
1462
  return JSON.parse(value);
@@ -540,6 +540,48 @@ test('runtime store persists bounded approval grants', () => {
540
540
  store.close();
541
541
  });
542
542
 
543
+ test('runtime store: a run-scope grant marks covered task grants approved (no orphans)', () => {
544
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
545
+ const store = openRuntimeStore({ stateDir });
546
+ // Two per-task approval requests for the same run.
547
+ for (const taskId of ['run-x:ingest-a', 'run-x:ingest-b']) {
548
+ store.persistEvent(createAgentEvent('approval.requested', {
549
+ origin: 'runtime',
550
+ runId: 'run-x',
551
+ workspace: 'docs',
552
+ taskId,
553
+ payload: { id: `approval:${taskId}`, scope: 'task', runId: 'run-x', workspaceId: 'docs', taskId },
554
+ }));
555
+ }
556
+ assert.equal(store.listApprovalGrants({ runId: 'run-x' }).filter((g) => g.status === 'pending_approval').length, 2);
557
+
558
+ // A single run-scope grant covers both.
559
+ store.persistEvent(createAgentEvent('approval.granted', {
560
+ origin: 'runtime',
561
+ runId: 'run-x',
562
+ workspace: 'docs',
563
+ payload: { id: 'grant-run-x', scope: 'run', runId: 'run-x', workspaceId: 'docs' },
564
+ }));
565
+ const grants = store.listApprovalGrants({ runId: 'run-x' });
566
+ assert.equal(grants.filter((g) => g.status === 'pending_approval').length, 0, 'no task grant left pending');
567
+ assert.equal(grants.filter((g) => g.status === 'approved').length, 3, 'both tasks + run grant approved');
568
+ store.close();
569
+ });
570
+
571
+ test('runtime store: clearWorkspaceState removes approval grants', () => {
572
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
573
+ const store = openRuntimeStore({ stateDir });
574
+ store.persistEvent(createAgentEvent('run_started', { origin: 'runtime', runId: 'run-y', workspace: 'docs', payload: { runId: 'run-y' } }));
575
+ store.persistEvent(createAgentEvent('approval.requested', {
576
+ origin: 'runtime', runId: 'run-y', workspace: 'docs', taskId: 'run-y:t',
577
+ payload: { id: 'approval:run-y:t', scope: 'task', runId: 'run-y', workspaceId: 'docs', taskId: 'run-y:t' },
578
+ }));
579
+ assert.equal(store.listApprovalGrants({ runId: 'run-y' }).length, 1);
580
+ store.clearWorkspaceState({ workspace: 'docs' });
581
+ assert.equal(store.listApprovalGrants({ runId: 'run-y' }).length, 0);
582
+ store.close();
583
+ });
584
+
543
585
  test('runtime store orders events by durable sequence', () => {
544
586
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
545
587
  const store = openRuntimeStore({ stateDir });
@@ -83,9 +83,9 @@ export function FileEditorDialog(props: {
83
83
  {(message) => <text height={1} fg="#F87171">{message()}</text>}
84
84
  </Show>
85
85
  <box height={1} flexDirection="row">
86
- <text fg="#111318" bg="#8BD5CA"> Save Ctrl+S </text>
86
+ <text fg="#111318" bg="#8BD5CA" onMouseUp={save}> Save Ctrl+S </text>
87
87
  <text fg="#7F8C8D"> </text>
88
- <text fg="#D6DEE8" bg="#4B5563"> Cancel Esc </text>
88
+ <text fg="#D6DEE8" bg="#4B5563" onMouseUp={props.onCancel}> Cancel Esc </text>
89
89
  </box>
90
90
  </box>
91
91
  )}
@@ -1,5 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
3
+ import { StyledText, bg as styledBg, fg as styledFg, link as styledLink } from '@opentui/core';
3
4
  import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
4
5
  import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
5
6
 
@@ -49,8 +50,20 @@ function wrapLine(line: string, width: number) {
49
50
  }
50
51
 
51
52
  type Segment = { text: string; color: string; width?: number; bg?: string; url?: string };
53
+
54
+ function styledSegments(segments: Segment[]) {
55
+ return new StyledText(segments.map((segment) => {
56
+ const text = segment.width ? segment.text.padEnd(segment.width) : segment.text;
57
+ let chunk = styledFg(segment.color)(text);
58
+ if (segment.bg) chunk = styledBg(segment.bg)(chunk);
59
+ if (segment.url) chunk = styledLink(segment.url)(chunk);
60
+ return chunk;
61
+ }));
62
+ }
52
63
  type RenderedLine = {
53
64
  segments: Segment[];
65
+ role?: string;
66
+ userBubbleWidth?: number;
54
67
  status?: boolean;
55
68
  statusLeft?: string;
56
69
  statusRight?: string;
@@ -376,19 +389,35 @@ function statusColumns(line: string): { left: string; right: string } {
376
389
  return { left: left || ' ', right };
377
390
  }
378
391
 
392
+ // Consecutive messages that render under the same header ([shell]/[user]/
393
+ // [donna]) are grouped: only the first one shows the label + separator, so a
394
+ // run of same-type messages doesn't repeat the header on every entry.
395
+ function headerGroupKey(role: string) {
396
+ return isDonnaRole(role) ? 'donna' : roleLabel(role);
397
+ }
398
+
379
399
  function conversationLines(messages: Array<{ role: string; content: string }>, columns: number): RenderedLine[] {
380
- return messages.flatMap((message) => {
400
+ return messages.flatMap((message, index) => {
381
401
  const raw = String(message.content || '');
402
+ const previous = index > 0 ? messages[index - 1] : null;
403
+ const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
404
+ const headerLines: RenderedLine[] = showHeader
405
+ ? [
406
+ { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
407
+ { segments: [{ text: ' ', color: '#D6DEE8' }] },
408
+ ]
409
+ : [];
410
+ const contentColumns = message.role === 'user' ? Math.max(12, Math.floor(columns * 0.6) - 2) : columns;
382
411
  if (isStatusOutput(message)) {
383
- return [
384
- { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
385
- { segments: [{ text: ' ', color: '#D6DEE8' }] },
412
+ const statusLines: RenderedLine[] = [
413
+ ...headerLines,
386
414
  ...raw.split('\n').map((line) => {
387
415
  const { left, right } = statusColumns(line || ' ');
388
416
  return { status: true, statusLeft: left, statusRight: right, segments: [] };
389
417
  }),
390
418
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
391
419
  ];
420
+ return statusLines.map((line) => ({ ...line, role: message.role }));
392
421
  }
393
422
  let inFence = false;
394
423
  const lines: Array<{ text: string; isCode: boolean }> = [];
@@ -399,12 +428,19 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
399
428
  if (/^\s*(`{3,}|~{3,})/.test(line)) { inFence = !inFence; continue; }
400
429
  lines.push({ text: line, isCode: inFence });
401
430
  }
402
- return [
403
- { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
404
- { segments: [{ text: ' ', color: '#D6DEE8' }] },
405
- ...renderMarkdownLines(lines, message.role, columns),
431
+ const renderedBody = renderMarkdownLines(lines, message.role, contentColumns);
432
+ const userBubbleWidth = message.role === 'user'
433
+ ? Math.min(
434
+ Math.max(1, ...renderedBody.map((line) => line.segments.reduce((sum, segment) => sum + segment.text.length, 0))) + 2,
435
+ Math.max(12, Math.floor(columns * 0.6)),
436
+ )
437
+ : undefined;
438
+ const bodyLines: RenderedLine[] = [
439
+ ...headerLines,
440
+ ...renderedBody.map((line) => ({ ...line, userBubbleWidth })),
406
441
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
407
442
  ];
443
+ return bodyLines.map((line) => ({ ...line, role: message.role }));
408
444
  });
409
445
  }
410
446
 
@@ -462,6 +498,21 @@ export function ConversationView(props: {
462
498
  </For>
463
499
  <text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
464
500
  </box>
501
+ ) : line.role === 'user' && line.userBubbleWidth ? (
502
+ <box height={1} flexDirection="row" overflow="hidden">
503
+ <box flexGrow={1} />
504
+ <box
505
+ width={line.userBubbleWidth}
506
+ height={1}
507
+ flexDirection="row"
508
+ flexShrink={1}
509
+ justifyContent="flex-start"
510
+ paddingX={1}
511
+ overflow="hidden"
512
+ >
513
+ <text>{styledSegments(line.segments)}</text>
514
+ </box>
515
+ </box>
465
516
  ) : line.status ? (
466
517
  <box height={1} flexDirection="row" gap={2} overflow="hidden">
467
518
  <box
@@ -499,13 +550,7 @@ export function ConversationView(props: {
499
550
  </box>
500
551
  ) : (
501
552
  <box height={1} flexDirection="row" overflow="hidden">
502
- <For each={line.segments}>
503
- {(seg: Segment) => (
504
- <text width={seg.width} fg={seg.color} bg={seg.bg}>
505
- {seg.url ? <a href={seg.url}>{seg.text}</a> : seg.text}
506
- </text>
507
- )}
508
- </For>
553
+ <text>{styledSegments(line.segments)}</text>
509
554
  </box>
510
555
  )
511
556
  )}