Repository: RocketChat/Rocket.Chat
Pull request #35721: feat: multi-instance app status
Author: login: d-gubert
Labels: name: stat: ready to merge
description: PR tested and approved waiting for merge
name: stat: QA assured
description: Means it has been tested and approved by a company insider
Created: 2025-04-07T21:03:34Z | Merged: 2025-04-20T08:33:20Z

<!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. -->

<!-- Your Pull Request name should start with one of the following tags
  feat: Adding a new feature
  refactor: A code change that doesn't change behavior (it doesn't add anything and doesn't fix anything)
  fix: For bug fixes that affect the end-user
  chore: For small tasks
  docs: For documentation
  ci: For updating CI configuration
  test: For adding tests
  i18n: For updating any translations
  regression: Issues created/reported/fixed during the development phase. kind of problem that never existed in production and that we don't need to list in a changelog for the end user
-->

<!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. 
  - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc
  - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat
  - Lint and unit tests pass locally with my changes
  - I have added tests that prove my fix is effective or that my feature works (if applicable)
  - I have added necessary documentation (if applicable)
  - Any dependent changes have been merged and published in downstream modules
-->

## Proposed changes (including videos or screenshots)
<!--
  Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request.
  If it fixes a bug or resolves a feature request, be sure to link to that issue below.
  This description won't be displayed to our end users in the release notes, so feel free to add as much technical context as needed.
  If the changes introduced in this pull request must be presented in the release notes, make sure to add a changeset file. Check our guidelines for adding a changeset to your pull request: https://developer.rocket.chat/contribute-to-rocket.chat/modes-of-contribution/participate-in-rocket.chat-development/development-workflow#4.-adding-changeset-to-your-pull-request 
-->

## Issue(s)
<!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 -->
[CONN-610]

## Steps to test or reproduce
<!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable -->

## Further comments
<!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... -->

[CONN-610]: https://rocketchat.atlassian.net/browse/CONN-610?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

---

This pull request introduces a feature for managing multi-instance app status in the Rocket.Chat application. It includes the following changes:

1. **Cluster App Status Fetching**: A new function is added to `fetchAppsStatusFromCluster.ts` to retrieve app statuses from a cluster, accommodating both Moleculer and high availability scenarios. It includes error handling for missing instances and ports.

2. **AppsEngineService Enhancements**: New methods are added to the `AppsEngineService` class in `service.ts` for retrieving app status locally and across a cluster, along with necessary type imports and utility functions.

3. **Network and Local Broker Updates**: The `NetworkBroker` and `LocalBroker` classes have updated method signatures for the `call` function, introducing an optional `options` parameter of type `CallingOptions`. A warning log is added in `LocalBroker` if options are provided.

4. **API and Interface Modifications**: 
   - The `Api` class and `IApiService` interface now include an optional `options` parameter in the `call` method.
   - A new type `AppStatusReport` and a method `getAppsStatusInCluster` are added to the `IAppsEngineService` interface.
   - The `IBroker` interface is updated to accept the new `CallingOptions` type as an optional parameter.

5. **Endpoint Authentication Change**: The authentication requirement for the 'installed' endpoint in `rest.ts` is removed, with a comment questioning if there's a better approach.

6. **Core Services Module Organization**: The `index.ts` file in `core-services` is updated to improve type safety and module organization through new exports and modifications to existing ones.

These changes aim to enhance the app status management capabilities in a multi-instance environment, improve type safety, and refine module organization.

---

This pull request introduces a feature to manage multi-instance app status within the Rocket.Chat application. Key changes include:

1. **Cluster App Status Fetching**: A new function is added to `fetchAppsStatusFromCluster.ts` to retrieve app statuses from a cluster, accommodating both Moleculer and high availability scenarios. It includes error handling for missing instances and ports.

2. **AppsEngineService Enhancements**: New methods are added to the `AppsEngineService` class in `service.ts` for retrieving app status locally and across a cluster.

3. **Network and Local Broker Updates**: The `NetworkBroker` and `LocalBroker` classes have updated method signatures for the `call` function, introducing an optional `options` parameter of type `CallingOptions`.

4. **Type and Interface Modifications**: 
   - A new type `AppStatusReport` and a method `getAppsStatusInCluster` are added to the `IAppsEngineService` interface.
   - The `CallingOptions` type is introduced and integrated into various interfaces and method signatures to enhance flexibility and type safety.

5. **REST Endpoint Change**: The authentication requirement for the 'installed' endpoint in `rest.ts` is removed, which may expose sensitive app information.

6. **Module Organization and Type Safety**: Improvements in module organization and type safety are made through updated exports and imports in `index.ts` and `Api.ts`.

These changes aim to improve the management and retrieval of app statuses in a multi-instance environment, while also enhancing the flexibility and organization of the codebase.

---

This pull request introduces a new feature to the Rocket.Chat repository, specifically targeting the multi-instance app status functionality. The changes include:

1. **Cluster App Status Fetching**: A new function is added to fetch the status of apps from a cluster, accommodating both Moleculer and high availability scenarios. It includes error handling for missing instances and ports, and processes app status data from the fetched results.

2. **AppsEngineService Enhancements**: New methods are introduced to the `AppsEngineService` class for retrieving app status both locally and across a cluster. This includes necessary imports and utility functions.

3. **NetworkBroker and LocalBroker Updates**: 
   - The `NetworkBroker` class now includes a new method signature for the `call` function, with an optional `options` parameter of type `CallingOptions`, allowing for more flexible method calls.
   - The `LocalBroker`'s `call` method is updated to support `CallingOptions`, maintaining backward compatibility and adding a warning for unsupported options.

4. **Type and Interface Modifications**:
   - New types `CallingOptions` and `AppStatusReport` are introduced.
   - The `IAppsEngineService` interface now includes a `getAppsStatusInCluster` method.
   - The `IBroker` interface's `call` method is modified to accept the `CallingOptions` type as an optional parameter.

5. **API and Service Interface Improvements**: 
   - The `Api` class and `IApiService` interface are updated to import `CallingOptions` and extend the `call` method to accept an options parameter, enhancing method flexibility.
   - New exports and modifications in `index.ts` improve type safety and accessibility of various service interfaces.

6. **Security Consideration**: The authentication requirement for the 'installed' endpoint in `rest.ts` is removed, which may expose sensitive app information to unauthorized users.

These changes aim to enhance the app status management across multiple instances in a cluster, while also improving the flexibility and type safety of service interfaces.


Diff:
diff --git a/.changeset/nine-paws-sit.md b/.changeset/nine-paws-sit.md
new file mode 100644
index 0000000000000..04b0112a8b8ad
--- /dev/null
+++ b/.changeset/nine-paws-sit.md
@@ -0,0 +1,16 @@
+---
+'@rocket.chat/network-broker': minor
+'@rocket.chat/mock-providers': minor
+'@rocket.chat/pdf-worker': minor
+'@rocket.chat/core-services': minor
+'@rocket.chat/model-typings': minor
+'@rocket.chat/core-typings': minor
+'@rocket.chat/rest-typings': minor
+'@rocket.chat/ui-contexts': minor
+'@rocket.chat/ui-voip': minor
+'@rocket.chat/models': minor
+'@rocket.chat/i18n': minor
+'@rocket.chat/meteor': minor
+---
+
+Enhances the `/api/apps/installed` and `/api/apps/:id/status` endpoints so they get apps' status across the cluster in High-Availability and Microservices deployments
diff --git a/apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts b/apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
new file mode 100644
index 0000000000000..830971dceb3cd
--- /dev/null
+++ b/apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
@@ -0,0 +1,12 @@
+import { Apps } from '@rocket.chat/core-services';
+
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+import { Instance } from '../../server/sdk';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInNodes();
+	}
+
+	return Instance.getAppsStatusInInstances();
+}
diff --git a/apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts b/apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts
index bf096122c50fe..7819404525d5f 100644
--- a/apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts
+++ b/apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts
@@ -3,6 +3,8 @@ import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
 import type { ProxiedApp } from '@rocket.chat/apps-engine/server/ProxiedApp';
 import type { AppLicenseValidationResult } from '@rocket.chat/apps-engine/server/marketplace/license';
 import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage';
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import type { App } from '@rocket.chat/core-typings';
 
 import { getInstallationSourceFromAppStorageItem } from '../../../lib/apps/getInstallationSourceFromAppStorageItem';
 
@@ -12,9 +14,10 @@ interface IAppInfoRest extends IAppInfo {
 	licenseValidation?: AppLicenseValidationResult;
 	private: boolean;
 	migrated: boolean;
+	clusterStatus?: App['clusterStatus'];
 }
 
