@oneuptime/common 14.0.9 → 14.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (383) hide show
  1. package/Models/DatabaseModels/AutoRemediationSuggestion.ts +60 -0
  2. package/Models/DatabaseModels/CephCluster.ts +213 -0
  3. package/Models/DatabaseModels/DatabaseServer.ts +213 -0
  4. package/Models/DatabaseModels/DockerHost.ts +213 -0
  5. package/Models/DatabaseModels/DockerSwarmCluster.ts +213 -0
  6. package/Models/DatabaseModels/GlobalConfig.ts +1 -1
  7. package/Models/DatabaseModels/Host.ts +213 -0
  8. package/Models/DatabaseModels/Index.ts +2 -0
  9. package/Models/DatabaseModels/PodmanHost.ts +213 -0
  10. package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
  11. package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
  12. package/Models/DatabaseModels/RunnerJob.ts +144 -0
  13. package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
  14. package/Server/API/AutoRemediationAPI.ts +392 -1
  15. package/Server/API/ResourceAiAccessAPI.ts +1669 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  18. package/Server/Infrastructure/Semaphore.ts +22 -0
  19. package/Server/Middleware/TelemetryIngest.ts +15 -5
  20. package/Server/Services/AnalyticsDatabaseService.ts +45 -1
  21. package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
  22. package/Server/Services/CephClusterService.ts +109 -1
  23. package/Server/Services/DatabaseServerService.ts +116 -1
  24. package/Server/Services/DockerHostService.ts +109 -1
  25. package/Server/Services/DockerSwarmClusterService.ts +113 -1
  26. package/Server/Services/HostService.ts +126 -4
  27. package/Server/Services/MetricRecordingRuleService.ts +47 -0
  28. package/Server/Services/PodmanHostService.ts +109 -1
  29. package/Server/Services/ProxmoxClusterService.ts +110 -1
  30. package/Server/Services/ResourceAiAccessService.ts +1439 -0
  31. package/Server/Services/ResourceAiAgentJobService.ts +202 -0
  32. package/Server/Services/ResourceAiAgentService.ts +2432 -0
  33. package/Server/Services/RunnerJobService.ts +611 -4
  34. package/Server/Services/TelemetryUsageBillingService.ts +28 -0
  35. package/Server/Services/TraceRecordingRuleService.ts +33 -0
  36. package/Server/Services/VMwareVCenterService.ts +110 -1
  37. package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1434 -32
  38. package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +935 -21
  39. package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
  40. package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
  41. package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
  42. package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
  43. package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
  44. package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
  45. package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
  46. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +72 -2
  47. package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
  48. package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
  49. package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +396 -13
  50. package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
  51. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
  52. package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
  53. package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
  54. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
  55. package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
  56. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
  57. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
  58. package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
  59. package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
  60. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +366 -143
  61. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
  62. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
  63. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
  64. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
  65. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
  66. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
  67. package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +93 -0
  68. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
  69. package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
  70. package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
  71. package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
  72. package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
  73. package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
  74. package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
  75. package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
  76. package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
  77. package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
  78. package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
  79. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
  80. package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
  81. package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
  82. package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
  83. package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
  84. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
  85. package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
  86. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
  87. package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +63 -5
  88. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +103 -5
  89. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +19 -1
  90. package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
  91. package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
  92. package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
  93. package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
  94. package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
  95. package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
  96. package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
  97. package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
  98. package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
  99. package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
  100. package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
  101. package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
  102. package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
  103. package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
  104. package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
  105. package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +105 -0
  106. package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
  107. package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
  108. package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
  109. package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
  110. package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
  111. package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
  112. package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
  113. package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
  114. package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
  115. package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
  116. package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
  117. package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
  118. package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
  119. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
  120. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
  121. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
  122. package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
  123. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
  124. package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
  125. package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
  126. package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
  127. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
  128. package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +33 -3
  129. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
  130. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
  131. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
  132. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
  133. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
  134. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
  135. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
  136. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
  137. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
  138. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
  139. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
  140. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
  141. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
  142. package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
  143. package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
  144. package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
  145. package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
  146. package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
  147. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
  148. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
  149. package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
  150. package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
  151. package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
  152. package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
  153. package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
  154. package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
  155. package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
  156. package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
  157. package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
  158. package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
  159. package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
  160. package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
  161. package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
  162. package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
  163. package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
  164. package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
  165. package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
  166. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
  167. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
  168. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
  169. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
  170. package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
  171. package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
  172. package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
  173. package/Types/AI/ResourceAiAccessApi.ts +140 -0
  174. package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
  175. package/Types/AutoRemediation/AiRemediationCommandPlan.ts +183 -2
  176. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
  177. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
  178. package/Types/Monitor/RumAlertTemplates.ts +351 -4
  179. package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
  180. package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
  181. package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
  182. package/Types/Runbook/RunbookStepType.ts +29 -0
  183. package/Types/Telemetry/TelemetryIngestSurface.ts +14 -2
  184. package/Utils/AI/InvestigationReport.ts +121 -15
  185. package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
  186. package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
  187. package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
  188. package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
  189. package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
  190. package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
  191. package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
  192. package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
  193. package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
  194. package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
  195. package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
  196. package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
  197. package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
  198. package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
  199. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
  200. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
  201. package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
  202. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  203. package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
  204. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
  205. package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
  206. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  207. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
  208. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
  209. package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
  210. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  211. package/build/dist/Models/DatabaseModels/Host.js +219 -0
  212. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  213. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  214. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  215. package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
  216. package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
  217. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
  218. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  219. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
  220. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
  221. package/build/dist/Models/DatabaseModels/RunnerJob.js +145 -0
  222. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  223. package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
  224. package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
  225. package/build/dist/Server/API/AutoRemediationAPI.js +231 -1
  226. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  227. package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
  228. package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
  230. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
  231. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  232. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  233. package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
  234. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  235. package/build/dist/Server/Middleware/TelemetryIngest.js +12 -5
  236. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  237. package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
  238. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  239. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
  240. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  241. package/build/dist/Server/Services/CephClusterService.js +104 -0
  242. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  243. package/build/dist/Server/Services/DatabaseServerService.js +99 -0
  244. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
  245. package/build/dist/Server/Services/DockerHostService.js +104 -0
  246. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  247. package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
  248. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  249. package/build/dist/Server/Services/HostService.js +114 -2
  250. package/build/dist/Server/Services/HostService.js.map +1 -1
  251. package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
  252. package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
  253. package/build/dist/Server/Services/PodmanHostService.js +104 -0
  254. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  255. package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
  256. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  257. package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
  258. package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
  259. package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
  260. package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
  261. package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
  262. package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
  263. package/build/dist/Server/Services/RunnerJobService.js +344 -5
  264. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  265. package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
  266. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  267. package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
  268. package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
  269. package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
  270. package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
  271. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1029 -30
  272. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
  273. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +633 -28
  274. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
  275. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
  276. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
  277. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
  278. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
  279. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
  280. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
  281. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
  282. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
  283. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
  284. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
  285. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
  286. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
  287. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
  288. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
  289. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +50 -4
  290. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  291. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
  292. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
  293. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
  294. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
  295. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +290 -13
  296. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
  297. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
  298. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
  299. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
  300. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  301. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
  302. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
  303. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
  304. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
  305. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
  306. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  307. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
  308. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
  309. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
  310. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  311. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
  312. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  313. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
  314. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
  315. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
  316. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
  317. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +263 -110
  318. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  319. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
  320. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
  321. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
  322. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
  323. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
  324. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
  325. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
  326. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
  327. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
  328. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
  329. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
  330. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
  331. package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
  332. package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
  333. package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
  334. package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
  335. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +116 -29
  336. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
  337. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
  338. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  339. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
  340. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  341. package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
  342. package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
  343. package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
  344. package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
  345. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
  346. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
  347. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
  348. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
  349. package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
  350. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  351. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +13 -2
  352. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
  353. package/build/dist/Utils/AI/InvestigationReport.js +71 -11
  354. package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
  355. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
  356. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
  357. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
  358. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
  359. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
  360. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
  361. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
  362. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
  363. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
  364. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
  365. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
  366. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
  367. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
  368. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
  369. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
  370. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
  371. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
  372. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
  373. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
  374. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
  375. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
  376. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
  377. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
  378. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
  379. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
  380. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
  381. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
  382. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
  383. package/package.json +1 -1
