@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.
- package/README.md +32 -7
- package/agents.docker-compose.yml +3 -0
- package/docker-compose.yml +1 -0
- package/package.json +3 -2
- package/src/activity/activityAggregator.js +109 -0
- package/src/activity/activityAggregator.test.js +60 -0
- package/src/activity/activityDeduplicator.js +50 -0
- package/src/activity/progressCalculator.js +61 -0
- package/src/activity/runSynthesis.js +15 -0
- package/src/agent/graph.js +101 -38
- package/src/agent/graph.test.js +75 -5
- package/src/cli/wiki-manager.js +22 -0
- package/src/contracts/schemas.js +249 -13
- package/src/contracts/schemas.test.js +147 -2
- package/src/core/activity.js +8 -5
- package/src/core/agentEvents.js +260 -23
- package/src/core/agentEvents.test.js +53 -0
- package/src/core/compose.js +6 -1
- package/src/core/dockerCompose.test.js +12 -0
- package/src/core/documentIntake.js +33 -38
- package/src/core/documentIntake.test.js +25 -0
- package/src/core/env.js +11 -1
- package/src/core/jobQueue.js +26 -2
- package/src/core/mcp.js +30 -3
- package/src/core/mcp.test.js +69 -1
- package/src/core/plan.js +46 -4
- package/src/core/plan.test.js +16 -1
- package/src/core/planPatch.js +64 -3
- package/src/core/planPatch.test.js +110 -1
- package/src/core/queueStore.test.js +21 -0
- package/src/core/runtimeLog.js +119 -0
- package/src/core/runtimeLog.test.js +84 -0
- package/src/core/wikirc.js +22 -0
- package/src/core/wikirc.test.js +49 -1
- package/src/core/workflow.js +14 -3
- package/src/graph/graphAggregator.js +5 -0
- package/src/graph/graphPatch.js +8 -0
- package/src/graph/graphSnapshot.js +14 -0
- package/src/graph/graphVisibilityPolicy.js +40 -0
- package/src/graph/runGraphProjector.js +87 -0
- package/src/graph/runGraphProjector.test.js +111 -0
- package/src/orchestrator/agentRegistry.js +172 -0
- package/src/orchestrator/agentRegistry.test.js +114 -0
- package/src/orchestrator/approvalPolicy.js +126 -0
- package/src/orchestrator/approvalPolicy.test.js +78 -0
- package/src/orchestrator/assignmentManager.js +80 -0
- package/src/orchestrator/attemptManager.js +168 -0
- package/src/orchestrator/attemptManager.test.js +160 -0
- package/src/orchestrator/budgetManager.js +127 -0
- package/src/orchestrator/capabilityRegistry.js +64 -0
- package/src/orchestrator/capabilityRegistry.test.js +59 -0
- package/src/orchestrator/capabilityResolver.js +151 -0
- package/src/orchestrator/capabilityResolver.test.js +127 -0
- package/src/orchestrator/dependencyResolver.js +112 -0
- package/src/orchestrator/dispatcher.js +302 -0
- package/src/orchestrator/lockManager.js +51 -0
- package/src/orchestrator/planIntegrator.js +268 -0
- package/src/orchestrator/planIntegrator.test.js +213 -0
- package/src/orchestrator/planValidator.js +534 -0
- package/src/orchestrator/planValidator.test.js +262 -0
- package/src/orchestrator/resultAggregator.js +248 -0
- package/src/orchestrator/resultAggregator.test.js +211 -0
- package/src/orchestrator/scheduler.js +103 -0
- package/src/orchestrator/scheduler.test.js +138 -0
- package/src/runtime/approvals.js +130 -1
- package/src/runtime/controlMessages.js +43 -0
- package/src/runtime/controlMessages.test.js +21 -0
- package/src/runtime/donna-contract.test.js +296 -33
- package/src/runtime/recoveryManager.js +176 -0
- package/src/runtime/recoveryManager.test.js +162 -0
- package/src/runtime/runner.e2e.test.js +98 -12
- package/src/runtime/runner.js +261 -227
- package/src/runtime/runner.test.js +249 -452
- package/src/runtime/server.js +142 -24
- package/src/runtime/server.test.js +192 -62
- package/src/runtime/store.js +837 -2
- package/src/runtime/store.test.js +269 -28
- package/src/runtime/supervisor.js +33 -1
- package/src/runtime/supervisor.test.js +47 -1
- package/src/shell/RightPane.tsx +8 -5
- package/src/shell/repl.js +7 -11
- package/src/shell/repl.test.js +26 -5
- package/src/shell/tui.tsx +1 -0
- package/src/shell/useAgent.ts +5 -1
- package/src/shell/useSession.ts +75 -19
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createAgentEvent } from '../core/agentEvents.js';
|
|
5
|
+
import { projectWorkflow } from '../core/workflow.js';
|
|
6
|
+
|
|
7
|
+
function sampleEvents() {
|
|
8
|
+
return [
|
|
9
|
+
createAgentEvent('run_started', { runId: 'run-graph', payload: { input: 'Run graph' } }),
|
|
10
|
+
createAgentEvent('task_group.created', {
|
|
11
|
+
runId: 'run-graph',
|
|
12
|
+
payload: { group: { id: 'ingest', label: 'Ingest group' } },
|
|
13
|
+
}),
|
|
14
|
+
createAgentEvent('task.created', {
|
|
15
|
+
runId: 'run-graph',
|
|
16
|
+
taskId: 'run-graph:ingest',
|
|
17
|
+
payload: {
|
|
18
|
+
task: {
|
|
19
|
+
id: 'run-graph:ingest',
|
|
20
|
+
label: 'Ingest',
|
|
21
|
+
description: 'Ingest',
|
|
22
|
+
status: 'running',
|
|
23
|
+
groupId: 'ingest',
|
|
24
|
+
requiredCapability: 'knowledge.update',
|
|
25
|
+
operation: 'ingest',
|
|
26
|
+
dependsOn: [],
|
|
27
|
+
outputRefs: [],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
createAgentEvent('task.created', {
|
|
32
|
+
runId: 'run-graph',
|
|
33
|
+
taskId: 'run-graph:apply',
|
|
34
|
+
payload: {
|
|
35
|
+
task: {
|
|
36
|
+
id: 'run-graph:apply',
|
|
37
|
+
label: 'Apply',
|
|
38
|
+
description: 'Apply',
|
|
39
|
+
status: 'pending',
|
|
40
|
+
dependsOnGroup: 'ingest',
|
|
41
|
+
barrier: true,
|
|
42
|
+
requiredCapability: 'knowledge.update',
|
|
43
|
+
operation: 'ingest_apply',
|
|
44
|
+
dependsOn: ['run-graph:ingest'],
|
|
45
|
+
outputRefs: [],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
createAgentEvent('task.assigned', {
|
|
50
|
+
runId: 'run-graph',
|
|
51
|
+
taskId: 'run-graph:ingest',
|
|
52
|
+
payload: {
|
|
53
|
+
taskId: 'run-graph:ingest',
|
|
54
|
+
attemptId: 'attempt-1',
|
|
55
|
+
assignment: { agentInstanceId: 'production-main' },
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
createAgentEvent('task.started', {
|
|
59
|
+
runId: 'run-graph',
|
|
60
|
+
taskId: 'run-graph:ingest',
|
|
61
|
+
payload: { taskId: 'run-graph:ingest', attemptId: 'attempt-1', jobId: 'job-1' },
|
|
62
|
+
}),
|
|
63
|
+
createAgentEvent('task.result_returned', {
|
|
64
|
+
runId: 'run-graph',
|
|
65
|
+
taskId: 'run-graph:ingest',
|
|
66
|
+
payload: { result: { attemptId: 'attempt-1', status: 'succeeded', outputRefs: [{ type: 'file', ref: 'out.json' }] } },
|
|
67
|
+
}),
|
|
68
|
+
createAgentEvent('plan.revision_changed', {
|
|
69
|
+
runId: 'run-graph',
|
|
70
|
+
payload: { runId: 'run-graph', previousRevision: 1, planRevision: 2, taskIds: ['run-graph:ingest'] },
|
|
71
|
+
}),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('run graph snapshot includes task groups, barrier, agent, assignment, attempt, result and expansion', () => {
|
|
76
|
+
const workflow = projectWorkflow({
|
|
77
|
+
status: 'running',
|
|
78
|
+
runId: 'run-graph',
|
|
79
|
+
plan: [
|
|
80
|
+
{ id: 'run-graph:ingest', description: 'Ingest', status: 'running', groupId: 'ingest', requiredCapability: 'knowledge.update', operation: 'ingest', dependsOn: [] },
|
|
81
|
+
{ id: 'run-graph:apply', description: 'Apply', status: 'pending', dependsOnGroup: 'ingest', barrier: true, requiredCapability: 'knowledge.update', operation: 'ingest_apply', dependsOn: ['run-graph:ingest'] },
|
|
82
|
+
],
|
|
83
|
+
activities: [],
|
|
84
|
+
queue: [],
|
|
85
|
+
approvals: [],
|
|
86
|
+
}, sampleEvents());
|
|
87
|
+
|
|
88
|
+
const types = new Set(workflow.graph.nodes.map((node) => node.type));
|
|
89
|
+
for (const type of ['task_group', 'barrier', 'agent_instance', 'assignment', 'attempt', 'result', 'plan_expansion']) {
|
|
90
|
+
assert.ok(types.has(type), `missing graph node type ${type}`);
|
|
91
|
+
}
|
|
92
|
+
assert.ok(workflow.graph.visibleNodes.length <= 18);
|
|
93
|
+
assert.ok(workflow.graph.nodes.find((node) => node.id === 'task:run-graph:ingest')?.tooltip.includes('Capacite : knowledge.update'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('run graph replay from the same events is deterministic', () => {
|
|
97
|
+
const state = {
|
|
98
|
+
status: 'running',
|
|
99
|
+
runId: 'run-graph',
|
|
100
|
+
plan: [
|
|
101
|
+
{ id: 'run-graph:ingest', description: 'Ingest', status: 'running', groupId: 'ingest', requiredCapability: 'knowledge.update', operation: 'ingest', dependsOn: [] },
|
|
102
|
+
],
|
|
103
|
+
activities: [],
|
|
104
|
+
queue: [],
|
|
105
|
+
approvals: [],
|
|
106
|
+
};
|
|
107
|
+
const first = projectWorkflow(state, sampleEvents()).graph;
|
|
108
|
+
const second = projectWorkflow(state, sampleEvents()).graph;
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(second, first);
|
|
111
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
|
+
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
3
|
+
import { assertContract } from '../contracts/schemas.js';
|
|
4
|
+
|
|
5
|
+
const AVAILABLE = 'available';
|
|
6
|
+
const UNAVAILABLE = 'unavailable';
|
|
7
|
+
|
|
8
|
+
export function createAgentRegistry({
|
|
9
|
+
callTool = callMcpTool,
|
|
10
|
+
now = () => new Date(),
|
|
11
|
+
} = {}) {
|
|
12
|
+
const agentsByInstance = new Map();
|
|
13
|
+
const instanceByServer = new Map();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
async discover(session, { signal = null } = {}) {
|
|
17
|
+
const discovered = [];
|
|
18
|
+
for (const [serverName, endpoint] of Object.entries(session?.mcp ?? {})) {
|
|
19
|
+
const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
|
|
20
|
+
discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer }));
|
|
21
|
+
}
|
|
22
|
+
session.agentRegistry = this;
|
|
23
|
+
session.agentRegistrySnapshot = this.snapshot();
|
|
24
|
+
return discovered;
|
|
25
|
+
},
|
|
26
|
+
snapshot() {
|
|
27
|
+
return [...agentsByInstance.values()]
|
|
28
|
+
.map((agent) => cloneAgent(agent))
|
|
29
|
+
.sort((a, b) => a.agentInstanceId.localeCompare(b.agentInstanceId));
|
|
30
|
+
},
|
|
31
|
+
get(agentInstanceId) {
|
|
32
|
+
const agent = agentsByInstance.get(String(agentInstanceId));
|
|
33
|
+
return agent ? cloneAgent(agent) : null;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function discoverServerAgent(session, serverName, endpoint = {}, { callTool, signal, now }) {
|
|
39
|
+
const lastSeenAt = now().toISOString();
|
|
40
|
+
if (endpoint.status !== 'connected') {
|
|
41
|
+
return legacyAgent(serverName, endpoint, { health: UNAVAILABLE, lastSeenAt });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const toolName = findAgentDescribeTool(serverName, endpoint.tools ?? []);
|
|
45
|
+
if (!toolName) {
|
|
46
|
+
return legacyAgent(serverName, endpoint, { health: AVAILABLE, lastSeenAt });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const result = await callTool(session.mcp, serverName, toolName, {}, signal);
|
|
51
|
+
const description = assertContract('agentDescription', parseToolJsonResult(result));
|
|
52
|
+
return {
|
|
53
|
+
serverName,
|
|
54
|
+
toolName,
|
|
55
|
+
agentInstanceId: description.agentInstanceId,
|
|
56
|
+
description,
|
|
57
|
+
health: description.health?.status ?? AVAILABLE,
|
|
58
|
+
firstSeenAt: lastSeenAt,
|
|
59
|
+
lastSeenAt,
|
|
60
|
+
legacy: false,
|
|
61
|
+
orchestrable: true,
|
|
62
|
+
};
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return legacyAgent(serverName, endpoint, {
|
|
65
|
+
health: UNAVAILABLE,
|
|
66
|
+
lastSeenAt,
|
|
67
|
+
toolName,
|
|
68
|
+
error: error instanceof Error ? error.message : String(error),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
|
|
74
|
+
const previousInstanceId = instanceByServer.get(agent.serverName);
|
|
75
|
+
const previous = previousInstanceId ? agentsByInstance.get(previousInstanceId) : null;
|
|
76
|
+
const firstSeenAt = previous?.firstSeenAt ?? agent.firstSeenAt ?? agent.lastSeenAt;
|
|
77
|
+
const next = {
|
|
78
|
+
...agent,
|
|
79
|
+
firstSeenAt,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (previous && previous.agentInstanceId !== next.agentInstanceId) {
|
|
83
|
+
agentsByInstance.delete(previous.agentInstanceId);
|
|
84
|
+
}
|
|
85
|
+
agentsByInstance.set(next.agentInstanceId, next);
|
|
86
|
+
instanceByServer.set(next.serverName, next.agentInstanceId);
|
|
87
|
+
|
|
88
|
+
if (!previous || previous.agentInstanceId !== next.agentInstanceId) {
|
|
89
|
+
dispatchRegistryEvent(session, 'agent.registered', { agent: next });
|
|
90
|
+
} else if (previous.health !== next.health) {
|
|
91
|
+
dispatchRegistryEvent(session, 'agent.health_changed', {
|
|
92
|
+
agent: next,
|
|
93
|
+
agentInstanceId: next.agentInstanceId,
|
|
94
|
+
previousHealth: previous.health,
|
|
95
|
+
health: next.health,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return cloneAgent(next);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function dispatchRegistryEvent(session, type, payload) {
|
|
102
|
+
if (!session) return;
|
|
103
|
+
dispatchAgentEvent(session, createAgentEvent(type, {
|
|
104
|
+
origin: 'agent_registry',
|
|
105
|
+
workspace: session.workspace ?? null,
|
|
106
|
+
payload,
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findAgentDescribeTool(serverName, tools) {
|
|
111
|
+
const names = tools.map((tool) => String(tool.name ?? '')).filter(Boolean);
|
|
112
|
+
return names.find((name) => name === 'agent_describe')
|
|
113
|
+
?? names.find((name) => name === `${serverName}__agent_describe`)
|
|
114
|
+
?? names.find((name) => name.endsWith('__agent_describe'))
|
|
115
|
+
?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseToolJsonResult(result) {
|
|
119
|
+
if (result && typeof result === 'object' && !Array.isArray(result) && !Array.isArray(result.content)) {
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
const text = formatMcpToolResult(result);
|
|
123
|
+
return JSON.parse(text);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function legacyAgent(serverName, endpoint = {}, { health, lastSeenAt, toolName = null, error = null }) {
|
|
127
|
+
const displayName = endpoint.displayName ?? serverName;
|
|
128
|
+
const description = {
|
|
129
|
+
contractVersion: 'legacy',
|
|
130
|
+
agentType: serverName,
|
|
131
|
+
agentInstanceId: `${serverName}-legacy`,
|
|
132
|
+
displayName,
|
|
133
|
+
capabilities: [],
|
|
134
|
+
orchestration: {
|
|
135
|
+
canPlan: false,
|
|
136
|
+
canExpandPlan: false,
|
|
137
|
+
canExecute: false,
|
|
138
|
+
canCancel: false,
|
|
139
|
+
canResume: false,
|
|
140
|
+
supportsIdempotency: false,
|
|
141
|
+
supportsParallelWorkers: false,
|
|
142
|
+
},
|
|
143
|
+
limits: {
|
|
144
|
+
recommendedConcurrency: 0,
|
|
145
|
+
maxConcurrency: 0,
|
|
146
|
+
},
|
|
147
|
+
health: { status: health },
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
serverName,
|
|
151
|
+
toolName,
|
|
152
|
+
agentInstanceId: description.agentInstanceId,
|
|
153
|
+
description,
|
|
154
|
+
health,
|
|
155
|
+
firstSeenAt: lastSeenAt,
|
|
156
|
+
lastSeenAt,
|
|
157
|
+
legacy: true,
|
|
158
|
+
orchestrable: false,
|
|
159
|
+
error,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cloneAgent(agent) {
|
|
164
|
+
return {
|
|
165
|
+
...agent,
|
|
166
|
+
description: cloneJson(agent.description),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cloneJson(value) {
|
|
171
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
172
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createAgentRegistry } from './agentRegistry.js';
|
|
4
|
+
|
|
5
|
+
function description({ agentInstanceId = 'production-main', health = 'available', contractVersion = '1' } = {}) {
|
|
6
|
+
return {
|
|
7
|
+
contractVersion,
|
|
8
|
+
agentType: 'production',
|
|
9
|
+
agentInstanceId,
|
|
10
|
+
displayName: 'Production',
|
|
11
|
+
capabilities: [{
|
|
12
|
+
id: 'knowledge.update',
|
|
13
|
+
version: '1',
|
|
14
|
+
description: 'Update wiki knowledge',
|
|
15
|
+
inputSchema: {},
|
|
16
|
+
outputSchema: {},
|
|
17
|
+
supportedOperations: ['ingest'],
|
|
18
|
+
defaultRequiresApproval: true,
|
|
19
|
+
}],
|
|
20
|
+
orchestration: {
|
|
21
|
+
canPlan: true,
|
|
22
|
+
canExpandPlan: false,
|
|
23
|
+
canExecute: true,
|
|
24
|
+
canCancel: true,
|
|
25
|
+
canResume: false,
|
|
26
|
+
supportsIdempotency: false,
|
|
27
|
+
supportsParallelWorkers: true,
|
|
28
|
+
},
|
|
29
|
+
limits: {
|
|
30
|
+
recommendedConcurrency: 2,
|
|
31
|
+
maxConcurrency: 4,
|
|
32
|
+
},
|
|
33
|
+
health: { status: health },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('agentRegistry discovers connected contract agents through agent_describe', async () => {
|
|
38
|
+
const events = [];
|
|
39
|
+
const session = {
|
|
40
|
+
workspace: 'docs',
|
|
41
|
+
mcp: {
|
|
42
|
+
production: {
|
|
43
|
+
status: 'connected',
|
|
44
|
+
tools: [{ name: 'production__agent_describe' }],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
_onAgentEvent: (event) => events.push(event),
|
|
48
|
+
};
|
|
49
|
+
const registry = createAgentRegistry({
|
|
50
|
+
callTool: async (_mcp, server, tool) => {
|
|
51
|
+
assert.equal(server, 'production');
|
|
52
|
+
assert.equal(tool, 'production__agent_describe');
|
|
53
|
+
return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const agents = await registry.discover(session);
|
|
58
|
+
|
|
59
|
+
assert.equal(agents.length, 1);
|
|
60
|
+
assert.equal(agents[0].agentInstanceId, 'production-main');
|
|
61
|
+
assert.equal(agents[0].legacy, false);
|
|
62
|
+
assert.equal(agents[0].health, 'available');
|
|
63
|
+
assert.equal(events[0].type, 'agent.registered');
|
|
64
|
+
assert.equal(events[0].payload.agent.agentInstanceId, 'production-main');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('agentRegistry records legacy visible agents when no contract tool exists', async () => {
|
|
68
|
+
const session = {
|
|
69
|
+
mcp: {
|
|
70
|
+
cme: {
|
|
71
|
+
status: 'connected',
|
|
72
|
+
tools: [{ name: 'cme_export_run' }],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const registry = createAgentRegistry({
|
|
77
|
+
callTool: async () => assert.fail('agent_describe should not be called'),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const [agent] = await registry.discover(session);
|
|
81
|
+
|
|
82
|
+
assert.equal(agent.agentInstanceId, 'cme-legacy');
|
|
83
|
+
assert.equal(agent.legacy, true);
|
|
84
|
+
assert.equal(agent.orchestrable, false);
|
|
85
|
+
assert.equal(agent.health, 'available');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('agentRegistry marks unavailable boot agents and emits health changes on re-scan', async () => {
|
|
89
|
+
const events = [];
|
|
90
|
+
let health = 'unavailable';
|
|
91
|
+
const session = {
|
|
92
|
+
mcp: {
|
|
93
|
+
production: {
|
|
94
|
+
status: 'connected',
|
|
95
|
+
tools: [{ name: 'agent_describe' }],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
_onAgentEvent: (event) => events.push(event),
|
|
99
|
+
};
|
|
100
|
+
const registry = createAgentRegistry({
|
|
101
|
+
callTool: async () => ({ content: [{ type: 'text', text: JSON.stringify(description({ health })) }] }),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await registry.discover(session);
|
|
105
|
+
health = 'available';
|
|
106
|
+
await registry.discover(session);
|
|
107
|
+
|
|
108
|
+
assert.equal(events[0].type, 'agent.registered');
|
|
109
|
+
assert.equal(events[0].payload.agent.health, 'unavailable');
|
|
110
|
+
assert.equal(events[1].type, 'agent.health_changed');
|
|
111
|
+
assert.equal(events[1].payload.previousHealth, 'unavailable');
|
|
112
|
+
assert.equal(events[1].payload.health, 'available');
|
|
113
|
+
assert.equal(registry.snapshot()[0].health, 'available');
|
|
114
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const APPROVAL_DEFAULT_CLASS = 'default';
|
|
4
|
+
|
|
5
|
+
const GRANTED_STATUSES = new Set(['approved', 'granted']);
|
|
6
|
+
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
|
|
7
|
+
|
|
8
|
+
export function approvalClassForTask(task) {
|
|
9
|
+
return String(task?.approvalClass ?? task?.mutationClass ?? APPROVAL_DEFAULT_CLASS);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeApprovalGrant(input = {}) {
|
|
13
|
+
const scope = normalizeScope(input.scope);
|
|
14
|
+
const approvalClasses = normalizeClasses(input.approvalClasses ?? input.approvalClass);
|
|
15
|
+
return {
|
|
16
|
+
id: String(input.id ?? input.approvalId ?? randomUUID()),
|
|
17
|
+
approvalId: input.approvalId == null ? null : String(input.approvalId),
|
|
18
|
+
scope,
|
|
19
|
+
status: normalizeStatus(input.status ?? 'approved'),
|
|
20
|
+
runId: input.runId == null ? null : String(input.runId),
|
|
21
|
+
workspaceId: input.workspaceId ?? input.workspace ?? null,
|
|
22
|
+
planRevision: input.planRevision == null ? null : Number(input.planRevision),
|
|
23
|
+
taskId: input.taskId ?? input.itemId ?? null,
|
|
24
|
+
itemId: input.itemId ?? input.taskId ?? null,
|
|
25
|
+
groupId: input.groupId ?? null,
|
|
26
|
+
approvalClasses,
|
|
27
|
+
reason: input.reason ?? null,
|
|
28
|
+
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
29
|
+
grantedAt: input.grantedAt ?? (GRANTED_STATUSES.has(normalizeStatus(input.status ?? 'approved')) ? new Date().toISOString() : null),
|
|
30
|
+
rejectedAt: input.rejectedAt ?? (normalizeStatus(input.status) === 'rejected' ? new Date().toISOString() : null),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function grantCoversTask(grant, task, {
|
|
35
|
+
runId = null,
|
|
36
|
+
workspaceId = null,
|
|
37
|
+
planRevision = null,
|
|
38
|
+
} = {}) {
|
|
39
|
+
if (task?.requiresApproval !== true) return true;
|
|
40
|
+
if (task?.approved === true || task?.approvalStatus === 'approved') return true;
|
|
41
|
+
if (!grant || !GRANTED_STATUSES.has(normalizeStatus(grant.status))) return false;
|
|
42
|
+
if (grant.runId != null && runId != null && String(grant.runId) !== String(runId)) return false;
|
|
43
|
+
const grantWorkspace = grant.workspaceId ?? grant.workspace ?? null;
|
|
44
|
+
if (grantWorkspace != null && workspaceId != null && String(grantWorkspace) !== String(workspaceId)) return false;
|
|
45
|
+
if (grant.planRevision != null && planRevision != null && Number(grant.planRevision) !== Number(planRevision)) return false;
|
|
46
|
+
|
|
47
|
+
const classes = normalizeClasses(grant.approvalClasses ?? grant.approvalClass);
|
|
48
|
+
if (classes.length > 0 && !classes.includes(approvalClassForTask(task))) return false;
|
|
49
|
+
|
|
50
|
+
const taskIds = new Set([task.id, task.localId, task.taskId].filter(Boolean).map(String));
|
|
51
|
+
const scope = normalizeScope(grant.scope);
|
|
52
|
+
if (scope === 'run' || scope === 'all') return true;
|
|
53
|
+
if (scope === 'group') return grant.groupId != null && String(grant.groupId) === String(task.groupId ?? '');
|
|
54
|
+
if (scope === 'task' || scope === 'tool') {
|
|
55
|
+
return [grant.taskId, grant.itemId, grant.approvalId, grant.id]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.some((id) => taskIds.has(String(id)));
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function approvalCovered(task, approvals = [], context = {}) {
|
|
63
|
+
return task?.requiresApproval !== true
|
|
64
|
+
|| task?.approved === true
|
|
65
|
+
|| task?.approvalStatus === 'approved'
|
|
66
|
+
|| approvals.some((approval) => grantCoversTask(approval, task, context));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function applyApprovalCoverage(tasks = [], {
|
|
70
|
+
approvals = [],
|
|
71
|
+
runId = null,
|
|
72
|
+
workspaceId = null,
|
|
73
|
+
planRevision = null,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const requested = [];
|
|
76
|
+
for (const task of tasks) {
|
|
77
|
+
if (task?.requiresApproval !== true || TERMINAL_STATUSES.has(String(task.status ?? '').toLowerCase())) continue;
|
|
78
|
+
const covered = approvalCovered(task, approvals, { runId, workspaceId, planRevision });
|
|
79
|
+
if (!covered) {
|
|
80
|
+
task.status = 'waiting_approval';
|
|
81
|
+
requested.push(approvalRequestForTask(task, { runId, workspaceId, planRevision }));
|
|
82
|
+
} else if (task.status === 'waiting_approval' || task.status === 'pending_approval') {
|
|
83
|
+
task.status = 'pending';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return requested;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function approvalRequestForTask(task, {
|
|
90
|
+
runId = null,
|
|
91
|
+
workspaceId = null,
|
|
92
|
+
planRevision = null,
|
|
93
|
+
} = {}) {
|
|
94
|
+
const approvalClass = approvalClassForTask(task);
|
|
95
|
+
return {
|
|
96
|
+
id: `approval:${runId ?? 'run'}:${task.id ?? task.localId ?? randomUUID()}:${approvalClass}`,
|
|
97
|
+
scope: 'task',
|
|
98
|
+
status: 'pending_approval',
|
|
99
|
+
runId,
|
|
100
|
+
workspaceId,
|
|
101
|
+
planRevision,
|
|
102
|
+
taskId: task.id ?? null,
|
|
103
|
+
groupId: task.groupId ?? null,
|
|
104
|
+
approvalClasses: [approvalClass],
|
|
105
|
+
reason: task.approvalSummary ?? task.label ?? task.description ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function normalizeClasses(value) {
|
|
110
|
+
if (value == null) return [];
|
|
111
|
+
const values = Array.isArray(value) ? value : [value];
|
|
112
|
+
return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeScope(scope) {
|
|
116
|
+
const normalized = String(scope ?? 'run').toLowerCase();
|
|
117
|
+
if (normalized === 'all') return 'run';
|
|
118
|
+
if (['run', 'task', 'group', 'tool'].includes(normalized)) return normalized;
|
|
119
|
+
return 'run';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeStatus(status) {
|
|
123
|
+
const normalized = String(status ?? '').toLowerCase();
|
|
124
|
+
if (normalized === 'granted') return 'approved';
|
|
125
|
+
return normalized || 'approved';
|
|
126
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
applyApprovalCoverage,
|
|
6
|
+
approvalCovered,
|
|
7
|
+
normalizeApprovalGrant,
|
|
8
|
+
} from './approvalPolicy.js';
|
|
9
|
+
|
|
10
|
+
function task(overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
id: 'run-1:build',
|
|
13
|
+
groupId: 'build',
|
|
14
|
+
requiresApproval: true,
|
|
15
|
+
approvalClass: 'workspace-write',
|
|
16
|
+
status: 'pending',
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('approvalPolicy: approve all is bounded to run, workspace, revision and classes', () => {
|
|
22
|
+
const grant = normalizeApprovalGrant({
|
|
23
|
+
scope: 'run',
|
|
24
|
+
runId: 'run-1',
|
|
25
|
+
workspaceId: 'docs',
|
|
26
|
+
planRevision: 2,
|
|
27
|
+
approvalClasses: ['workspace-write'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
assert.equal(approvalCovered(task(), [grant], { runId: 'run-1', workspaceId: 'docs', planRevision: 2 }), true);
|
|
31
|
+
assert.equal(approvalCovered(task(), [grant], { runId: 'run-2', workspaceId: 'docs', planRevision: 2 }), false);
|
|
32
|
+
assert.equal(approvalCovered(task(), [grant], { runId: 'run-1', workspaceId: 'docs', planRevision: 3 }), false);
|
|
33
|
+
assert.equal(approvalCovered(task({ approvalClass: 'publish' }), [grant], { runId: 'run-1', workspaceId: 'docs', planRevision: 2 }), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('approvalPolicy: task and group grants cover only their target scope', () => {
|
|
37
|
+
const taskGrant = normalizeApprovalGrant({
|
|
38
|
+
scope: 'task',
|
|
39
|
+
runId: 'run-1',
|
|
40
|
+
workspaceId: 'docs',
|
|
41
|
+
planRevision: 1,
|
|
42
|
+
taskId: 'run-1:build',
|
|
43
|
+
approvalClasses: ['workspace-write'],
|
|
44
|
+
});
|
|
45
|
+
const groupGrant = normalizeApprovalGrant({
|
|
46
|
+
scope: 'group',
|
|
47
|
+
runId: 'run-1',
|
|
48
|
+
workspaceId: 'docs',
|
|
49
|
+
planRevision: 1,
|
|
50
|
+
groupId: 'publish',
|
|
51
|
+
approvalClasses: ['workspace-write'],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
assert.equal(approvalCovered(task(), [taskGrant], { runId: 'run-1', workspaceId: 'docs', planRevision: 1 }), true);
|
|
55
|
+
assert.equal(approvalCovered(task({ id: 'run-1:other' }), [taskGrant], { runId: 'run-1', workspaceId: 'docs', planRevision: 1 }), false);
|
|
56
|
+
assert.equal(approvalCovered(task({ groupId: 'publish' }), [groupGrant], { runId: 'run-1', workspaceId: 'docs', planRevision: 1 }), true);
|
|
57
|
+
assert.equal(approvalCovered(task({ groupId: 'build' }), [groupGrant], { runId: 'run-1', workspaceId: 'docs', planRevision: 1 }), false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('approvalPolicy: uncovered approved tasks become waiting_approval requests', () => {
|
|
61
|
+
const tasks = [task({ id: 'run-1:a' }), task({ id: 'run-1:b', approvalClass: 'publish' })];
|
|
62
|
+
const requests = applyApprovalCoverage(tasks, {
|
|
63
|
+
runId: 'run-1',
|
|
64
|
+
workspaceId: 'docs',
|
|
65
|
+
planRevision: 1,
|
|
66
|
+
approvals: [normalizeApprovalGrant({
|
|
67
|
+
scope: 'run',
|
|
68
|
+
runId: 'run-1',
|
|
69
|
+
workspaceId: 'docs',
|
|
70
|
+
planRevision: 1,
|
|
71
|
+
approvalClasses: ['workspace-write'],
|
|
72
|
+
})],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
assert.deepEqual(tasks.map((item) => item.status), ['pending', 'waiting_approval']);
|
|
76
|
+
assert.deepEqual(requests.map((request) => request.taskId), ['run-1:b']);
|
|
77
|
+
assert.deepEqual(requests[0].approvalClasses, ['publish']);
|
|
78
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createCapabilityRegistry } from './capabilityRegistry.js';
|
|
2
|
+
import { CapabilityUnavailableError, resolve } from './capabilityResolver.js';
|
|
3
|
+
|
|
4
|
+
export function createAssignmentManager({
|
|
5
|
+
session = null,
|
|
6
|
+
registry = null,
|
|
7
|
+
workspaceConfig = null,
|
|
8
|
+
} = {}) {
|
|
9
|
+
return {
|
|
10
|
+
assign(task, options = {}) {
|
|
11
|
+
return assign(task, {
|
|
12
|
+
session,
|
|
13
|
+
registry,
|
|
14
|
+
workspaceConfig,
|
|
15
|
+
...options,
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function assign(task, {
|
|
22
|
+
session = null,
|
|
23
|
+
registry = null,
|
|
24
|
+
workspaceConfig = null,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const capability = task?.requiredCapability;
|
|
27
|
+
if (!capability) {
|
|
28
|
+
throw new CapabilityUnavailableError(capability, 'task_missing_required_capability', { taskId: task?.id ?? task?.step });
|
|
29
|
+
}
|
|
30
|
+
const effectiveRegistry = registry ?? session?.capabilityRegistry ?? createCapabilityRegistry({
|
|
31
|
+
agents: session?.agentRegistrySnapshot ?? session?.agents ?? [],
|
|
32
|
+
});
|
|
33
|
+
const effectiveWorkspaceConfig = workspaceConfig ?? session?.wikircConfig ?? session?.wikirc?.config ?? {};
|
|
34
|
+
const retryAssignment = task?.retryAssignment;
|
|
35
|
+
if (retryAssignment?.agentInstanceId) {
|
|
36
|
+
const provider = providerFor(effectiveRegistry, capability, retryAssignment.agentInstanceId);
|
|
37
|
+
if (!provider) {
|
|
38
|
+
throw new CapabilityUnavailableError(capability, 'retry_agent_unavailable', {
|
|
39
|
+
agentInstanceId: retryAssignment.agentInstanceId,
|
|
40
|
+
taskId: task?.id ?? task?.step,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const agent = agentFor(session, retryAssignment.agentInstanceId) ?? provider;
|
|
44
|
+
return {
|
|
45
|
+
agentInstanceId: retryAssignment.agentInstanceId,
|
|
46
|
+
capability,
|
|
47
|
+
operation: task?.operation ?? null,
|
|
48
|
+
serverName: agent?.serverName ?? provider?.serverName ?? null,
|
|
49
|
+
agent,
|
|
50
|
+
retry: true,
|
|
51
|
+
previousAgentInstanceId: retryAssignment.previousAgentInstanceId ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const resolved = resolve(capability, {
|
|
55
|
+
workspaceConfig: effectiveWorkspaceConfig,
|
|
56
|
+
registry: effectiveRegistry,
|
|
57
|
+
});
|
|
58
|
+
const provider = providerFor(effectiveRegistry, capability, resolved.agentInstanceId);
|
|
59
|
+
const agent = agentFor(session, resolved.agentInstanceId) ?? provider ?? null;
|
|
60
|
+
return {
|
|
61
|
+
...resolved,
|
|
62
|
+
capability,
|
|
63
|
+
operation: task?.operation ?? null,
|
|
64
|
+
serverName: agent?.serverName ?? provider?.serverName ?? null,
|
|
65
|
+
agent,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function providerFor(registry, capability, agentInstanceId) {
|
|
70
|
+
if (typeof registry?.providersFor !== 'function') return null;
|
|
71
|
+
return (registry.providersFor(capability) ?? [])
|
|
72
|
+
.find((provider) => provider.agentInstanceId === agentInstanceId) ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function agentFor(session, agentInstanceId) {
|
|
76
|
+
return [
|
|
77
|
+
...(session?.agentRegistrySnapshot ?? []),
|
|
78
|
+
...(session?.agents ?? []),
|
|
79
|
+
].find((agent) => agent?.agentInstanceId === agentInstanceId) ?? null;
|
|
80
|
+
}
|