@dotdrelle/wiki-manager 0.11.10 → 0.12.1

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 (85) 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 +101 -38
  11. package/src/agent/graph.test.js +75 -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 +147 -2
  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 +30 -3
  25. package/src/core/mcp.test.js +69 -1
  26. package/src/core/plan.js +46 -4
  27. package/src/core/plan.test.js +16 -1
  28. package/src/core/planPatch.js +64 -3
  29. package/src/core/planPatch.test.js +110 -1
  30. package/src/core/queueStore.test.js +21 -0
  31. package/src/core/runtimeLog.js +119 -0
  32. package/src/core/runtimeLog.test.js +84 -0
  33. package/src/core/wikirc.js +22 -0
  34. package/src/core/wikirc.test.js +49 -1
  35. package/src/core/workflow.js +14 -3
  36. package/src/graph/graphAggregator.js +5 -0
  37. package/src/graph/graphPatch.js +8 -0
  38. package/src/graph/graphSnapshot.js +14 -0
  39. package/src/graph/graphVisibilityPolicy.js +40 -0
  40. package/src/graph/runGraphProjector.js +87 -0
  41. package/src/graph/runGraphProjector.test.js +111 -0
  42. package/src/orchestrator/agentRegistry.js +172 -0
  43. package/src/orchestrator/agentRegistry.test.js +114 -0
  44. package/src/orchestrator/approvalPolicy.js +126 -0
  45. package/src/orchestrator/approvalPolicy.test.js +78 -0
  46. package/src/orchestrator/assignmentManager.js +80 -0
  47. package/src/orchestrator/attemptManager.js +168 -0
  48. package/src/orchestrator/attemptManager.test.js +160 -0
  49. package/src/orchestrator/budgetManager.js +127 -0
  50. package/src/orchestrator/capabilityRegistry.js +64 -0
  51. package/src/orchestrator/capabilityRegistry.test.js +59 -0
  52. package/src/orchestrator/capabilityResolver.js +151 -0
  53. package/src/orchestrator/capabilityResolver.test.js +127 -0
  54. package/src/orchestrator/dependencyResolver.js +112 -0
  55. package/src/orchestrator/dispatcher.js +302 -0
  56. package/src/orchestrator/lockManager.js +51 -0
  57. package/src/orchestrator/planIntegrator.js +268 -0
  58. package/src/orchestrator/planIntegrator.test.js +213 -0
  59. package/src/orchestrator/planValidator.js +534 -0
  60. package/src/orchestrator/planValidator.test.js +262 -0
  61. package/src/orchestrator/resultAggregator.js +248 -0
  62. package/src/orchestrator/resultAggregator.test.js +211 -0
  63. package/src/orchestrator/scheduler.js +103 -0
  64. package/src/orchestrator/scheduler.test.js +138 -0
  65. package/src/runtime/approvals.js +130 -1
  66. package/src/runtime/controlMessages.js +43 -0
  67. package/src/runtime/controlMessages.test.js +21 -0
  68. package/src/runtime/donna-contract.test.js +296 -33
  69. package/src/runtime/recoveryManager.js +176 -0
  70. package/src/runtime/recoveryManager.test.js +162 -0
  71. package/src/runtime/runner.e2e.test.js +98 -12
  72. package/src/runtime/runner.js +261 -227
  73. package/src/runtime/runner.test.js +249 -452
  74. package/src/runtime/server.js +142 -24
  75. package/src/runtime/server.test.js +192 -62
  76. package/src/runtime/store.js +837 -2
  77. package/src/runtime/store.test.js +269 -28
  78. package/src/runtime/supervisor.js +33 -1
  79. package/src/runtime/supervisor.test.js +47 -1
  80. package/src/shell/RightPane.tsx +8 -5
  81. package/src/shell/repl.js +7 -11
  82. package/src/shell/repl.test.js +26 -5
  83. package/src/shell/tui.tsx +1 -0
  84. package/src/shell/useAgent.ts +5 -1
  85. package/src/shell/useSession.ts +75 -19