-export async function formatAppInstanceForRest(app: ProxiedApp): Promise<IAppInfoRest> {
+export async function formatAppInstanceForRest(app: ProxiedApp, clusterStatus?: AppStatusReport): Promise<IAppInfoRest> {
 	const appRest: IAppInfoRest = {
 		...app.getInfo(),
 		status: await app.getStatus(),
@@ -23,6 +26,10 @@ export async function formatAppInstanceForRest(app: ProxiedApp): Promise<IAppInf
 		migrated: !!app.getStorageItem().migrated,
 	};
 
+	if (clusterStatus?.[app.getID()]) {
+		appRest.clusterStatus = clusterStatus[app.getID()];
+	}
+
 	const licenseValidation = app.getLatestLicenseValidationResult();
 
 	if (licenseValidation?.hasErrors || licenseValidation?.hasWarnings) {
diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts
index 58088a9d0289f..8af2aefe6be6d 100644
--- a/apps/meteor/ee/server/apps/communication/rest.ts
+++ b/apps/meteor/ee/server/apps/communication/rest.ts
@@ -2,6 +2,7 @@ import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/A
 import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
 import type { AppManager } from '@rocket.chat/apps-engine/server/AppManager';
 import type { IMarketplaceInfo } from '@rocket.chat/apps-engine/server/marketplace';
+import type { AppStatusReport } from '@rocket.chat/core-services';
 import type { IUser, IMessage } from '@rocket.chat/core-typings';
 import { License } from '@rocket.chat/license';
 import { Settings, Users } from '@rocket.chat/models';
@@ -22,6 +23,7 @@ import { Info } from '../../../../app/utils/rocketchat.info';
 import { i18n } from '../../../../server/lib/i18n';
 import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins';
 import { canEnableApp } from '../../../app/license/server/canEnableApp';
+import { fetchAppsStatusFromCluster } from '../../../lib/misc/fetchAppsStatusFromCluster';
 import { formatAppInstanceForRest } from '../../../lib/misc/formatAppInstanceForRest';
 import { notifyAppInstall } from '../marketplace/appInstall';
 import { fetchMarketplaceApps } from '../marketplace/fetchMarketplaceApps';
@@ -204,7 +206,14 @@ export class AppsRestApi {
 			{
 				async get() {
 					const apps = await manager.get();
-					const formatted = await Promise.all(apps.map(formatAppInstanceForRest));
+					let clusterStatus: AppStatusReport | undefined;
+
+					if (this.queryParams.includeClusterStatus === 'true') {
+						clusterStatus = await fetchAppsStatusFromCluster();
+					}
+
+					const formatted = await Promise.all(apps.map((app) => formatAppInstanceForRest(app, clusterStatus)));
+
 					return API.v1.success({ apps: formatted });
 				},
 			},
@@ -298,7 +307,7 @@ export class AppsRestApi {
 					apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, 'Use /apps/installed to get the installed apps list.');
 
 					const proxiedApps = await manager.get();
-					const apps = await Promise.all(proxiedApps.map(formatAppInstanceForRest));
+					const apps = await Promise.all(proxiedApps.map((app) => formatAppInstanceForRest(app)));
 
 					return API.v1.success({ apps });
 				},
@@ -1268,12 +1277,25 @@ export class AppsRestApi {
 			{ authRequired: true, permissionsRequired: ['manage-apps'] },
 			{
 				async get() {
-					const prl = manager.getOneById(this.urlParams.id);
+					const app = manager.getOneById(this.urlParams.id);
 
-					if (prl) {
-						return API.v1.success({ status: await prl.getStatus() });
+					if (!app) {
+						return API.v1.notFound(`No App found by the id of: ${this.urlParams.id}`);
 					}
-					return API.v1.notFound(`No App found by the id of: ${this.urlParams.id}`);
+
+					const response: { status: AppStatus; clusterStatus?: AppStatusReport[string] } = { status: await app.getStatus() };
+
+					try {
+						const clusterStatus = await fetchAppsStatusFromCluster();
+
+						if (clusterStatus?.[app.getID()]) {
+							response.clusterStatus = clusterStatus[app.getID()];
+						}
+					} catch (e) {
+						orchestrator.getRocketChatLogger().warn('App status endpoint: could not fetch status across cluster', e);
+					}
+
+					return API.v1.success(response);
 				},
 				async post() {
 					const { id: appId } = this.urlParams;
diff --git a/apps/meteor/ee/server/local-services/instance/service.ts b/apps/meteor/ee/server/local-services/instance/service.ts
index 93d6d0c45e980..0c6dfa8f1695d 100644
--- a/apps/meteor/ee/server/local-services/instance/service.ts
+++ b/apps/meteor/ee/server/local-services/instance/service.ts
@@ -1,6 +1,7 @@
 import os from 'os';
 
-import { License, ServiceClassInternal } from '@rocket.chat/core-services';
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps, License, ServiceClassInternal } from '@rocket.chat/core-services';
 import { InstanceStatus, defaultPingInterval, indexExpire } from '@rocket.chat/instance-status';
 import { InstanceStatus as InstanceStatusRaw } from '@rocket.chat/models';
 import EJSON from 'ejson';
@@ -117,6 +118,11 @@ export class InstanceService extends ServiceClassInternal implements IInstanceSe
 					}
 				},
 			},
+			actions: {
+				getAppsStatus(_ctx) {
+					return Apps.getAppsStatusLocal();
+				},
+			},
 		});
 	}
 
@@ -176,4 +182,45 @@ export class InstanceService extends ServiceClassInternal implements IInstanceSe
 	async getInstances(): Promise<BrokerNode[]> {
 		return this.broker.call('$node.list', { onlyAvailable: true });
 	}
+
+	async getAppsStatusInInstances(): Promise<AppStatusReport> {
+		const instances = await this.getInstances();
+
+		const control: Promise<void>[] = [];
+		const statusByApp: AppStatusReport = {};
+
+		instances.forEach((instance) => {
+			if (instance.local) {
+				return;
+			}
+
+			const { id: instanceId } = instance;
+
+			control.push(
+				(async () => {
+					const appsStatus = await this.broker.call<Awaited<ReturnType<(typeof Apps)['getAppsStatusLocal']>>, null>(
+						'matrix.getAppsStatus',
+						null,
+						{ nodeID: instanceId },
+					);
+
+					if (!appsStatus) {
+						throw new Error(`Failed to get apps status from instance ${instanceId}`);
+					}
+
+					appsStatus.forEach(({ status, appId }) => {
+						if (!statusByApp[appId]) {
+							statusByApp[appId] = [];
+						}
+
+						statusByApp[appId].push({ instanceId, status });
+					});
+				})(),
+			);
+		});
+
+		await Promise.all(control);
+
+		return statusByApp;
+	}
 }
diff --git a/apps/meteor/ee/server/sdk/types/IInstanceService.ts b/apps/meteor/ee/server/sdk/types/IInstanceService.ts
index b5c54349dfa16..7ce7b4be285a3 100644
--- a/apps/meteor/ee/server/sdk/types/IInstanceService.ts
+++ b/apps/meteor/ee/server/sdk/types/IInstanceService.ts
@@ -1,5 +1,7 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
 import type { BrokerNode } from 'moleculer';
 
 export interface IInstanceService {
 	getInstances(): Promise<BrokerNode[]>;
+	getAppsStatusInInstances(): Promise<AppStatusReport>;
 }
diff --git a/apps/meteor/server/services/apps-engine/service.ts b/apps/meteor/server/services/apps-engine/service.ts
index 486a788563946..a858eead4c430 100644
--- a/apps/meteor/server/services/apps-engine/service.ts
+++ b/apps/meteor/server/services/apps-engine/service.ts
@@ -4,9 +4,10 @@ import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
 import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
 import type { IGetAppsFilter } from '@rocket.chat/apps-engine/server/IGetAppsFilter';
 import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage';
-import type { IAppsEngineService } from '@rocket.chat/core-services';
+import type { AppStatusReport, IAppsEngineService } from '@rocket.chat/core-services';
 import { ServiceClassInternal } from '@rocket.chat/core-services';
 
+import { isRunningMs } from '../../lib/isRunningMs';
 import { SystemLogger } from '../../lib/logger/system';
 
 export class AppsEngineService extends ServiceClassInternal implements IAppsEngineService {
@@ -133,4 +134,68 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi
 
 		return app.getStorageItem();
 	}
+
+	async getAppsStatusLocal(): Promise<{ status: AppStatus; appId: string }[]> {
+		const apps = await Apps.self?.getManager()?.get();
+
+		if (!apps) {
+			return [];
+		}
+
+		return Promise.all(
+			apps.map(async (app) => ({
+				status: await app.getStatus(),
+				appId: app.getID(),
+			})),
+		);
+	}
+
+	async getAppsStatusInNodes(): Promise<AppStatusReport> {
+		if (!isRunningMs()) {
+			throw new Error('Getting apps status in cluster is only available in microservices mode');
+		}
+
+		if (!this.api) {
+			throw new Error('AppsEngineService is not initialized');
+		}
+
+		// If we are running MS AND this.api is defined, we KNOW there is a local node
+		/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
+		const { id: localNodeId } = (await this.api.nodeList()).find((node) => node.local)!;
+
+		const services: { name: string; nodes: string[] }[] = await this.api?.call('$node.services', { onlyActive: true });
+
+		// We can filter out the local node because we already know its status
+		const availableNodes = services?.find((service) => service.name === 'apps-engine')?.nodes.filter((node) => node !== localNodeId);
+
+		if (!availableNodes || availableNodes.length < 1) {
+			throw new Error('Not enough Apps-Engine nodes in deployment');
+		}
+
+		const statusByApp: AppStatusReport = {};
+
+		const apps: Promise<void>[] = availableNodes.map(async (nodeID) => {
+			const appsStatus: Awaited<ReturnType<typeof this.getAppsStatusLocal>> | undefined = await this.api?.call(
+				'apps-engine.getAppsStatusLocal',
+				[],
+				{ nodeID },
+			);
+
+			if (!appsStatus) {
+				throw new Error(`Failed to get apps status from node ${nodeID}`);
+			}
+
+			appsStatus.forEach(({ status, appId }) => {
+				if (!statusByApp[appId]) {
+					statusByApp[appId] = [];
+				}
+
+				statusByApp[appId].push({ instanceId: nodeID, status });
+			});
+		});
+
+		await Promise.all(apps);
+
+		return statusByApp;
+	}
 }
