WorkflowEngine.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. import { nanoid } from 'nanoid';
  2. import browser from 'webextension-polyfill';
  3. import cloneDeep from 'lodash.clonedeep';
  4. import { getBlocks } from '@/utils/getSharedData';
  5. import { clearCache, sleep, parseJSON, isObject } from '@/utils/helper';
  6. import dbStorage from '@/db/storage';
  7. import WorkflowWorker from './WorkflowWorker';
  8. let blocks = getBlocks();
  9. class WorkflowEngine {
  10. constructor(workflow, { states, logger, blocksHandler, isPopup, options }) {
  11. this.id = nanoid();
  12. this.states = states;
  13. this.logger = logger;
  14. this.workflow = workflow;
  15. this.isPopup = isPopup ?? true;
  16. this.blocksHandler = blocksHandler;
  17. this.isTestingMode = workflow.testingMode;
  18. this.parentWorkflow = options?.parentWorkflow;
  19. this.saveLog = workflow.settings?.saveLog ?? true;
  20. this.isMV2 = browser.runtime.getManifest().manifest_version === 2;
  21. this.workerId = 0;
  22. this.workers = new Map();
  23. this.packagesCache = {};
  24. this.extractedGroup = {};
  25. this.connectionsMap = {};
  26. this.waitConnections = {};
  27. this.isDestroyed = false;
  28. this.isUsingProxy = false;
  29. this.isInBreakpoint = false;
  30. this.triggerBlockId = null;
  31. this.blocks = {};
  32. this.history = [];
  33. this.columnsId = {};
  34. this.historyCtxData = {};
  35. this.eventListeners = {};
  36. this.preloadScripts = [];
  37. this.columns = {
  38. column: {
  39. index: 0,
  40. type: 'any',
  41. name: this.workflow.settings?.defaultColumnName || 'column',
  42. },
  43. };
  44. this.rowData = {};
  45. this.logsLimit = 1001;
  46. this.logHistoryId = 0;
  47. let variables = {};
  48. let { globalData } = workflow;
  49. if (options && options?.data) {
  50. globalData = options.data.globalData || globalData;
  51. variables = isObject(options.data.variables)
  52. ? options?.data.variables
  53. : {};
  54. options.data = { globalData, variables };
  55. }
  56. this.options = options;
  57. this.refDataSnapshots = {};
  58. this.refDataSnapshotsKeys = {
  59. loopData: {
  60. index: 0,
  61. key: '##loopData0',
  62. },
  63. variables: {
  64. index: 0,
  65. key: '##variables0',
  66. },
  67. };
  68. this.referenceData = {
  69. variables,
  70. table: [],
  71. secrets: {},
  72. loopData: {},
  73. workflow: {},
  74. googleSheets: {},
  75. globalData: parseJSON(globalData, globalData),
  76. };
  77. this.onDebugEvent = ({ tabId }, method, params) => {
  78. let isActiveTabEvent = false;
  79. this.workers.forEach((worker) => {
  80. if (isActiveTabEvent) return;
  81. isActiveTabEvent = worker.activeTab.id === tabId;
  82. });
  83. if (!isActiveTabEvent) return;
  84. (this.eventListeners[method] || []).forEach((listener) => {
  85. listener(params);
  86. });
  87. };
  88. this.onWorkflowStopped = (id) => {
  89. if (this.id !== id || this.isDestroyed) return;
  90. this.stop();
  91. };
  92. this.onResumeExecution = ({ id, nextBlock }) => {
  93. if (this.id !== id || this.isDestroyed) return;
  94. this.workers.forEach((worker) => {
  95. worker.resume(nextBlock);
  96. });
  97. };
  98. }
  99. async init() {
  100. try {
  101. if (this.workflow.isDisabled) return;
  102. if (!this.states) {
  103. console.error(`"${this.workflow.name}" workflow doesn't have states`);
  104. this.destroy('error');
  105. return;
  106. }
  107. const { nodes, edges } = this.workflow.drawflow;
  108. if (!nodes || nodes.length === 0) {
  109. console.error(`${this.workflow.name} doesn't have blocks`);
  110. return;
  111. }
  112. const triggerBlock = nodes.find((node) => {
  113. if (this.options?.blockId) return node.id === this.options.blockId;
  114. return node.label === 'trigger';
  115. });
  116. if (!triggerBlock) {
  117. console.error(`${this.workflow.name} doesn't have a trigger block`);
  118. return;
  119. }
  120. if (!this.workflow.settings) {
  121. this.workflow.settings = {};
  122. }
  123. blocks = getBlocks();
  124. const checkParams = this.options?.checkParams ?? true;
  125. const hasParams =
  126. checkParams && triggerBlock.data?.parameters?.length > 0;
  127. if (hasParams) {
  128. this.eventListeners = {};
  129. if (triggerBlock.data.preferParamsInTab) {
  130. const [activeTab] = await browser.tabs.query({
  131. active: true,
  132. url: '*://*/*',
  133. lastFocusedWindow: true,
  134. });
  135. if (activeTab) {
  136. const result = await browser.tabs.sendMessage(activeTab.id, {
  137. type: 'input-workflow-params',
  138. data: {
  139. workflow: this.workflow,
  140. params: triggerBlock.data.parameters,
  141. },
  142. });
  143. if (result) return;
  144. }
  145. }
  146. const paramUrl = browser.runtime.getURL('params.html');
  147. const tabs = await browser.tabs.query({});
  148. const paramTab = tabs.find((tab) => tab.url?.includes(paramUrl));
  149. if (paramTab) {
  150. browser.tabs.sendMessage(paramTab.id, {
  151. name: 'workflow:params',
  152. data: this.workflow,
  153. });
  154. browser.windows.update(paramTab.windowId, { focused: true });
  155. } else {
  156. browser.windows.create({
  157. type: 'popup',
  158. width: 480,
  159. height: 700,
  160. url: browser.runtime.getURL(
  161. `/params.html?workflowId=${this.workflow.id}`
  162. ),
  163. });
  164. }
  165. return;
  166. }
  167. this.triggerBlockId = triggerBlock.id;
  168. this.blocks = nodes.reduce((acc, node) => {
  169. acc[node.id] = node;
  170. return acc;
  171. }, {});
  172. this.connectionsMap = edges.reduce(
  173. (acc, { sourceHandle, target, targetHandle }) => {
  174. if (!acc[sourceHandle]) acc[sourceHandle] = new Map();
  175. acc[sourceHandle].set(target, {
  176. id: target,
  177. targetHandle,
  178. sourceHandle,
  179. });
  180. return acc;
  181. },
  182. {}
  183. );
  184. const workflowTable =
  185. this.workflow.table || this.workflow.dataColumns || [];
  186. let columns = Array.isArray(workflowTable)
  187. ? workflowTable
  188. : Object.values(workflowTable);
  189. if (this.workflow.connectedTable) {
  190. const connectedTable = await dbStorage.tablesItems
  191. .where('id')
  192. .equals(this.workflow.connectedTable)
  193. .first();
  194. const connectedTableData = await dbStorage.tablesData
  195. .where('tableId')
  196. .equals(connectedTable?.id)
  197. .first();
  198. if (connectedTable && connectedTableData) {
  199. columns = Object.values(connectedTable.columns);
  200. Object.assign(this.columns, connectedTableData.columnsIndex);
  201. this.referenceData.table = connectedTableData.items || [];
  202. } else {
  203. this.workflow.connectedTable = null;
  204. }
  205. }
  206. columns.forEach(({ name, type, id }) => {
  207. const columnId = id || name;
  208. this.rowData[name] = null;
  209. this.columnsId[name] = columnId;
  210. if (!this.columns[columnId])
  211. this.columns[columnId] = { index: 0, name, type };
  212. });
  213. if (BROWSER_TYPE !== 'chrome') {
  214. this.workflow.settings.debugMode = false;
  215. } else if (this.workflow.settings.debugMode) {
  216. chrome.debugger.onEvent.addListener(this.onDebugEvent);
  217. }
  218. if (
  219. this.workflow.settings.reuseLastState &&
  220. !this.workflow.connectedTable
  221. ) {
  222. const lastStateKey = `state:${this.workflow.id}`;
  223. const value = await browser.storage.local.get(lastStateKey);
  224. const lastState = value[lastStateKey];
  225. if (lastState) {
  226. Object.assign(this.columns, lastState.columns);
  227. Object.assign(this.referenceData, lastState.referenceData);
  228. }
  229. }
  230. const { settings: userSettings } = await browser.storage.local.get(
  231. 'settings'
  232. );
  233. this.logsLimit = userSettings?.logsLimit || 1001;
  234. this.workflow.table = columns;
  235. this.startedTimestamp = Date.now();
  236. this.states.on('stop', this.onWorkflowStopped);
  237. this.states.on('resume', this.onResumeExecution);
  238. const credentials = await dbStorage.credentials.toArray();
  239. credentials.forEach(({ name, value }) => {
  240. this.referenceData.secrets[name] = value;
  241. });
  242. const variables = await dbStorage.variables.toArray();
  243. variables.forEach(({ name, value }) => {
  244. this.referenceData.variables[`$$${name}`] = value;
  245. });
  246. this.addRefDataSnapshot('variables');
  247. await this.states.add(this.id, {
  248. id: this.id,
  249. status: 'running',
  250. state: this.state,
  251. workflowId: this.workflow.id,
  252. parentState: this.parentWorkflow,
  253. teamId: this.workflow.teamId || null,
  254. });
  255. this.addWorker({ blockId: triggerBlock.id });
  256. } catch (error) {
  257. console.error(error);
  258. }
  259. }
  260. addRefDataSnapshot(key) {
  261. this.refDataSnapshotsKeys[key].index += 1;
  262. this.refDataSnapshotsKeys[
  263. key
  264. ].key = `##${key}${this.refDataSnapshotsKeys[key].index}`;
  265. const keyName = this.refDataSnapshotsKeys[key].key;
  266. this.refDataSnapshots[keyName] = cloneDeep(this.referenceData[key]);
  267. }
  268. addWorker(detail) {
  269. this.workerId += 1;
  270. const workerId = `worker-${this.workerId}`;
  271. const worker = new WorkflowWorker(workerId, this, { blocksDetail: blocks });
  272. worker.init(detail);
  273. this.workers.set(worker.id, worker);
  274. }
  275. addLogHistory(detail) {
  276. if (detail.name === 'blocks-group') return;
  277. const isLimit = this.history?.length >= this.logsLimit;
  278. const notErrorLog = detail.type !== 'error';
  279. if ((isLimit || !this.saveLog) && notErrorLog) return;
  280. this.logHistoryId += 1;
  281. detail.id = this.logHistoryId;
  282. if (
  283. detail.name !== 'delay' ||
  284. detail.replacedValue ||
  285. detail.name === 'javascript-code' ||
  286. (blocks[detail.name]?.refDataKeys && this.saveLog)
  287. ) {
  288. const { variables, loopData } = this.refDataSnapshotsKeys;
  289. this.historyCtxData[this.logHistoryId] = {
  290. referenceData: {
  291. loopData: loopData.key,
  292. variables: variables.key,
  293. activeTabUrl: detail.activeTabUrl,
  294. prevBlockData: detail.prevBlockData || '',
  295. },
  296. replacedValue: cloneDeep(detail.replacedValue),
  297. ...(detail?.ctxData || {}),
  298. };
  299. delete detail.replacedValue;
  300. }
  301. this.history.push(detail);
  302. }
  303. async stop() {
  304. try {
  305. if (this.childWorkflowId) {
  306. await this.states.stop(this.childWorkflowId);
  307. }
  308. await this.destroy('stopped');
  309. } catch (error) {
  310. console.error(error);
  311. }
  312. }
  313. async executeQueue() {
  314. const { workflowQueue } = await browser.storage.local.get('workflowQueue');
  315. const queueIndex = (workflowQueue || []).indexOf(this.workflow?.id);
  316. if (!workflowQueue || queueIndex === -1) return;
  317. const engine = new WorkflowEngine(this.workflow, {
  318. logger: this.logger,
  319. states: this.states,
  320. blocksHandler: this.blocksHandler,
  321. });
  322. engine.init();
  323. workflowQueue.splice(queueIndex, 1);
  324. await browser.storage.local.set({ workflowQueue });
  325. }
  326. async destroyWorker(workerId) {
  327. // is last worker
  328. if (this.workers.size === 1 && this.workers.has(workerId)) {
  329. this.addLogHistory({
  330. type: 'finish',
  331. name: 'finish',
  332. });
  333. this.dispatchEvent('finish');
  334. await this.destroy('success');
  335. }
  336. // wait detach debugger
  337. this.workers.delete(workerId);
  338. }
  339. async destroy(status, message, blockDetail) {
  340. const cleanUp = () => {
  341. this.id = null;
  342. this.states = null;
  343. this.logger = null;
  344. this.saveLog = null;
  345. this.workflow = null;
  346. this.blocksHandler = null;
  347. this.parentWorkflow = null;
  348. this.isDestroyed = true;
  349. this.referenceData = null;
  350. this.eventListeners = null;
  351. this.packagesCache = null;
  352. this.extractedGroup = null;
  353. this.connectionsMap = null;
  354. this.waitConnections = null;
  355. this.blocks = null;
  356. this.history = null;
  357. this.columnsId = null;
  358. this.historyCtxData = null;
  359. this.preloadScripts = null;
  360. };
  361. try {
  362. if (this.isDestroyed) return;
  363. if (this.isUsingProxy) browser.proxy.settings.clear({});
  364. if (this.workflow.settings.debugMode && BROWSER_TYPE === 'chrome') {
  365. chrome.debugger.onEvent.removeListener(this.onDebugEvent);
  366. await sleep(1000);
  367. this.workers.forEach((worker) => {
  368. if (!worker.debugAttached) return;
  369. chrome.debugger.detach({ tabId: worker.activeTab.id });
  370. });
  371. }
  372. const endedTimestamp = Date.now();
  373. this.workers.clear();
  374. this.executeQueue();
  375. this.states.off('stop', this.onWorkflowStopped);
  376. await this.states.delete(this.id);
  377. this.dispatchEvent('destroyed', {
  378. status,
  379. message,
  380. blockDetail,
  381. id: this.id,
  382. endedTimestamp,
  383. history: this.history,
  384. startedTimestamp: this.startedTimestamp,
  385. });
  386. if (this.workflow.settings.reuseLastState) {
  387. const workflowState = {
  388. [`state:${this.workflow.id}`]: {
  389. columns: this.columns,
  390. referenceData: {
  391. table: this.referenceData.table,
  392. variables: this.referenceData.variables,
  393. },
  394. },
  395. };
  396. browser.storage.local.set(workflowState);
  397. } else if (status === 'success') {
  398. clearCache(this.workflow);
  399. }
  400. const { table, variables } = this.referenceData;
  401. const tableId = this.workflow.connectedTable;
  402. // Merge all table and variables from all workflows
  403. Object.values(this.referenceData.workflow).forEach((data) => {
  404. Object.assign(table, data.table);
  405. Object.assign(variables, data.variables);
  406. });
  407. await dbStorage.transaction(
  408. 'rw',
  409. dbStorage.tablesItems,
  410. dbStorage.tablesData,
  411. async () => {
  412. if (!tableId) return;
  413. await dbStorage.tablesItems.update(tableId, {
  414. modifiedAt: Date.now(),
  415. rowsCount: table.length,
  416. });
  417. await dbStorage.tablesData.where('tableId').equals(tableId).modify({
  418. items: table,
  419. columnsIndex: this.columns,
  420. });
  421. }
  422. );
  423. if (!this.workflow?.isTesting) {
  424. const { name, id, teamId } = this.workflow;
  425. await this.logger.add({
  426. detail: {
  427. name,
  428. status,
  429. teamId,
  430. message,
  431. id: this.id,
  432. workflowId: id,
  433. saveLog: this.saveLog,
  434. endedAt: endedTimestamp,
  435. parentLog: this.parentWorkflow,
  436. startedAt: this.startedTimestamp,
  437. },
  438. history: {
  439. logId: this.id,
  440. data: this.saveLog ? this.history : [],
  441. },
  442. ctxData: {
  443. logId: this.id,
  444. data: {
  445. ctxData: this.historyCtxData,
  446. dataSnapshot: this.refDataSnapshots,
  447. },
  448. },
  449. data: {
  450. logId: this.id,
  451. data: {
  452. table: [...this.referenceData.table],
  453. variables: { ...this.referenceData.variables },
  454. },
  455. },
  456. });
  457. }
  458. cleanUp();
  459. } catch (error) {
  460. console.error(error);
  461. cleanUp();
  462. }
  463. }
  464. async updateState(data) {
  465. const state = {
  466. ...data,
  467. tabIds: [],
  468. currentBlock: [],
  469. name: this.workflow.name,
  470. logs: this.history,
  471. ctxData: {
  472. ctxData: this.historyCtxData,
  473. dataSnapshot: this.refDataSnapshots,
  474. },
  475. startedTimestamp: this.startedTimestamp,
  476. };
  477. this.workers.forEach((worker) => {
  478. const { id, label, startedAt } = worker.currentBlock;
  479. state.currentBlock.push({ id, name: label, startedAt });
  480. state.tabIds.push(worker.activeTab.id);
  481. });
  482. await this.states.update(this.id, { state });
  483. this.dispatchEvent('update', { state });
  484. }
  485. dispatchEvent(name, params) {
  486. const listeners = this.eventListeners[name];
  487. if (!listeners) return;
  488. listeners.forEach((callback) => {
  489. callback(params);
  490. });
  491. }
  492. on(name, listener) {
  493. (this.eventListeners[name] = this.eventListeners[name] || []).push(
  494. listener
  495. );
  496. }
  497. }
  498. export default WorkflowEngine;