@@ -0,0 +1,211 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
5
+ import { accept } from './resultAggregator.js';
6
+
7
+ test('resultAggregator expands a plan after a task result and rewires insertion after the task', async () => {
8
+ const session = sessionWithPlan();
9
+ session.approvals = [{ id: 'approval-run', scope: 'run', status: 'approved', runId: 'run-expansion' }];
10
+ const calls = [];
11
+
12
+ const result = await accept(taskResultWithExpansion(), {
13
+ session,
14
+ runId: 'run-expansion',
15
+ task: session.headlessPlan[0],
16
+ assignment: { agentInstanceId: 'production-main', serverName: 'production' },
17
+ registry: expansionRegistry(),
18
+ callTool: async (_mcp, serverName, toolName, args) => {
19
+ calls.push({ serverName, toolName, args });
20
+ assert.equal(serverName, 'production');
21
+ assert.equal(toolName, 'agent_plan');
22
+ return expansionFragment();
23
+ },
24
+ });
25
+
26
+ assert.equal(result.ok, true);
27
+ assert.equal(result.expansion.ok, true);
28
+ assert.deepEqual(calls.map((call) => call.args.capability), ['document.build']);
29
+ const plan = Object.fromEntries(session.headlessPlan.map((task) => [task.id, task]));
30
+ assert.deepEqual(plan['run-expansion:build-x'].dependsOn, ['run-expansion:a']);
31
+ assert.deepEqual(plan['run-expansion:build-y'].dependsOn, ['run-expansion:build-x']);
32
+ assert.deepEqual(plan['run-expansion:b'].dependsOn, ['run-expansion:build-y']);
33
+ assert.equal(plan['run-expansion:build-x'].status, 'pending');
34
+ assert.equal(session.planRevision, 1);
35
+ });
36
+
37
+ test('resultAggregator marks expanded tasks waiting_approval when approval is not covered', async () => {
38
+ const session = sessionWithPlan();
39
+
40
+ const result = await accept(taskResultWithExpansion(), {
41
+ session,
42
+ runId: 'run-expansion',
43
+ task: session.headlessPlan[0],
44
+ assignment: { agentInstanceId: 'production-main', serverName: 'production' },
45
+ registry: expansionRegistry(),
46
+ callTool: async () => expansionFragment(),
47
+ });
48
+
49
+ assert.equal(result.expansion.ok, true);
50
+ const created = session.headlessPlan.filter((task) => ['run-expansion:build-x', 'run-expansion:build-y'].includes(task.id));
51
+ assert.deepEqual(created.map((task) => task.status), ['waiting_approval', 'waiting_approval']);
52
+ });
53
+
54
+ test('resultAggregator rejects an expansion with an unknown capability and preserves task success', async () => {
55
+ const session = sessionWithPlan();
56
+
57
+ const result = await accept({
58
+ ok: true,
59
+ taskId: 'run-expansion:a',
60
+ status: 'succeeded',
61
+ outputRefs: [],
62
+ planExpansionRequest: {
63
+ capability: 'unknown.capability',
64
+ operation: 'expand',
65
+ arguments: {},
66
+ insertAfterTasks: ['run-expansion:a'],
67
+ },
68
+ }, {
69
+ session,
70
+ runId: 'run-expansion',
71
+ task: session.headlessPlan[0],
72
+ assignment: { agentInstanceId: 'production-main', serverName: 'production' },
73
+ registry: expansionRegistry(),
74
+ callTool: async () => assert.fail('agent_plan must not be called for an unknown capability'),
75
+ });
76
+
77
+ assert.equal(result.ok, true);
78
+ assert.equal(result.expansion.ok, false);
79
+ assert.equal(result.expansion.errors[0].code, 'capability_unavailable');
80
+ assert.deepEqual(session.headlessPlan.map((task) => task.id), ['run-expansion:a', 'run-expansion:b']);
81
+ assert.equal(session.headlessPlan[0].status, 'done');
82
+ assert.ok(session.agentEvents.some((event) => event.type === 'plan.rejected'));
83
+ });
84
+
85
+ function sessionWithPlan() {
86
+ const session = {
87
+ mcp: {
88
+ production: {
89
+ tools: [{ name: 'agent_plan' }],
90
+ },
91
+ },
92
+ agentEvents: [],
93
+ activities: {},
94
+ workspace: 'docs',
95
+ headlessPlan: [
96
+ {
97
+ id: 'run-expansion:a',
98
+ step: 1,
99
+ label: 'Task A',
100
+ description: 'Task A',
101
+ status: 'running',
102
+ dependsOn: [],
103
+ outputRefs: [],
104
+ },
105
+ {
106
+ id: 'run-expansion:b',
107
+ step: 2,
108
+ label: 'Task B',
109
+ description: 'Task B',
110
+ status: 'pending',
111
+ dependsOn: ['run-expansion:a'],
112
+ outputRefs: [],
113
+ },
114
+ ],
115
+ };
116
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
117
+ origin: 'test',
118
+ runId: 'run-expansion',
119
+ payload: { steps: session.headlessPlan, planRevision: 0 },
120
+ }));
121
+ session.agentEvents = [];
122
+ return session;
123
+ }
124
+
125
+ function taskResultWithExpansion() {
126
+ return {
127
+ ok: true,
128
+ taskId: 'run-expansion:a',
129
+ status: 'succeeded',
130
+ outputRefs: [{ type: 'file', ref: 'out/a.json' }],
131
+ planExpansionRequest: {
132
+ capability: 'document.build',
133
+ operation: 'build',
134
+ objective: 'Build downstream generated documents.',
135
+ arguments: { templates: ['templates/x.md'] },
136
+ insertAfterTasks: ['run-expansion:a'],
137
+ },
138
+ };
139
+ }
140
+
141
+ function expansionFragment() {
142
+ return {
143
+ contractVersion: '1',
144
+ agentInstanceId: 'production-main',
145
+ capability: 'document.build',
146
+ summary: {
147
+ label: 'Build generated documents',
148
+ initialSynthesis: ['Build two generated documents.'],
149
+ estimatedTasks: 2,
150
+ },
151
+ groups: [{ id: 'build', label: 'Build', recommendedConcurrency: 1 }],
152
+ tasks: [
153
+ plannedBuildTask('build-x', []),
154
+ plannedBuildTask('build-y', ['build-x']),
155
+ ],
156
+ expectedOutputs: [{ type: 'file', ref: 'deliverables/y.md' }],
157
+ };
158
+ }
159
+
160
+ function plannedBuildTask(id, dependsOn) {
161
+ return {
162
+ id,
163
+ label: `Build ${id}`,
164
+ requiredCapability: 'document.build',
165
+ operation: 'build',
166
+ arguments: { templates: [`templates/${id}.md`] },
167
+ groupId: 'build',
168
+ dependsOn,
169
+ parallelizable: true,
170
+ inputRefs: [{ type: 'file', ref: `templates/${id}.md` }],
171
+ expectedOutputRefs: [{ type: 'file', ref: `deliverables/${id}.md` }],
172
+ locks: [`deliverable:${id}.md`],
173
+ requiresApproval: true,
174
+ idempotencyKey: `idem-${id}`,
175
+ progressWeight: 1,
176
+ };
177
+ }
178
+
179
+ function expansionRegistry() {
180
+ return {
181
+ providersFor(capability) {
182
+ if (capability !== 'document.build') return [];
183
+ return [{
184
+ agentInstanceId: 'production-main',
185
+ serverName: 'production',
186
+ health: 'available',
187
+ capability: {
188
+ id: 'document.build',
189
+ version: '1',
190
+ description: 'Build documents',
191
+ inputSchema: {
192
+ type: 'object',
193
+ required: ['templates'],
194
+ additionalProperties: true,
195
+ properties: {
196
+ templates: { type: 'array', items: { type: 'string' } },
197
+ },
198
+ },
199
+ outputSchema: {},
200
+ supportedOperations: ['build'],
201
+ mutationClass: 'workspace',
202
+ defaultRequiresApproval: true,
203
+ },
204
+ description: { contractVersion: '1' },
205
+ }];
206
+ },
207
+ isCompatible(contractVersion) {
208
+ return String(contractVersion) === '1';
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,103 @@
1
+ import { readyTasks } from './dependencyResolver.js';
2
+
3
+ export const DEFAULT_SCHEDULER_CONCURRENCY = 3;
4
+
5
+ export function resolveSchedulerConcurrency(value = process.env.WIKI_MANAGER_SCHEDULER_CONCURRENCY) {
6
+ const parsed = Number(value);
7
+ return Number.isFinite(parsed) && parsed > 0
8
+ ? Math.max(1, Math.floor(parsed))
9
+ : DEFAULT_SCHEDULER_CONCURRENCY;
10
+ }
11
+
12
+ export function effectiveConcurrency(group = null, agent = null, donna = null, provider = null) {
13
+ const limits = [
14
+ ...concurrencyValues(donna),
15
+ ...concurrencyValues(group),
16
+ ...concurrencyValues(provider),
17
+ ...concurrencyValues(agent),
18
+ ].filter((value) => value != null && value > 0);
19
+ return Math.max(1, Math.min(...(limits.length ? limits : [DEFAULT_SCHEDULER_CONCURRENCY])));
20
+ }
21
+
22
+ export async function drainActive(active, attemptManager = null) {
23
+ if (active.size === 0) return;
24
+ const entries = [...active.values()];
25
+ await Promise.all(entries.map((entry) => entry.promise));
26
+ for (const entry of entries) entry.cleanup?.();
27
+ active.clear();
28
+ attemptManager?.clear?.();
29
+ }
30
+
31
+ export function startReadyTasks({
32
+ plan,
33
+ active,
34
+ attemptManager,
35
+ startTask,
36
+ limit,
37
+ session = null,
38
+ registry = null,
39
+ lockManager = null,
40
+ budgetManager = null,
41
+ approvals = [],
42
+ onDuplicateTask = null,
43
+ onTaskReady = null,
44
+ onAttemptCreated = null,
45
+ onTaskStarting = null,
46
+ } = {}) {
47
+ let started = 0;
48
+ const seenTaskIds = new Set(active.keys());
49
+ const ready = readyTasks(plan, {
50
+ registry,
51
+ lockManager,
52
+ budgetManager,
53
+ approvals,
54
+ activeTaskIds: active.keys(),
55
+ });
56
+ for (const task of ready) {
57
+ if (active.size >= limit) break;
58
+ const taskId = planTaskId(task);
59
+ if (active.has(taskId)) continue;
60
+ if (seenTaskIds.has(taskId)) {
61
+ onDuplicateTask?.(taskId, task);
62
+ continue;
63
+ }
64
+ seenTaskIds.add(taskId);
65
+ onTaskReady?.(task);
66
+ const attempt = attemptManager.reserve(task);
67
+ if (!attempt) continue;
68
+ onAttemptCreated?.(task, attempt);
69
+ budgetManager?.recordTaskStart?.(task);
70
+ onTaskStarting?.(task, attempt);
71
+ const entry = startTask(task, attempt);
72
+ active.set(taskId, { taskId, ...entry });
73
+ started += 1;
74
+ }
75
+ return started;
76
+ }
77
+
78
+ function concurrencyValues(source) {
79
+ if (source == null) return [];
80
+ if (typeof source === 'number') return [positiveInteger(source)].filter(Boolean);
81
+ const candidates = [
82
+ source,
83
+ source.limits,
84
+ source.description?.limits,
85
+ source.capability?.limits,
86
+ ].filter(Boolean);
87
+ return candidates.flatMap((limits) => [
88
+ limits.effectiveConcurrency,
89
+ limits.maxConcurrency,
90
+ limits.recommendedConcurrency,
91
+ limits.concurrency,
92
+ limits.schedulerConcurrency,
93
+ ]).map(positiveInteger).filter(Boolean);
94
+ }
95
+
96
+ function positiveInteger(value) {
97
+ const number = Number(value);
98
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : null;
99
+ }
100
+
101
+ function planTaskId(task) {
102
+ return String(task?.id ?? task?.step);
103
+ }
@@ -0,0 +1,138 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import test from 'node:test';
5
+
6
+ import { createBudgetManager } from './budgetManager.js';
7
+ import { readyTasks } from './dependencyResolver.js';
8
+ import { createLockManager } from './lockManager.js';
9
+ import { effectiveConcurrency, startReadyTasks } from './scheduler.js';
10
+
11
+ test('dependencyResolver holds a barrier task until its dependsOnGroup is done', () => {
12
+ const plan = [
13
+ task('ingest-a', { groupId: 'ingest', status: 'done' }),
14
+ task('ingest-b', { groupId: 'ingest', status: 'pending' }),
15
+ task('consolidate', { dependsOnGroup: 'ingest', priority: 1 }),
16
+ ];
17
+
18
+ assert.deepEqual(readyTasks(plan).map((item) => item.id), ['ingest-b']);
19
+ plan[1].status = 'done';
20
+ assert.deepEqual(readyTasks(plan).map((item) => item.id), ['consolidate']);
21
+ });
22
+
23
+ test('dependencyResolver orders ready tasks by priority before step', () => {
24
+ const plan = [
25
+ task('low', { step: 1, priority: 20 }),
26
+ task('high', { step: 2, priority: 5 }),
27
+ task('none', { step: 0 }),
28
+ ];
29
+
30
+ assert.deepEqual(readyTasks(plan).map((item) => item.id), ['high', 'low', 'none']);
31
+ });
32
+
33
+ test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
34
+ const lockManager = createLockManager();
35
+ const held = lockManager.acquire(['deliverable:a.md']);
36
+ const plan = [
37
+ task('blocked', { locks: ['deliverable:a.md'], priority: 1 }),
38
+ task('free', { locks: ['deliverable:b.md'], priority: 2 }),
39
+ ];
40
+
41
+ assert.deepEqual(readyTasks(plan, { lockManager }).map((item) => item.id), ['free']);
42
+ held.release();
43
+ assert.deepEqual(readyTasks(plan, { lockManager }).map((item) => item.id), ['blocked', 'free']);
44
+ });
45
+
46
+ test('budgetManager blocks ready tasks once the run budget is exceeded', () => {
47
+ const budgetManager = createBudgetManager({ budgets: { maxTasks: 0 }, runId: 'run-budget' });
48
+
49
+ assert.deepEqual(readyTasks([task('a')], { budgetManager }), []);
50
+ assert.equal(budgetManager.exceeded().reason, 'max_tasks_exceeded');
51
+ });
52
+
53
+ test('budgetManager tracks attempts depth duration and tokens per run', () => {
54
+ const budgetManager = createBudgetManager({
55
+ budgets: { maxAttempts: 1, maxDepth: 2, maxDurationMs: 5, maxTokens: 10 },
56
+ runId: 'run-budget-counters',
57
+ });
58
+
59
+ budgetManager.recordTaskStart(task('a', { depth: 2 }));
60
+ assert.equal(budgetManager.canStartTask(task('b')), false);
61
+ assert.equal(budgetManager.exceeded().reason, 'max_attempts_exceeded');
62
+ assert.throws(
63
+ () => budgetManager.recordTaskResult({ metrics: { durationMs: 6, totalTokens: 9 } }),
64
+ /max_duration_exceeded/,
65
+ );
66
+ assert.equal(budgetManager.snapshot().durationMs, 6);
67
+ assert.equal(budgetManager.snapshot().tokens, 9);
68
+ });
69
+
70
+ test('scheduler.effectiveConcurrency returns the minimum effective concurrency', () => {
71
+ const group = { recommendedConcurrency: 4 };
72
+ const agent = { description: { limits: { recommendedConcurrency: 3, maxConcurrency: 8 } } };
73
+ const donna = { schedulerConcurrency: 6 };
74
+ const provider = { capability: { limits: { maxConcurrency: 2 } }, maxConcurrency: 5 };
75
+
76
+ assert.equal(effectiveConcurrency(group, agent, donna, provider), 2);
77
+ });
78
+
79
+ test('startReadyTasks starts only ready tasks and respects lock starvation', () => {
80
+ const active = new Map();
81
+ const lockManager = createLockManager();
82
+ const attemptManager = {
83
+ reserve(item) {
84
+ const reservation = lockManager.acquire(item);
85
+ if (!reservation) return null;
86
+ return { attemptId: `${item.id}:attempt-1`, release: reservation.release };
87
+ },
88
+ };
89
+ const started = [];
90
+ const count = startReadyTasks({
91
+ plan: [
92
+ task('blocked', { locks: ['workspace:write'], priority: 1 }),
93
+ task('free', { locks: ['deliverable:a.md'], priority: 2 }),
94
+ ],
95
+ active,
96
+ attemptManager,
97
+ lockManager,
98
+ limit: 2,
99
+ startTask(item) {
100
+ started.push(item.id);
101
+ return { promise: Promise.resolve({ ok: true, taskId: item.id }) };
102
+ },
103
+ });
104
+
105
+ assert.equal(count, 2);
106
+ assert.deepEqual(started, ['blocked', 'free']);
107
+ assert.deepEqual(lockManager.snapshot(), ['deliverable:a.md', 'workspace:write']);
108
+ });
109
+
110
+ test('orchestrator modules do not import business-specific packages', () => {
111
+ const banned = /\b(?:production|cme|commercial|documents|wiki)\b/;
112
+ const orchestratorDir = new URL('.', import.meta.url).pathname;
113
+ const offenders = readdirSync(orchestratorDir)
114
+ .filter((file) => file.endsWith('.js') && !file.endsWith('.test.js'))
115
+ .flatMap((file) => {
116
+ const content = readFileSync(join(orchestratorDir, file), 'utf8');
117
+ return content
118
+ .split('\n')
119
+ .filter((line) => /^\s*import\s/.test(line) && banned.test(line))
120
+ .map((line) => `${file}: ${line.trim()}`);
121
+ });
122
+
123
+ assert.deepEqual(offenders, []);
124
+ });
125
+
126
+ function task(id, overrides = {}) {
127
+ return {
128
+ id,
129
+ step: overrides.step ?? 1,
130
+ status: overrides.status ?? 'pending',
131
+ dependsOn: overrides.dependsOn ?? [],
132
+ requiredCapability: 'workspace.diagnose',
133
+ operation: 'doctor',
134
+ locks: [],
135
+ requiresApproval: false,
136
+ ...overrides,
137
+ };
138
+ }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
+ import { normalizeApprovalGrant } from '../orchestrator/approvalPolicy.js';
3
4
 
4
5
  const DEFAULT_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
5
6
 
@@ -12,6 +13,9 @@ export function createApprovalManager(session, {
12
13
  const scope = request.scope === 'tool' ? 'tool' : 'run';
13
14
  const approvalId = request.approvalId ?? randomUUID();
14
15
  const runId = request.runId ?? session._currentRunIdentity?.runId ?? null;
16
+ const workspaceId = request.workspaceId ?? request.workspace ?? session._currentRunIdentity?.workspace ?? session.workspace ?? null;
17
+ const planRevision = request.planRevision ?? session.planRevision ?? null;
18
+ const approvalClasses = normalizeClasses(request.approvalClasses ?? request.approvalClass);
15
19
  const timeoutMs = Number.isFinite(Number(request.timeoutMs))
16
20
  ? Math.max(1, Number(request.timeoutMs))
17
21
  : defaultTimeoutMs;
@@ -31,6 +35,25 @@ export function createApprovalManager(session, {
31
35
  timeoutMs,
32
36
  },
33
37
  }));
38
+ dispatchAgentEvent(session, createAgentEvent('approval.requested', {
39
+ origin: 'runtime',
40
+ runId,
41
+ taskId: request.taskId ?? request.itemId ?? null,
42
+ workspace: workspaceId,
43
+ payload: {
44
+ id: approvalId,
45
+ approvalId,
46
+ scope: normalizeGrantScope(request.scope),
47
+ runId,
48
+ workspaceId,
49
+ planRevision,
50
+ taskId: request.taskId ?? request.itemId ?? null,
51
+ itemId: request.itemId ?? request.taskId ?? null,
52
+ groupId: request.groupId ?? null,
53
+ approvalClasses,
54
+ reason: request.reason ?? null,
55
+ },
56
+ }));
34
57
 
35
58
  await new Promise((resolve, reject) => {
36
59
  const timer = setTimeout(() => {
@@ -53,8 +76,15 @@ export function createApprovalManager(session, {
53
76
  pending.set(approvalId, {
54
77
  approvalId,
55
78
  scope,
79
+ grantScope: normalizeGrantScope(request.scope),
56
80
  runId,
81
+ workspaceId,
82
+ planRevision,
57
83
  itemId: request.itemId ?? null,
84
+ taskId: request.taskId ?? request.itemId ?? null,
85
+ groupId: request.groupId ?? null,
86
+ approvalClasses,
87
+ reason: request.reason ?? null,
58
88
  resolve: () => {
59
89
  pending.delete(approvalId);
60
90
  cleanup();
@@ -77,10 +107,59 @@ export function createApprovalManager(session, {
77
107
  itemId: request.itemId ?? null,
78
108
  },
79
109
  }));
110
+ const grant = normalizeApprovalGrant({
111
+ id: approvalId,
112
+ approvalId,
113
+ scope: normalizeGrantScope(request.scope),
114
+ runId,
115
+ workspaceId,
116
+ planRevision,
117
+ taskId: request.taskId ?? request.itemId ?? null,
118
+ itemId: request.itemId ?? request.taskId ?? null,
119
+ groupId: request.groupId ?? null,
120
+ approvalClasses,
121
+ status: 'approved',
122
+ reason: request.reason ?? null,
123
+ });
124
+ dispatchAgentEvent(session, createAgentEvent('approval.granted', {
125
+ origin: 'runtime',
126
+ runId,
127
+ taskId: grant.taskId,
128
+ workspace: workspaceId,
129
+ payload: grant,
130
+ }));
80
131
  return { approved: true, approvalId, runId, itemId: request.itemId ?? null };
81
132
  }
82
133
 
83
- function approve({ approvalId = null, runId = null, itemId = null } = {}) {
134
+ function approve(request = {}) {
135
+ const { approvalId = null, runId = null, itemId = null } = request;
136
+ if (request.scope || request.planRevision != null || request.approvalClasses || request.approvalClass || request.taskId || request.groupId) {
137
+ const grant = normalizeApprovalGrant({
138
+ ...request,
139
+ id: request.id ?? request.approvalId ?? randomUUID(),
140
+ workspaceId: request.workspaceId ?? request.workspace ?? session.workspace ?? null,
141
+ scope: normalizeGrantScope(request.scope),
142
+ itemId: request.itemId ?? request.taskId ?? null,
143
+ status: 'approved',
144
+ });
145
+ dispatchAgentEvent(session, createAgentEvent('approval.granted', {
146
+ origin: 'runtime',
147
+ runId: grant.runId,
148
+ taskId: grant.taskId,
149
+ workspace: grant.workspaceId,
150
+ payload: grant,
151
+ }));
152
+ resolveMatchingPending(grant);
153
+ return {
154
+ approved: true,
155
+ approvalId: grant.approvalId ?? grant.id,
156
+ runId: grant.runId,
157
+ itemId: grant.itemId,
158
+ scope: grant.scope,
159
+ planRevision: grant.planRevision,
160
+ approvalClasses: grant.approvalClasses,
161
+ };
162
+ }
84
163
  const entry = approvalId
85
164
  ? pending.get(approvalId)
86
165
  : [...pending.values()].find((item) =>
@@ -96,6 +175,31 @@ export function createApprovalManager(session, {
96
175
  };
97
176
  }
98
177
 
178
+ function reject(request = {}) {
179
+ const grant = normalizeApprovalGrant({
180
+ ...request,
181
+ id: request.id ?? request.approvalId ?? randomUUID(),
182
+ workspaceId: request.workspaceId ?? request.workspace ?? session.workspace ?? null,
183
+ scope: normalizeGrantScope(request.scope),
184
+ status: 'rejected',
185
+ });
186
+ dispatchAgentEvent(session, createAgentEvent('approval.rejected', {
187
+ origin: 'runtime',
188
+ runId: grant.runId,
189
+ taskId: grant.taskId,
190
+ workspace: grant.workspaceId,
191
+ payload: grant,
192
+ }));
193
+ return {
194
+ approved: false,
195
+ rejected: true,
196
+ approvalId: grant.approvalId ?? grant.id,
197
+ runId: grant.runId,
198
+ itemId: grant.itemId,
199
+ scope: grant.scope,
200
+ };
201
+ }
202
+
99
203
  function list() {
100
204
  return [...pending.entries()].map(([approvalId, item]) => ({
101
205
  approvalId,
@@ -108,6 +212,31 @@ export function createApprovalManager(session, {
108
212
  return {
109
213
  requestApproval,
110
214
  approve,
215
+ reject,
111
216
  list,
112
217
  };
218
+
219
+ function resolveMatchingPending(grant) {
220
+ for (const entry of pending.values()) {
221
+ if (grant.approvalId && entry.approvalId === grant.approvalId) entry.resolve();
222
+ else if (grant.taskId && entry.taskId === grant.taskId) entry.resolve();
223
+ else if (grant.itemId && entry.itemId === grant.itemId) entry.resolve();
224
+ else if (grant.scope === 'run' && grant.runId && entry.runId === grant.runId) entry.resolve();
225
+ else continue;
226
+ break;
227
+ }
228
+ }
229
+ }
230
+
231
+ function normalizeGrantScope(scope) {
232
+ const normalized = String(scope ?? 'run').toLowerCase();
233
+ if (normalized === 'all') return 'run';
234
+ if (['run', 'task', 'group', 'tool'].includes(normalized)) return normalized;
235
+ return normalized === 'tool' ? 'tool' : 'run';
236
+ }
237
+
238
+ function normalizeClasses(value) {
239
+ if (value == null) return [];
240
+ const values = Array.isArray(value) ? value : [value];
241
+ return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))];
113
242
  }
@@ -0,0 +1,43 @@
1
+ // Deterministic, localized messages for the runtime control lane.
2
+ //
3
+ // Control-lane acknowledgements (run queued, ambiguous input, conversation
4
+ // fallback…) are intentionally NOT generated by Donna: spending an LLM turn
5
+ // to say "your request is queued" would reintroduce exactly the per-message
6
+ // cost the orchestration refactor removed. But they are user-facing, so they
7
+ // must follow the session's configured reply language. This catalog is the
8
+ // single source for those strings — never hardcode a control-lane message in
9
+ // the shell or the server directly.
10
+
11
+ const CONTROL_MESSAGES = {
12
+ queued_for_future_run: {
13
+ en: 'Request added to the queue — it will start automatically after the current run.',
14
+ fr: 'Demande ajoutée à la file — elle démarrera automatiquement à la fin du run en cours.',
15
+ },
16
+ plan_patch_proposed: {
17
+ en: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
18
+ fr: 'Modification de plan proposée. Approuvez-la explicitement pour l’appliquer au plan actif.',
19
+ },
20
+ ambiguous_control: {
21
+ en: 'The runtime cannot safely classify this message.',
22
+ fr: 'Le runtime ne peut pas classer ce message de façon sûre.',
23
+ },
24
+ converse_while_running: {
25
+ en: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
26
+ fr: 'Un run est toujours actif. Ce message a été traité comme conversation et n’a pas créé de run en file.',
27
+ },
28
+ converse_while_idle: {
29
+ en: 'Runtime is idle. This message was treated as conversation and did not create a run.',
30
+ fr: 'Le runtime est inactif. Ce message a été traité comme conversation et n’a pas créé de run.',
31
+ },
32
+ };
33
+
34
+ export function controlLanguage(session) {
35
+ const raw = String(session?.language ?? 'en').toLowerCase();
36
+ return raw.startsWith('fr') ? 'fr' : 'en';
37
+ }
38
+
39
+ export function controlMessage(session, key) {
40
+ const entry = CONTROL_MESSAGES[key];
41
+ if (!entry) throw new Error(`Unknown control message key: ${key}`);
42
+ return entry[controlLanguage(session)] ?? entry.en;
43
+ }
@@ -0,0 +1,21 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { controlLanguage, controlMessage } from './controlMessages.js';
4
+
5
+ test('controlLanguage maps fr locales to fr and everything else to en', () => {
6
+ assert.equal(controlLanguage({ language: 'fr-FR' }), 'fr');
7
+ assert.equal(controlLanguage({ language: 'fr' }), 'fr');
8
+ assert.equal(controlLanguage({ language: 'en-US' }), 'en');
9
+ assert.equal(controlLanguage({ language: null }), 'en');
10
+ assert.equal(controlLanguage(null), 'en');
11
+ });
12
+
13
+ test('controlMessage returns the localized queued acknowledgement', () => {
14
+ assert.match(controlMessage({ language: 'fr-FR' }, 'queued_for_future_run'), /ajoutée à la file/);
15
+ assert.match(controlMessage({ language: 'en-US' }, 'queued_for_future_run'), /added to the queue/);
16
+ });
17
+
18
+ test('controlMessage falls back to en for unknown locales and throws on unknown keys', () => {
19
+ assert.match(controlMessage({ language: 'de-DE' }, 'queued_for_future_run'), /added to the queue/);
20
+ assert.throws(() => controlMessage({ language: 'fr-FR' }, 'nope'), /Unknown control message key/);
21
+ });