diff --git a/apps/meteor/tests/unit/server/services/apps-engine/service.tests.ts b/apps/meteor/tests/unit/server/services/apps-engine/service.tests.ts
new file mode 100644
index 0000000000000..277b3011eddfe
--- /dev/null
+++ b/apps/meteor/tests/unit/server/services/apps-engine/service.tests.ts
@@ -0,0 +1,231 @@
+import type { IAppsEngineService } from '@rocket.chat/core-services';
+import { expect } from 'chai';
+import { describe, it, beforeEach, afterEach } from 'mocha';
+import proxyquire from 'proxyquire';
+import sinon from 'sinon';
+
+const AppsMock = {
+	self: {
+		isInitialized: sinon.stub(),
+		getManager: sinon.stub(),
+		getStorage: sinon.stub(),
+		getAppSourceStorage: sinon.stub(),
+		getRocketChatLogger: sinon.stub(),
+		triggerEvent: sinon.stub(),
+	},
+};
+
+const apiMock = {
+	call: sinon.stub(),
+	nodeList: sinon.stub(),
+};
+
+const isRunningMsMock = sinon.stub();
+
+const serviceMocks = {
+	'@rocket.chat/apps': { Apps: AppsMock },
+	'@rocket.chat/core-services': {
+		api: apiMock,
+		ServiceClassInternal: class {
+			onEvent = sinon.stub();
+		},
+	},
+	'../../lib/isRunningMs': { isRunningMs: isRunningMsMock },
+	'../../lib/logger/system': { SystemLogger: { error: sinon.stub() } },
+};
+
+const { AppsEngineService } = proxyquire.noCallThru().load('../../../../../server/services/apps-engine/service', serviceMocks);
+
+describe('AppsEngineService', () => {
+	let service: IAppsEngineService;
+
+	it('should instantiate properly', () => {
+		expect(new AppsEngineService()).to.be.instanceOf(AppsEngineService);
+	});
+
+	describe('#getAppsStatusInNode - part 1', () => {
+		it('should error if api is not available', async () => {
+			isRunningMsMock.returns(true);
+
+			const service = new AppsEngineService();
+			await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('AppsEngineService is not initialized');
+		});
+	});
+
+	beforeEach(() => {
+		service = new AppsEngineService();
+		(service as any).api = apiMock;
+	});
+
+	afterEach(() => {
+		apiMock.call.reset();
+		apiMock.nodeList.reset();
+		AppsMock.self.isInitialized.reset();
+		AppsMock.self.getManager.reset();
+		AppsMock.self.getStorage.reset();
+		AppsMock.self.getAppSourceStorage.reset();
+		AppsMock.self.getRocketChatLogger.reset();
+		AppsMock.self.triggerEvent.reset();
+		isRunningMsMock.reset();
+	});
+
+	describe('#isInitialized', () => {
+		it('should return true when Apps is initialized', () => {
+			AppsMock.self.isInitialized.returns(true);
+			expect(service.isInitialized()).to.be.true;
+		});
+
+		it('should return false when Apps is not initialized', () => {
+			AppsMock.self.isInitialized.returns(false);
+			expect(service.isInitialized()).to.be.false;
+		});
+	});
+
+	describe('#getApps', () => {
+		it('should return app info from manager', async () => {
+			const mockApps = [{ getInfo: () => ({ id: 'app1' }) }];
+			const mockManager = { get: sinon.stub().resolves(mockApps) };
+			AppsMock.self.getManager.returns(mockManager);
+
+			const result = await service.getApps({});
+			expect(result).to.deep.equal([{ id: 'app1' }]);
+		});
+
+		it('should return undefined when manager is not available', async () => {
+			AppsMock.self.getManager.returns(undefined);
+			const result = await service.getApps({});
+			expect(result).to.be.undefined;
+		});
+	});
+
+	describe('#getAppsStatusLocal', () => {
+		it('should return app status reports', async () => {
+			const mockApps = [
+				{
+					getStatus: sinon.stub().resolves('enabled'),
+					getID: sinon.stub().returns('app1'),
+				},
+			];
+			const mockManager = { get: sinon.stub().resolves(mockApps) };
+			AppsMock.self.getManager.returns(mockManager);
+
+			const result = await service.getAppsStatusLocal();
+			expect(result).to.deep.equal([
+				{
+					status: 'enabled',
+					appId: 'app1',
+				},
+			]);
+		});
+
+		it('should return empty array when manager is not available', async () => {
+			AppsMock.self.getManager.returns(undefined);
+			const result = await service.getAppsStatusLocal();
+			expect(result).to.deep.equal([]);
+		});
+	});
+
+	describe('#getAppStorageItemById', () => {
+		it('should return storage item for existing app', async () => {
+			const mockStorageItem = { id: 'app1' };
+			const mockApp = {
+				getStorageItem: sinon.stub().returns(mockStorageItem),
+			};
+			const mockManager = { getOneById: sinon.stub().returns(mockApp) };
+			AppsMock.self.getManager.returns(mockManager);
+
+			const result = await service.getAppStorageItemById('app1');
+			expect(result).to.equal(mockStorageItem);
+		});
+
+		it('should return undefined for non-existent app', async () => {
+			const mockManager = { getOneById: sinon.stub().returns(undefined) };
+			AppsMock.self.getManager.returns(mockManager);
+
+			const result = await service.getAppStorageItemById('non-existent');
+			expect(result).to.be.undefined;
+		});
+	});
+
+	describe('#getAppsStatusInNode - part 2', () => {
+		it('should throw error when not in microservices mode', async () => {
+			isRunningMsMock.returns(false);
+			await expect(service.getAppsStatusInNodes()).to.be.rejectedWith(
+				'Getting apps status in cluster is only available in microservices mode',
+			);
+		});
+
+		it('should throw error when not enough apps-engine nodes are available', async () => {
+			isRunningMsMock.returns(true);
+			apiMock.nodeList.resolves([{ id: 'node1', local: true }]);
+			apiMock.call.resolves([{ name: 'apps-engine', nodes: ['node1'] }]);
+
+			await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('Not enough Apps-Engine nodes in deployment');
+		});
+
+		it('should not call the service for the local node', async () => {
+			isRunningMsMock.returns(true);
+			apiMock.nodeList.resolves([{ id: 'node1', local: true }]);
+			apiMock.call
+				.onFirstCall()
+				.resolves([{ name: 'apps-engine', nodes: ['node1', 'node2'] }])
+				.onSecondCall()
+				.resolves([
+					{ status: 'enabled', appId: 'app1' },
+					{ status: 'enabled', appId: 'app2' },
+				])
+				.onThirdCall()
+				.rejects(new Error('Should not be called'));
+
+			const result = await service.getAppsStatusInNodes();
+
+			expect(result).to.deep.equal({
+				app1: [{ instanceId: 'node2', status: 'enabled' }],
+				app2: [{ instanceId: 'node2', status: 'enabled' }],
+			});
+		});
+
+		it('should return status from all nodes', async () => {
+			isRunningMsMock.returns(true);
+			apiMock.nodeList.resolves([{ id: 'node1', local: true }]);
+			apiMock.call
+				.onFirstCall()
+				.resolves([{ name: 'apps-engine', nodes: ['node1', 'node2', 'node3'] }])
+				.onSecondCall()
+				.resolves([
+					{ status: 'enabled', appId: 'app1' },
+					{ status: 'enabled', appId: 'app2' },
+				])
+				.onThirdCall()
+				.resolves([
+					{ status: 'initialized', appId: 'app1' },
+					{ status: 'enabled', appId: 'app2' },
+				]);
+
+			const result = await service.getAppsStatusInNodes();
+
+			expect(result).to.deep.equal({
+				app1: [
+					{ instanceId: 'node2', status: 'enabled' },
+					{ instanceId: 'node3', status: 'initialized' },
+				],
+				app2: [
+					{ instanceId: 'node2', status: 'enabled' },
+					{ instanceId: 'node3', status: 'enabled' },
+				],
+			});
+		});
+
+		it('should throw error when failed to get status from a node', async () => {
+			isRunningMsMock.returns(true);
+			apiMock.nodeList.resolves([{ id: 'node1', local: true }]);
+			apiMock.call
+				.onFirstCall()
+				.resolves([{ name: 'apps-engine', nodes: ['node1', 'node2'] }])
+				.onSecondCall()
+				.resolves(undefined);
+
+			await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('Failed to get apps status from node node2');
+		});
+	});
+});
diff --git a/apps/meteor/tests/unit/server/services/instance/service.tests.ts b/apps/meteor/tests/unit/server/services/instance/service.tests.ts
new file mode 100644
index 0000000000000..dac15385514a5
--- /dev/null
+++ b/apps/meteor/tests/unit/server/services/instance/service.tests.ts
@@ -0,0 +1,122 @@
+import { expect } from 'chai';
+import { describe, it, beforeEach, afterEach } from 'mocha';
+import proxyquire from 'proxyquire';
+import sinon from 'sinon';
+
+import type { IInstanceService } from '../../../../../ee/server/sdk/types/IInstanceService';
+
+const ServiceBrokerMock = {
+	call: sinon.stub(),
+};
+
+const AppsMock = {
+	getAppsStatusLocal: sinon.stub(),
+};
+
+const serviceMocks = {
+	'@rocket.chat/core-services': {
+		ServiceClassInternal: class {
+			onEvent = sinon.stub();
+		},
+		Apps: AppsMock,
+	},
+	'moleculer': {
+		ServiceBroker: sinon.stub().returns(ServiceBrokerMock),
+		Serializers: {
+			Base: class {},
+		},
+	},
+};
+
+const { InstanceService } = proxyquire
+	.noPreserveCache()
+	.noCallThru()
+	.load('../../../../../ee/server/local-services/instance/service', serviceMocks);
+
+describe('InstanceService', () => {
+	let service: IInstanceService;
+
+	beforeEach(() => {
+		service = new InstanceService();
+		(service as any).broker = ServiceBrokerMock;
+	});
+
+	afterEach(() => {
+		ServiceBrokerMock.call.reset();
+		AppsMock.getAppsStatusLocal.reset();
+	});
+
+	describe('#getInstances', () => {
+		it('should return list of instances', async () => {
+			const mockInstances = [{ id: 'node1' }];
+			ServiceBrokerMock.call.resolves(mockInstances);
+
+			const instances = await service.getInstances();
+
+			expect(instances).to.deep.equal(mockInstances);
+			expect(ServiceBrokerMock.call.calledWith('$node.list', { onlyAvailable: true })).to.be.true;
+		});
+
+		it('should handle empty instance list', async () => {
+			ServiceBrokerMock.call.resolves([]);
+
+			const instances = await service.getInstances();
+
+			expect(instances).to.deep.equal([]);
+			expect(ServiceBrokerMock.call.calledWith('$node.list', { onlyAvailable: true })).to.be.true;
+		});
+	});
+
+	describe('#getAppsStatusInInstances', () => {
+		it('should return app status from all non-local instances', async () => {
+			const mockInstances = [
+				{ id: 'node1', local: true },
+				{ id: 'node2', local: false },
+				{ id: 'node3', local: false },
+			];
+
+			ServiceBrokerMock.call
+				.onFirstCall()
+				.resolves(mockInstances)
+				.onSecondCall()
+				.resolves([{ status: 'enabled', appId: 'app1' }])
+				.onThirdCall()
+				.resolves([{ status: 'disabled', appId: 'app2' }]);
+
+			const result = await service.getAppsStatusInInstances();
+
+			expect(result).to.deep.equal({
+				app1: [{ instanceId: 'node2', status: 'enabled' }],
+				app2: [{ instanceId: 'node3', status: 'disabled' }],
+			});
+
+			expect(ServiceBrokerMock.call.calledThrice).to.be.true;
+		});
+
+		it('should handle empty app status response', async () => {
+			const mockInstances = [
+				{ id: 'node1', local: true },
+				{ id: 'node2', local: false },
+			];
+
+			ServiceBrokerMock.call.onFirstCall().resolves(mockInstances).onSecondCall().resolves([]);
+
+			const result = await service.getAppsStatusInInstances();
+
+			expect(result).to.deep.equal({});
+			expect(ServiceBrokerMock.call.calledTwice).to.be.true;
+		});
+
+		it('should handle undefined app status response', async () => {
+			const mockInstances = [
+				{ id: 'node1', local: true },
+				{ id: 'node2', local: false },
+			];
+
+			ServiceBrokerMock.call.onFirstCall().resolves(mockInstances).onSecondCall().resolves(undefined);
+
+			await expect(service.getAppsStatusInInstances()).to.be.rejectedWith(`Failed to get apps status from instance node2`);
+			expect(ServiceBrokerMock.call.calledTwice).to.be.true;
+		});
+	});
+});
diff --git a/ee/packages/network-broker/src/NetworkBroker.ts b/ee/packages/network-broker/src/NetworkBroker.ts
index c38650e9ad7de..99d8716222413 100644
--- a/ee/packages/network-broker/src/NetworkBroker.ts
+++ b/ee/packages/network-broker/src/NetworkBroker.ts
@@ -1,5 +1,5 @@
 import { asyncLocalStorage } from '@rocket.chat/core-services';