@@ -0,0 +1,1049 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ jest,
7
+ test,
8
+ } from "@jest/globals";
9
+ import type { SpyInstance } from "jest-mock";
10
+ import GlobalCache from "../../../../../Server/Infrastructure/GlobalCache";
11
+ import Redis, { ClientType } from "../../../../../Server/Infrastructure/Redis";
12
+ import logger, { LogAttributes } from "../../../../../Server/Utils/Logger";
13
+ import MicrosoftTeamsActivityDeduplicator from "../../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator";
14
+ import DatabaseNotConnectedException from "../../../../../Types/Exception/DatabaseNotConnectedException";
15
+ import { JSONObject } from "../../../../../Types/JSON";
16
+
17
+ /*
18
+ * Issue #4111: in Microsoft Teams every error bubble showed up twice. The bot
19
+ * replied and then rethrew, the CloudAdapter answered Teams with HTTP 500, and
20
+ * Teams delivered the same activity (same id) again, so the handler failed
21
+ * and replied a second time. Teams also redelivers an activity the bot is
22
+ * slow to answer, and a redelivered form submit would create a second
23
+ * incident. MicrosoftTeamsActivityDeduplicator.claim() is the guard in front
24
+ * of handleBotMessageActivity: the first delivery of
25
+ * "<conversation id>|<activity id>" is processed, every later one is dropped
26
+ * for ten minutes.
27
+ *
28
+ * The claim is a Redis SET NX, so a redelivery that lands on another App
29
+ * instance is caught too, and this process remembers every claim it makes
30
+ * besides. That memory is consulted first: an activity claimed here is
31
+ * refused here whatever Redis says (Redis may have been down for the first
32
+ * delivery, or have lost the key since). Redis is asked about every delivery
33
+ * all the same, so the other instances learn of the claim. When Redis is down
34
+ * (GlobalCache throws DatabaseNotConnectedException) or hangs, this process's
35
+ * memory decides, and a hung Redis may hold a reply up by two seconds at
36
+ * most. claim() is called before handleBotMessageActivity's try block, so it
37
+ * must never reject: otherwise every message would get the generic error
38
+ * reply while Redis misbehaves.
39
+ *
40
+ * Falling back to memory means a redelivery that reaches another instance is
41
+ * handled twice, so a process with a Redis client logs the fallback at error
42
+ * level, classed as infrastructure, at most once a minute. A process without
43
+ * one (a script, or this test file) has no other instance to share with and
44
+ * only logs it at debug.
45
+ *
46
+ * The in-process claims are a static cache shared by every test in this
47
+ * file, so each test claims activity ids of its own. The time of the last
48
+ * logged fallback is static too, so each test that gives the process a Redis
49
+ * client runs on a clock of its own (see startClockOfItsOwn).
50
+ */
51
+
52
+ const CLAIM_NAMESPACE: string = "microsoft-teams-inbound-activity";
53
+ const CLAIM_TTL_IN_SECONDS: number = 10 * 60;
54
+ const CLAIM_TTL_IN_MS: number = CLAIM_TTL_IN_SECONDS * 1000;
55
+ const REDIS_CLAIM_TIMEOUT_IN_MS: number = 2000;
56
+
57
+ const PERSONAL_CHAT_ID: string =
58
+ "a:1pQ3dFxkGqKZ8Yv0cWm7bT2nR5sL9hJ4uE6oA-personal";
59
+ const CHANNEL_THREAD_ID: string =
60
+ "19:7b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e@thread.tacv2;messageid=1727712000000";
61
+
62
+ type SetStringIfNotExists = typeof GlobalCache.setStringIfNotExists;
63
+
64
+ let lastActivityNumber: number = 0;
65
+
66
+ // Teams activity ids look like "1727712345678"; every call returns a new one.
67
+ function nextActivityId(): string {
68
+ lastActivityNumber += 1;
69
+ return `17277123${String(lastActivityNumber).padStart(5, "0")}`;
70
+ }
71
+
72
+ function buildActivity(data: {
73
+ id: string;
74
+ conversationId: string;
75
+ }): JSONObject {
76
+ return {
77
+ type: "message",
78
+ id: data.id,
79
+ text: "create incident",
80
+ conversation: { id: data.conversationId },
81
+ from: {
82
+ id: "29:1Hk8-teams-user",
83
+ aadObjectId: "6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b",
84
+ },
85
+ };
86
+ }
87
+
88
+ // A message activity no earlier test has claimed.
89
+ function newActivity(conversationId?: string): JSONObject {
90
+ return buildActivity({
91
+ id: nextActivityId(),
92
+ conversationId: conversationId || PERSONAL_CHAT_ID,
93
+ });
94
+ }
95
+
96
+ function claimKeyOf(activity: JSONObject): string {
97
+ return `${(activity["conversation"] as JSONObject)["id"] as string}|${
98
+ activity["id"] as string
99
+ }`;
100
+ }
101
+
102
+ function claim(activity: JSONObject): Promise<boolean> {
103
+ return MicrosoftTeamsActivityDeduplicator.claim(activity);
104
+ }
105
+
106
+ // What GlobalCache.setStringIfNotExists throws while Redis is not connected.
107
+ function cacheNotConnected(): DatabaseNotConnectedException {
108
+ return new DatabaseNotConnectedException("Cache is not connected");
109
+ }
110
+
111
+ function redisIsDown(): SpyInstance<SetStringIfNotExists> {
112
+ return jest
113
+ .spyOn(GlobalCache, "setStringIfNotExists")
114
+ .mockRejectedValue(cacheNotConnected());
115
+ }
116
+
117
+ interface Deferred<T> {
118
+ promise: Promise<T>;
119
+ resolve: (value: T) => void;
120
+ reject: (reason: unknown) => void;
121
+ }
122
+
123
+ function deferred<T>(): Deferred<T> {
124
+ let resolve!: (value: T) => void;
125
+ let reject!: (reason: unknown) => void;
126
+ const promise: Promise<T> = new Promise<T>(
127
+ (res: (value: T) => void, rej: (reason: unknown) => void) => {
128
+ resolve = res;
129
+ reject = rej;
130
+ },
131
+ );
132
+ return { promise, resolve, reject };
133
+ }
134
+
135
+ // A Redis call that never answers: the connection hangs.
136
+ function redisHangs(): SpyInstance<SetStringIfNotExists> {
137
+ return jest
138
+ .spyOn(GlobalCache, "setStringIfNotExists")
139
+ .mockImplementation(() => {
140
+ return deferred<boolean>().promise;
141
+ });
142
+ }
143
+
144
+ // Runs every promise callback that is already due (fake timers stay put).
145
+ async function flushPromises(): Promise<void> {
146
+ for (let turn: number = 0; turn < 20; turn++) {
147
+ await Promise.resolve();
148
+ }
149
+ }
150
+
151
+ let lastClockStartedAt: number = Date.parse("2031-01-01T00:00:00.000Z");
152
+
153
+ /*
154
+ * Fake timers starting a day after the last test that called this, so no
155
+ * minute an earlier test's logged fallback began is still running. Returns
156
+ * the start.
157
+ */
158
+ function startClockOfItsOwn(): number {
159
+ lastClockStartedAt += 24 * 60 * 60 * 1000;
160
+ jest.useFakeTimers({ now: lastClockStartedAt });
161
+ return lastClockStartedAt;
162
+ }
163
+
164
+ afterEach(() => {
165
+ jest.useRealTimers();
166
+ jest.restoreAllMocks();
167
+ });
168
+
169
+ describe("MicrosoftTeamsActivityDeduplicator.getClaimKey", () => {
170
+ test("keys an activity by its conversation id and its activity id", () => {
171
+ expect(
172
+ MicrosoftTeamsActivityDeduplicator.getClaimKey(
173
+ buildActivity({
174
+ id: "1727712345678",
175
+ conversationId: PERSONAL_CHAT_ID,
176
+ }),
177
+ ),
178
+ ).toBe(`${PERSONAL_CHAT_ID}|1727712345678`);
179
+
180
+ expect(
181
+ MicrosoftTeamsActivityDeduplicator.getClaimKey(
182
+ buildActivity({
183
+ id: "1727712345678",
184
+ conversationId: CHANNEL_THREAD_ID,
185
+ }),
186
+ ),
187
+ ).toBe(`${CHANNEL_THREAD_ID}|1727712345678`);
188
+ });
189
+
190
+ test("an activity without an id has no key, whatever its conversation", () => {
191
+ expect(
192
+ MicrosoftTeamsActivityDeduplicator.getClaimKey({
193
+ type: "message",
194
+ conversation: { id: PERSONAL_CHAT_ID },
195
+ }),
196
+ ).toBeNull();
197
+
198
+ expect(
199
+ MicrosoftTeamsActivityDeduplicator.getClaimKey({
200
+ type: "message",
201
+ id: "",
202
+ conversation: { id: PERSONAL_CHAT_ID },
203
+ }),
204
+ ).toBeNull();
205
+
206
+ expect(MicrosoftTeamsActivityDeduplicator.getClaimKey({})).toBeNull();
207
+ });
208
+
209
+ test("an activity with an id but no conversation is still keyed by its id", () => {
210
+ expect(
211
+ MicrosoftTeamsActivityDeduplicator.getClaimKey({
212
+ type: "message",
213
+ id: "1727712345678",
214
+ }),
215
+ ).toBe("|1727712345678");
216
+
217
+ expect(
218
+ MicrosoftTeamsActivityDeduplicator.getClaimKey({
219
+ type: "message",
220
+ id: "1727712345678",
221
+ conversation: {},
222
+ }),
223
+ ).toBe("|1727712345678");
224
+ });
225
+ });
226
+
227
+ describe("MicrosoftTeamsActivityDeduplicator.claim while Redis cannot be reached", () => {
228
+ let redis: SpyInstance<SetStringIfNotExists>;
229
+
230
+ beforeEach(() => {
231
+ redis = redisIsDown();
232
+ });
233
+
234
+ test("the first delivery is claimed and every redelivery of it is refused", async () => {
235
+ const activity: JSONObject = newActivity();
236
+
237
+ expect(await claim(activity)).toBe(true);
238
+ expect(await claim(activity)).toBe(false);
239
+ expect(await claim({ ...activity })).toBe(false);
240
+
241
+ // Redis was asked first; its failure is what sent the claim to memory.
242
+ expect(redis).toHaveBeenNthCalledWith(
243
+ 1,
244
+ CLAIM_NAMESPACE,
245
+ claimKeyOf(activity),
246
+ "1",
247
+ { expiresInSeconds: CLAIM_TTL_IN_SECONDS },
248
+ );
249
+ });
250
+
251
+ test("two deliveries of one activity at the same moment: exactly one is claimed", async () => {
252
+ const activity: JSONObject = newActivity();
253
+
254
+ expect(
255
+ await Promise.all([claim(activity), claim({ ...activity })]),
256
+ ).toEqual([true, false]);
257
+ });
258
+
259
+ test("the same activity id in another conversation is another activity", async () => {
260
+ const activityId: string = nextActivityId();
261
+
262
+ expect(
263
+ await claim(
264
+ buildActivity({ id: activityId, conversationId: PERSONAL_CHAT_ID }),
265
+ ),
266
+ ).toBe(true);
267
+ expect(
268
+ await claim(
269
+ buildActivity({ id: activityId, conversationId: CHANNEL_THREAD_ID }),
270
+ ),
271
+ ).toBe(true);
272
+
273
+ // Each is still a single claim of its own.
274
+ expect(
275
+ await claim(
276
+ buildActivity({ id: activityId, conversationId: CHANNEL_THREAD_ID }),
277
+ ),
278
+ ).toBe(false);
279
+ });
280
+
281
+ test("another activity in the same conversation is claimed", async () => {
282
+ expect(await claim(newActivity(PERSONAL_CHAT_ID))).toBe(true);
283
+ expect(await claim(newActivity(PERSONAL_CHAT_ID))).toBe(true);
284
+ });
285
+
286
+ test("an activity without an id is always processed, and Redis is not asked", async () => {
287
+ const withoutId: JSONObject = {
288
+ type: "message",
289
+ text: "create incident",
290
+ conversation: { id: PERSONAL_CHAT_ID },
291
+ };
292
+ const withEmptyId: JSONObject = { ...withoutId, id: "" };
293
+
294
+ expect(await claim(withoutId)).toBe(true);
295
+ expect(await claim(withoutId)).toBe(true);
296
+ expect(await claim(withEmptyId)).toBe(true);
297
+ expect(await claim(withEmptyId)).toBe(true);
298
+
299
+ expect(redis).not.toHaveBeenCalled();
300
+ });
301
+
302
+ test("a failing Redis does not hold the reply up: no wait for the two-second timeout", async () => {
303
+ jest.useFakeTimers();
304
+
305
+ const claimed: Promise<boolean> = claim(newActivity());
306
+
307
+ // Nothing is advanced: the claim settles on the Redis failure alone.
308
+ expect(await claimed).toBe(true);
309
+ expect(jest.getTimerCount()).toBe(0);
310
+ });
311
+
312
+ test("the claim lasts ten minutes, then the same activity can be claimed again", async () => {
313
+ const claimedAt: number = Date.parse("2026-10-01T18:00:00.000Z");
314
+ jest.useFakeTimers({ now: claimedAt });
315
+
316
+ const activity: JSONObject = newActivity();
317
+ expect(await claim(activity)).toBe(true);
318
+
319
+ /*
320
+ * Ten minutes from the claim: the redelivery refused at 9:59 does not
321
+ * extend it, as a refused SET NX leaves the Redis key's expiry alone.
322
+ */
323
+ jest.setSystemTime(claimedAt + CLAIM_TTL_IN_MS - 1000);
324
+ expect(await claim(activity)).toBe(false);
325
+
326
+ jest.setSystemTime(claimedAt + CLAIM_TTL_IN_MS + 1000);
327
+ expect(await claim(activity)).toBe(true);
328
+
329
+ // The new claim holds in its turn.
330
+ expect(await claim(activity)).toBe(false);
331
+ });
332
+ });
333
+
334
+ /*
335
+ * "Not connected" is only one way GlobalCache fails. ioredis rejects with
336
+ * errors of its own, a replica answers writes with READONLY during a
337
+ * failover, and the @CaptureSpan decorator wraps setStringIfNotExists in a
338
+ * plain (not async) function, so a telemetry fault throws before any promise
339
+ * exists. None of them may escape claim(): it runs outside
340
+ * handleBotMessageActivity's try block.
341
+ */
342
+ describe("MicrosoftTeamsActivityDeduplicator.claim never rejects, whatever GlobalCache fails with", () => {
343
+ interface RedisFailure {
344
+ name: string;
345
+ fail: (redis: SpyInstance<SetStringIfNotExists>) => void;
346
+ }
347
+
348
+ const failures: Array<RedisFailure> = [
349
+ {
350
+ name: "a synchronous throw, before any promise exists",
351
+ fail: (redis: SpyInstance<SetStringIfNotExists>): void => {
352
+ redis.mockImplementation(() => {
353
+ throw new Error("Could not start the span");
354
+ });
355
+ },
356
+ },
357
+ {
358
+ name: "ioredis's own error",
359
+ fail: (redis: SpyInstance<SetStringIfNotExists>): void => {
360
+ redis.mockRejectedValue(new Error("Connection is closed."));
361
+ },
362
+ },
363
+ {
364
+ name: "a READONLY reply during a failover",
365
+ fail: (redis: SpyInstance<SetStringIfNotExists>): void => {
366
+ redis.mockRejectedValue(
367
+ new Error("READONLY You can't write against a read only replica."),
368
+ );
369
+ },
370
+ },
371
+ {
372
+ name: "a rejection that is not an Error",
373
+ fail: (redis: SpyInstance<SetStringIfNotExists>): void => {
374
+ redis.mockRejectedValue("ETIMEDOUT");
375
+ },
376
+ },
377
+ ];
378
+
379
+ test.each(failures)(
380
+ "$name: this process's memory decides, at once and without a timer left behind",
381
+ async (failure: RedisFailure) => {
382
+ jest.useFakeTimers();
383
+ const redis: SpyInstance<SetStringIfNotExists> = jest.spyOn(
384
+ GlobalCache,
385
+ "setStringIfNotExists",
386
+ );
387
+ failure.fail(redis);
388
+ const activity: JSONObject = newActivity();
389
+
390
+ // No time is advanced: the failure alone settles each claim.
391
+ await expect(claim(activity)).resolves.toBe(true);
392
+ await expect(claim(activity)).resolves.toBe(false);
393
+ await expect(claim(newActivity())).resolves.toBe(true);
394
+
395
+ expect(redis).toHaveBeenNthCalledWith(
396
+ 1,
397
+ CLAIM_NAMESPACE,
398
+ claimKeyOf(activity),
399
+ "1",
400
+ { expiresInSeconds: CLAIM_TTL_IN_SECONDS },
401
+ );
402
+ expect(jest.getTimerCount()).toBe(0);
403
+ },
404
+ );
405
+ });
406
+
407
+ describe("MicrosoftTeamsActivityDeduplicator.claim while Redis answers", () => {
408
+ test("returns Redis's answer, and claims the activity's key for ten minutes", async () => {
409
+ const redis: SpyInstance<SetStringIfNotExists> = jest
410
+ .spyOn(GlobalCache, "setStringIfNotExists")
411
+ .mockResolvedValueOnce(true)
412
+ .mockResolvedValueOnce(false);
413
+ const activity: JSONObject = newActivity(CHANNEL_THREAD_ID);
414
+
415
+ expect(await claim(activity)).toBe(true);
416
+ expect(await claim(activity)).toBe(false);
417
+
418
+ expect(redis).toHaveBeenNthCalledWith(
419
+ 1,
420
+ CLAIM_NAMESPACE,
421
+ claimKeyOf(activity),
422
+ "1",
423
+ { expiresInSeconds: CLAIM_TTL_IN_SECONDS },
424
+ );
425
+ });
426
+
427
+ test("an activity another App instance already claimed is refused on its first delivery here", async () => {
428
+ const redis: SpyInstance<SetStringIfNotExists> = jest
429
+ .spyOn(GlobalCache, "setStringIfNotExists")
430
+ .mockResolvedValue(false);
431
+ const activity: JSONObject = newActivity(CHANNEL_THREAD_ID);
432
+
433
+ // This process has never seen it, so the refusal can only be Redis's.
434
+ expect(await claim(activity)).toBe(false);
435
+ expect(redis).toHaveBeenCalledTimes(1);
436
+ expect(redis).toHaveBeenCalledWith(
437
+ CLAIM_NAMESPACE,
438
+ claimKeyOf(activity),
439
+ "1",
440
+ { expiresInSeconds: CLAIM_TTL_IN_SECONDS },
441
+ );
442
+ });
443
+
444
+ test("a Redis claim is remembered here, so a Redis outage right after it does not let the redelivery through", async () => {
445
+ jest
446
+ .spyOn(GlobalCache, "setStringIfNotExists")
447
+ .mockResolvedValueOnce(true)
448
+ .mockRejectedValue(cacheNotConnected());
449
+ const activity: JSONObject = newActivity();
450
+
451
+ expect(await claim(activity)).toBe(true);
452
+ expect(await claim(activity)).toBe(false);
453
+ expect(await claim(activity)).toBe(false);
454
+ });
455
+
456
+ test("a refusal from Redis is remembered here too", async () => {
457
+ jest
458
+ .spyOn(GlobalCache, "setStringIfNotExists")
459
+ .mockResolvedValueOnce(false)
460
+ .mockRejectedValue(cacheNotConnected());
461
+ const activity: JSONObject = newActivity();
462
+
463
+ expect(await claim(activity)).toBe(false);
464
+ expect(await claim(activity)).toBe(false);
465
+ });
466
+
467
+ test("what is remembered from Redis lapses after ten minutes, like the Redis key", async () => {
468
+ const claimedAt: number = Date.parse("2026-10-01T18:00:00.000Z");
469
+ jest.useFakeTimers({ now: claimedAt });
470
+ jest
471
+ .spyOn(GlobalCache, "setStringIfNotExists")
472
+ .mockResolvedValueOnce(true)
473
+ .mockRejectedValue(cacheNotConnected());
474
+ const activity: JSONObject = newActivity();
475
+
476
+ expect(await claim(activity)).toBe(true);
477
+
478
+ // Refused from memory, which does not push the lapse back.
479
+ jest.setSystemTime(claimedAt + CLAIM_TTL_IN_MS - 1000);
480
+ expect(await claim(activity)).toBe(false);
481
+
482
+ jest.setSystemTime(claimedAt + CLAIM_TTL_IN_MS + 1000);
483
+ expect(await claim(activity)).toBe(true);
484
+ });
485
+
486
+ test("the two-second race timer is cleared as soon as Redis answers", async () => {
487
+ jest.useFakeTimers();
488
+ jest.spyOn(GlobalCache, "setStringIfNotExists").mockResolvedValue(true);
489
+
490
+ const claimed: Promise<boolean> = claim(newActivity());
491
+
492
+ // The race timeout is armed while Redis is being asked...
493
+ expect(jest.getTimerCount()).toBe(1);
494
+
495
+ // ...and gone once it has answered, without any time passing.
496
+ expect(await claimed).toBe(true);
497
+ expect(jest.getTimerCount()).toBe(0);
498
+ });
499
+
500
+ test("the two-second race timer is cleared as soon as Redis fails", async () => {
501
+ jest.useFakeTimers();
502
+ redisIsDown();
503
+
504
+ const claimed: Promise<boolean> = claim(newActivity());
505
+ expect(jest.getTimerCount()).toBe(1);
506
+
507
+ expect(await claimed).toBe(true);
508
+ expect(jest.getTimerCount()).toBe(0);
509
+ });
510
+ });
511
+
512
+ describe("MicrosoftTeamsActivityDeduplicator.claim while Redis hangs", () => {
513
+ beforeEach(() => {
514
+ jest.useFakeTimers();
515
+ });
516
+
517
+ test("this process's memory decides after two seconds, not before", async () => {
518
+ const redis: SpyInstance<SetStringIfNotExists> = redisHangs();
519
+ const activity: JSONObject = newActivity();
520
+
521
+ let answer: boolean | undefined = undefined;
522
+ const claimed: Promise<void> = claim(activity).then((value: boolean) => {
523
+ answer = value;
524
+ });
525
+
526
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS - 1);
527
+ await flushPromises();
528
+ expect(answer).toBeUndefined();
529
+
530
+ jest.advanceTimersByTime(1);
531
+ await claimed;
532
+ expect(answer).toBe(true);
533
+
534
+ expect(redis).toHaveBeenCalledTimes(1);
535
+ expect(redis).toHaveBeenCalledWith(
536
+ CLAIM_NAMESPACE,
537
+ claimKeyOf(activity),
538
+ "1",
539
+ { expiresInSeconds: CLAIM_TTL_IN_SECONDS },
540
+ );
541
+ expect(jest.getTimerCount()).toBe(0);
542
+ });
543
+
544
+ test("a redelivery during the same hang is refused from memory", async () => {
545
+ redisHangs();
546
+ const activity: JSONObject = newActivity();
547
+
548
+ const first: Promise<boolean> = claim(activity);
549
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS);
550
+ expect(await first).toBe(true);
551
+
552
+ const redelivery: Promise<boolean> = claim(activity);
553
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS);
554
+ expect(await redelivery).toBe(false);
555
+
556
+ expect(jest.getTimerCount()).toBe(0);
557
+ });
558
+
559
+ test("two deliveries racing through the same hang: exactly one is claimed", async () => {
560
+ redisHangs();
561
+ const activity: JSONObject = newActivity();
562
+
563
+ const first: Promise<boolean> = claim(activity);
564
+ const second: Promise<boolean> = claim({ ...activity });
565
+ expect(jest.getTimerCount()).toBe(2);
566
+
567
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS);
568
+
569
+ expect([await first, await second]).toEqual([true, false]);
570
+ expect(jest.getTimerCount()).toBe(0);
571
+ });
572
+
573
+ test("a Redis answer that turns up after the fallback changes nothing", async () => {
574
+ const lateAnswer: Deferred<boolean> = deferred<boolean>();
575
+ const lateFailure: Deferred<boolean> = deferred<boolean>();
576
+ jest
577
+ .spyOn(GlobalCache, "setStringIfNotExists")
578
+ .mockReturnValueOnce(lateAnswer.promise)
579
+ .mockReturnValueOnce(lateFailure.promise)
580
+ .mockRejectedValue(cacheNotConnected());
581
+ const activity: JSONObject = newActivity();
582
+ const otherActivity: JSONObject = newActivity();
583
+
584
+ const claimed: Promise<boolean> = claim(activity);
585
+ const otherClaimed: Promise<boolean> = claim(otherActivity);
586
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS);
587
+ expect(await claimed).toBe(true);
588
+ expect(await otherClaimed).toBe(true);
589
+
590
+ // Redis finally answers the first, and the second fails, too late.
591
+ lateAnswer.resolve(false);
592
+ lateFailure.reject(new Error("Connection is closed."));
593
+ await flushPromises();
594
+
595
+ expect(jest.getTimerCount()).toBe(0);
596
+ // This process's memory still holds both claims.
597
+ expect(await claim(activity)).toBe(false);
598
+ expect(await claim(otherActivity)).toBe(false);
599
+ });
600
+ });
601
+
602
+ describe("MicrosoftTeamsActivityDeduplicator.claim while Redis hangs, on the real clock", () => {
603
+ test("the reply waits about two seconds for Redis, then this process's memory decides", async () => {
604
+ redisHangs();
605
+ const activity: JSONObject = newActivity();
606
+
607
+ const startedAt: number = Date.now();
608
+ const claimed: boolean = await claim(activity);
609
+ const waitedInMs: number = Date.now() - startedAt;
610
+
611
+ expect(claimed).toBe(true);
612
+ // A timer may fire a millisecond early by the wall clock.
613
+ expect(waitedInMs).toBeGreaterThanOrEqual(REDIS_CLAIM_TIMEOUT_IN_MS - 50);
614
+ // Generous for a busy CI runner; a real hang would sit out the test timeout.
615
+ expect(waitedInMs).toBeLessThan(REDIS_CLAIM_TIMEOUT_IN_MS + 5000);
616
+ });
617
+ });
618
+
619
+ /*
620
+ * This process's memory knows every activity it has claimed in the last ten
621
+ * minutes, whichever way the claim was made. A delivery of one of them is a
622
+ * redelivery, so it must be refused even when Redis, asked again, has no
623
+ * record of the first delivery: Redis was down (or hung) when the first one
624
+ * came in and is back for the redelivery, or it lost the key in a restart or
625
+ * an eviction. That is "a single instance is still covered", the promise the
626
+ * in-process fallback makes; a redelivered form submit would otherwise create
627
+ * a second incident.
628
+ */
629
+ describe("MicrosoftTeamsActivityDeduplicator.claim when Redis has no record of a claim this process made", () => {
630
+ test("a redelivery claimed here while Redis was down is refused when Redis is back", async () => {
631
+ jest
632
+ .spyOn(GlobalCache, "setStringIfNotExists")
633
+ .mockRejectedValueOnce(cacheNotConnected())
634
+ // Back, and the first delivery never reached it.
635
+ .mockResolvedValue(true);
636
+ const activity: JSONObject = newActivity();
637
+
638
+ expect(await claim(activity)).toBe(true);
639
+ expect(await claim(activity)).toBe(false);
640
+ });
641
+
642
+ test("a redelivery claimed here through Redis is refused after Redis lost the key", async () => {
643
+ jest
644
+ .spyOn(GlobalCache, "setStringIfNotExists")
645
+ .mockResolvedValueOnce(true)
646
+ // Restarted without persistence (or evicted the key).
647
+ .mockResolvedValue(true);
648
+ const activity: JSONObject = newActivity();
649
+
650
+ expect(await claim(activity)).toBe(true);
651
+ expect(await claim(activity)).toBe(false);
652
+ });
653
+ });
654
+
655
+ /*
656
+ * This process's memory decides first, and Redis is asked about every
657
+ * delivery all the same: a claim this process made while Redis could not
658
+ * take it still reaches Redis with the next delivery, so a redelivery that
659
+ * lands on another App instance after that is refused there too.
660
+ */
661
+ describe("MicrosoftTeamsActivityDeduplicator.claim asks Redis about every delivery, after this process's memory", () => {
662
+ test("Redis is asked about each delivery with an id, repeats included, and memory has the last word", async () => {
663
+ const redis: SpyInstance<SetStringIfNotExists> = jest
664
+ .spyOn(GlobalCache, "setStringIfNotExists")
665
+ .mockRejectedValueOnce(cacheNotConnected())
666
+ // Back, without the first delivery's claim: it takes it now.
667
+ .mockResolvedValueOnce(true)
668
+ .mockResolvedValueOnce(false);
669
+ const activity: JSONObject = newActivity();
670
+
671
+ expect(await claim(activity)).toBe(true);
672
+ expect(await claim(activity)).toBe(false);
673
+ expect(await claim({ ...activity })).toBe(false);
674
+
675
+ expect(redis).toHaveBeenCalledTimes(3);
676
+ for (let call: number = 1; call <= 3; call++) {
677
+ expect(redis).toHaveBeenNthCalledWith(
678
+ call,
679
+ CLAIM_NAMESPACE,
680
+ claimKeyOf(activity),
681
+ "1",
682
+ { expiresInSeconds: CLAIM_TTL_IN_SECONDS },
683
+ );
684
+ }
685
+ });
686
+
687
+ test("a first delivery Redis hung on, which is what makes Teams redeliver, is refused on the redelivery when Redis is back, and Redis takes the claim then", async () => {
688
+ jest.useFakeTimers();
689
+ const redis: SpyInstance<SetStringIfNotExists> = jest
690
+ .spyOn(GlobalCache, "setStringIfNotExists")
691
+ .mockReturnValueOnce(deferred<boolean>().promise)
692
+ // Back, with no record of the first delivery.
693
+ .mockResolvedValueOnce(true);
694
+ const activity: JSONObject = newActivity();
695
+
696
+ const first: Promise<boolean> = claim(activity);
697
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS);
698
+ expect(await first).toBe(true);
699
+
700
+ // Teams redelivers the activity the bot was slow to answer.
701
+ expect(await claim(activity)).toBe(false);
702
+
703
+ expect(redis).toHaveBeenCalledTimes(2);
704
+ expect(redis).toHaveBeenNthCalledWith(
705
+ 2,
706
+ CLAIM_NAMESPACE,
707
+ claimKeyOf(activity),
708
+ "1",
709
+ { expiresInSeconds: CLAIM_TTL_IN_SECONDS },
710
+ );
711
+ expect(jest.getTimerCount()).toBe(0);
712
+ });
713
+
714
+ test("a delivery racing the first is refused by memory, even if Redis were to take both claims", async () => {
715
+ const firstAnswer: Deferred<boolean> = deferred<boolean>();
716
+ const secondAnswer: Deferred<boolean> = deferred<boolean>();
717
+ jest
718
+ .spyOn(GlobalCache, "setStringIfNotExists")
719
+ .mockReturnValueOnce(firstAnswer.promise)
720
+ .mockReturnValueOnce(secondAnswer.promise);
721
+ const activity: JSONObject = newActivity();
722
+
723
+ // Both are asked of Redis before either answer is in.
724
+ const first: Promise<boolean> = claim(activity);
725
+ const second: Promise<boolean> = claim({ ...activity });
726
+
727
+ secondAnswer.resolve(true);
728
+ expect(await second).toBe(false);
729
+
730
+ firstAnswer.resolve(true);
731
+ expect(await first).toBe(true);
732
+ });
733
+ });
734
+
735
+ describe("MicrosoftTeamsActivityDeduplicator logs a fallback to this process's memory", () => {
736
+ const FALLBACK_LOG_INTERVAL_IN_MS: number = 60 * 1000;
737
+ // Spelled out, not ErrorClass.Infrastructure: the value is what an operator filters on.
738
+ const INFRASTRUCTURE: LogAttributes = { "error.class": "infrastructure" };
739
+ const REFUSED: string = "Redis refused the claim";
740
+ const TIMED_OUT: string = "Redis did not answer within 2000 ms";
741
+
742
+ let errorLog: SpyInstance<typeof logger.error>;
743
+ let debugLog: SpyInstance<typeof logger.debug>;
744
+
745
+ beforeEach(() => {
746
+ errorLog = jest.spyOn(logger, "error").mockImplementation((): void => {});
747
+ debugLog = jest.spyOn(logger, "debug").mockImplementation((): void => {});
748
+ });
749
+
750
+ // An App instance: connected to Redis, which other instances share.
751
+ function withRedisClient(): void {
752
+ jest.spyOn(Redis, "getClient").mockReturnValue({} as ClientType);
753
+ }
754
+
755
+ // A script or a test: this process never had a Redis client.
756
+ function withoutRedisClient(): void {
757
+ jest.spyOn(Redis, "getClient").mockReturnValue(null);
758
+ }
759
+
760
+ function fellBack(reason: string): string {
761
+ return `Microsoft Teams activity dedupe fell back to this process's memory (${reason}); a Teams redelivery that reaches another App instance may be handled twice.`;
762
+ }
763
+
764
+ function usingMemory(reason: string): string {
765
+ return `Microsoft Teams activity dedupe is using this process's memory: ${reason}`;
766
+ }
767
+
768
+ test("with a Redis client, a claim Redis refused is logged at error level as infrastructure, and so is the error it refused with", async () => {
769
+ startClockOfItsOwn();
770
+ withRedisClient();
771
+ const refusal: DatabaseNotConnectedException = cacheNotConnected();
772
+ jest.spyOn(GlobalCache, "setStringIfNotExists").mockRejectedValue(refusal);
773
+
774
+ expect(await claim(newActivity())).toBe(true);
775
+
776
+ expect(errorLog).toHaveBeenCalledTimes(2);
777
+ expect(errorLog).toHaveBeenNthCalledWith(
778
+ 1,
779
+ fellBack(REFUSED),
780
+ INFRASTRUCTURE,
781
+ );
782
+ expect(errorLog).toHaveBeenNthCalledWith(2, refusal, INFRASTRUCTURE);
783
+ // The very error, with its class and stack, not a copy of its message.
784
+ expect(errorLog.mock.calls[1]?.[0]).toBe(refusal);
785
+ expect(debugLog).not.toHaveBeenCalled();
786
+ });
787
+
788
+ test("with a Redis client, a Redis that does not answer is logged when the two seconds are up, with no error to go with it", async () => {
789
+ startClockOfItsOwn();
790
+ withRedisClient();
791
+ redisHangs();
792
+
793
+ const claimed: Promise<boolean> = claim(newActivity());
794
+
795
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS - 1);
796
+ expect(errorLog).not.toHaveBeenCalled();
797
+
798
+ jest.advanceTimersByTime(1);
799
+ expect(await claimed).toBe(true);
800
+
801
+ expect(errorLog).toHaveBeenCalledTimes(1);
802
+ expect(errorLog).toHaveBeenCalledWith(fellBack(TIMED_OUT), INFRASTRUCTURE);
803
+ expect(debugLog).not.toHaveBeenCalled();
804
+ });
805
+
806
+ interface Refusal {
807
+ name: string;
808
+ thrown: unknown;
809
+ // Thrown before any promise exists, as @CaptureSpan can.
810
+ isSynchronous: boolean;
811
+ }
812
+
813
+ const refusals: Array<Refusal> = [
814
+ {
815
+ name: "a synchronous throw",
816
+ thrown: new Error("Could not start the span"),
817
+ isSynchronous: true,
818
+ },
819
+ {
820
+ name: "ioredis's own error",
821
+ thrown: new Error("Connection is closed."),
822
+ isSynchronous: false,
823
+ },
824
+ {
825
+ name: "a READONLY reply during a failover",
826
+ thrown: new Error(
827
+ "READONLY You can't write against a read only replica.",
828
+ ),
829
+ isSynchronous: false,
830
+ },
831
+ {
832
+ name: "a rejection that is not an Error",
833
+ thrown: "ETIMEDOUT",
834
+ isSynchronous: false,
835
+ },
836
+ ];
837
+
838
+ test.each(refusals)(
839
+ "with a Redis client, $name is logged as a refusal, with what was thrown",
840
+ async (refusal: Refusal) => {
841
+ startClockOfItsOwn();
842
+ withRedisClient();
843
+ const redis: SpyInstance<SetStringIfNotExists> = jest.spyOn(
844
+ GlobalCache,
845
+ "setStringIfNotExists",
846
+ );
847
+
848
+ if (refusal.isSynchronous) {
849
+ redis.mockImplementation((): Promise<boolean> => {
850
+ throw refusal.thrown;
851
+ });
852
+ } else {
853
+ redis.mockRejectedValue(refusal.thrown);
854
+ }
855
+
856
+ expect(await claim(newActivity())).toBe(true);
857
+
858
+ expect(errorLog).toHaveBeenCalledTimes(2);
859
+ expect(errorLog).toHaveBeenNthCalledWith(
860
+ 1,
861
+ fellBack(REFUSED),
862
+ INFRASTRUCTURE,
863
+ );
864
+ expect(errorLog).toHaveBeenNthCalledWith(
865
+ 2,
866
+ refusal.thrown,
867
+ INFRASTRUCTURE,
868
+ );
869
+ expect(errorLog.mock.calls[1]?.[0]).toBe(refusal.thrown);
870
+ },
871
+ );
872
+
873
+ test("with a Redis client, a rejection without a reason is logged as the fallback alone", async () => {
874
+ startClockOfItsOwn();
875
+ withRedisClient();
876
+ jest
877
+ .spyOn(GlobalCache, "setStringIfNotExists")
878
+ .mockRejectedValue(undefined);
879
+
880
+ expect(await claim(newActivity())).toBe(true);
881
+
882
+ expect(errorLog).toHaveBeenCalledTimes(1);
883
+ expect(errorLog).toHaveBeenCalledWith(fellBack(REFUSED), INFRASTRUCTURE);
884
+ });
885
+
886
+ test("at most once a minute: until a minute after the last logged fallback the next ones only go to debug", async () => {
887
+ const startedAt: number = startClockOfItsOwn();
888
+ withRedisClient();
889
+ redisIsDown();
890
+
891
+ expect(await claim(newActivity())).toBe(true);
892
+ expect(errorLog).toHaveBeenCalledTimes(2);
893
+
894
+ jest.setSystemTime(startedAt + 1000);
895
+ expect(await claim(newActivity())).toBe(true);
896
+ jest.setSystemTime(startedAt + FALLBACK_LOG_INTERVAL_IN_MS - 1);
897
+ expect(await claim(newActivity())).toBe(true);
898
+
899
+ expect(errorLog).toHaveBeenCalledTimes(2);
900
+ expect(debugLog).toHaveBeenCalledTimes(2);
901
+ expect(debugLog).toHaveBeenNthCalledWith(1, usingMemory(REFUSED));
902
+ expect(debugLog).toHaveBeenNthCalledWith(2, usingMemory(REFUSED));
903
+
904
+ // The minute runs from the one logged, not from the ones sent to debug.
905
+ jest.setSystemTime(startedAt + FALLBACK_LOG_INTERVAL_IN_MS);
906
+ expect(await claim(newActivity())).toBe(true);
907
+
908
+ expect(errorLog).toHaveBeenCalledTimes(4);
909
+ expect(errorLog).toHaveBeenNthCalledWith(
910
+ 3,
911
+ fellBack(REFUSED),
912
+ INFRASTRUCTURE,
913
+ );
914
+ expect(debugLog).toHaveBeenCalledTimes(2);
915
+ });
916
+
917
+ test("the minute is shared by a refused claim and a Redis that does not answer", async () => {
918
+ const startedAt: number = startClockOfItsOwn();
919
+ withRedisClient();
920
+ jest
921
+ .spyOn(GlobalCache, "setStringIfNotExists")
922
+ .mockRejectedValueOnce(cacheNotConnected())
923
+ .mockReturnValueOnce(deferred<boolean>().promise);
924
+
925
+ expect(await claim(newActivity())).toBe(true);
926
+ expect(errorLog).toHaveBeenCalledTimes(2);
927
+
928
+ jest.setSystemTime(startedAt + 30 * 1000);
929
+ const claimed: Promise<boolean> = claim(newActivity());
930
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS);
931
+ expect(await claimed).toBe(true);
932
+
933
+ expect(errorLog).toHaveBeenCalledTimes(2);
934
+ expect(debugLog).toHaveBeenCalledTimes(1);
935
+ expect(debugLog).toHaveBeenCalledWith(usingMemory(TIMED_OUT));
936
+ });
937
+
938
+ test("without a Redis client a fallback only goes to debug, and does not start the minute", async () => {
939
+ startClockOfItsOwn();
940
+ withoutRedisClient();
941
+ jest
942
+ .spyOn(GlobalCache, "setStringIfNotExists")
943
+ .mockRejectedValueOnce(cacheNotConnected())
944
+ .mockReturnValueOnce(deferred<boolean>().promise)
945
+ .mockRejectedValueOnce(cacheNotConnected());
946
+
947
+ expect(await claim(newActivity())).toBe(true);
948
+ const claimed: Promise<boolean> = claim(newActivity());
949
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS);
950
+ expect(await claimed).toBe(true);
951
+
952
+ expect(errorLog).not.toHaveBeenCalled();
953
+ expect(debugLog).toHaveBeenCalledTimes(2);
954
+ expect(debugLog).toHaveBeenNthCalledWith(1, usingMemory(REFUSED));
955
+ expect(debugLog).toHaveBeenNthCalledWith(2, usingMemory(TIMED_OUT));
956
+
957
+ // Moments later, with a client, the first fallback is logged at once.
958
+ withRedisClient();
959
+ expect(await claim(newActivity())).toBe(true);
960
+
961
+ expect(errorLog).toHaveBeenCalledTimes(2);
962
+ expect(errorLog).toHaveBeenNthCalledWith(
963
+ 1,
964
+ fellBack(REFUSED),
965
+ INFRASTRUCTURE,
966
+ );
967
+ });
968
+
969
+ test("a Redis failure that turns up after the two seconds is not logged again", async () => {
970
+ startClockOfItsOwn();
971
+ withRedisClient();
972
+ const lateFailure: Deferred<boolean> = deferred<boolean>();
973
+ jest
974
+ .spyOn(GlobalCache, "setStringIfNotExists")
975
+ .mockReturnValueOnce(lateFailure.promise);
976
+
977
+ const claimed: Promise<boolean> = claim(newActivity());
978
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS);
979
+ expect(await claimed).toBe(true);
980
+ expect(errorLog).toHaveBeenCalledTimes(1);
981
+
982
+ lateFailure.reject(new Error("Connection is closed."));
983
+ await flushPromises();
984
+
985
+ expect(errorLog).toHaveBeenCalledTimes(1);
986
+ expect(debugLog).not.toHaveBeenCalled();
987
+ });
988
+
989
+ test("a Redis that answers is not logged at all", async () => {
990
+ startClockOfItsOwn();
991
+ withRedisClient();
992
+ jest
993
+ .spyOn(GlobalCache, "setStringIfNotExists")
994
+ .mockResolvedValueOnce(true)
995
+ .mockResolvedValueOnce(false);
996
+ const activity: JSONObject = newActivity();
997
+
998
+ expect(await claim(activity)).toBe(true);
999
+ expect(await claim(activity)).toBe(false);
1000
+
1001
+ expect(errorLog).not.toHaveBeenCalled();
1002
+ expect(debugLog).not.toHaveBeenCalled();
1003
+ });
1004
+ });
1005
+
1006
+ /*
1007
+ * The fallback is logged from inside claim(): from the catch that takes a
1008
+ * Redis refusal, and from the timer that stands in for a Redis answer.
1009
+ * Redis.getClient is wrapped by @CaptureSpan just as GlobalCache is, so the
1010
+ * telemetry fault behind the synchronous throw above can make it throw as
1011
+ * well. Logging a fallback must not turn into a way for claim() to reject, or
1012
+ * for the timer to throw before it hands the decision to memory.
1013
+ */
1014
+ describe("MicrosoftTeamsActivityDeduplicator.claim when logging the fallback throws", () => {
1015
+ function getClientThrows(): void {
1016
+ jest.spyOn(Redis, "getClient").mockImplementation((): ClientType | null => {
1017
+ throw new Error("Could not start the span");
1018
+ });
1019
+ }
1020
+
1021
+ test("a claim Redis refused still resolves, from memory", async () => {
1022
+ getClientThrows();
1023
+ redisIsDown();
1024
+ const activity: JSONObject = newActivity();
1025
+
1026
+ await expect(claim(activity)).resolves.toBe(true);
1027
+ await expect(claim(activity)).resolves.toBe(false);
1028
+ });
1029
+
1030
+ test("a Redis that does not answer still leaves the decision to memory after two seconds", async () => {
1031
+ jest.useFakeTimers();
1032
+ getClientThrows();
1033
+ redisHangs();
1034
+
1035
+ let answer: boolean | undefined = undefined;
1036
+ void claim(newActivity()).then((value: boolean) => {
1037
+ answer = value;
1038
+ });
1039
+
1040
+ // On the real clock a throw here is an uncaught exception.
1041
+ expect(() => {
1042
+ jest.advanceTimersByTime(REDIS_CLAIM_TIMEOUT_IN_MS);
1043
+ }).not.toThrow();
1044
+ await flushPromises();
1045
+
1046
+ expect(answer).toBe(true);
1047
+ expect(jest.getTimerCount()).toBe(0);
1048
+ });
1049
+ });