1 | import _ from 'lodash';
|
2 | import createRequest from './request';
|
3 | import Debug from 'debug';
|
4 | import { decide } from './interpreter';
|
5 | import DEFAULTS from './defaults';
|
6 | import jwtDecode from 'jwt-decode';
|
7 | import Time from './time';
|
8 | import {
|
9 | AGENT_ID_ALLOWED_REGEXP,
|
10 | AGENT_ID_MAX_LENGTH,
|
11 | DEFAULT_DECISION_TREE_VERSION,
|
12 | deprecation
|
13 | } from './constants';
|
14 | import {
|
15 | CraftAiBadRequestError,
|
16 | CraftAiCredentialsError,
|
17 | CraftAiLongRequestTimeOutError
|
18 | } from './errors';
|
19 |
|
20 | let debug = Debug('craft-ai:client');
|
21 |
|
22 | function resolveAfterTimeout(timeout) {
|
23 | return new Promise((resolve) => setTimeout(() => resolve(), timeout));
|
24 | }
|
25 |
|
26 |
|
27 | const SIMPLE_HTTP_URL_REGEX = /^https?:\/\/.*$/;
|
28 |
|
29 | function isUrl(url) {
|
30 | return SIMPLE_HTTP_URL_REGEX.test(url);
|
31 | }
|
32 |
|
33 | export default function createClient(tokenOrCfg) {
|
34 | let cfg = _.defaults(
|
35 | {},
|
36 | _.isString(tokenOrCfg) ? { token: tokenOrCfg } : tokenOrCfg,
|
37 | DEFAULTS
|
38 | );
|
39 |
|
40 |
|
41 | if (!_.has(cfg, 'token') || !_.isString(cfg.token)) {
|
42 | throw new CraftAiBadRequestError('Bad Request, unable to create a client with no or invalid token provided.');
|
43 | }
|
44 | try {
|
45 | const { owner, platform, project } = jwtDecode(cfg.token);
|
46 |
|
47 |
|
48 | cfg.owner = cfg.owner || owner;
|
49 | cfg.project = cfg.project || project;
|
50 | cfg.url = cfg.url || platform;
|
51 | }
|
52 | catch (e) {
|
53 | throw new CraftAiCredentialsError();
|
54 | }
|
55 | if (!_.has(cfg, 'url') || !isUrl(cfg.url)) {
|
56 | throw new CraftAiBadRequestError('Bad Request, unable to create a client with no or invalid url provided.');
|
57 | }
|
58 | if (!_.has(cfg, 'project') || !_.isString(cfg.project)) {
|
59 | throw new CraftAiBadRequestError('Bad Request, unable to create a client with no or invalid project provided.');
|
60 | }
|
61 | else {
|
62 | const splittedProject = cfg.project.split('/');
|
63 | if (splittedProject.length >= 2) {
|
64 | cfg.owner = splittedProject[0];
|
65 | cfg.project = splittedProject[1];
|
66 | }
|
67 | }
|
68 | if (!_.has(cfg, 'owner') || !_.isString(cfg.owner)) {
|
69 | throw new CraftAiBadRequestError('Bad Request, unable to create a client with no or invalid owner provided.');
|
70 | }
|
71 | if (cfg.proxy != null && !isUrl(cfg.proxy)) {
|
72 | throw new CraftAiBadRequestError('Bad Request, unable to create a client with an invalid proxy url provided.');
|
73 | }
|
74 |
|
75 | debug(`Creating a client instance for project '${cfg.owner}/${cfg.project}' on '${cfg.url}'.`);
|
76 |
|
77 | const request = createRequest(cfg);
|
78 |
|
79 |
|
80 | let instance = {
|
81 | cfg: cfg,
|
82 | createAgent: function(configuration, id = undefined) {
|
83 | if (_.isUndefined(configuration) || !_.isObject(configuration)) {
|
84 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to create an agent with no or invalid configuration provided.'));
|
85 | }
|
86 |
|
87 | if (!_.isUndefined(id) && !AGENT_ID_ALLOWED_REGEXP.test(id)) {
|
88 | return Promise.reject(new CraftAiBadRequestError(`Bad Request, unable to create an agent with invalid agent id. It must only contain characters in "a-zA-Z0-9_-" and must be a string between 1 and ${AGENT_ID_MAX_LENGTH} characters.`));
|
89 | }
|
90 |
|
91 | return request({
|
92 | method: 'POST',
|
93 | path: '/agents',
|
94 | body: {
|
95 | id: id,
|
96 | configuration: configuration
|
97 | }
|
98 | })
|
99 | .then(({ body }) => {
|
100 | debug(`Agent '${body.id}' created.`);
|
101 | return body;
|
102 | });
|
103 | },
|
104 | getAgent: function(agentId) {
|
105 | if (!AGENT_ID_ALLOWED_REGEXP.test(agentId)) {
|
106 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to get an agent with invalid agent id. It must only contain characters in "a-zA-Z0-9_-" and cannot be the empty string.'));
|
107 | }
|
108 |
|
109 | return request({
|
110 | method: 'GET',
|
111 | path: `/agents/${agentId}`
|
112 | })
|
113 | .then(({ body }) => body);
|
114 | },
|
115 | listAgents: function(agentId) {
|
116 | return request({
|
117 | method: 'GET',
|
118 | path: '/agents'
|
119 | })
|
120 | .then(({ body }) => body.agentsList);
|
121 | },
|
122 | deleteAgent: function(agentId) {
|
123 | if (_.isUndefined(agentId)) {
|
124 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to delete an agent with no agentId provided.'));
|
125 | }
|
126 |
|
127 | return request({
|
128 | method: 'DELETE',
|
129 | path: `/agents/${agentId}`
|
130 | })
|
131 | .then(({ body }) => {
|
132 | debug(`Agent '${agentId}' deleted`);
|
133 | return body;
|
134 | });
|
135 | },
|
136 | destroyAgent: function(agentId) {
|
137 | deprecation('client.destroyAgent', 'client.deleteAgent');
|
138 | return this.deleteAgent(agentId);
|
139 | },
|
140 | getAgentContext: function(agentId, t = undefined) {
|
141 | if (_.isUndefined(agentId)) {
|
142 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to get the agent context with no agentId provided.'));
|
143 | }
|
144 | let posixTimestamp = Time(t).timestamp;
|
145 | if (_.isUndefined(posixTimestamp)) {
|
146 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to get the agent context with an invalid timestamp provided.'));
|
147 | }
|
148 |
|
149 | return request({
|
150 | method: 'GET',
|
151 | path: `/agents/${agentId}/context/state`,
|
152 | query: {
|
153 | t: posixTimestamp
|
154 | }
|
155 | })
|
156 | .then(({ body }) => body);
|
157 | },
|
158 | addAgentContextOperations: function(agentId, operations) {
|
159 | if (_.isUndefined(agentId)) {
|
160 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to add agent context operations with no agentId provided.'));
|
161 | }
|
162 | if (!_.isArray(operations)) {
|
163 |
|
164 | operations = [operations];
|
165 | }
|
166 | operations = _.compact(operations);
|
167 |
|
168 | if (!operations.length) {
|
169 | const message = `No operation to add to the agent ${cfg.owner}/${cfg.project}/${agentId} context.`;
|
170 |
|
171 | debug(message);
|
172 |
|
173 | return Promise.resolve({ message });
|
174 | }
|
175 |
|
176 | return _(operations)
|
177 | .map(({ context, timestamp }) => ({
|
178 | context: context,
|
179 | timestamp: Time(timestamp).timestamp
|
180 | }))
|
181 | .orderBy('timestamp')
|
182 | .chunk(cfg.operationsChunksSize)
|
183 | .reduce((p, chunk) => p.then(
|
184 | () => request({
|
185 | method: 'POST',
|
186 | path: `/agents/${agentId}/context`,
|
187 | body: chunk
|
188 | })
|
189 | ),
|
190 | Promise.resolve())
|
191 | .then(() => {
|
192 | const message = `Successfully added ${operations.length} operation(s) to the agent ${cfg.owner}/${cfg.project}/${agentId} context.`;
|
193 | debug(message);
|
194 | return { message };
|
195 | });
|
196 | },
|
197 | getAgentContextOperations: function(agentId, start = undefined, end = undefined) {
|
198 | if (_.isUndefined(agentId)) {
|
199 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to get agent context operations with no agentId provided.'));
|
200 | }
|
201 | let startTimestamp;
|
202 | if (start) {
|
203 | startTimestamp = Time(start).timestamp;
|
204 | if (_.isUndefined(startTimestamp)) {
|
205 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to get agent context operations with an invalid \'start\' timestamp provided.'));
|
206 | }
|
207 | }
|
208 | let endTimestamp;
|
209 | if (end) {
|
210 | endTimestamp = Time(end).timestamp;
|
211 | if (_.isUndefined(endTimestamp)) {
|
212 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to get agent context operations with an invalid \'end\' timestamp provided.'));
|
213 | }
|
214 | }
|
215 |
|
216 | const requestFollowingPages = ({ operations, nextPageUrl }) => {
|
217 | if (!nextPageUrl) {
|
218 | return Promise.resolve(operations);
|
219 | }
|
220 | return request({ url: nextPageUrl }, this)
|
221 | .then(({ body, nextPageUrl }) => requestFollowingPages({
|
222 | operations: operations.concat(body),
|
223 | nextPageUrl
|
224 | }));
|
225 | };
|
226 |
|
227 | return request({
|
228 | method: 'GET',
|
229 | path: `/agents/${agentId}/context`,
|
230 | query: {
|
231 | start: startTimestamp,
|
232 | end: endTimestamp
|
233 | }
|
234 | })
|
235 | .then(({ body, nextPageUrl }) => requestFollowingPages({
|
236 | operations: body,
|
237 | nextPageUrl
|
238 | }));
|
239 | },
|
240 | getAgentStateHistory: function(agentId, start = undefined, end = undefined) {
|
241 | if (_.isUndefined(agentId)) {
|
242 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to get agent state history with no agentId provided.'));
|
243 | }
|
244 | let startTimestamp;
|
245 | if (start) {
|
246 | startTimestamp = Time(start).timestamp;
|
247 | if (_.isUndefined(startTimestamp)) {
|
248 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to get agent state history with an invalid \'start\' timestamp provided.'));
|
249 | }
|
250 | }
|
251 | let endTimestamp;
|
252 | if (end) {
|
253 | endTimestamp = Time(end).timestamp;
|
254 | if (_.isUndefined(endTimestamp)) {
|
255 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to get agent state history with an invalid \'end\' timestamp provided.'));
|
256 | }
|
257 | }
|
258 |
|
259 | const requestFollowingPages = ({ stateHistory, nextPageUrl }) => {
|
260 | if (!nextPageUrl) {
|
261 | return Promise.resolve(stateHistory);
|
262 | }
|
263 | return request({ url: nextPageUrl })
|
264 | .then(({ body, nextPageUrl }) => requestFollowingPages({
|
265 | stateHistory: stateHistory.concat(body),
|
266 | nextPageUrl
|
267 | }));
|
268 | };
|
269 |
|
270 | return request({
|
271 | method: 'GET',
|
272 | path: `/agents/${agentId}/context/state/history`,
|
273 | query: {
|
274 | start: startTimestamp,
|
275 | end: endTimestamp
|
276 | }
|
277 | })
|
278 | .then(({ body, nextPageUrl }) => requestFollowingPages({
|
279 | stateHistory: body,
|
280 | nextPageUrl
|
281 | }));
|
282 | },
|
283 | getAgentInspectorUrl: function(agentId, t = undefined) {
|
284 | deprecation('client.getAgentInspectorUrl', 'client.getSharedAgentInspectorUrl');
|
285 | return this.getSharedAgentInspectorUrl(agentId, t);
|
286 | },
|
287 | getSharedAgentInspectorUrl: function(agentId, t = undefined) {
|
288 | return request({
|
289 | method: 'GET',
|
290 | path: `/agents/${agentId}/shared`
|
291 | })
|
292 | .then(({ body }) => {
|
293 | if (_.isUndefined(t)) {
|
294 | return body.shortUrl;
|
295 | }
|
296 | else {
|
297 | let posixTimestamp = Time(t).timestamp;
|
298 | return `${body.shortUrl}?t=${posixTimestamp}`;
|
299 | }
|
300 | });
|
301 | },
|
302 | deleteSharedAgentInspectorUrl: function(agentId) {
|
303 | return request({
|
304 | method: 'DELETE',
|
305 | path: `/agents/${agentId}/shared`
|
306 | })
|
307 | .then(() => {
|
308 | debug(`Delete shared inspector link for agent "${agentId}".`);
|
309 | });
|
310 | },
|
311 | getAgentDecisionTree: function(agentId, t = undefined, version = DEFAULT_DECISION_TREE_VERSION) {
|
312 | if (_.isUndefined(agentId)) {
|
313 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to retrieve an agent decision tree with no agentId provided.'));
|
314 | }
|
315 | let posixTimestamp = Time(t).timestamp;
|
316 | if (_.isUndefined(posixTimestamp)) {
|
317 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to retrieve an agent decision tree with an invalid timestamp provided.'));
|
318 | }
|
319 |
|
320 | const agentDecisionTreeRequest = () => request({
|
321 | method: 'GET',
|
322 | path: `/agents/${agentId}/decision/tree`,
|
323 | query: {
|
324 | t: posixTimestamp
|
325 | },
|
326 | headers: {
|
327 | 'x-craft-ai-tree-version': version
|
328 | }
|
329 | })
|
330 | .then(({ body }) => body);
|
331 |
|
332 | if (!cfg.decisionTreeRetrievalTimeout) {
|
333 |
|
334 | return agentDecisionTreeRequest();
|
335 | }
|
336 | else {
|
337 | const start = Date.now();
|
338 | return Promise.race([
|
339 | agentDecisionTreeRequest()
|
340 | .catch((error) => {
|
341 | const requestDuration = Date.now() - start;
|
342 | const expectedRetryDuration = requestDuration + 2000;
|
343 | const timeoutBeforeRetrying = cfg.decisionTreeRetrievalTimeout - requestDuration - expectedRetryDuration;
|
344 | if (error instanceof CraftAiLongRequestTimeOutError && timeoutBeforeRetrying > 0) {
|
345 |
|
346 | return resolveAfterTimeout(timeoutBeforeRetrying)
|
347 | .then(() => agentDecisionTreeRequest());
|
348 | }
|
349 | else {
|
350 | return Promise.reject(error);
|
351 | }
|
352 | }),
|
353 | resolveAfterTimeout(cfg.decisionTreeRetrievalTimeout)
|
354 | .then(() => {
|
355 | throw new CraftAiLongRequestTimeOutError();
|
356 | })
|
357 | ]);
|
358 | }
|
359 | },
|
360 | computeAgentDecision: function(agentId, t, ...contexts) {
|
361 | if (_.isUndefined(agentId)) {
|
362 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to compute an agent decision with no agentId provided.'));
|
363 | }
|
364 | let posixTimestamp = Time(t).timestamp;
|
365 | if (_.isUndefined(posixTimestamp)) {
|
366 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to compute an agent decision with no or invalid timestamp provided.'));
|
367 | }
|
368 | if (_.isUndefined(contexts) || _.size(contexts) === 0) {
|
369 | return Promise.reject(new CraftAiBadRequestError('Bad Request, unable to compute an agent decision with no context provided.'));
|
370 | }
|
371 |
|
372 | return request({
|
373 | method: 'GET',
|
374 | path: `/agents/${agentId}/decision/tree`,
|
375 | query: {
|
376 | t: posixTimestamp
|
377 | }
|
378 | })
|
379 | .then(({ body }) => {
|
380 | let decision = decide(body, ...contexts);
|
381 | decision.timestamp = posixTimestamp;
|
382 | return decision;
|
383 | });
|
384 | }
|
385 | };
|
386 |
|
387 | return instance;
|
388 | }
|