-import type { IBroker, IBrokerNode, IServiceMetrics, IServiceClass, EventSignatures } from '@rocket.chat/core-services';
+import type { CallingOptions, IBroker, IBrokerNode, IServiceMetrics, IServiceClass, EventSignatures } from '@rocket.chat/core-services';
 import { injectCurrentContext, tracerSpan } from '@rocket.chat/tracing';
 import type { ServiceBroker, Context, ServiceSchema } from 'moleculer';
 
@@ -32,7 +32,7 @@ export class NetworkBroker implements IBroker {
 		this.metrics = broker.metrics;
 	}
 
-	async call(method: string, data: any): Promise<any> {
+	async call(method: string, data: any, options?: CallingOptions): Promise<any> {
 		if (!(await this.started)) {
 			return;
 		}
@@ -40,17 +40,19 @@ export class NetworkBroker implements IBroker {
 		const context = asyncLocalStorage.getStore();
 
 		if (context?.ctx?.call) {
-			return context.ctx.call(method, data);
+			return context.ctx.call(method, data, options);
 		}
 
 		const services: { name: string }[] = await this.broker.call('$node.services', {
 			onlyAvailable: true,
 		});
+
 		if (!services.find((service) => service.name === method.split('.')[0])) {
 			return new Error('method-not-available');
 		}
 
 		return this.broker.call(method, data, {
+			...options,
 			meta: {
 				optl: injectCurrentContext(),
 			},
diff --git a/packages/core-services/src/LocalBroker.ts b/packages/core-services/src/LocalBroker.ts
index 9e319c4cdbdb0..be19791097658 100644
--- a/packages/core-services/src/LocalBroker.ts
+++ b/packages/core-services/src/LocalBroker.ts
@@ -6,7 +6,7 @@ import { injectCurrentContext, tracerActiveSpan } from '@rocket.chat/tracing';
 
 import { asyncLocalStorage } from '.';
 import type { EventSignatures } from './events/Events';
-import type { IBroker, IBrokerNode } from './types/IBroker';
+import type { CallingOptions, IBroker, IBrokerNode } from './types/IBroker';
 import type { ServiceClass, IServiceClass } from './types/ServiceClass';
 
 type ExtendedServiceClass = { instance: IServiceClass; dependencies: string[]; isStarted: boolean };
@@ -29,7 +29,11 @@ export class LocalBroker implements IBroker {
 
 	private defaultDependencies = ['settings'];
 
-	async call(method: string, data: any): Promise<any> {
+	async call(method: string, data: any, options?: CallingOptions): Promise<any> {
+		if (options) {
+			logger.warn('Options are not supported in LocalBroker');
+		}
+
 		return tracerActiveSpan(
 			`action ${method}`,
 			{},
diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts
index dedbe3571aaac..c59df5ee55e66 100644
--- a/packages/core-services/src/index.ts
+++ b/packages/core-services/src/index.ts
@@ -50,13 +50,14 @@ import type { IVideoConfService, VideoConferenceJoinOptions } from './types/IVid
 import type { IVoipFreeSwitchService } from './types/IVoipFreeSwitchService';
 import type { IVoipService } from './types/IVoipService';
 
+export { AppStatusReport } from './types/IAppsEngineService';
 export { asyncLocalStorage } from './lib/asyncLocalStorage';
 export { MeteorError, isMeteorError } from './MeteorError';
 export { api } from './api';
 export { EventSignatures } from './events/Events';
 export { LocalBroker } from './LocalBroker';
 
-export { IBroker, IBrokerNode, BaseMetricOptions, IServiceMetrics } from './types/IBroker';
+export { IBroker, IBrokerNode, BaseMetricOptions, CallingOptions, IServiceMetrics } from './types/IBroker';
 
 export { IServiceContext, ServiceClass, IServiceClass, ServiceClassInternal } from './types/ServiceClass';
 
diff --git a/packages/core-services/src/lib/Api.ts b/packages/core-services/src/lib/Api.ts
index de028dcafd1d5..86516e27b31e1 100644
--- a/packages/core-services/src/lib/Api.ts
+++ b/packages/core-services/src/lib/Api.ts
@@ -1,6 +1,6 @@
 import type { EventSignatures } from '../events/Events';
 import type { IApiService } from '../types/IApiService';
-import type { IBroker, IBrokerNode } from '../types/IBroker';
+import type { CallingOptions, IBroker, IBrokerNode } from '../types/IBroker';
 import type { IServiceClass } from '../types/ServiceClass';
 
 export class Api implements IApiService {
@@ -37,8 +37,8 @@ export class Api implements IApiService {
 		}
 	}
 
-	async call(method: string, data?: unknown): Promise<any> {
-		return this.broker?.call(method, data);
+	async call(method: string, data?: unknown, options?: CallingOptions): Promise<any> {
+		return this.broker?.call(method, data, options);
 	}
 
 	async broadcast<T extends keyof EventSignatures>(event: T, ...args: Parameters<EventSignatures[T]>): Promise<void> {
diff --git a/packages/core-services/src/types/IApiService.ts b/packages/core-services/src/types/IApiService.ts
index bff4bc3a2d82a..ab01517f9a0eb 100644
--- a/packages/core-services/src/types/IApiService.ts
+++ b/packages/core-services/src/types/IApiService.ts
@@ -1,4 +1,4 @@
-import type { IBroker, IBrokerNode } from './IBroker';
+import type { CallingOptions, IBroker, IBrokerNode } from './IBroker';
 import type { IServiceClass } from './ServiceClass';
 import type { EventSignatures } from '../events/Events';
 
@@ -9,7 +9,7 @@ export interface IApiService {
 
 	registerService(instance: IServiceClass): void;
 
-	call(method: string, data?: unknown): Promise<any>;
+	call(method: string, data?: unknown, options?: CallingOptions): Promise<any>;
 
 	broadcast<T extends keyof EventSignatures>(event: T, ...args: Parameters<EventSignatures[T]>): Promise<void>;
 
diff --git a/packages/core-services/src/types/IAppsEngineService.ts b/packages/core-services/src/types/IAppsEngineService.ts
index 9158d2fe3b299..e3abbb350e934 100644
--- a/packages/core-services/src/types/IAppsEngineService.ts
+++ b/packages/core-services/src/types/IAppsEngineService.ts
@@ -1,9 +1,16 @@
+import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
 import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
 import type { IGetAppsFilter } from '@rocket.chat/apps-engine/server/IGetAppsFilter';
 import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage';
 
+export type AppStatusReport = {
+	[appId: string]: { instanceId: string; status: AppStatus }[];
+};
+
 export interface IAppsEngineService {
 	isInitialized(): boolean;
 	getApps(query: IGetAppsFilter): Promise<IAppInfo[] | undefined>;
 	getAppStorageItemById(appId: string): Promise<IAppStorageItem | undefined>;
+	getAppsStatusLocal(): Promise<{ appId: string; status: AppStatus }[]>;
+	getAppsStatusInNodes(): Promise<AppStatusReport>;
 }
diff --git a/packages/core-services/src/types/IBroker.ts b/packages/core-services/src/types/IBroker.ts
index d72221a09cabe..9ce5b693eaa41 100644
--- a/packages/core-services/src/types/IBroker.ts
+++ b/packages/core-services/src/types/IBroker.ts
@@ -30,6 +30,20 @@ export type BaseMetricOptions = {
 	[key: string]: unknown;
 };
 
+export type CallingOptions = {
+	nodeID?: string;
+	// timeout?: number;
+	// retries?: number;
+	// fallbackResponse?: FallbackResponse | FallbackResponse[] | FallbackResponseHandler;
+	// meta?: GenericObject;
+	// parentSpan?: ContextParentSpan;
+	// parentCtx?: Context;
+	// requestID?: string;
+	// tracking?: boolean;
+	// paramsCloning?: boolean;
+	// caller?: string;
+};
+
 export interface IServiceMetrics {
 	register(opts: BaseMetricOptions): void;
 
@@ -50,7 +64,7 @@ export interface IBroker {
 	metrics?: IServiceMetrics;
 	destroyService(service: IServiceClass): Promise<void>;
 	createService(service: IServiceClass, serviceDependencies?: string[]): void;
-	call(method: string, data: any): Promise<any>;
+	call(method: string, data: any, options?: CallingOptions): Promise<any>;
 	broadcastToServices<T extends keyof EventSignatures>(
 		services: string[],
 		event: T,
diff --git a/packages/core-typings/src/Apps.ts b/packages/core-typings/src/Apps.ts
index 456aa987bb19c..06a18581cd442 100644
--- a/packages/core-typings/src/Apps.ts
+++ b/packages/core-typings/src/Apps.ts
@@ -129,6 +129,11 @@ export type App = {
 	private: boolean;
 	documentationUrl: string;
 	migrated: boolean;
+	// Status of the app across the cluster (when deployment includes multiple instances)
+	clusterStatus?: {
+		instanceId: string;
+		status: AppStatus;
+	}[];
 };
 
 export type AppCategory = {
diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts
index 528a1cc224078..d5f9de98d0ca8 100644
--- a/packages/rest-typings/src/apps/index.ts
+++ b/packages/rest-typings/src/apps/index.ts
@@ -123,6 +123,7 @@ export type AppsEndpoints = {
 	'/apps/:id/status': {
 		GET: () => {
 			status: string;
+			clusterStatus: App['clusterStatus'];
 		};
 		POST: (params: { status: AppStatus }) => {
 			status: AppStatus;
@@ -173,7 +174,7 @@ export type AppsEndpoints = {
 	};
 
 	'/apps/installed': {
-		GET: () => { apps: App[] };
+		GET: (params: { includeClusterStatus?: 'true' | 'false' }) => { success: true; apps: App[] } | { success: false; error: string };
 	};
 
 	'/apps/buildExternalAppRequest': {


Reviews:
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-14T22:00:47Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-14T22:00:50Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-14T22:00:53Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-14T22:04:25Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-14T22:04:28Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-14T22:24:18Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-14T22:24:22Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T15:15:13Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-15T15:16:21Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T17:49:38Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-15T17:51:42Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T21:13:48Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T13:12:02Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-16T13:12:20Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T13:12:34Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T13:12:43Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T13:12:58Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-16T13:20:11Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T13:22:04Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-16T13:22:17Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T13:24:43Z
user: login: kody-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-16T13:24:57Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T13:27:34Z
user: login: debdutdeb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:15:49Z
user: login: julio-rocketchat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:18:55Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:19:14Z
user: login: debdutdeb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:19:21Z
user: login: debdutdeb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:20:53Z
user: login: debdutdeb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:23:03Z
user: login: debdutdeb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:24:39Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:49:12Z
user: login: debdutdeb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:56:30Z
user: login: debdutdeb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:57:53Z
user: login: debdutdeb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:58:53Z
user: login: debdutdeb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T17:05:17Z
user: login: d-gubert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T15:09:25Z
user: login: sampaiodiego
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-20T05:54:23Z

Review comments:
diff_hunk: @@ -0,0 +1,86 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<Record<string, AppStatusReport[]>> {
+	// Moleculer connections
+	const connections = await getInstanceList();
+
+	// Instance status from the database
+	const instances = await InstanceStatus.find().toArray();
+
+	const appsStatusPromises: Promise<{ instanceId: string; appsStatus: AppStatusReport[] }>[] = [];
+
+	connections.forEach((conn) => {
+		if (conn.local) {
+			return;
+		}
+
+		const instance = instances.find((i) => i._id === conn.id);
+
+		if (!instance) {
+			throw new Error('Instance not found');
+		}
+
+		if (!instance.extraInformation?.port) {
+			throw new Error('Instance has no port');
+		}
+
+		const [ip] = conn.ipList;
+		const url = new URL('api/apps/installed', `http://${ip}`);
+
+		url.port = String(instance.extraInformation.port);
+
+		appsStatusPromises.push(
+			fetch(url.toString(), {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: kody-ai[bot]
body: ![kody code-review](https://img.shields.io/badge/kody-code--review-312B4B?labelColor=C9BBF2) ![Error Handling](https://img.shields.io/badge/Error_Handling-FF6D00) ![high](https://img.shields.io/badge/severity_level-high-6B6B92)
```typescript
fetch(url.toString(), {
    method: 'GET',
    headers: {
        'x-instance-id': conn.id,
    },
})
    .then((res) => {
        if (!res.ok) {
            throw new Error(`Network response was not ok: ${res.statusText}`);
        }
        return res.json();
    })
    .then((data: OperationResult<'GET', '/apps/installed'> & { success: boolean }) => {
        if (!data.success) {
            throw new Error('Failed to fetch apps status');
        }

        return {
            instanceId: instance._id,
            appsStatus: data.apps.map((app) => {
                if (!app.status) {
                    throw new Error('App status is undefined');
                }

                return {
                    status: app.status,
                    appId: app.id,
                };
            }),
        };
    })
    .catch((error) => {
        console.error('Error fetching app status:', error);
        throw error;
    });
```
Multiple instances of insufficient error handling in asynchronous operations, leading to unhandled promise rejections and lack of robust error reporting.

This issue appears in multiple locations:
* apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts: Lines 48-73
* apps/meteor/server/services/apps-engine/service.ts: Lines 167-178
Please add comprehensive error handling to all asynchronous operations, including try-catch blocks and appropriate logging, to manage promise rejections gracefully and improve error reporting.


<sub>Talk to Kody by mentioning @kody</sub>


<sub>Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.</sub>

<!-- kody-codereview -->&#8203;
&#8203;
created_at: 2025-04-14T22:00:47Z
author_association: NONE
diff_hunk: @@ -32,25 +32,27 @@ export class NetworkBroker implements IBroker {
 		this.metrics = broker.metrics;
 	}
 
-	async call(method: string, data: any): Promise<any> {
+	async call(method: string, data: any, options?: CallingOptions): Promise<any> {
 		if (!(await this.started)) {
 			return;
 		}
 
 		const context = asyncLocalStorage.getStore();
 
 		if (context?.ctx?.call) {
-			return context.ctx.call(method, data);
+			return context.ctx.call(method, data, options);
 		}
 
 		const services: { name: string }[] = await this.broker.call('$node.services', {
 			onlyAvailable: true,
 		});
+
 		if (!services.find((service) => service.name === method.split('.')[0])) {
 			return new Error('method-not-available');
path: ee/packages/network-broker/src/NetworkBroker.ts
user: login: kody-ai[bot]
body: ![kody code-review](https://img.shields.io/badge/kody-code--review-312B4B?labelColor=C9BBF2) ![Error Handling](https://img.shields.io/badge/Error_Handling-FF6D00) ![high](https://img.shields.io/badge/severity_level-high-6B6B92)
```typescript
throw new Error('method-not-available');
```
Returning Error objects instead of throwing errors, which deviates from standard error handling practices in JavaScript and TypeScript.

This issue appears in multiple locations:
* ee/packages/network-broker/src/NetworkBroker.ts: Lines 51-51
Please replace the return of Error objects with throwing errors to align with typical error handling practices.


<sub>Talk to Kody by mentioning @kody</sub>


<sub>Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.</sub>

<!-- kody-codereview -->&#8203;
&#8203;
created_at: 2025-04-14T22:00:50Z
author_association: NONE
diff_hunk: @@ -1,9 +1,18 @@
+import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
 import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
 import type { IGetAppsFilter } from '@rocket.chat/apps-engine/server/IGetAppsFilter';
 import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage';
 
+export type AppStatusReport = {
+	status: AppStatus;
+	appId: string;
+	createdAt?: Date;
+	updatedAt?: Date;
+};
+
 export interface IAppsEngineService {
 	isInitialized(): boolean;
 	getApps(query: IGetAppsFilter): Promise<IAppInfo[] | undefined>;
 	getAppStorageItemById(appId: string): Promise<IAppStorageItem | undefined>;
+	getAppsStatusInCluster(): Promise<Record<string, AppStatusReport[]>>;
path: packages/core-services/src/types/IAppsEngineService.ts
user: login: kody-ai[bot]
body: ![kody code-review](https://img.shields.io/badge/kody-code--review-312B4B?labelColor=C9BBF2) ![Error Handling](https://img.shields.io/badge/Error_Handling-FF6D00) ![high](https://img.shields.io/badge/severity_level-high-6B6B92)
```typescript
getAppsStatusInCluster(): Promise<Result<Record<string, AppStatusReport[]>, AppStatusError>>;

export type Result<T, E> = { ok: true, value: T } | { ok: false, error: E };

export type AppStatusError = { code: string, message: string };
```
Consider adding error handling for the `getAppsStatusInCluster` method.  Instead of returning a general Error object, use a Result type to clearly distinguish between success and failure, and provide more context in case of errors. This aligns with Kody Rule regarding explicit error handling.

<sub>Talk to Kody by mentioning @kody</sub>


<sub>Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.</sub>

<!-- kody-codereview -->&#8203;
&#8203;
created_at: 2025-04-14T22:00:53Z
author_association: NONE
diff_hunk: @@ -0,0 +1,86 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<Record<string, AppStatusReport[]>> {
+	// Moleculer connections
+	const connections = await getInstanceList();
+
+	// Instance status from the database
+	const instances = await InstanceStatus.find().toArray();
+
+	const appsStatusPromises: Promise<{ instanceId: string; appsStatus: AppStatusReport[] }>[] = [];
+
+	connections.forEach((conn) => {
+		if (conn.local) {
+			return;
+		}
+
+		const instance = instances.find((i) => i._id === conn.id);
+
+		if (!instance) {
+			throw new Error('Instance not found');
+		}
+
+		if (!instance.extraInformation?.port) {
+			throw new Error('Instance has no port');
+		}
+
+		const [ip] = conn.ipList;
+		const url = new URL('api/apps/installed', `http://${ip}`);
+
+		url.port = String(instance.extraInformation.port);
+
+		appsStatusPromises.push(
+			fetch(url.toString(), {
+				method: 'GET',
+				headers: {
+					'x-instance-id': conn.id,
+				},
+			})
+				.then((res) => res.json())
+				.then((data: OperationResult<'GET', '/apps/installed'> & { success: boolean }) => {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: kody-ai[bot]
body: ![kody code-review](https://img.shields.io/badge/kody-code--review-312B4B?labelColor=C9BBF2) ![Error Handling](https://img.shields.io/badge/Error_Handling-FF6D00) ![high](https://img.shields.io/badge/severity_level-high-6B6B92)
```typescript
fetch(url.toString(), {
    method: 'GET',
    headers: {
        'x-instance-id': conn.id,
    },
})
    .then((res) => {
        if (!res.ok) {
            throw new Error(`Network response was not ok: ${res.statusText}`);
        }
        return res.json();
    })
    .then((data: OperationResult<'GET', '/apps/installed'> & { success: boolean }) => {
```
The fetch operation lacks proper error handling, which can lead to unhandled promise rejections and poor error reporting.

This issue appears in multiple locations:
* apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts: Lines 48-55
Please add error handling to catch network errors and unexpected response formats in the fetch operation.


<sub>Talk to Kody by mentioning @kody</sub>


<sub>Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.</sub>

<!-- kody-codereview -->&#8203;
&#8203;
created_at: 2025-04-14T22:04:25Z
author_association: NONE
diff_hunk: @@ -37,8 +37,8 @@ export class Api implements IApiService {
 		}
 	}
 
-	async call(method: string, data?: unknown): Promise<any> {
-		return this.broker?.call(method, data);
+	async call(method: string, data?: unknown, options?: CallingOptions): Promise<any> {
+		return this.broker?.call(method, data, options);
path: packages/core-services/src/lib/Api.ts
user: login: kody-ai[bot]
body: ![kody code-review](https://img.shields.io/badge/kody-code--review-312B4B?labelColor=C9BBF2) ![Maintainability](https://img.shields.io/badge/Maintainability-0091EA) ![medium](https://img.shields.io/badge/severity_level-medium-1A7BBE)
```typescript
async call<T>(method: string, data?: unknown, options?: CallingOptions): Promise<T>
```
The 'call' method uses 'any' for the return type, which reduces type safety and can lead to runtime errors.

This issue appears in multiple locations:
* packages/core-services/src/lib/Api.ts: Lines 40-41
* packages/core-services/src/types/IApiService.ts: Lines 12-12
Please use a generic type parameter for the return type of the 'call' method to improve type safety.


<sub>Talk to Kody by mentioning @kody</sub>


<sub>Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.</sub>

<!-- kody-codereview -->&#8203;
&#8203;
created_at: 2025-04-14T22:04:28Z
author_association: NONE
diff_hunk: @@ -37,8 +37,8 @@ export class Api implements IApiService {
 		}
 	}
 
-	async call(method: string, data?: unknown): Promise<any> {
-		return this.broker?.call(method, data);
+	async call(method: string, data?: unknown, options?: CallingOptions): Promise<any> {
+		return this.broker?.call(method, data, options);
path: packages/core-services/src/lib/Api.ts
user: login: kody-ai[bot]
body: ![kody code-review](https://img.shields.io/badge/kody-code--review-312B4B?labelColor=C9BBF2) ![Error Handling](https://img.shields.io/badge/Error_Handling-FF6D00) ![high](https://img.shields.io/badge/severity_level-high-6B6B92)
```typescript
async call(method: string, data?: unknown, options?: CallingOptions): Promise<any> {
		if (!this.broker) {
			throw new Error('No broker set to make calls');
		}
		return this.broker.call(method, data, options);
	}
```
Insufficient error handling in methods that rely on critical components like the broker.

This issue appears in multiple locations:
* packages/core-services/src/lib/Api.ts: Lines 40-41
* packages/core-services/src/types/IAppsEngineService.ts: Lines 17-17
Please add robust error handling to ensure methods fail gracefully when critical components are not available.


<sub>Talk to Kody by mentioning @kody</sub>


<sub>Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.</sub>

<!-- kody-codereview -->&#8203;
&#8203;
created_at: 2025-04-14T22:24:18Z
author_association: NONE
diff_hunk: @@ -37,8 +37,8 @@ export class Api implements IApiService {
 		}
 	}
 
-	async call(method: string, data?: unknown): Promise<any> {
-		return this.broker?.call(method, data);
+	async call(method: string, data?: unknown, options?: CallingOptions): Promise<any> {
+		return this.broker?.call(method, data, options);
path: packages/core-services/src/lib/Api.ts
user: login: kody-ai[bot]
body: ![kody code-review](https://img.shields.io/badge/kody-code--review-312B4B?labelColor=C9BBF2) ![Maintainability](https://img.shields.io/badge/Maintainability-0091EA) ![medium](https://img.shields.io/badge/severity_level-medium-1A7BBE)
```typescript
async call<T>(method: string, data?: unknown, options?: CallingOptions): Promise<T>
```
Repeated use of 'any' type in method return types, reducing type safety.

This issue appears in multiple locations:
* packages/core-services/src/lib/Api.ts: Lines 40-41
* packages/core-services/src/types/IApiService.ts: Lines 12-12
* packages/core-services/src/types/IBroker.ts: Lines 67-67
Please replace 'any' with generic type parameters to improve type safety and reduce potential runtime errors.


<sub>Talk to Kody by mentioning @kody</sub>


<sub>Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.</sub>

<!-- kody-codereview -->&#8203;
&#8203;
created_at: 2025-04-14T22:24:21Z
author_association: NONE
diff_hunk: @@ -1,9 +1,18 @@
+import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
 import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
 import type { IGetAppsFilter } from '@rocket.chat/apps-engine/server/IGetAppsFilter';
 import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage';
 
+export type AppStatusReport = {
+	status: AppStatus;
+	appId: string;
+	createdAt?: Date;
+	updatedAt?: Date;
+};
+
 export interface IAppsEngineService {
 	isInitialized(): boolean;
 	getApps(query: IGetAppsFilter): Promise<IAppInfo[] | undefined>;
 	getAppStorageItemById(appId: string): Promise<IAppStorageItem | undefined>;
+	getAppsStatusInCluster(): Promise<Record<string, AppStatusReport[]>>;
path: packages/core-services/src/types/IAppsEngineService.ts
user: login: d-gubert
body: @kody that would be valid for a service endpoint interacting with an external client, but in this case a simple error thrown to the calling function will suffice
created_at: 2025-04-15T15:15:11Z
author_association: MEMBER
diff_hunk: @@ -1,9 +1,18 @@
+import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
 import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
 import type { IGetAppsFilter } from '@rocket.chat/apps-engine/server/IGetAppsFilter';
 import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage';
 
+export type AppStatusReport = {
+	status: AppStatus;
+	appId: string;
+	createdAt?: Date;
+	updatedAt?: Date;
+};
+
 export interface IAppsEngineService {
 	isInitialized(): boolean;
 	getApps(query: IGetAppsFilter): Promise<IAppInfo[] | undefined>;
 	getAppStorageItemById(appId: string): Promise<IAppStorageItem | undefined>;
+	getAppsStatusInCluster(): Promise<Record<string, AppStatusReport[]>>;
path: packages/core-services/src/types/IAppsEngineService.ts
user: login: kody-ai[bot]
body: Hey @d-gubert! I see your point about using a simple error throw for internal function calls. It's a valid approach when you want to keep things straightforward and the context of the error is clear to the calling function. However, using a `Result` type can still be beneficial for maintaining consistency and providing more detailed error information, especially if the function might evolve to handle more complex scenarios in the future.

If the current implementation is working well for your use case and the error context is sufficiently clear, sticking with a simple error throw is perfectly fine. Just keep in mind that if the function's complexity increases, revisiting the error handling strategy might be worthwhile.

Let me know if you need any more help or if there's anything else you'd like to discuss! 😊
created_at: 2025-04-15T15:16:21Z
author_association: NONE
diff_hunk: @@ -32,25 +32,27 @@ export class NetworkBroker implements IBroker {
 		this.metrics = broker.metrics;
 	}
 
-	async call(method: string, data: any): Promise<any> {
+	async call(method: string, data: any, options?: CallingOptions): Promise<any> {
 		if (!(await this.started)) {
 			return;
 		}
 
 		const context = asyncLocalStorage.getStore();
 
 		if (context?.ctx?.call) {
-			return context.ctx.call(method, data);
+			return context.ctx.call(method, data, options);
 		}
 
 		const services: { name: string }[] = await this.broker.call('$node.services', {
 			onlyAvailable: true,
 		});
+
 		if (!services.find((service) => service.name === method.split('.')[0])) {
 			return new Error('method-not-available');
path: ee/packages/network-broker/src/NetworkBroker.ts
user: login: d-gubert
body: @kody this may be an area of improvement, however it's out of scope for this PR
created_at: 2025-04-15T17:49:38Z
author_association: MEMBER
diff_hunk: @@ -32,25 +32,27 @@ export class NetworkBroker implements IBroker {
 		this.metrics = broker.metrics;
 	}
 
-	async call(method: string, data: any): Promise<any> {
+	async call(method: string, data: any, options?: CallingOptions): Promise<any> {
 		if (!(await this.started)) {
 			return;
 		}
 
 		const context = asyncLocalStorage.getStore();
 
 		if (context?.ctx?.call) {
-			return context.ctx.call(method, data);
+			return context.ctx.call(method, data, options);
 		}
 
 		const services: { name: string }[] = await this.broker.call('$node.services', {
 			onlyAvailable: true,
 		});
+
 		if (!services.find((service) => service.name === method.split('.')[0])) {
 			return new Error('method-not-available');
path: ee/packages/network-broker/src/NetworkBroker.ts
user: login: kody-ai[bot]
body: Hey @d-gubert! I see what you're saying. It's important to keep our focus on the scope of the current PR. However, it's great that you've identified this as a potential area for improvement. We can definitely address this in a future update or create a separate task to handle it. If you want, I can help you draft a ticket for this improvement to ensure it doesn't get lost. Let me know how you'd like to proceed! 😊
created_at: 2025-04-15T17:51:42Z
author_association: NONE
diff_hunk: @@ -0,0 +1,86 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<Record<string, AppStatusReport[]>> {
+	// Moleculer connections
+	const connections = await getInstanceList();
+
+	// Instance status from the database
+	const instances = await InstanceStatus.find().toArray();
+
+	const appsStatusPromises: Promise<{ instanceId: string; appsStatus: AppStatusReport[] }>[] = [];
+
+	connections.forEach((conn) => {
+		if (conn.local) {
+			return;
+		}
+
+		const instance = instances.find((i) => i._id === conn.id);
+
+		if (!instance) {
+			throw new Error('Instance not found');
+		}
+
+		if (!instance.extraInformation?.port) {
+			throw new Error('Instance has no port');
+		}
+
+		const [ip] = conn.ipList;
+		const url = new URL('api/apps/installed', `http://${ip}`);
+
+		url.port = String(instance.extraInformation.port);
+
+		appsStatusPromises.push(
+			fetch(url.toString(), {
+				method: 'GET',
+				headers: {
+					'x-instance-id': conn.id,
+				},
+			})
+				.then((res) => res.json())
+				.then((data: OperationResult<'GET', '/apps/installed'> & { success: boolean }) => {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: d-gubert
body: The next `then` chain handles this case. And if `fetch` rejects, we want the caller to know
created_at: 2025-04-15T21:13:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,86 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<Record<string, AppStatusReport[]>> {
+	// Moleculer connections
+	const connections = await getInstanceList();
+
+	// Instance status from the database
+	const instances = await InstanceStatus.find().toArray();
+
+	const appsStatusPromises: Promise<{ instanceId: string; appsStatus: AppStatusReport[] }>[] = [];
+
+	connections.forEach((conn) => {
+		if (conn.local) {
+			return;
+		}
+
+		const instance = instances.find((i) => i._id === conn.id);
+
+		if (!instance) {
+			throw new Error('Instance not found');
+		}
+
+		if (!instance.extraInformation?.port) {
+			throw new Error('Instance has no port');
+		}
+
+		const [ip] = conn.ipList;
+		const url = new URL('api/apps/installed', `http://${ip}`);
+
+		url.port = String(instance.extraInformation.port);
+
+		appsStatusPromises.push(
+			fetch(url.toString(), {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: d-gubert
body: @kody the check `if (!res.ok)` can still have a valid json body containing an error message, as the response might be in 4xx or 5xx status range. The extra check before calling `res.json()` is redundant in this case, as errors are handled in the next callback
created_at: 2025-04-16T13:12:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,86 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<Record<string, AppStatusReport[]>> {
+	// Moleculer connections
+	const connections = await getInstanceList();
+
+	// Instance status from the database
+	const instances = await InstanceStatus.find().toArray();
+
+	const appsStatusPromises: Promise<{ instanceId: string; appsStatus: AppStatusReport[] }>[] = [];
+
+	connections.forEach((conn) => {
+		if (conn.local) {
+			return;
+		}
+
+		const instance = instances.find((i) => i._id === conn.id);
+
+		if (!instance) {
+			throw new Error('Instance not found');
+		}
+
+		if (!instance.extraInformation?.port) {
+			throw new Error('Instance has no port');
+		}
+
+		const [ip] = conn.ipList;
+		const url = new URL('api/apps/installed', `http://${ip}`);
+
+		url.port = String(instance.extraInformation.port);
+
+		appsStatusPromises.push(
+			fetch(url.toString(), {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: kody-ai[bot]
body: Hey @d-gubert! You're absolutely right. The check `if (!res.ok)` can indeed have a valid JSON body with an error message, especially when dealing with 4xx or 5xx status codes. The extra check before calling `res.json()` is redundant since the errors are handled in the subsequent `.catch()` block. 

To streamline the code, you can directly call `res.json()` and handle any errors in the `.catch()` block. This will simplify the error handling and make the code cleaner. If you need any more help or have further questions, feel free to ask! 😊
created_at: 2025-04-16T13:12:20Z
author_association: NONE
diff_hunk: @@ -37,8 +37,8 @@ export class Api implements IApiService {
 		}
 	}
 
-	async call(method: string, data?: unknown): Promise<any> {
-		return this.broker?.call(method, data);
+	async call(method: string, data?: unknown, options?: CallingOptions): Promise<any> {
+		return this.broker?.call(method, data, options);
path: packages/core-services/src/lib/Api.ts
user: login: d-gubert
body: Out of scope in this PR
created_at: 2025-04-16T13:12:34Z
author_association: MEMBER
diff_hunk: @@ -37,8 +37,8 @@ export class Api implements IApiService {
 		}
 	}
 
-	async call(method: string, data?: unknown): Promise<any> {
-		return this.broker?.call(method, data);
+	async call(method: string, data?: unknown, options?: CallingOptions): Promise<any> {
+		return this.broker?.call(method, data, options);
path: packages/core-services/src/lib/Api.ts
user: login: d-gubert
body: Out of scope in this PR
created_at: 2025-04-16T13:12:43Z
author_association: MEMBER
diff_hunk: @@ -37,8 +37,8 @@ export class Api implements IApiService {
 		}
 	}
 
-	async call(method: string, data?: unknown): Promise<any> {
-		return this.broker?.call(method, data);
+	async call(method: string, data?: unknown, options?: CallingOptions): Promise<any> {
+		return this.broker?.call(method, data, options);
path: packages/core-services/src/lib/Api.ts
user: login: d-gubert
body: Out of scope in this PR
created_at: 2025-04-16T13:12:57Z
author_association: MEMBER
diff_hunk: @@ -123,6 +130,10 @@ export type AppsEndpoints = {
 	'/apps/:id/status': {
 		GET: () => {
 			status: string;
+			clusterStatus: {
path: packages/rest-typings/src/apps/index.ts
user: login: kody-ai[bot]
body: ![kody code-review](https://img.shields.io/badge/kody-code--review-312B4B?labelColor=C9BBF2) ![Maintainability](https://img.shields.io/badge/Maintainability-0091EA) ![medium](https://img.shields.io/badge/severity_level-medium-1A7BBE)
```typescript
clusterStatus?: {
    instanceId: string;
    status: AppStatus;
}[];
```
The structure for `clusterStatus` in the GET response for `/apps/:id/status` is inconsistent with the `clusterStatus` defined in the `AppWithClusterStatus` type.

This issue appears in multiple locations:
* packages/rest-typings/src/apps/index.ts: Lines 133-136
Please align the structure or naming of `clusterStatus` in the `/apps/:id/status` response with the `AppWithClusterStatus` type for consistency.


<sub>Talk to Kody by mentioning @kody</sub>


<sub>Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.</sub>

<!-- kody-codereview -->&#8203;
&#8203;
created_at: 2025-04-16T13:20:11Z
author_association: NONE
diff_hunk: @@ -123,6 +130,10 @@ export type AppsEndpoints = {
 	'/apps/:id/status': {
 		GET: () => {
 			status: string;
+			clusterStatus: {
path: packages/rest-typings/src/apps/index.ts
user: login: d-gubert
body: @kody There might be something here. I think I'll extract the type to the "core-typings" package for better dependency management
created_at: 2025-04-16T13:22:04Z
author_association: MEMBER
diff_hunk: @@ -123,6 +130,10 @@ export type AppsEndpoints = {
 	'/apps/:id/status': {
 		GET: () => {
 			status: string;
+			clusterStatus: {
path: packages/rest-typings/src/apps/index.ts
user: login: kody-ai[bot]
body: Hey @d-gubert! Extracting the type to the "core-typings" package sounds like a great idea for improving dependency management. This approach will help maintain consistency across different parts of the codebase and make it easier to manage changes in the future. By centralizing the type definitions, you can ensure that any updates or modifications are reflected wherever the type is used, reducing the risk of inconsistencies.

If you need any assistance with the extraction process or have any other questions, feel free to reach out! 😊
created_at: 2025-04-16T13:22:17Z
author_association: NONE
diff_hunk: @@ -123,6 +130,10 @@ export type AppsEndpoints = {
 	'/apps/:id/status': {
 		GET: () => {
 			status: string;
+			clusterStatus: {
path: packages/rest-typings/src/apps/index.ts
user: login: d-gubert
body: @kody well, can you apply the modifications? Or generate a git patch file which I can then apply with git in the command line?
created_at: 2025-04-16T13:24:43Z
author_association: MEMBER
diff_hunk: @@ -123,6 +130,10 @@ export type AppsEndpoints = {
 	'/apps/:id/status': {
 		GET: () => {
 			status: string;
+			clusterStatus: {
path: packages/rest-typings/src/apps/index.ts
user: login: kody-ai[bot]
body: Hey @d-gubert! I can't directly apply the modifications or generate a git patch file myself, but I can guide you on how to do it. Here's a step-by-step guide to create a git patch file for your changes:

1. **Make the Changes**: First, make the necessary changes in your codebase. For example, extract the type to the "core-typings" package and update the references.

2. **Stage the Changes**: Use the following command to stage the changes you want to include in the patch:
   ```bash
   git add <file1> <file2> ...
   ```

3. **Create the Patch File**: Once the changes are staged, create a patch file using:
   ```bash
   git diff --cached > my-changes.patch
   ```

4. **Apply the Patch**: To apply the patch file later, you can use:
   ```bash
   git apply my-changes.patch
   ```

This will help you manage and apply your changes efficiently. If you need further assistance, feel free to ask! 😊
created_at: 2025-04-16T13:24:57Z
author_association: NONE
diff_hunk: @@ -123,6 +130,10 @@ export type AppsEndpoints = {
 	'/apps/:id/status': {
 		GET: () => {
 			status: string;
+			clusterStatus: {
path: packages/rest-typings/src/apps/index.ts
user: login: d-gubert
body: Eh, worth a try :shrug:
created_at: 2025-04-16T13:27:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: debdutdeb
body: couldn't this be a molecular method?

like, get all instance ids, then ids.map.broker.call(...., { nodeID: <> });?

https://moleculer.services/docs/0.14/actions.html#Syntax
created_at: 2025-04-17T16:15:49Z
author_association: MEMBER
diff_hunk: @@ -200,11 +202,20 @@ export class AppsRestApi {
 
 		this.api.addRoute(
 			'installed',
-			{ authRequired: true },
path: apps/meteor/ee/server/apps/communication/rest.ts
user: login: julio-rocketchat
body: Probably more of a style question, but should we keep it and have it explicitly set to `false` as `{ authRequired: false },`? I saw that some other endpoints still keep the "authRequired" argument and set it to false when auth isn't needed.
created_at: 2025-04-17T16:18:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: d-gubert
body: that's how we do it for microservices deployments https://github.com/RocketChat/Rocket.Chat/pull/35721/files#diff-d973506566aa8200afab092fd512290f1c6ffc872c7a2df8bfe0644e99748323R153-R200
created_at: 2025-04-17T16:19:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: debdutdeb
body: **Why?**

because this adds a new dependency (or maybe brings back an old one) - i.e. the ports between the processes neet to be open, instead of only needing it be open to a loadbalancer. 

with tcp transporter TCP_PORT or extrainformation.tcpPort is already needed to be open as a deployment requirement. It is not true for 3000s.
created_at: 2025-04-17T16:19:20Z
author_association: MEMBER
diff_hunk: @@ -200,11 +202,20 @@ export class AppsRestApi {
 
 		this.api.addRoute(
 			'installed',
-			{ authRequired: true },
+			// This is required for apps status queries to happen from other instances
+			// Is there a better way to do this?
+			// { authRequired: true },
path: apps/meteor/ee/server/apps/communication/rest.ts
user: login: debdutdeb
body: with molecular we can also avoid doing this
created_at: 2025-04-17T16:20:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: debdutdeb
body: Oh why can't that happen for normal though? both times it's registering a molecular node iirc. 
created_at: 2025-04-17T16:23:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: debdutdeb
body: yeah since it's the same "service" handing those requests (in our lingo, the service itself is "internal" so ms or monolith part of the same process, building the same list of instances either way), Im not sure why we need two separate paths to get the app information? It's part of the same group of instances that manage those apps, microservice or monolith
created_at: 2025-04-17T16:24:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: d-gubert
body: On High-Availability we start a LocalBroker, though, not the NetworkBroker. I've just tried calling `broker.call('$node.list')` in an HA deployment and it returns undefined :/
created_at: 2025-04-17T16:49:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: debdutdeb
body: ohho that's right
created_at: 2025-04-17T16:56:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: debdutdeb
body: i think the only "thing" in monolith that registers into the molecular network is instance service for health stuff and broadcasting
created_at: 2025-04-17T16:57:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: debdutdeb
body: https://github.com/RocketChat/Rocket.Chat/blob/7edc3dbdfa4bc3969a60e39ffb491b326988d364/apps/meteor/ee/server/local-services/instance/service.ts#L176-L178
created_at: 2025-04-17T16:58:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: debdutdeb
body: ```ts
		this.broker.createService({
			name: 'matrix',
			methods: {
				// call somewhere else InstanceService.getInstances().map(({ nodeID }) => InstanceService.getAppStatus())
				getAppStatus(opts: { nodeID: string }) {
					return App.getStatus();
				},
			},
```

thinking... better than making endpoint noauth and adding another dependency?
created_at: 2025-04-17T17:05:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+import type { AppStatusReport } from '@rocket.chat/core-services';
+import { Apps } from '@rocket.chat/core-services';
+import { InstanceStatus } from '@rocket.chat/models';
+import type { OperationResult } from '@rocket.chat/rest-typings';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
+
+import { getInstanceList } from '../../../app/api/server/helpers/getInstanceList';
+import { isRunningMs } from '../../../server/lib/isRunningMs';
+
+export async function fetchAppsStatusFromCluster() {
+	if (isRunningMs()) {
+		return Apps.getAppsStatusInCluster();
+	}
+
+	return fetchAppsStatusFromHighAvailability();
+}
+
+export async function fetchAppsStatusFromHighAvailability(): Promise<AppStatusReport> {
path: apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
user: login: d-gubert
body: Thanks for the pointers @debdutdeb , I've switched strategies :)
created_at: 2025-04-18T15:09:25Z
author_association: MEMBER

PR comments:
user: login: dionisio-bot[bot]
created_at: 2025-04-07T21:03:38Z
body: Looks like this PR is ready to merge! 🎉
If you have any trouble, please check the [PR guidelines](https://handbook.rocket.chat/departments-and-operations/research-and-development/engineering/development/pr-general-instructions-and-handling)
author_association: CONTRIBUTOR
user: login: changeset-bot[bot]
created_at: 2025-04-07T21:03:38Z
body: ###  🦋  Changeset detected

Latest commit: 74a1b94186fa73a151f7b76f0b97d2d9b805b6d7

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 37 packages</summary>

  | Name                                | Type  |
| ----------------------------------- | ----- |
| @rocket.chat/network-broker         | Minor |
| @rocket.chat/mock-providers         | Minor |
| @rocket.chat/pdf-worker             | Minor |
| @rocket.chat/core-services          | Minor |
| @rocket.chat/model-typings          | Minor |
| @rocket.chat/core-typings           | Minor |
| @rocket.chat/rest-typings           | Minor |
| @rocket.chat/ui-contexts            | Major |
| @rocket.chat/ui-voip                | Major |
| @rocket.chat/models                 | Minor |
| @rocket.chat/i18n                   | Minor |
| @rocket.chat/meteor                 | Minor |
| @rocket.chat/account-service        | Patch |
| @rocket.chat/authorization-service  | Patch |
| @rocket.chat/ddp-streamer           | Patch |
| @rocket.chat/omnichannel-transcript | Patch |
| @rocket.chat/presence-service       | Patch |
| @rocket.chat/queue-worker           | Patch |
| @rocket.chat/stream-hub-service     | Patch |
| rocketchat-services                 | Patch |
| @rocket.chat/fuselage-ui-kit        | Major |
| @rocket.chat/ui-client              | Major |
| @rocket.chat/web-ui-registration    | Major |
| @rocket.chat/omnichannel-services   | Patch |
| @rocket.chat/presence               | Patch |
| @rocket.chat/apps                   | Patch |
| @rocket.chat/uikit-playground       | Patch |
| @rocket.chat/api-client             | Patch |
| @rocket.chat/cron                   | Patch |
| @rocket.chat/ddp-client             | Patch |
| @rocket.chat/freeswitch             | Patch |
| @rocket.chat/gazzodown              | Major |
| @rocket.chat/livechat               | Patch |
| @rocket.chat/license                | Patch |
| @rocket.chat/ui-avatar              | Major |
| @rocket.chat/ui-video-conf          | Major |
| @rocket.chat/instance-status        | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/RocketChat/Rocket.Chat/new/feat/status-in-cluster?filename=.changeset/two-poems-march.md&value=---%0A%22%40rocket.chat%2Fmeteor%22%3A%20patch%0A%22%40rocket.chat%2Fcore-services%22%3A%20patch%0A%22%40rocket.chat%2Fcore-typings%22%3A%20patch%0A%22%40rocket.chat%2Frest-typings%22%3A%20patch%0A%22%40rocket.chat%2Fnetwork-broker%22%3A%20patch%0A---%0A%0Afeat%3A%20multi-instance%20app%20status%0A)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-14T12:17:51Z
body: [PR Preview Action](https://github.com/rossjrw/pr-preview-action) v1.6.1
:---:
| <p></p> :rocket: View preview at <br> https://RocketChat.github.io/Rocket.Chat/pr-preview/pr-35721/ <br><br>
| <h6>Built to branch [`gh-pages`](https://github.com/RocketChat/Rocket.Chat/tree/gh-pages) at 2025-04-20 06:07 UTC. <br> Preview will be ready when the [GitHub Pages deployment](https://github.com/RocketChat/Rocket.Chat/deployments) is complete. <br><br> </h6>
<!-- Sticky Pull Request Commentpr-preview -->
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-14T12:18:08Z
body: ## [Codecov](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/35721?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) Report
Attention: Patch coverage is `79.62963%` with `11 lines` in your changes missing coverage. Please review.
> Project coverage is 61.15%. Comparing base [(`79d0054`)](https://app.codecov.io/gh/RocketChat/Rocket.Chat/commit/79d00548b66f98752280ca855f1a4f6f7c357fdc?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) to head [(`74a1b94`)](https://app.codecov.io/gh/RocketChat/Rocket.Chat/commit/74a1b94186fa73a151f7b76f0b97d2d9b805b6d7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat).
> Report is 1 commits behind head on develop.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/35721/graphs/tree.svg?width=650&height=150&src=pr&token=lEAH159Fca&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat)](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/35721?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat)

```diff
@@             Coverage Diff             @@
##           develop   #35721      +/-   ##
===========================================
- Coverage    61.21%   61.15%   -0.07%     
===========================================
  Files         3001     3003       +2     
  Lines        71121    71350     +229     
  Branches     16241    16333      +92     
===========================================
+ Hits         43538    43634      +96     
- Misses       24620    24750     +130     
- Partials      2963     2966       +3     
```

| [Flag](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/35721/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) | Coverage Δ | |
|---|---|---|
| [e2e](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/35721/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) | `57.86% <ø> (+0.03%)` | :arrow_up: |
| [unit](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/35721/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) | `74.99% <79.62%> (-0.59%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-14T21:51:25Z
body: ## Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.



<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/automations/AutomationCodeReview/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-14T21:52:34Z
body: ## Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.



<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/automations/AutomationCodeReview/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-14T22:16:20Z
body: ## Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.



<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/automations/AutomationCodeReview/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-15T23:45:17Z
body: # Kody Review Complete
**Great news!** 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/automations/AutomationCodeReview/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-16T13:18:54Z
body: ## Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.



<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/automations/AutomationCodeReview/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-16T14:32:56Z
body: # Kody Review Complete
**Great news!** 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/automations/AutomationCodeReview/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-16T14:45:55Z
body: # Kody Review Complete
**Great news!** 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/automations/AutomationCodeReview/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-17T16:02:28Z
body: # Kody Review Complete
**Great news!** 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/settings/code-review/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-18T15:07:37Z
body: # Kody Review Complete
**Great news!** 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/settings/code-review/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-04-20T05:55:21Z
body: ## Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.



<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/settings/code-review/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE