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 | const isUnvalidId = (id) =>
|
34 | !_.isUndefined(id) && !AGENT_ID_ALLOWED_REGEXP.test(id);
|
35 | const isUnvalidConfiguration = (configuration) =>
|
36 | _.isUndefined(configuration) || !_.isObject(configuration);
|
37 | const areUnvalidOperations = (operations) =>
|
38 | _.isUndefined(operations) || !_.isArray(operations);
|
39 |
|
40 | function checkBulkParameters(bulkArray) {
|
41 | if (_.isUndefined(bulkArray)) {
|
42 | throw new CraftAiBadRequestError(
|
43 | 'Bad Request, unable to use bulk functionalities without list provided.'
|
44 | );
|
45 | }
|
46 | if (!_.isArray(bulkArray)) {
|
47 | throw new CraftAiBadRequestError(
|
48 | 'Bad Request, bulk inputs should be provided within an array.'
|
49 | );
|
50 | }
|
51 | if (!bulkArray.length) {
|
52 | throw new CraftAiBadRequestError(
|
53 | 'Bad Request, the array containing bulk inputs is empty.'
|
54 | );
|
55 | }
|
56 | }
|
57 |
|
58 | export default function createClient(tokenOrCfg) {
|
59 | let cfg = _.defaults(
|
60 | {},
|
61 | _.isString(tokenOrCfg) ? { token: tokenOrCfg } : tokenOrCfg,
|
62 | DEFAULTS
|
63 | );
|
64 |
|
65 |
|
66 | if (!_.has(cfg, 'token') || !_.isString(cfg.token)) {
|
67 | throw new CraftAiBadRequestError(
|
68 | 'Bad Request, unable to create a client with no or invalid token provided.'
|
69 | );
|
70 | }
|
71 | try {
|
72 | const { owner, platform, project } = jwtDecode(cfg.token);
|
73 |
|
74 |
|
75 | cfg.owner = cfg.owner || owner;
|
76 | cfg.project = cfg.project || project;
|
77 | cfg.url = cfg.url || platform;
|
78 | }
|
79 | catch (e) {
|
80 | throw new CraftAiCredentialsError();
|
81 | }
|
82 | if (!_.has(cfg, 'url') || !isUrl(cfg.url)) {
|
83 | throw new CraftAiBadRequestError(
|
84 | 'Bad Request, unable to create a client with no or invalid url provided.'
|
85 | );
|
86 | }
|
87 | if (!_.has(cfg, 'project') || !_.isString(cfg.project)) {
|
88 | throw new CraftAiBadRequestError(
|
89 | 'Bad Request, unable to create a client with no or invalid project provided.'
|
90 | );
|
91 | }
|
92 | else {
|
93 | const splittedProject = cfg.project.split('/');
|
94 | if (splittedProject.length >= 2) {
|
95 | cfg.owner = splittedProject[0];
|
96 | cfg.project = splittedProject[1];
|
97 | }
|
98 | }
|
99 | if (!_.has(cfg, 'owner') || !_.isString(cfg.owner)) {
|
100 | throw new CraftAiBadRequestError(
|
101 | 'Bad Request, unable to create a client with no or invalid owner provided.'
|
102 | );
|
103 | }
|
104 | if (cfg.proxy != null && !isUrl(cfg.proxy)) {
|
105 | throw new CraftAiBadRequestError(
|
106 | 'Bad Request, unable to create a client with an invalid proxy url provided.'
|
107 | );
|
108 | }
|
109 |
|
110 | debug(
|
111 | `Creating a client instance for project '${cfg.owner}/${cfg.project}' on '${
|
112 | cfg.url
|
113 | }'.`
|
114 | );
|
115 |
|
116 | const request = createRequest(cfg);
|
117 |
|
118 |
|
119 | let instance = {
|
120 | cfg: cfg,
|
121 | createAgent: function(configuration, id = undefined) {
|
122 | if (isUnvalidConfiguration(configuration)) {
|
123 | return Promise.reject(
|
124 | new CraftAiBadRequestError(
|
125 | 'Bad Request, unable to create an agent with no or invalid configuration provided.'
|
126 | )
|
127 | );
|
128 | }
|
129 |
|
130 | if (isUnvalidId(id)) {
|
131 | return Promise.reject(
|
132 | new CraftAiBadRequestError(
|
133 | `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.`
|
134 | )
|
135 | );
|
136 | }
|
137 |
|
138 | return request({
|
139 | method: 'POST',
|
140 | path: '/agents',
|
141 | body: {
|
142 | id: id,
|
143 | configuration: configuration
|
144 | }
|
145 | })
|
146 | .then(({ body }) => {
|
147 | debug(`Agent '${body.id}' created.`);
|
148 | return body;
|
149 | });
|
150 | },
|
151 | createAgentBulk: function(agentsList) {
|
152 | checkBulkParameters(agentsList);
|
153 |
|
154 | return request({
|
155 | method: 'POST',
|
156 | path: '/bulk/agents',
|
157 | body: agentsList
|
158 | })
|
159 | .then(({ body }) => body);
|
160 | },
|
161 | getAgent: function(agentId) {
|
162 | if (!AGENT_ID_ALLOWED_REGEXP.test(agentId)) {
|
163 | return Promise.reject(
|
164 | new CraftAiBadRequestError(
|
165 | '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.'
|
166 | )
|
167 | );
|
168 | }
|
169 |
|
170 | return request({
|
171 | method: 'GET',
|
172 | path: `/agents/${agentId}`
|
173 | })
|
174 | .then(({ body }) => body);
|
175 | },
|
176 | listAgents: function(agentId) {
|
177 | return request({
|
178 | method: 'GET',
|
179 | path: '/agents'
|
180 | })
|
181 | .then(({ body }) => body.agentsList);
|
182 | },
|
183 | deleteAgent: function(agentId) {
|
184 | if (_.isUndefined(agentId)) {
|
185 | return Promise.reject(
|
186 | new CraftAiBadRequestError(
|
187 | 'Bad Request, unable to delete an agent with no agentId provided.'
|
188 | )
|
189 | );
|
190 | }
|
191 |
|
192 | return request({
|
193 | method: 'DELETE',
|
194 | path: `/agents/${agentId}`
|
195 | })
|
196 | .then(({ body }) => {
|
197 | debug(`Agent '${agentId}' deleted`);
|
198 | return body;
|
199 | });
|
200 | },
|
201 | deleteAgentBulk: function(agentsList) {
|
202 | checkBulkParameters(agentsList);
|
203 |
|
204 | return request({
|
205 | method: 'DELETE',
|
206 | path: '/bulk/agents',
|
207 | body: agentsList
|
208 | })
|
209 | .then(({ body }) => body);
|
210 | },
|
211 | destroyAgent: function(agentId) {
|
212 | deprecation('client.destroyAgent', 'client.deleteAgent');
|
213 | return this.deleteAgent(agentId);
|
214 | },
|
215 | getAgentContext: function(agentId, t = undefined) {
|
216 | if (_.isUndefined(agentId)) {
|
217 | return Promise.reject(
|
218 | new CraftAiBadRequestError(
|
219 | 'Bad Request, unable to get the agent context with no agentId provided.'
|
220 | )
|
221 | );
|
222 | }
|
223 | let posixTimestamp = Time(t).timestamp;
|
224 | if (_.isUndefined(posixTimestamp)) {
|
225 | return Promise.reject(
|
226 | new CraftAiBadRequestError(
|
227 | 'Bad Request, unable to get the agent context with an invalid timestamp provided.'
|
228 | )
|
229 | );
|
230 | }
|
231 |
|
232 | return request({
|
233 | method: 'GET',
|
234 | path: `/agents/${agentId}/context/state`,
|
235 | query: {
|
236 | t: posixTimestamp
|
237 | }
|
238 | })
|
239 | .then(({ body }) => body);
|
240 | },
|
241 | addAgentContextOperations: function(agentId, operations) {
|
242 | if (_.isUndefined(agentId)) {
|
243 | return Promise.reject(
|
244 | new CraftAiBadRequestError(
|
245 | 'Bad Request, unable to add agent context operations with no agentId provided.'
|
246 | )
|
247 | );
|
248 | }
|
249 | if (!_.isArray(operations)) {
|
250 |
|
251 | operations = [operations];
|
252 | }
|
253 | operations = _.compact(operations);
|
254 |
|
255 | if (!operations.length) {
|
256 | const message = `No operation to add to the agent ${cfg.owner}/${
|
257 | cfg.project
|
258 | }/${agentId} context.`;
|
259 |
|
260 | debug(message);
|
261 |
|
262 | return Promise.resolve({ message });
|
263 | }
|
264 |
|
265 | return _(operations)
|
266 | .map(({ context, timestamp }) => ({
|
267 | context: context,
|
268 | timestamp: Time(timestamp).timestamp
|
269 | }))
|
270 | .orderBy('timestamp')
|
271 | .chunk(cfg.operationsChunksSize)
|
272 | .reduce(
|
273 | (p, chunk) =>
|
274 | p.then(() =>
|
275 | request({
|
276 | method: 'POST',
|
277 | path: `/agents/${agentId}/context`,
|
278 | body: chunk
|
279 | })
|
280 | ),
|
281 | Promise.resolve()
|
282 | )
|
283 | .then(() => {
|
284 | const message = `Successfully added ${
|
285 | operations.length
|
286 | } operation(s) to the agent ${cfg.owner}/${
|
287 | cfg.project
|
288 | }/${agentId} context.`;
|
289 | debug(message);
|
290 | return { message };
|
291 | });
|
292 | },
|
293 | addAgentContextOperationsBulk: function(agentsOperationsList) {
|
294 | checkBulkParameters(agentsOperationsList);
|
295 | agentsOperationsList.map(({ id, operations }) => {
|
296 | if (areUnvalidOperations(operations)) {
|
297 | throw new CraftAiBadRequestError(
|
298 | `Bad Request, unable to handle operations for agent ${id}. Operations should be provided within an array.`
|
299 | );
|
300 | }
|
301 | });
|
302 |
|
303 | let chunkedData = [];
|
304 | let currentChunk = [];
|
305 | let currentChunkSize = 0;
|
306 |
|
307 | for (let agent of agentsOperationsList) {
|
308 | if (agent.operations && _.isArray(agent.operations)) {
|
309 | if (
|
310 | currentChunkSize + agent.operations.length >
|
311 | cfg.operationsChunksSize &&
|
312 | currentChunkSize.length
|
313 | ) {
|
314 | chunkedData.push(currentChunk);
|
315 | currentChunkSize = 0;
|
316 | currentChunk = [];
|
317 | }
|
318 |
|
319 | if (agent.operations.length > cfg.operationsChunksSize) {
|
320 | chunkedData.push([agent]);
|
321 | currentChunkSize = 0;
|
322 | }
|
323 | else {
|
324 | currentChunkSize += agent.operations.length;
|
325 | currentChunk.push(agent);
|
326 | }
|
327 | }
|
328 | }
|
329 |
|
330 | if (currentChunk.length) {
|
331 | chunkedData.push(currentChunk);
|
332 | }
|
333 |
|
334 | return Promise.all(
|
335 | chunkedData.map((chunk) => {
|
336 | if (chunk.length > 1) {
|
337 | return request({
|
338 | method: 'POST',
|
339 | path: '/bulk/context',
|
340 | body: chunk
|
341 | })
|
342 | .then(({ body }) => body);
|
343 | }
|
344 | else {
|
345 | return this.addAgentContextOperations(
|
346 | chunk[0].id,
|
347 | chunk[0].operations
|
348 | )
|
349 | .then(({ message }) => [
|
350 | { id: chunk[0].id, status: 201, message }
|
351 | ]);
|
352 | }
|
353 | })
|
354 | )
|
355 | .then(_.flattenDeep);
|
356 | },
|
357 | getAgentContextOperations: function(
|
358 | agentId,
|
359 | start = undefined,
|
360 | end = undefined
|
361 | ) {
|
362 | if (_.isUndefined(agentId)) {
|
363 | return Promise.reject(
|
364 | new CraftAiBadRequestError(
|
365 | 'Bad Request, unable to get agent context operations with no agentId provided.'
|
366 | )
|
367 | );
|
368 | }
|
369 | let startTimestamp;
|
370 | if (start) {
|
371 | startTimestamp = Time(start).timestamp;
|
372 | if (_.isUndefined(startTimestamp)) {
|
373 | return Promise.reject(
|
374 | new CraftAiBadRequestError(
|
375 | 'Bad Request, unable to get agent context operations with an invalid \'start\' timestamp provided.'
|
376 | )
|
377 | );
|
378 | }
|
379 | }
|
380 | let endTimestamp;
|
381 | if (end) {
|
382 | endTimestamp = Time(end).timestamp;
|
383 | if (_.isUndefined(endTimestamp)) {
|
384 | return Promise.reject(
|
385 | new CraftAiBadRequestError(
|
386 | 'Bad Request, unable to get agent context operations with an invalid \'end\' timestamp provided.'
|
387 | )
|
388 | );
|
389 | }
|
390 | }
|
391 |
|
392 | const requestFollowingPages = ({ operations, nextPageUrl }) => {
|
393 | if (!nextPageUrl) {
|
394 | return Promise.resolve(operations);
|
395 | }
|
396 | return request({ url: nextPageUrl }, this)
|
397 | .then(
|
398 | ({ body, nextPageUrl }) =>
|
399 | requestFollowingPages({
|
400 | operations: operations.concat(body),
|
401 | nextPageUrl
|
402 | })
|
403 | );
|
404 | };
|
405 |
|
406 | return request({
|
407 | method: 'GET',
|
408 | path: `/agents/${agentId}/context`,
|
409 | query: {
|
410 | start: startTimestamp,
|
411 | end: endTimestamp
|
412 | }
|
413 | })
|
414 | .then(({ body, nextPageUrl }) =>
|
415 | requestFollowingPages({
|
416 | operations: body,
|
417 | nextPageUrl
|
418 | })
|
419 | );
|
420 | },
|
421 | getAgentStateHistory: function(
|
422 | agentId,
|
423 | start = undefined,
|
424 | end = undefined
|
425 | ) {
|
426 | if (_.isUndefined(agentId)) {
|
427 | return Promise.reject(
|
428 | new CraftAiBadRequestError(
|
429 | 'Bad Request, unable to get agent state history with no agentId provided.'
|
430 | )
|
431 | );
|
432 | }
|
433 | let startTimestamp;
|
434 | if (start) {
|
435 | startTimestamp = Time(start).timestamp;
|
436 | if (_.isUndefined(startTimestamp)) {
|
437 | return Promise.reject(
|
438 | new CraftAiBadRequestError(
|
439 | 'Bad Request, unable to get agent state history with an invalid \'start\' timestamp provided.'
|
440 | )
|
441 | );
|
442 | }
|
443 | }
|
444 | let endTimestamp;
|
445 | if (end) {
|
446 | endTimestamp = Time(end).timestamp;
|
447 | if (_.isUndefined(endTimestamp)) {
|
448 | return Promise.reject(
|
449 | new CraftAiBadRequestError(
|
450 | 'Bad Request, unable to get agent state history with an invalid \'end\' timestamp provided.'
|
451 | )
|
452 | );
|
453 | }
|
454 | }
|
455 |
|
456 | const requestFollowingPages = ({ stateHistory, nextPageUrl }) => {
|
457 | if (!nextPageUrl) {
|
458 | return Promise.resolve(stateHistory);
|
459 | }
|
460 | return request({ url: nextPageUrl })
|
461 | .then(({ body, nextPageUrl }) =>
|
462 | requestFollowingPages({
|
463 | stateHistory: stateHistory.concat(body),
|
464 | nextPageUrl
|
465 | })
|
466 | );
|
467 | };
|
468 |
|
469 | return request({
|
470 | method: 'GET',
|
471 | path: `/agents/${agentId}/context/state/history`,
|
472 | query: {
|
473 | start: startTimestamp,
|
474 | end: endTimestamp
|
475 | }
|
476 | })
|
477 | .then(({ body, nextPageUrl }) =>
|
478 | requestFollowingPages({
|
479 | stateHistory: body,
|
480 | nextPageUrl
|
481 | })
|
482 | );
|
483 | },
|
484 | getAgentInspectorUrl: function(agentId, t = undefined) {
|
485 | deprecation(
|
486 | 'client.getAgentInspectorUrl',
|
487 | 'client.getSharedAgentInspectorUrl'
|
488 | );
|
489 | return this.getSharedAgentInspectorUrl(agentId, t);
|
490 | },
|
491 | getSharedAgentInspectorUrl: function(agentId, t = undefined) {
|
492 | return request({
|
493 | method: 'GET',
|
494 | path: `/agents/${agentId}/shared`
|
495 | })
|
496 | .then(({ body }) => {
|
497 | if (_.isUndefined(t)) {
|
498 | return body.shortUrl;
|
499 | }
|
500 | else {
|
501 | let posixTimestamp = Time(t).timestamp;
|
502 | return `${body.shortUrl}?t=${posixTimestamp}`;
|
503 | }
|
504 | });
|
505 | },
|
506 | deleteSharedAgentInspectorUrl: function(agentId) {
|
507 | return request({
|
508 | method: 'DELETE',
|
509 | path: `/agents/${agentId}/shared`
|
510 | })
|
511 | .then(() => {
|
512 | debug(`Delete shared inspector link for agent '${agentId}'.`);
|
513 | });
|
514 | },
|
515 | getAgentDecisionTree: function(
|
516 | agentId,
|
517 | t = undefined,
|
518 | version = DEFAULT_DECISION_TREE_VERSION
|
519 | ) {
|
520 | if (_.isUndefined(agentId)) {
|
521 | return Promise.reject(
|
522 | new CraftAiBadRequestError(
|
523 | 'Bad Request, unable to retrieve an agent decision tree with no agentId provided.'
|
524 | )
|
525 | );
|
526 | }
|
527 | let posixTimestamp = Time(t).timestamp;
|
528 | if (_.isUndefined(posixTimestamp)) {
|
529 | return Promise.reject(
|
530 | new CraftAiBadRequestError(
|
531 | 'Bad Request, unable to retrieve an agent decision tree with an invalid timestamp provided.'
|
532 | )
|
533 | );
|
534 | }
|
535 |
|
536 | const agentDecisionTreeRequest = () =>
|
537 | request({
|
538 | method: 'GET',
|
539 | path: `/agents/${agentId}/decision/tree`,
|
540 | query: {
|
541 | t: posixTimestamp
|
542 | },
|
543 | headers: {
|
544 | 'x-craft-ai-tree-version': version
|
545 | }
|
546 | })
|
547 | .then(({ body }) => body);
|
548 |
|
549 | if (!cfg.decisionTreeRetrievalTimeout) {
|
550 |
|
551 | return agentDecisionTreeRequest();
|
552 | }
|
553 | else {
|
554 | const start = Date.now();
|
555 | return Promise.race([
|
556 | agentDecisionTreeRequest()
|
557 | .catch((error) => {
|
558 | const requestDuration = Date.now() - start;
|
559 | const expectedRetryDuration = requestDuration + 2000;
|
560 | const timeoutBeforeRetrying =
|
561 | cfg.decisionTreeRetrievalTimeout -
|
562 | requestDuration -
|
563 | expectedRetryDuration;
|
564 | if (
|
565 | error instanceof CraftAiLongRequestTimeOutError &&
|
566 | timeoutBeforeRetrying > 0
|
567 | ) {
|
568 |
|
569 | return resolveAfterTimeout(timeoutBeforeRetrying)
|
570 | .then(() =>
|
571 | agentDecisionTreeRequest()
|
572 | );
|
573 | }
|
574 | else {
|
575 | return Promise.reject(error);
|
576 | }
|
577 | }),
|
578 | resolveAfterTimeout(cfg.decisionTreeRetrievalTimeout)
|
579 | .then(() => {
|
580 | throw new CraftAiLongRequestTimeOutError();
|
581 | })
|
582 | ]);
|
583 | }
|
584 | },
|
585 | getAgentDecisionTreeBulk: function(agentsList) {
|
586 | checkBulkParameters(agentsList);
|
587 |
|
588 | return request({
|
589 | method: 'POST',
|
590 | path: '/bulk/decision_tree',
|
591 | body: agentsList
|
592 | })
|
593 | .then(({ body }) => body);
|
594 | },
|
595 | computeAgentDecision: function(agentId, t, ...contexts) {
|
596 | if (_.isUndefined(agentId)) {
|
597 | return Promise.reject(
|
598 | new CraftAiBadRequestError(
|
599 | 'Bad Request, unable to compute an agent decision with no agentId provided.'
|
600 | )
|
601 | );
|
602 | }
|
603 | let posixTimestamp = Time(t).timestamp;
|
604 | if (_.isUndefined(posixTimestamp)) {
|
605 | return Promise.reject(
|
606 | new CraftAiBadRequestError(
|
607 | 'Bad Request, unable to compute an agent decision with no or invalid timestamp provided.'
|
608 | )
|
609 | );
|
610 | }
|
611 | if (_.isUndefined(contexts) || _.size(contexts) === 0) {
|
612 | return Promise.reject(
|
613 | new CraftAiBadRequestError(
|
614 | 'Bad Request, unable to compute an agent decision with no context provided.'
|
615 | )
|
616 | );
|
617 | }
|
618 |
|
619 | return request({
|
620 | method: 'GET',
|
621 | path: `/agents/${agentId}/decision/tree`,
|
622 | query: {
|
623 | t: posixTimestamp
|
624 | }
|
625 | })
|
626 | .then(({ body }) => {
|
627 | let decision = decide(body, ...contexts);
|
628 | decision.timestamp = posixTimestamp;
|
629 | return decision;
|
630 | });
|
631 | }
|
632 | };
|
633 |
|
634 | return instance;
|
635 | }
|