@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,2251 @@
1
+ import DatabaseServer from "../../../Models/DatabaseModels/DatabaseServer";
2
+ import DockerHost from "../../../Models/DatabaseModels/DockerHost";
3
+ import ResourceAiAgent from "../../../Models/DatabaseModels/ResourceAiAgent";
4
+ import CephClusterFeedService from "../../../Server/Services/CephClusterFeedService";
5
+ import CephClusterService from "../../../Server/Services/CephClusterService";
6
+ import DatabaseServerFeedService from "../../../Server/Services/DatabaseServerFeedService";
7
+ import DatabaseServerService from "../../../Server/Services/DatabaseServerService";
8
+ import DockerHostFeedService from "../../../Server/Services/DockerHostFeedService";
9
+ import DockerHostService from "../../../Server/Services/DockerHostService";
10
+ import DockerSwarmClusterFeedService from "../../../Server/Services/DockerSwarmClusterFeedService";
11
+ import DockerSwarmClusterService from "../../../Server/Services/DockerSwarmClusterService";
12
+ import HostFeedService from "../../../Server/Services/HostFeedService";
13
+ import HostService from "../../../Server/Services/HostService";
14
+ import PodmanHostFeedService from "../../../Server/Services/PodmanHostFeedService";
15
+ import PodmanHostService from "../../../Server/Services/PodmanHostService";
16
+ import ProxmoxClusterFeedService from "../../../Server/Services/ProxmoxClusterFeedService";
17
+ import ProxmoxClusterService from "../../../Server/Services/ProxmoxClusterService";
18
+ import ResourceAiAgentService, {
19
+ MAX_NEW_RESOURCE_AI_AGENTS_PER_PROJECT_PER_HOUR,
20
+ MAX_RESOURCE_AI_AGENTS_PER_PROJECT,
21
+ MAX_RESOURCE_AI_AGENT_IDENTIFIER_LENGTH,
22
+ RESOURCE_AI_AGENT_RECLAIM_AFTER_DAYS,
23
+ RESOURCE_AI_AGENT_REGISTRATION_RETRY_AFTER_SECONDS,
24
+ RESOURCE_AI_AGENT_SELECT_WITHOUT_KEY,
25
+ ResourceAiAgentRegistrationRefusedException,
26
+ ResourceAiAgentRegistrationResult,
27
+ Service as ResourceAiAgentServiceClass,
28
+ } from "../../../Server/Services/ResourceAiAgentService";
29
+ import VMwareVCenterFeedService from "../../../Server/Services/VMwareVCenterFeedService";
30
+ import VMwareVCenterService from "../../../Server/Services/VMwareVCenterService";
31
+ import logger from "../../../Server/Utils/Logger";
32
+ import ColumnLength from "../../../Types/Database/ColumnLength";
33
+ import { DatabaseEndpoint } from "../../../Types/DatabaseServer/DatabaseEndpoint";
34
+ import DatabaseServerDiscoverySource from "../../../Types/DatabaseServer/DatabaseServerDiscoverySource";
35
+ import ForbiddenException from "../../../Types/Exception/ForbiddenException";
36
+ import { JSONObject } from "../../../Types/JSON";
37
+ import ObjectID from "../../../Types/ObjectID";
38
+ import PositiveNumber from "../../../Types/PositiveNumber";
39
+ import AiResourceType from "../../../Types/ResourceAiAgent/AiResourceType";
40
+ import {
41
+ MAX_POSTURE_STRING_LENGTH,
42
+ RESOURCE_AI_AGENT_ALIVE_WINDOW_IN_MINUTES,
43
+ ResourceAiAgentRegistrationRefusalReason,
44
+ ResourceAiRemediationMode,
45
+ isTransientResourceAiAgentRegistrationRefusal,
46
+ } from "../../../Types/ResourceAiAgent/ResourceAiAccess";
47
+ import { afterEach, describe, expect, jest, test } from "@jest/globals";
48
+ import { FindOperator } from "typeorm";
49
+
50
+ /*
51
+ * ResourceAiAgentService.register — how a resource AI agent gets its
52
+ * identity with nothing but the project's telemetry ingestion key, its
53
+ * resource type and the identity its collector reports:
54
+ *
55
+ * 1. an unknown resource type is refused (resource_type_invalid), and an
56
+ * empty or over-long identity (resource_identifier_invalid);
57
+ * 2. the resource is found or created through its own model's
58
+ * find-or-create (DockerHost / PodmanHost / Host by host identifier,
59
+ * the clusters and vCenters by name); a database by its pinned id (in
60
+ * this project, else resource_not_found) or its endpoint;
61
+ * 3. an existing agent row is re-keyed on proof of continuity, or when it
62
+ * is reset / signed off / offline; a live one is refused
63
+ * (previous_instance_online, transient, 20s) and the refusal recorded;
64
+ * 4. a new row is bounded per project (250) and per hour (30);
65
+ * 5. only the key's hash is stored, and the posture is stored with the
66
+ * resource type and the registered identity forced;
67
+ * 6. a resource nobody configured gets the first-connection defaults
68
+ * (investigation on; Ask for approval when the agent allows writes),
69
+ * never aiAccessConfiguredAt;
70
+ * 7. nothing is deleted or unbound, and one feed item on the resource's
71
+ * own feed marks the first connection.
72
+ *
73
+ * Everything below the service boundary is stubbed: no database.
74
+ */
75
+
76
+ interface SpyCalls {
77
+ mock: { calls: Array<Array<unknown>> };
78
+ }
79
+
80
+ type AnyObject = Record<string, any>;
81
+
82
+ const PROJECT_ID: ObjectID = new ObjectID(
83
+ "11111111-1111-4111-8111-111111111111",
84
+ );
85
+ const RESOURCE_ID: ObjectID = new ObjectID(
86
+ "22222222-2222-4222-8222-222222222222",
87
+ );
88
+ const AGENT_ID: ObjectID = new ObjectID("33333333-3333-4333-8333-333333333333");
89
+ const INGESTION_KEY_ID: ObjectID = new ObjectID(
90
+ "55555555-5555-4555-8555-555555555555",
91
+ );
92
+
93
+ const CURRENT_KEY: string = "ab".repeat(32);
94
+ const ALIVE_WINDOW_MS: number =
95
+ RESOURCE_AI_AGENT_ALIVE_WINDOW_IN_MINUTES * 60 * 1000;
96
+
97
+ function msAgo(ms: number): Date {
98
+ return new Date(Date.now() - ms);
99
+ }
100
+
101
+ // How each type's resource is found, and where its feed goes.
102
+ interface TypeFixture {
103
+ resourceType: AiResourceType;
104
+ alias: string;
105
+ identity: string;
106
+ service: AnyObject;
107
+ findOrCreateMethod: string;
108
+ findOrCreateIdentityKey: string;
109
+ feedService: AnyObject;
110
+ feedMethod: string;
111
+ feedIdKey: string;
112
+ }
113
+
114
+ const NAMED_TYPES: Array<TypeFixture> = [
115
+ {
116
+ resourceType: AiResourceType.DockerHost,
117
+ alias: "docker",
118
+ identity: "web-1",
119
+ service: DockerHostService as unknown as AnyObject,
120
+ findOrCreateMethod: "findOrCreateByHostIdentifier",
121
+ findOrCreateIdentityKey: "hostIdentifier",
122
+ feedService: DockerHostFeedService as unknown as AnyObject,
123
+ feedMethod: "createDockerHostFeedItem",
124
+ feedIdKey: "dockerHostId",
125
+ },
126
+ {
127
+ resourceType: AiResourceType.PodmanHost,
128
+ alias: "podman",
129
+ identity: "pod-host-1",
130
+ service: PodmanHostService as unknown as AnyObject,
131
+ findOrCreateMethod: "findOrCreateByHostIdentifier",
132
+ findOrCreateIdentityKey: "hostIdentifier",
133
+ feedService: PodmanHostFeedService as unknown as AnyObject,
134
+ feedMethod: "createPodmanHostFeedItem",
135
+ feedIdKey: "podmanHostId",
136
+ },
137
+ {
138
+ resourceType: AiResourceType.DockerSwarmCluster,
139
+ alias: "docker-swarm",
140
+ identity: "Prod-Swarm",
141
+ service: DockerSwarmClusterService as unknown as AnyObject,
142
+ findOrCreateMethod: "findOrCreateByName",
143
+ findOrCreateIdentityKey: "name",
144
+ feedService: DockerSwarmClusterFeedService as unknown as AnyObject,
145
+ feedMethod: "createDockerSwarmClusterFeedItem",
146
+ feedIdKey: "dockerSwarmClusterId",
147
+ },
148
+ {
149
+ resourceType: AiResourceType.ProxmoxCluster,
150
+ alias: "proxmox",
151
+ identity: "pve-lab",
152
+ service: ProxmoxClusterService as unknown as AnyObject,
153
+ findOrCreateMethod: "findOrCreateByName",
154
+ findOrCreateIdentityKey: "name",
155
+ feedService: ProxmoxClusterFeedService as unknown as AnyObject,
156
+ feedMethod: "createProxmoxClusterFeedItem",
157
+ feedIdKey: "proxmoxClusterId",
158
+ },
159
+ {
160
+ resourceType: AiResourceType.VMwareVCenter,
161
+ alias: "vmware",
162
+ identity: "vcenter-eu",
163
+ service: VMwareVCenterService as unknown as AnyObject,
164
+ findOrCreateMethod: "findOrCreateByName",
165
+ findOrCreateIdentityKey: "name",
166
+ feedService: VMwareVCenterFeedService as unknown as AnyObject,
167
+ feedMethod: "createVMwareVCenterFeedItem",
168
+ feedIdKey: "vmwareVCenterId",
169
+ },
170
+ {
171
+ resourceType: AiResourceType.CephCluster,
172
+ alias: "ceph",
173
+ identity: "ceph-prod",
174
+ service: CephClusterService as unknown as AnyObject,
175
+ findOrCreateMethod: "findOrCreateByName",
176
+ findOrCreateIdentityKey: "name",
177
+ feedService: CephClusterFeedService as unknown as AnyObject,
178
+ feedMethod: "createCephClusterFeedItem",
179
+ feedIdKey: "cephClusterId",
180
+ },
181
+ {
182
+ resourceType: AiResourceType.Host,
183
+ alias: "host",
184
+ identity: "node-7",
185
+ service: HostService as unknown as AnyObject,
186
+ findOrCreateMethod: "findOrCreateByHostIdentifier",
187
+ findOrCreateIdentityKey: "hostIdentifier",
188
+ feedService: HostFeedService as unknown as AnyObject,
189
+ feedMethod: "createHostFeedItem",
190
+ feedIdKey: "hostId",
191
+ },
192
+ ];
193
+
194
+ const DOCKER: TypeFixture = NAMED_TYPES[0]!;
195
+
196
+ // A resource row as findResource reads it.
197
+ function makeResourceRow(overrides: AnyObject = {}): AnyObject {
198
+ const row: DockerHost = new DockerHost();
199
+ row._id = RESOURCE_ID.toString();
200
+ row.projectId = PROJECT_ID;
201
+ row.name = "web-1";
202
+ row.isAiInvestigationEnabled = false;
203
+ row.aiRemediationMode = ResourceAiRemediationMode.Disabled;
204
+ Object.assign(row, overrides);
205
+ return row as unknown as AnyObject;
206
+ }
207
+
208
+ // An existing agent row as the registration lookup reads it (with its hash).
209
+ function makeAgent(overrides: AnyObject = {}): ResourceAiAgent {
210
+ const agent: ResourceAiAgent = new ResourceAiAgent();
211
+ agent.id = AGENT_ID;
212
+ agent.projectId = PROJECT_ID;
213
+ agent.resourceType = AiResourceType.DockerHost;
214
+ agent.resourceId = RESOURCE_ID;
215
+ agent.resourceIdentifier = "web-1";
216
+ agent.keyHash = ResourceAiAgentServiceClass.hashKey(CURRENT_KEY);
217
+ agent.connectionStatus = "connected";
218
+ agent.lastAliveAt = msAgo(20 * 1000);
219
+ agent.lastRegisteredAt = msAgo(60 * 60 * 1000);
220
+ agent.agentVersion = "14.1.0";
221
+ agent.posture = {
222
+ resourceType: AiResourceType.DockerHost,
223
+ resourceIdentifier: "web-1",
224
+ allowWrites: false,
225
+ writeTargets: [],
226
+ protectedTargets: [],
227
+ reachable: true,
228
+ };
229
+ Object.assign(agent, overrides);
230
+ return agent;
231
+ }
232
+
233
+ interface Harness {
234
+ findOrCreate: SpyCalls;
235
+ resourceFindOneBy: SpyCalls;
236
+ resourceUpdateOneBy: SpyCalls;
237
+ resourceUpdateOneById: SpyCalls;
238
+ resourceDeletes: Array<SpyCalls>;
239
+ feed: SpyCalls;
240
+ agentFindOneBy: SpyCalls;
241
+ agentCountBy: SpyCalls;
242
+ agentDeleteBy: SpyCalls;
243
+ agentCreate: SpyCalls;
244
+ agentUpdateOneById: SpyCalls;
245
+ agentUpdateColumns: SpyCalls;
246
+ }
247
+
248
+ interface HarnessOptions {
249
+ fixture?: TypeFixture;
250
+ resource?: AnyObject | null;
251
+ existing?: ResourceAiAgent | null;
252
+ totalAgents?: number;
253
+ // How many of totalAgents were not heard from in the reclaim window.
254
+ longOfflineAgents?: number;
255
+ agentsInLastHour?: number;
256
+ resourceUpdateCount?: number;
257
+ }
258
+
259
+ function silenceLogger(): void {
260
+ for (const level of ["info", "warn", "error", "debug"] as const) {
261
+ jest.spyOn(logger, level).mockImplementation((): void => {
262
+ return undefined;
263
+ });
264
+ }
265
+ }
266
+
267
+ function setUp(options: HarnessOptions = {}): Harness {
268
+ const fixture: TypeFixture = options.fixture || DOCKER;
269
+ const resource: AnyObject | null =
270
+ options.resource === undefined ? makeResourceRow() : options.resource;
271
+
272
+ silenceLogger();
273
+
274
+ const found: AnyObject = { _id: RESOURCE_ID.toString() };
275
+ Object.defineProperty(found, "id", { value: RESOURCE_ID });
276
+
277
+ let reclaimed: number = 0;
278
+
279
+ return {
280
+ findOrCreate: jest
281
+ .spyOn(fixture.service, fixture.findOrCreateMethod)
282
+ .mockResolvedValue(found as never) as unknown as SpyCalls,
283
+ resourceFindOneBy: jest
284
+ .spyOn(fixture.service, "findOneBy")
285
+ .mockResolvedValue(resource as never) as unknown as SpyCalls,
286
+ resourceUpdateOneBy: jest
287
+ .spyOn(fixture.service, "updateOneBy")
288
+ .mockResolvedValue(
289
+ (options.resourceUpdateCount ?? 1) as never,
290
+ ) as unknown as SpyCalls,
291
+ resourceUpdateOneById: jest
292
+ .spyOn(fixture.service, "updateOneById")
293
+ .mockResolvedValue(1 as never) as unknown as SpyCalls,
294
+ resourceDeletes: (
295
+ ["deleteBy", "deleteOneBy", "deleteOneById", "hardDeleteBy"] as const
296
+ ).map((method: string) => {
297
+ return jest
298
+ .spyOn(fixture.service, method)
299
+ .mockResolvedValue(0 as never) as unknown as SpyCalls;
300
+ }),
301
+ feed: jest
302
+ .spyOn(fixture.feedService, fixture.feedMethod)
303
+ .mockResolvedValue(undefined as never) as unknown as SpyCalls,
304
+ agentFindOneBy: jest
305
+ .spyOn(ResourceAiAgentService, "findOneBy")
306
+ .mockResolvedValue(
307
+ options.existing === undefined ? null : options.existing,
308
+ ) as unknown as SpyCalls,
309
+ agentCountBy: jest
310
+ .spyOn(ResourceAiAgentService, "countBy")
311
+ .mockImplementation(async (countBy: { query: AnyObject }) => {
312
+ return new PositiveNumber(
313
+ countBy.query["createdAt"] !== undefined
314
+ ? options.agentsInLastHour || 0
315
+ : (options.totalAgents || 0) - reclaimed,
316
+ );
317
+ }) as unknown as SpyCalls,
318
+ agentDeleteBy: jest
319
+ .spyOn(ResourceAiAgentService, "deleteBy")
320
+ .mockImplementation(async () => {
321
+ reclaimed = options.longOfflineAgents || 0;
322
+ return reclaimed;
323
+ }) as unknown as SpyCalls,
324
+ agentCreate: jest
325
+ .spyOn(ResourceAiAgentService, "create")
326
+ .mockImplementation(async (createBy: { data: ResourceAiAgent }) => {
327
+ const created: ResourceAiAgent = createBy.data;
328
+ created.id = AGENT_ID;
329
+ return created;
330
+ }) as unknown as SpyCalls,
331
+ agentUpdateOneById: jest
332
+ .spyOn(ResourceAiAgentService, "updateOneById")
333
+ .mockResolvedValue(undefined as never) as unknown as SpyCalls,
334
+ agentUpdateColumns: jest
335
+ .spyOn(ResourceAiAgentService, "updateColumnsByIdWithoutHooks")
336
+ .mockResolvedValue(undefined) as unknown as SpyCalls,
337
+ };
338
+ }
339
+
340
+ const DOCKER_POSTURE: JSONObject = {
341
+ resourceType: AiResourceType.DockerHost,
342
+ resourceIdentifier: "web-1",
343
+ agentVersion: "14.1.0",
344
+ allowWrites: false,
345
+ allowWritesSetting: "false",
346
+ writeTargets: [],
347
+ protectedTargets: ["oneuptime-ai-agent"],
348
+ toolVersion: "27.3.1",
349
+ reachable: true,
350
+ details: { dockerHost: "unix:///var/run/docker.sock" },
351
+ reportedAt: "2026-09-28T10:00:00.000Z",
352
+ };
353
+
354
+ function register(
355
+ overrides: Partial<{
356
+ resourceType: unknown;
357
+ resourceIdentifier: unknown;
358
+ resourceId: unknown;
359
+ agentVersion: string;
360
+ previousAgentKey: string;
361
+ posture: unknown;
362
+ ingestionKeyId: ObjectID;
363
+ }> = {},
364
+ ): Promise<ResourceAiAgentRegistrationResult> {
365
+ return ResourceAiAgentService.register({
366
+ projectId: PROJECT_ID,
367
+ resourceType: AiResourceType.DockerHost,
368
+ resourceIdentifier: "web-1",
369
+ agentVersion: "14.1.0",
370
+ posture: DOCKER_POSTURE,
371
+ ingestionKeyId: INGESTION_KEY_ID,
372
+ ...overrides,
373
+ });
374
+ }
375
+
376
+ async function refusalOf(
377
+ promise: Promise<unknown>,
378
+ ): Promise<ResourceAiAgentRegistrationRefusedException> {
379
+ try {
380
+ await promise;
381
+ } catch (error) {
382
+ if (error instanceof ResourceAiAgentRegistrationRefusedException) {
383
+ return error;
384
+ }
385
+
386
+ throw error;
387
+ }
388
+
389
+ throw new Error("Expected the registration to be refused");
390
+ }
391
+
392
+ function createdRow(harness: Harness): ResourceAiAgent {
393
+ expect(harness.agentCreate.mock.calls).toHaveLength(1);
394
+ return (harness.agentCreate.mock.calls[0]![0] as { data: ResourceAiAgent })
395
+ .data;
396
+ }
397
+
398
+ function reKeyWrite(harness: Harness): {
399
+ id: ObjectID;
400
+ data: AnyObject;
401
+ props: AnyObject;
402
+ } {
403
+ expect(harness.agentUpdateOneById.mock.calls).toHaveLength(1);
404
+ return harness.agentUpdateOneById.mock.calls[0]![0] as {
405
+ id: ObjectID;
406
+ data: AnyObject;
407
+ props: AnyObject;
408
+ };
409
+ }
410
+
411
+ function nothingWrittenToTheAgentTable(harness: Harness): void {
412
+ expect(harness.agentCreate.mock.calls).toHaveLength(0);
413
+ expect(harness.agentUpdateOneById.mock.calls).toHaveLength(0);
414
+ }
415
+
416
+ afterEach(() => {
417
+ jest.restoreAllMocks();
418
+ });
419
+
420
+ describe("register: a resource's first agent", () => {
421
+ test("creates the row and returns the key, storing only the key's sha256", async () => {
422
+ const harness: Harness = setUp();
423
+
424
+ const result: ResourceAiAgentRegistrationResult = await register();
425
+
426
+ expect(result.admission).toBe("created");
427
+ expect(result.agentId.toString()).toBe(AGENT_ID.toString());
428
+ expect(result.resourceType).toBe(AiResourceType.DockerHost);
429
+ expect(result.resourceId.toString()).toBe(RESOURCE_ID.toString());
430
+ expect(result.resourceName).toBe("web-1");
431
+ expect(result.agentKey).toMatch(/^[0-9a-f]{64}$/);
432
+
433
+ const row: ResourceAiAgent = createdRow(harness);
434
+ expect(row.projectId!.toString()).toBe(PROJECT_ID.toString());
435
+ expect(row.resourceType).toBe(AiResourceType.DockerHost);
436
+ expect(row.resourceId!.toString()).toBe(RESOURCE_ID.toString());
437
+ expect(row.resourceIdentifier).toBe("web-1");
438
+ expect(row.keyHash).toBe(
439
+ ResourceAiAgentServiceClass.hashKey(result.agentKey),
440
+ );
441
+ expect(Object.values(row)).not.toContain(result.agentKey);
442
+
443
+ expect(
444
+ (harness.agentCreate.mock.calls[0]![0] as { props: AnyObject }).props[
445
+ "isRoot"
446
+ ],
447
+ ).toBe(true);
448
+ });
449
+
450
+ test("the new row is connected, alive and registered now, with the agent's version and the ingestion key that minted it", async () => {
451
+ const harness: Harness = setUp();
452
+ const before: number = Date.now();
453
+
454
+ await register();
455
+
456
+ const row: ResourceAiAgent = createdRow(harness);
457
+ expect(row.connectionStatus).toBe("connected");
458
+ expect(row.lastAliveAt!.getTime()).toBeGreaterThanOrEqual(before);
459
+ expect(row.lastRegisteredAt!.getTime()).toBeGreaterThanOrEqual(before);
460
+ expect(row.agentVersion).toBe("14.1.0");
461
+ expect(row.registeredWithIngestionKeyId!.toString()).toBe(
462
+ INGESTION_KEY_ID.toString(),
463
+ );
464
+ });
465
+
466
+ test("a registration without an ingestion key id or version leaves them unset", async () => {
467
+ const harness: Harness = setUp();
468
+
469
+ await ResourceAiAgentService.register({
470
+ projectId: PROJECT_ID,
471
+ resourceType: AiResourceType.DockerHost,
472
+ resourceIdentifier: "web-1",
473
+ });
474
+
475
+ const row: ResourceAiAgent = createdRow(harness);
476
+ expect(row.registeredWithIngestionKeyId).toBeUndefined();
477
+ expect(row.agentVersion).toBeUndefined();
478
+ });
479
+
480
+ test("every registration mints a different key", async () => {
481
+ setUp();
482
+
483
+ const first: ResourceAiAgentRegistrationResult = await register();
484
+ const second: ResourceAiAgentRegistrationResult = await register();
485
+
486
+ expect(first.agentKey).not.toBe(second.agentKey);
487
+ });
488
+
489
+ test("the resource is named by its row, else by the identity", async () => {
490
+ setUp({ resource: makeResourceRow({ name: "Web server one" }) });
491
+ expect((await register()).resourceName).toBe("Web server one");
492
+ jest.restoreAllMocks();
493
+
494
+ setUp({ resource: makeResourceRow({ name: "" }) });
495
+ expect((await register()).resourceName).toBe("web-1");
496
+ });
497
+
498
+ test("looks the agent row up by project, type and resource, as root, selecting the key hash", async () => {
499
+ const harness: Harness = setUp();
500
+
501
+ await register();
502
+
503
+ const lookup: AnyObject = harness.agentFindOneBy.mock
504
+ .calls[0]![0] as AnyObject;
505
+
506
+ expect(lookup["query"]["projectId"]).toBe(PROJECT_ID);
507
+ expect(lookup["query"]["resourceType"]).toBe(AiResourceType.DockerHost);
508
+ expect(String(lookup["query"]["resourceId"])).toBe(RESOURCE_ID.toString());
509
+ expect(lookup["select"]).toEqual({
510
+ ...RESOURCE_AI_AGENT_SELECT_WITHOUT_KEY,
511
+ keyHash: true,
512
+ });
513
+ expect(lookup["props"]["isRoot"]).toBe(true);
514
+ });
515
+
516
+ test("the version is trimmed and bounded to its column", async () => {
517
+ const harness: Harness = setUp();
518
+
519
+ await register({ agentVersion: ` ${"9".repeat(300)} ` });
520
+
521
+ expect(createdRow(harness).agentVersion).toBe(
522
+ "9".repeat(ColumnLength.ShortText),
523
+ );
524
+ });
525
+ });
526
+
527
+ describe("register: resolving the resource of every named type", () => {
528
+ test.each(NAMED_TYPES)(
529
+ "$resourceType: found or created by $findOrCreateMethod with the trimmed identity, in the registering project, then re-read as root",
530
+ async (fixture: TypeFixture) => {
531
+ const harness: Harness = setUp({ fixture });
532
+
533
+ const result: ResourceAiAgentRegistrationResult = await register({
534
+ resourceType: fixture.resourceType,
535
+ resourceIdentifier: ` ${fixture.identity} `,
536
+ posture: {
537
+ resourceType: "somethingElse",
538
+ resourceIdentifier: "someone-else",
539
+ allowWrites: false,
540
+ },
541
+ });
542
+
543
+ expect(harness.findOrCreate.mock.calls[0]![0]).toEqual({
544
+ projectId: PROJECT_ID,
545
+ [fixture.findOrCreateIdentityKey]: fixture.identity,
546
+ });
547
+
548
+ const read: AnyObject = harness.resourceFindOneBy.mock
549
+ .calls[0]![0] as AnyObject;
550
+ expect(read["query"]["_id"]).toBe(RESOURCE_ID.toString());
551
+ expect(read["query"]["projectId"]).toBe(PROJECT_ID);
552
+ expect(read["select"]["aiAccessConfiguredAt"]).toBe(true);
553
+ expect(read["select"]["isAiInvestigationEnabled"]).toBe(true);
554
+ expect(read["select"]["aiRemediationMode"]).toBe(true);
555
+ expect(read["props"]["isRoot"]).toBe(true);
556
+
557
+ expect(result.resourceType).toBe(fixture.resourceType);
558
+ expect(result.resourceId.toString()).toBe(RESOURCE_ID.toString());
559
+
560
+ const row: ResourceAiAgent = createdRow(harness);
561
+ expect(row.resourceType).toBe(fixture.resourceType);
562
+ expect(row.resourceIdentifier).toBe(fixture.identity);
563
+ expect(row.posture!["resourceType"]).toBe(fixture.resourceType);
564
+ expect(row.posture!["resourceIdentifier"]).toBe(fixture.identity);
565
+
566
+ // The first connection is on the resource's own feed.
567
+ expect(harness.feed.mock.calls).toHaveLength(1);
568
+ expect(
569
+ String(
570
+ (harness.feed.mock.calls[0]![0] as AnyObject)[fixture.feedIdKey],
571
+ ),
572
+ ).toBe(RESOURCE_ID.toString());
573
+ },
574
+ );
575
+
576
+ test.each(NAMED_TYPES)(
577
+ "$resourceType is also accepted by its agent alias ($alias), in any case",
578
+ async (fixture: TypeFixture) => {
579
+ const harness: Harness = setUp({ fixture });
580
+
581
+ const result: ResourceAiAgentRegistrationResult = await register({
582
+ resourceType: ` ${fixture.alias.toUpperCase()} `,
583
+ resourceIdentifier: fixture.identity,
584
+ });
585
+
586
+ expect(result.resourceType).toBe(fixture.resourceType);
587
+ expect(harness.findOrCreate.mock.calls).toHaveLength(1);
588
+ },
589
+ );
590
+
591
+ test("a resourceId sent for a named type is not trusted: the identity decides", async () => {
592
+ const harness: Harness = setUp();
593
+
594
+ await register({ resourceId: "99999999-9999-4999-8999-999999999999" });
595
+
596
+ expect(harness.findOrCreate.mock.calls).toHaveLength(1);
597
+ expect(createdRow(harness).resourceId!.toString()).toBe(
598
+ RESOURCE_ID.toString(),
599
+ );
600
+ });
601
+
602
+ test("a resource that cannot be read back after find-or-create is an error, and nothing is written", async () => {
603
+ const harness: Harness = setUp({ resource: null });
604
+
605
+ await expect(register()).rejects.toThrow("could not be resolved");
606
+ nothingWrittenToTheAgentTable(harness);
607
+ });
608
+
609
+ /*
610
+ * HostService memoizes a host's id for a minute, and a delete does not
611
+ * clear the memo: an agent registering again right after its host was
612
+ * deleted gets the deleted id first. It is resolved once more past the
613
+ * memo (which creates the host anew), not refused for five minutes.
614
+ */
615
+ test("a resource deleted since its id was memoized is resolved once more, past the memo", async () => {
616
+ const HOST: TypeFixture = NAMED_TYPES.find((fixture: TypeFixture) => {
617
+ return fixture.resourceType === AiResourceType.Host;
618
+ })!;
619
+ const harness: Harness = setUp({ fixture: HOST });
620
+ const recreatedId: ObjectID = new ObjectID(
621
+ "66666666-6666-4666-8666-666666666666",
622
+ );
623
+ const recreated: AnyObject = { _id: recreatedId.toString() };
624
+ Object.defineProperty(recreated, "id", { value: recreatedId });
625
+ const stale: AnyObject = { _id: RESOURCE_ID.toString() };
626
+ Object.defineProperty(stale, "id", { value: RESOURCE_ID });
627
+
628
+ (
629
+ harness.findOrCreate as unknown as {
630
+ mockImplementation: (
631
+ fn: (data: AnyObject) => Promise<AnyObject>,
632
+ ) => void;
633
+ }
634
+ ).mockImplementation(async (data: AnyObject) => {
635
+ return data["bypassMemo"] ? recreated : stale;
636
+ });
637
+ (
638
+ harness.resourceFindOneBy as unknown as {
639
+ mockImplementation: (
640
+ fn: (findBy: AnyObject) => Promise<AnyObject | null>,
641
+ ) => void;
642
+ }
643
+ ).mockImplementation(async (findBy: AnyObject) => {
644
+ return findBy["query"]["_id"] === recreatedId.toString()
645
+ ? makeResourceRow({ _id: recreatedId.toString(), name: "node-7" })
646
+ : null;
647
+ });
648
+
649
+ const result: ResourceAiAgentRegistrationResult = await register({
650
+ resourceType: AiResourceType.Host,
651
+ resourceIdentifier: "node-7",
652
+ });
653
+
654
+ expect(harness.findOrCreate.mock.calls).toHaveLength(2);
655
+ expect(harness.findOrCreate.mock.calls[0]![0]).toEqual({
656
+ projectId: PROJECT_ID,
657
+ hostIdentifier: "node-7",
658
+ });
659
+ expect(harness.findOrCreate.mock.calls[1]![0]).toEqual({
660
+ projectId: PROJECT_ID,
661
+ hostIdentifier: "node-7",
662
+ bypassMemo: true,
663
+ });
664
+ expect(result.resourceId.toString()).toBe(recreatedId.toString());
665
+ expect(createdRow(harness).resourceId!.toString()).toBe(
666
+ recreatedId.toString(),
667
+ );
668
+ });
669
+
670
+ test("a find-or-create failure is not dressed up as a refusal", async () => {
671
+ const harness: Harness = setUp();
672
+ (
673
+ harness.findOrCreate as unknown as {
674
+ mockRejectedValue: (error: Error) => void;
675
+ }
676
+ ).mockRejectedValue(new Error("database is down"));
677
+
678
+ await expect(register()).rejects.toThrow("database is down");
679
+ nothingWrittenToTheAgentTable(harness);
680
+ });
681
+
682
+ test("registration never deletes or renames the resource", async () => {
683
+ const harness: Harness = setUp();
684
+
685
+ await register();
686
+
687
+ for (const spy of harness.resourceDeletes) {
688
+ expect(spy.mock.calls).toHaveLength(0);
689
+ }
690
+ expect(harness.resourceUpdateOneById.mock.calls).toHaveLength(0);
691
+ });
692
+ });
693
+
694
+ describe("register: resource_type_invalid", () => {
695
+ test.each([
696
+ ["no type", undefined],
697
+ ["an empty type", " "],
698
+ ["an unknown type", "KubernetesCluster"],
699
+ ["a number", 7],
700
+ ["an object", { type: "docker" }],
701
+ ])(
702
+ "%s is refused before anything is read or written",
703
+ async (_label: string, resourceType: unknown) => {
704
+ const harness: Harness = setUp();
705
+
706
+ const refusal: ResourceAiAgentRegistrationRefusedException =
707
+ await refusalOf(register({ resourceType }));
708
+
709
+ expect(refusal.reason).toBe("resource_type_invalid");
710
+ expect(refusal.retryAfterSeconds).toBeUndefined();
711
+ expect(refusal.message).toContain("ONEUPTIME_AI_AGENT_RESOURCE_TYPE");
712
+ expect(refusal.message).toContain("docker, podman, docker-swarm");
713
+ expect(harness.findOrCreate.mock.calls).toHaveLength(0);
714
+ nothingWrittenToTheAgentTable(harness);
715
+ },
716
+ );
717
+
718
+ test("is a 403 refusal that needs an operator", async () => {
719
+ setUp();
720
+
721
+ const refusal: ResourceAiAgentRegistrationRefusedException =
722
+ await refusalOf(register({ resourceType: "mainframe" }));
723
+
724
+ expect(refusal).toBeInstanceOf(ForbiddenException);
725
+ expect(refusal.message).toContain('"mainframe"');
726
+ expect(isTransientResourceAiAgentRegistrationRefusal(refusal.reason)).toBe(
727
+ false,
728
+ );
729
+ });
730
+ });
731
+
732
+ describe("register: resource_identifier_invalid", () => {
733
+ test.each([
734
+ ["an empty identity", ""],
735
+ ["a blank identity", " "],
736
+ ["no identity", undefined],
737
+ ["a number", 42],
738
+ ["an object", { name: "web-1" }],
739
+ ])(
740
+ "%s is refused before anything is read or written, naming the variables to set",
741
+ async (_label: string, resourceIdentifier: unknown) => {
742
+ const harness: Harness = setUp();
743
+
744
+ const refusal: ResourceAiAgentRegistrationRefusedException =
745
+ await refusalOf(register({ resourceIdentifier }));
746
+
747
+ expect(refusal.reason).toBe("resource_identifier_invalid");
748
+ expect(refusal.retryAfterSeconds).toBeUndefined();
749
+ expect(refusal.message).toContain("DOCKER_HOST_NAME");
750
+ expect(refusal.message).toContain("ONEUPTIME_AI_AGENT_RESOURCE_NAME");
751
+ expect(harness.findOrCreate.mock.calls).toHaveLength(0);
752
+ nothingWrittenToTheAgentTable(harness);
753
+ },
754
+ );
755
+
756
+ test("an identity longer than the resource's identity column is refused, saying the limit", async () => {
757
+ const harness: Harness = setUp();
758
+
759
+ const refusal: ResourceAiAgentRegistrationRefusedException =
760
+ await refusalOf(
761
+ register({
762
+ resourceIdentifier: "h".repeat(
763
+ MAX_RESOURCE_AI_AGENT_IDENTIFIER_LENGTH + 1,
764
+ ),
765
+ }),
766
+ );
767
+
768
+ expect(refusal.reason).toBe("resource_identifier_invalid");
769
+ expect(refusal.message).toContain(
770
+ String(MAX_RESOURCE_AI_AGENT_IDENTIFIER_LENGTH),
771
+ );
772
+ expect(harness.findOrCreate.mock.calls).toHaveLength(0);
773
+ });
774
+
775
+ test("an identity exactly at the limit is accepted", async () => {
776
+ const harness: Harness = setUp();
777
+
778
+ await register({
779
+ resourceIdentifier: "h".repeat(MAX_RESOURCE_AI_AGENT_IDENTIFIER_LENGTH),
780
+ });
781
+
782
+ expect(harness.agentCreate.mock.calls).toHaveLength(1);
783
+ });
784
+ });
785
+
786
+ describe("register: a database server", () => {
787
+ const DB_ID: ObjectID = RESOURCE_ID;
788
+
789
+ interface DatabaseHarness extends Harness {
790
+ findByIdInProject: SpyCalls;
791
+ findOrCreateByEndpoint: SpyCalls;
792
+ }
793
+
794
+ function dbRow(): DatabaseServer {
795
+ const row: DatabaseServer = new DatabaseServer();
796
+ row._id = DB_ID.toString();
797
+ row.projectId = PROJECT_ID;
798
+ row.name = "PostgreSQL db.prod.example.com:5432";
799
+ row.isAiInvestigationEnabled = false;
800
+ row.aiRemediationMode = ResourceAiRemediationMode.Disabled;
801
+ return row;
802
+ }
803
+
804
+ function setUpDatabase(
805
+ options: {
806
+ byId?: DatabaseServer | null;
807
+ byEndpoint?: DatabaseServer | null;
808
+ existing?: ResourceAiAgent | null;
809
+ } = {},
810
+ ): DatabaseHarness {
811
+ silenceLogger();
812
+
813
+ return {
814
+ findOrCreate: { mock: { calls: [] } },
815
+ findByIdInProject: jest
816
+ .spyOn(DatabaseServerService, "findByIdInProject")
817
+ .mockResolvedValue(
818
+ options.byId === undefined ? dbRow() : options.byId,
819
+ ) as unknown as SpyCalls,
820
+ findOrCreateByEndpoint: jest
821
+ .spyOn(DatabaseServerService, "findOrCreateByEndpoint")
822
+ .mockResolvedValue(
823
+ options.byEndpoint === undefined ? dbRow() : options.byEndpoint,
824
+ ) as unknown as SpyCalls,
825
+ resourceFindOneBy: jest
826
+ .spyOn(DatabaseServerService, "findOneBy")
827
+ .mockResolvedValue(dbRow()) as unknown as SpyCalls,
828
+ resourceUpdateOneBy: jest
829
+ .spyOn(DatabaseServerService, "updateOneBy")
830
+ .mockResolvedValue(1) as unknown as SpyCalls,
831
+ resourceUpdateOneById: jest
832
+ .spyOn(DatabaseServerService, "updateOneById")
833
+ .mockResolvedValue(undefined as never) as unknown as SpyCalls,
834
+ resourceDeletes: [],
835
+ feed: jest
836
+ .spyOn(DatabaseServerFeedService, "createDatabaseServerFeedItem")
837
+ .mockResolvedValue(undefined) as unknown as SpyCalls,
838
+ agentFindOneBy: jest
839
+ .spyOn(ResourceAiAgentService, "findOneBy")
840
+ .mockResolvedValue(
841
+ options.existing === undefined ? null : options.existing,
842
+ ) as unknown as SpyCalls,
843
+ agentCountBy: jest
844
+ .spyOn(ResourceAiAgentService, "countBy")
845
+ .mockResolvedValue(new PositiveNumber(0)) as unknown as SpyCalls,
846
+ agentDeleteBy: jest
847
+ .spyOn(ResourceAiAgentService, "deleteBy")
848
+ .mockResolvedValue(0) as unknown as SpyCalls,
849
+ agentCreate: jest
850
+ .spyOn(ResourceAiAgentService, "create")
851
+ .mockImplementation(async (createBy: { data: ResourceAiAgent }) => {
852
+ createBy.data.id = AGENT_ID;
853
+ return createBy.data;
854
+ }) as unknown as SpyCalls,
855
+ agentUpdateOneById: jest
856
+ .spyOn(ResourceAiAgentService, "updateOneById")
857
+ .mockResolvedValue(undefined as never) as unknown as SpyCalls,
858
+ agentUpdateColumns: jest
859
+ .spyOn(ResourceAiAgentService, "updateColumnsByIdWithoutHooks")
860
+ .mockResolvedValue(undefined) as unknown as SpyCalls,
861
+ };
862
+ }
863
+
864
+ function registerDatabase(
865
+ overrides: Partial<{
866
+ resourceIdentifier: unknown;
867
+ resourceId: unknown;
868
+ posture: unknown;
869
+ }> = {},
870
+ ): Promise<ResourceAiAgentRegistrationResult> {
871
+ return ResourceAiAgentService.register({
872
+ projectId: PROJECT_ID,
873
+ resourceType: "database",
874
+ resourceIdentifier: "postgres|db.prod.example.com:5432",
875
+ posture: {
876
+ allowWrites: false,
877
+ reachable: true,
878
+ details: {
879
+ databaseSystem: "postgres",
880
+ serverAddress: "db.prod.example.com",
881
+ serverPort: 5432,
882
+ },
883
+ },
884
+ ...overrides,
885
+ });
886
+ }
887
+
888
+ function endpointCall(harness: DatabaseHarness): AnyObject {
889
+ expect(harness.findOrCreateByEndpoint.mock.calls).toHaveLength(1);
890
+ return harness.findOrCreateByEndpoint.mock.calls[0]![0] as AnyObject;
891
+ }
892
+
893
+ test("a pinned DATABASE_SERVER_ID that is this project's: that row, and never the endpoint", async () => {
894
+ const harness: DatabaseHarness = setUpDatabase();
895
+
896
+ const result: ResourceAiAgentRegistrationResult = await registerDatabase({
897
+ resourceIdentifier: DB_ID.toString(),
898
+ resourceId: DB_ID.toString().toUpperCase(),
899
+ });
900
+
901
+ expect(result.resourceType).toBe(AiResourceType.DatabaseServer);
902
+ expect(result.resourceId.toString()).toBe(DB_ID.toString());
903
+ expect(result.resourceName).toBe("PostgreSQL db.prod.example.com:5432");
904
+
905
+ expect(harness.findByIdInProject.mock.calls[0]![0]).toBe(PROJECT_ID);
906
+ expect(String(harness.findByIdInProject.mock.calls[0]![1])).toBe(
907
+ DB_ID.toString(),
908
+ );
909
+ expect(harness.findOrCreateByEndpoint.mock.calls).toHaveLength(0);
910
+
911
+ // The identity is the agent's own: the pinned id.
912
+ expect(createdRow(harness).resourceIdentifier).toBe(DB_ID.toString());
913
+ });
914
+
915
+ test("an identity that is itself a database id pins that row too", async () => {
916
+ const harness: DatabaseHarness = setUpDatabase();
917
+
918
+ await registerDatabase({ resourceIdentifier: DB_ID.toString() });
919
+
920
+ expect(harness.findByIdInProject.mock.calls).toHaveLength(1);
921
+ expect(harness.findOrCreateByEndpoint.mock.calls).toHaveLength(0);
922
+ });
923
+
924
+ test("a pinned id that is not this project's is refused (resource_not_found), with no fallback to the endpoint", async () => {
925
+ const harness: DatabaseHarness = setUpDatabase({ byId: null });
926
+
927
+ const refusal: ResourceAiAgentRegistrationRefusedException =
928
+ await refusalOf(registerDatabase({ resourceId: DB_ID.toString() }));
929
+
930
+ expect(refusal.reason).toBe("resource_not_found");
931
+ expect(refusal.retryAfterSeconds).toBeUndefined();
932
+ expect(refusal.message).toContain("DATABASE_SERVER_ID");
933
+ expect(refusal.message).toContain(DB_ID.toString());
934
+ expect(harness.findOrCreateByEndpoint.mock.calls).toHaveLength(0);
935
+ nothingWrittenToTheAgentTable(harness);
936
+ });
937
+
938
+ test("a pinned id that is not a UUID is refused without a lookup", async () => {
939
+ const harness: DatabaseHarness = setUpDatabase();
940
+
941
+ const refusal: ResourceAiAgentRegistrationRefusedException =
942
+ await refusalOf(registerDatabase({ resourceId: "orders-db" }));
943
+
944
+ expect(refusal.reason).toBe("resource_not_found");
945
+ expect(harness.findByIdInProject.mock.calls).toHaveLength(0);
946
+ expect(harness.findOrCreateByEndpoint.mock.calls).toHaveLength(0);
947
+ });
948
+
949
+ test("an endpoint identity: canonicalized like the Database Agent's telemetry (engine as reported, collector source), found or created", async () => {
950
+ const harness: DatabaseHarness = setUpDatabase();
951
+
952
+ const result: ResourceAiAgentRegistrationResult = await registerDatabase();
953
+
954
+ const call: AnyObject = endpointCall(harness);
955
+ expect(call["projectId"]).toBe(PROJECT_ID);
956
+ expect(call["dbSystem"]).toBe("postgresql");
957
+ expect(call["endpoint"] as DatabaseEndpoint).toEqual({
958
+ host: "db.prod.example.com",
959
+ port: 5432,
960
+ });
961
+ expect(call["discoverySource"]).toBe(
962
+ DatabaseServerDiscoverySource.Collector,
963
+ );
964
+ expect(call["allowCreate"]).toBe(true);
965
+ expect(call["displayName"]).toContain("db.prod.example.com");
966
+ expect(harness.findByIdInProject.mock.calls).toHaveLength(0);
967
+
968
+ expect(result.resourceId.toString()).toBe(DB_ID.toString());
969
+ // The agent row keeps the agent's own spelling of its identity.
970
+ expect(createdRow(harness).resourceIdentifier).toBe(
971
+ "postgres|db.prod.example.com:5432",
972
+ );
973
+ });
974
+
975
+ /*
976
+ * A registration (every restart, every refused retry of a duplicate) is
977
+ * no sign the database is in use: it must not restore a database
978
+ * discovery archived for going quiet, as a collector's report does.
979
+ */
980
+ test("an endpoint lookup is not a sighting of the database", async () => {
981
+ const harness: DatabaseHarness = setUpDatabase();
982
+
983
+ await registerDatabase();
984
+
985
+ expect(endpointCall(harness)["isSighting"]).toBe(false);
986
+ });
987
+
988
+ test("an endpoint without a port takes the engine's default port", async () => {
989
+ const harness: DatabaseHarness = setUpDatabase();
990
+
991
+ await registerDatabase({
992
+ resourceIdentifier: "mysql|db.prod.example.com",
993
+ posture: {},
994
+ });
995
+
996
+ expect(endpointCall(harness)["endpoint"]).toEqual({
997
+ host: "db.prod.example.com",
998
+ port: 3306,
999
+ });
1000
+ });
1001
+
1002
+ test("an IPv6 endpoint in brackets is read with its port", async () => {
1003
+ const harness: DatabaseHarness = setUpDatabase();
1004
+
1005
+ await registerDatabase({
1006
+ resourceIdentifier: "postgresql|[2001:db8::10]:6432",
1007
+ posture: {},
1008
+ });
1009
+
1010
+ expect(endpointCall(harness)["endpoint"]).toEqual({
1011
+ host: "2001:db8::10",
1012
+ port: 6432,
1013
+ });
1014
+ });
1015
+
1016
+ test("an identity that is not an endpoint falls back to the posture's details", async () => {
1017
+ const harness: DatabaseHarness = setUpDatabase();
1018
+
1019
+ await registerDatabase({ resourceIdentifier: "orders-db" });
1020
+
1021
+ expect(endpointCall(harness)["endpoint"]).toEqual({
1022
+ host: "db.prod.example.com",
1023
+ port: 5432,
1024
+ });
1025
+ });
1026
+
1027
+ test("an identity that names neither an id nor an endpoint (and no details) is refused as resource_identifier_invalid", async () => {
1028
+ const harness: DatabaseHarness = setUpDatabase();
1029
+
1030
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1031
+ await refusalOf(
1032
+ registerDatabase({ resourceIdentifier: "orders-db", posture: {} }),
1033
+ );
1034
+
1035
+ expect(refusal.reason).toBe("resource_identifier_invalid");
1036
+ expect(refusal.message).toContain("DATABASE_SYSTEM");
1037
+ expect(refusal.message).toContain("DATABASE_SERVER_ID");
1038
+ expect(harness.findOrCreateByEndpoint.mock.calls).toHaveLength(0);
1039
+ nothingWrittenToTheAgentTable(harness);
1040
+ });
1041
+
1042
+ test("a loopback address names no server: resource_not_found, pointing at DATABASE_SERVER_ADDRESS", async () => {
1043
+ const harness: DatabaseHarness = setUpDatabase();
1044
+
1045
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1046
+ await refusalOf(
1047
+ registerDatabase({
1048
+ resourceIdentifier: "postgresql|localhost:5432",
1049
+ posture: {},
1050
+ }),
1051
+ );
1052
+
1053
+ expect(refusal.reason).toBe("resource_not_found");
1054
+ expect(refusal.message).toContain("DATABASE_SERVER_ADDRESS");
1055
+ expect(harness.findOrCreateByEndpoint.mock.calls).toHaveLength(0);
1056
+ });
1057
+
1058
+ test("a local-scope endpoint may only join an existing row (allowCreate false)", async () => {
1059
+ const harness: DatabaseHarness = setUpDatabase();
1060
+
1061
+ await registerDatabase({
1062
+ resourceIdentifier: "postgresql|orders-db:5432",
1063
+ posture: {},
1064
+ });
1065
+
1066
+ expect(endpointCall(harness)["allowCreate"]).toBe(false);
1067
+ });
1068
+
1069
+ test("an endpoint no row owns and none may be created for is refused (resource_not_found)", async () => {
1070
+ const harness: DatabaseHarness = setUpDatabase({ byEndpoint: null });
1071
+
1072
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1073
+ await refusalOf(registerDatabase());
1074
+
1075
+ expect(refusal.reason).toBe("resource_not_found");
1076
+ expect(refusal.message).toContain("db.prod.example.com:5432");
1077
+ expect(refusal.message).toContain("DATABASE_SERVER_ID");
1078
+ expect(isTransientResourceAiAgentRegistrationRefusal(refusal.reason)).toBe(
1079
+ false,
1080
+ );
1081
+ nothingWrittenToTheAgentTable(harness);
1082
+ });
1083
+
1084
+ test("an endpoint identity up to the posture's limit is accepted; one over it is refused", async () => {
1085
+ setUpDatabase();
1086
+
1087
+ const host: string = `${"a".repeat(60)}.${"b".repeat(60)}.${"c".repeat(
1088
+ 60,
1089
+ )}.example.com`;
1090
+ const identity: string = `postgresql|${host}`.padEnd(
1091
+ MAX_POSTURE_STRING_LENGTH,
1092
+ "",
1093
+ );
1094
+
1095
+ await expect(
1096
+ registerDatabase({ resourceIdentifier: identity, posture: {} }),
1097
+ ).resolves.toBeDefined();
1098
+
1099
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1100
+ await refusalOf(
1101
+ registerDatabase({
1102
+ resourceIdentifier: `postgresql|${"d".repeat(MAX_POSTURE_STRING_LENGTH)}`,
1103
+ }),
1104
+ );
1105
+
1106
+ expect(refusal.reason).toBe("resource_identifier_invalid");
1107
+ expect(refusal.message).toContain(String(MAX_POSTURE_STRING_LENGTH));
1108
+ });
1109
+
1110
+ test("the first connection is on the database's own feed", async () => {
1111
+ const harness: DatabaseHarness = setUpDatabase();
1112
+
1113
+ await registerDatabase();
1114
+
1115
+ expect(harness.feed.mock.calls).toHaveLength(1);
1116
+ const item: AnyObject = harness.feed.mock.calls[0]![0] as AnyObject;
1117
+ expect(String(item["databaseServerId"])).toBe(DB_ID.toString());
1118
+ expect(String(item["feedInfoInMarkdown"])).toContain(
1119
+ "The Database AI agent connected (read-only).",
1120
+ );
1121
+ expect(String(item["feedInfoInMarkdown"])).toContain(
1122
+ "investigate this database server",
1123
+ );
1124
+ });
1125
+
1126
+ test("the defaults are written to the database server's row", async () => {
1127
+ const harness: DatabaseHarness = setUpDatabase();
1128
+
1129
+ await registerDatabase();
1130
+
1131
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(1);
1132
+ expect(
1133
+ (harness.resourceUpdateOneBy.mock.calls[0]![0] as AnyObject)["data"],
1134
+ ).toEqual({ isAiInvestigationEnabled: true });
1135
+ });
1136
+ });
1137
+
1138
+ describe("register: the stored posture", () => {
1139
+ test("keeps what the agent reported, validated, and forces the type and the registered identity", async () => {
1140
+ const harness: Harness = setUp();
1141
+
1142
+ await register({
1143
+ posture: {
1144
+ ...DOCKER_POSTURE,
1145
+ resourceType: AiResourceType.Host,
1146
+ resourceIdentifier: "someone-else",
1147
+ allowWrites: true,
1148
+ allowWritesSetting: "true",
1149
+ writeTargets: ["web-*", "api"],
1150
+ },
1151
+ });
1152
+
1153
+ expect(createdRow(harness).posture).toEqual({
1154
+ resourceType: AiResourceType.DockerHost,
1155
+ resourceIdentifier: "web-1",
1156
+ agentVersion: "14.1.0",
1157
+ allowWrites: true,
1158
+ allowWritesSetting: "true",
1159
+ writeTargets: ["web-*", "api"],
1160
+ protectedTargets: ["oneuptime-ai-agent"],
1161
+ toolVersion: "27.3.1",
1162
+ reachable: true,
1163
+ reachError: null,
1164
+ details: { dockerHost: "unix:///var/run/docker.sock" },
1165
+ reportedAt: "2026-09-28T10:00:00.000Z",
1166
+ });
1167
+ });
1168
+
1169
+ test("malformed fields are never trusted — writes stay off", async () => {
1170
+ const harness: Harness = setUp();
1171
+
1172
+ await register({
1173
+ posture: {
1174
+ allowWrites: "true",
1175
+ writeTargets: ["web", "", 42],
1176
+ reachable: "yes",
1177
+ toolVersion: { v: 1 },
1178
+ },
1179
+ });
1180
+
1181
+ const posture: JSONObject = createdRow(harness).posture!;
1182
+ expect(posture["allowWrites"]).toBe(false);
1183
+ expect(posture["writeTargets"]).toEqual([]);
1184
+ expect(posture["reachable"]).toBe(false);
1185
+ expect(posture["toolVersion"]).toBeNull();
1186
+ });
1187
+
1188
+ test("a malformed protected-targets list forces writes off", async () => {
1189
+ const harness: Harness = setUp();
1190
+
1191
+ await register({
1192
+ posture: { allowWrites: true, protectedTargets: "oneuptime-ai-agent" },
1193
+ });
1194
+
1195
+ expect(createdRow(harness).posture!["allowWrites"]).toBe(false);
1196
+ });
1197
+
1198
+ test.each([
1199
+ ["no posture", undefined],
1200
+ ["a string", "allowWrites=true"],
1201
+ ["an array", [{ allowWrites: true }]],
1202
+ ])(
1203
+ "%s is a read-only agent of this resource",
1204
+ async (_label: string, posture: unknown) => {
1205
+ const harness: Harness = setUp();
1206
+
1207
+ await register({ posture });
1208
+
1209
+ const stored: JSONObject = createdRow(harness).posture!;
1210
+ expect(stored["resourceType"]).toBe(AiResourceType.DockerHost);
1211
+ expect(stored["resourceIdentifier"]).toBe("web-1");
1212
+ expect(stored["allowWrites"]).toBe(false);
1213
+ expect(Object.values(stored)).not.toContain(undefined);
1214
+ },
1215
+ );
1216
+
1217
+ test("buildStoredPosture never stores undefined keys", () => {
1218
+ const posture: JSONObject = ResourceAiAgentService.buildStoredPosture({
1219
+ reported: { allowWrites: true, extra: "ignored" },
1220
+ resourceType: AiResourceType.CephCluster,
1221
+ resourceIdentifier: "ceph-prod",
1222
+ }) as unknown as JSONObject;
1223
+
1224
+ expect(posture["resourceType"]).toBe(AiResourceType.CephCluster);
1225
+ expect(posture["resourceIdentifier"]).toBe("ceph-prod");
1226
+ expect(posture["allowWrites"]).toBe(true);
1227
+ expect(posture).not.toHaveProperty("extra");
1228
+ expect(posture).not.toHaveProperty("reportedAt");
1229
+ expect(Object.values(posture)).not.toContain(undefined);
1230
+ });
1231
+ });
1232
+
1233
+ describe("register: re-registering an existing agent", () => {
1234
+ test("continuity: the current key re-keys even a live agent", async () => {
1235
+ const harness: Harness = setUp({ existing: makeAgent() });
1236
+
1237
+ const result: ResourceAiAgentRegistrationResult = await register({
1238
+ previousAgentKey: CURRENT_KEY,
1239
+ });
1240
+
1241
+ expect(result.admission).toBe("continuity");
1242
+ expect(result.agentId.toString()).toBe(AGENT_ID.toString());
1243
+ expect(result.agentKey).not.toBe(CURRENT_KEY);
1244
+
1245
+ const write: { id: ObjectID; data: AnyObject } = reKeyWrite(harness);
1246
+ expect(write.id.toString()).toBe(AGENT_ID.toString());
1247
+ expect(write.data["keyHash"]).toBe(
1248
+ ResourceAiAgentServiceClass.hashKey(result.agentKey),
1249
+ );
1250
+ expect(harness.agentCreate.mock.calls).toHaveLength(0);
1251
+ });
1252
+
1253
+ test("the re-key writes the new hash, the identity, connected, alive and registered now, the posture and the minting key", async () => {
1254
+ const harness: Harness = setUp({
1255
+ existing: makeAgent({
1256
+ connectionStatus: "disconnected",
1257
+ lastAliveAt: msAgo(ALIVE_WINDOW_MS * 3),
1258
+ resourceIdentifier: "WEB-1",
1259
+ }),
1260
+ });
1261
+ const before: number = Date.now();
1262
+
1263
+ await register({ agentVersion: "14.2.0" });
1264
+
1265
+ const write: { data: AnyObject; props: AnyObject } = reKeyWrite(harness);
1266
+
1267
+ expect(write.props["isRoot"]).toBe(true);
1268
+ expect(write.data["resourceIdentifier"]).toBe("web-1");
1269
+ expect(write.data["connectionStatus"]).toBe("connected");
1270
+ expect(
1271
+ (write.data["lastAliveAt"] as Date).getTime(),
1272
+ ).toBeGreaterThanOrEqual(before);
1273
+ expect(
1274
+ (write.data["lastRegisteredAt"] as Date).getTime(),
1275
+ ).toBeGreaterThanOrEqual(before);
1276
+ expect(write.data["agentVersion"]).toBe("14.2.0");
1277
+ expect(String(write.data["registeredWithIngestionKeyId"])).toBe(
1278
+ INGESTION_KEY_ID.toString(),
1279
+ );
1280
+ expect((write.data["posture"] as JSONObject)["resourceIdentifier"]).toBe(
1281
+ "web-1",
1282
+ );
1283
+ });
1284
+
1285
+ test("a re-key without an ingestion key id clears the previous one, and keeps the stored version when none is sent", async () => {
1286
+ const harness: Harness = setUp({
1287
+ existing: makeAgent({ connectionStatus: "disconnected" }),
1288
+ });
1289
+
1290
+ await ResourceAiAgentService.register({
1291
+ projectId: PROJECT_ID,
1292
+ resourceType: AiResourceType.DockerHost,
1293
+ resourceIdentifier: "web-1",
1294
+ });
1295
+
1296
+ const write: { data: AnyObject } = reKeyWrite(harness);
1297
+ expect(write.data["registeredWithIngestionKeyId"]).toBeNull();
1298
+ expect(write.data).not.toHaveProperty("agentVersion");
1299
+ });
1300
+
1301
+ test("offline: an agent that stopped heartbeating is replaced without its key", async () => {
1302
+ const harness: Harness = setUp({
1303
+ existing: makeAgent({ lastAliveAt: msAgo(ALIVE_WINDOW_MS + 60 * 1000) }),
1304
+ });
1305
+
1306
+ const result: ResourceAiAgentRegistrationResult = await register();
1307
+
1308
+ expect(result.admission).toBe("offline");
1309
+ expect(harness.agentUpdateOneById.mock.calls).toHaveLength(1);
1310
+ });
1311
+
1312
+ test("signed off: an agent that said goodbye is replaced at once", async () => {
1313
+ const harness: Harness = setUp({
1314
+ existing: makeAgent({
1315
+ connectionStatus: "disconnected",
1316
+ lastAliveAt: msAgo(5 * 1000),
1317
+ }),
1318
+ });
1319
+
1320
+ const result: ResourceAiAgentRegistrationResult = await register();
1321
+
1322
+ expect(result.admission).toBe("signed_off");
1323
+ expect(harness.agentUpdateOneById.mock.calls).toHaveLength(1);
1324
+ });
1325
+
1326
+ test("reset: a row an admin reset admits the next registration, even if it still reads as connected", async () => {
1327
+ const harness: Harness = setUp({
1328
+ existing: makeAgent({
1329
+ keyHash: null,
1330
+ connectionStatus: "connected",
1331
+ lastAliveAt: msAgo(5 * 1000),
1332
+ }),
1333
+ });
1334
+
1335
+ const result: ResourceAiAgentRegistrationResult = await register();
1336
+
1337
+ expect(result.admission).toBe("reset");
1338
+ expect(reKeyWrite(harness).data["keyHash"]).toBe(
1339
+ ResourceAiAgentServiceClass.hashKey(result.agentKey),
1340
+ );
1341
+ });
1342
+
1343
+ test("the caps are not consulted for an existing row", async () => {
1344
+ const harness: Harness = setUp({
1345
+ existing: makeAgent({ connectionStatus: "disconnected" }),
1346
+ totalAgents: MAX_RESOURCE_AI_AGENTS_PER_PROJECT,
1347
+ agentsInLastHour: MAX_NEW_RESOURCE_AI_AGENTS_PER_PROJECT_PER_HOUR,
1348
+ });
1349
+
1350
+ await register();
1351
+
1352
+ expect(harness.agentCountBy.mock.calls).toHaveLength(0);
1353
+ });
1354
+
1355
+ test("no connected feed item for a re-key that changed no setting", async () => {
1356
+ const harness: Harness = setUp({
1357
+ existing: makeAgent({ connectionStatus: "disconnected" }),
1358
+ resource: makeResourceRow({ isAiInvestigationEnabled: true }),
1359
+ });
1360
+
1361
+ await register();
1362
+
1363
+ expect(harness.feed.mock.calls).toHaveLength(0);
1364
+ });
1365
+ });
1366
+
1367
+ describe("register: another agent is online (previous_instance_online)", () => {
1368
+ test.each([
1369
+ ["no key", undefined],
1370
+ ["a wrong key", "cd".repeat(32)],
1371
+ [
1372
+ "the key's hash instead of the key",
1373
+ ResourceAiAgentServiceClass.hashKey(CURRENT_KEY),
1374
+ ],
1375
+ ])(
1376
+ "%s: refused, transient, 20 seconds — and the key is not changed",
1377
+ async (_label: string, previousAgentKey: string | undefined) => {
1378
+ const harness: Harness = setUp({ existing: makeAgent() });
1379
+
1380
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1381
+ await refusalOf(register(previousAgentKey ? { previousAgentKey } : {}));
1382
+
1383
+ expect(refusal.reason).toBe("previous_instance_online");
1384
+ expect(refusal.retryAfterSeconds).toBe(20);
1385
+ expect(RESOURCE_AI_AGENT_REGISTRATION_RETRY_AFTER_SECONDS).toBe(20);
1386
+ expect(
1387
+ isTransientResourceAiAgentRegistrationRefusal(refusal.reason),
1388
+ ).toBe(true);
1389
+ expect(refusal.message).toContain('"web-1"');
1390
+ expect(refusal.message).toContain("Docker AI agent");
1391
+ nothingWrittenToTheAgentTable(harness);
1392
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(0);
1393
+ expect(harness.feed.mock.calls).toHaveLength(0);
1394
+ },
1395
+ );
1396
+
1397
+ test("the refusal is recorded on the row: when, and why", async () => {
1398
+ const harness: Harness = setUp({ existing: makeAgent() });
1399
+ const before: number = Date.now();
1400
+
1401
+ await refusalOf(register());
1402
+
1403
+ expect(harness.agentUpdateColumns.mock.calls).toHaveLength(1);
1404
+
1405
+ const record: { id: ObjectID; data: AnyObject } = harness.agentUpdateColumns
1406
+ .mock.calls[0]![0] as { id: ObjectID; data: AnyObject };
1407
+
1408
+ expect(record.id.toString()).toBe(AGENT_ID.toString());
1409
+ expect(record.data["lastRefusedRegistrationReason"]).toBe(
1410
+ "previous_instance_online",
1411
+ );
1412
+ expect(
1413
+ (record.data["lastRefusedRegistrationAt"] as Date).getTime(),
1414
+ ).toBeGreaterThanOrEqual(before);
1415
+ expect(Object.keys(record.data).sort()).toEqual([
1416
+ "lastRefusedRegistrationAt",
1417
+ "lastRefusedRegistrationReason",
1418
+ ]);
1419
+ });
1420
+
1421
+ test("a failure to record it never turns the refusal into a server error", async () => {
1422
+ const harness: Harness = setUp({ existing: makeAgent() });
1423
+ (
1424
+ harness.agentUpdateColumns as unknown as {
1425
+ mockRejectedValue: (error: Error) => void;
1426
+ }
1427
+ ).mockRejectedValue(new Error("database is down"));
1428
+
1429
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1430
+ await refusalOf(register());
1431
+
1432
+ expect(refusal.reason).toBe("previous_instance_online");
1433
+ });
1434
+
1435
+ test("an agent heard from just inside the alive window is still online", async () => {
1436
+ setUp({
1437
+ existing: makeAgent({ lastAliveAt: msAgo(ALIVE_WINDOW_MS - 10 * 1000) }),
1438
+ });
1439
+
1440
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1441
+ await refusalOf(register());
1442
+
1443
+ expect(refusal.reason).toBe("previous_instance_online");
1444
+ });
1445
+ });
1446
+
1447
+ describe("register: agent_cap_reached", () => {
1448
+ test(`a project with ${MAX_RESOURCE_AI_AGENTS_PER_PROJECT} agents gets no new one`, async () => {
1449
+ const harness: Harness = setUp({
1450
+ totalAgents: MAX_RESOURCE_AI_AGENTS_PER_PROJECT,
1451
+ });
1452
+
1453
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1454
+ await refusalOf(register());
1455
+
1456
+ expect(refusal.reason).toBe("agent_cap_reached");
1457
+ expect(refusal.retryAfterSeconds).toBeUndefined();
1458
+ expect(isTransientResourceAiAgentRegistrationRefusal(refusal.reason)).toBe(
1459
+ false,
1460
+ );
1461
+ expect(refusal.message).toContain(
1462
+ String(MAX_RESOURCE_AI_AGENTS_PER_PROJECT),
1463
+ );
1464
+ nothingWrittenToTheAgentTable(harness);
1465
+ expect(harness.feed.mock.calls).toHaveLength(0);
1466
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(0);
1467
+ });
1468
+
1469
+ test(`${MAX_NEW_RESOURCE_AI_AGENTS_PER_PROJECT_PER_HOUR} new agents in the last hour is the hourly brake`, async () => {
1470
+ const harness: Harness = setUp({
1471
+ totalAgents: 40,
1472
+ agentsInLastHour: MAX_NEW_RESOURCE_AI_AGENTS_PER_PROJECT_PER_HOUR,
1473
+ });
1474
+
1475
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1476
+ await refusalOf(register());
1477
+
1478
+ expect(refusal.reason).toBe("agent_cap_reached");
1479
+ expect(refusal.message).toContain(
1480
+ String(MAX_NEW_RESOURCE_AI_AGENTS_PER_PROJECT_PER_HOUR),
1481
+ );
1482
+ nothingWrittenToTheAgentTable(harness);
1483
+ });
1484
+
1485
+ test("one under either limit is admitted", async () => {
1486
+ const harness: Harness = setUp({
1487
+ totalAgents: MAX_RESOURCE_AI_AGENTS_PER_PROJECT - 1,
1488
+ agentsInLastHour: MAX_NEW_RESOURCE_AI_AGENTS_PER_PROJECT_PER_HOUR - 1,
1489
+ });
1490
+
1491
+ await register();
1492
+
1493
+ expect(harness.agentCreate.mock.calls).toHaveLength(1);
1494
+ });
1495
+
1496
+ test("both counts are scoped to the project (every resource type together), as root; the brake counts only the last hour's rows", async () => {
1497
+ const harness: Harness = setUp();
1498
+
1499
+ await register();
1500
+
1501
+ expect(harness.agentCountBy.mock.calls).toHaveLength(2);
1502
+
1503
+ const [total, lastHour] = harness.agentCountBy.mock.calls.map(
1504
+ (call: Array<unknown>) => {
1505
+ return call[0] as { query: AnyObject; props: AnyObject };
1506
+ },
1507
+ ) as [
1508
+ { query: AnyObject; props: AnyObject },
1509
+ { query: AnyObject; props: AnyObject },
1510
+ ];
1511
+
1512
+ expect(total.query).toEqual({ projectId: PROJECT_ID });
1513
+ expect(total.props["isRoot"]).toBe(true);
1514
+ expect(lastHour.query["projectId"]).toBe(PROJECT_ID);
1515
+ expect(lastHour.query["createdAt"]).toBeDefined();
1516
+ expect(lastHour.props["isRoot"]).toBe(true);
1517
+ });
1518
+
1519
+ test("the caps match the Kubernetes AI agent's", () => {
1520
+ expect(MAX_RESOURCE_AI_AGENTS_PER_PROJECT).toBe(250);
1521
+ expect(MAX_NEW_RESOURCE_AI_AGENTS_PER_PROJECT_PER_HOUR).toBe(30);
1522
+ });
1523
+
1524
+ /*
1525
+ * Hosts come and go (an autoscaled fleet names every machine anew) while
1526
+ * their resources, and so their agent rows, stay: rows of agents gone
1527
+ * quiet long ago must not lock every later machine out for good.
1528
+ */
1529
+ test(`at the cap, agents not heard from in ${RESOURCE_AI_AGENT_RECLAIM_AFTER_DAYS} days make room, and the new agent is admitted`, async () => {
1530
+ const harness: Harness = setUp({
1531
+ totalAgents: MAX_RESOURCE_AI_AGENTS_PER_PROJECT,
1532
+ longOfflineAgents: 3,
1533
+ });
1534
+
1535
+ const result: ResourceAiAgentRegistrationResult = await register();
1536
+
1537
+ expect(result.admission).toBe("created");
1538
+ expect(harness.agentCreate.mock.calls).toHaveLength(1);
1539
+
1540
+ expect(harness.agentDeleteBy.mock.calls).toHaveLength(1);
1541
+ const reclaim: { query: AnyObject; props: AnyObject } = harness
1542
+ .agentDeleteBy.mock.calls[0]![0] as {
1543
+ query: AnyObject;
1544
+ props: AnyObject;
1545
+ };
1546
+ expect(Object.keys(reclaim.query).sort()).toEqual([
1547
+ "lastAliveAt",
1548
+ "projectId",
1549
+ ]);
1550
+ expect(reclaim.query["projectId"]).toBe(PROJECT_ID);
1551
+ expect(reclaim.query["lastAliveAt"]).toBeInstanceOf(FindOperator);
1552
+ const cutoff: Date | undefined = Object.values(
1553
+ (reclaim.query["lastAliveAt"] as AnyObject)["_objectLiteralParameters"] ||
1554
+ {},
1555
+ ).find((value: unknown): boolean => {
1556
+ return value instanceof Date;
1557
+ }) as Date | undefined;
1558
+ expect(cutoff).toBeDefined();
1559
+ expect(
1560
+ Math.abs(
1561
+ Date.now() -
1562
+ RESOURCE_AI_AGENT_RECLAIM_AFTER_DAYS * 24 * 60 * 60 * 1000 -
1563
+ cutoff!.getTime(),
1564
+ ),
1565
+ ).toBeLessThan(60 * 1000);
1566
+ expect(reclaim.props["isRoot"]).toBe(true);
1567
+ });
1568
+
1569
+ test("at the cap with no long-offline agent, the refusal stands (and says how room is made)", async () => {
1570
+ const harness: Harness = setUp({
1571
+ totalAgents: MAX_RESOURCE_AI_AGENTS_PER_PROJECT,
1572
+ longOfflineAgents: 0,
1573
+ });
1574
+
1575
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1576
+ await refusalOf(register());
1577
+
1578
+ expect(refusal.reason).toBe("agent_cap_reached");
1579
+ expect(refusal.message).toContain(
1580
+ `not heard from in ${RESOURCE_AI_AGENT_RECLAIM_AFTER_DAYS} days`,
1581
+ );
1582
+ expect(harness.agentDeleteBy.mock.calls).toHaveLength(1);
1583
+ nothingWrittenToTheAgentTable(harness);
1584
+ });
1585
+
1586
+ test("a failure to reclaim never turns the refusal into a server error", async () => {
1587
+ const harness: Harness = setUp({
1588
+ totalAgents: MAX_RESOURCE_AI_AGENTS_PER_PROJECT,
1589
+ });
1590
+ (
1591
+ harness.agentDeleteBy as unknown as {
1592
+ mockRejectedValue: (error: Error) => void;
1593
+ }
1594
+ ).mockRejectedValue(new Error("database is down"));
1595
+
1596
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1597
+ await refusalOf(register());
1598
+
1599
+ expect(refusal.reason).toBe("agent_cap_reached");
1600
+ nothingWrittenToTheAgentTable(harness);
1601
+ });
1602
+
1603
+ test("under the cap nothing is reclaimed", async () => {
1604
+ const harness: Harness = setUp({
1605
+ totalAgents: MAX_RESOURCE_AI_AGENTS_PER_PROJECT - 1,
1606
+ longOfflineAgents: 5,
1607
+ });
1608
+
1609
+ await register();
1610
+
1611
+ expect(harness.agentDeleteBy.mock.calls).toHaveLength(0);
1612
+ expect(harness.agentCreate.mock.calls).toHaveLength(1);
1613
+ });
1614
+ });
1615
+
1616
+ describe("register: two agents registering a new resource at once", () => {
1617
+ test("the one whose create loses is told the other agent is online", async () => {
1618
+ const harness: Harness = setUp();
1619
+ (
1620
+ harness.agentCreate as unknown as {
1621
+ mockRejectedValue: (error: Error) => void;
1622
+ }
1623
+ ).mockRejectedValue(new Error("duplicate key value"));
1624
+ (
1625
+ harness.agentFindOneBy as unknown as {
1626
+ mockResolvedValueOnce: (value: unknown) => {
1627
+ mockResolvedValueOnce: (value: unknown) => void;
1628
+ };
1629
+ }
1630
+ )
1631
+ .mockResolvedValueOnce(null)
1632
+ .mockResolvedValueOnce(makeAgent());
1633
+
1634
+ const refusal: ResourceAiAgentRegistrationRefusedException =
1635
+ await refusalOf(register());
1636
+
1637
+ expect(refusal.reason).toBe("previous_instance_online");
1638
+ expect(refusal.retryAfterSeconds).toBe(20);
1639
+ expect(harness.feed.mock.calls).toHaveLength(0);
1640
+
1641
+ // The winner is looked up by the same resource.
1642
+ const winnerLookup: AnyObject = harness.agentFindOneBy.mock
1643
+ .calls[1]![0] as AnyObject;
1644
+ expect(winnerLookup["query"]["resourceType"]).toBe(
1645
+ AiResourceType.DockerHost,
1646
+ );
1647
+ expect(String(winnerLookup["query"]["resourceId"])).toBe(
1648
+ RESOURCE_ID.toString(),
1649
+ );
1650
+ });
1651
+
1652
+ test("any other create failure is not dressed up as a refusal", async () => {
1653
+ const harness: Harness = setUp();
1654
+ (
1655
+ harness.agentCreate as unknown as {
1656
+ mockRejectedValue: (error: Error) => void;
1657
+ }
1658
+ ).mockRejectedValue(new Error("connection reset"));
1659
+
1660
+ await expect(register()).rejects.toThrow("connection reset");
1661
+ });
1662
+ });
1663
+
1664
+ describe("register: first-connection defaults", () => {
1665
+ function defaultsWrite(harness: Harness): {
1666
+ query: AnyObject;
1667
+ data: AnyObject;
1668
+ props: AnyObject;
1669
+ } {
1670
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(1);
1671
+ return harness.resourceUpdateOneBy.mock.calls[0]![0] as {
1672
+ query: AnyObject;
1673
+ data: AnyObject;
1674
+ props: AnyObject;
1675
+ };
1676
+ }
1677
+
1678
+ test("a resource nobody configured gets investigation on (the resource models default it off)", async () => {
1679
+ const harness: Harness = setUp();
1680
+
1681
+ await register();
1682
+
1683
+ const write: { query: AnyObject; data: AnyObject; props: AnyObject } =
1684
+ defaultsWrite(harness);
1685
+
1686
+ expect(write.data).toEqual({ isAiInvestigationEnabled: true });
1687
+ expect(write.query["_id"]).toBe(RESOURCE_ID.toString());
1688
+ expect(write.query["projectId"]).toBe(PROJECT_ID);
1689
+ expect(write.props["isRoot"]).toBe(true);
1690
+ });
1691
+
1692
+ test("and, when the agent allows writes, fixes Ask for approval instead of Off", async () => {
1693
+ const harness: Harness = setUp();
1694
+
1695
+ await register({
1696
+ posture: { ...DOCKER_POSTURE, allowWrites: true },
1697
+ });
1698
+
1699
+ expect(defaultsWrite(harness).data).toEqual({
1700
+ isAiInvestigationEnabled: true,
1701
+ aiRemediationMode: ResourceAiRemediationMode.RequireApproval,
1702
+ });
1703
+ });
1704
+
1705
+ test.each(NAMED_TYPES)(
1706
+ "$resourceType: the defaults are written to its own row",
1707
+ async (fixture: TypeFixture) => {
1708
+ const harness: Harness = setUp({ fixture });
1709
+
1710
+ await register({
1711
+ resourceType: fixture.resourceType,
1712
+ resourceIdentifier: fixture.identity,
1713
+ posture: { allowWrites: true },
1714
+ });
1715
+
1716
+ expect(defaultsWrite(harness).data).toEqual({
1717
+ isAiInvestigationEnabled: true,
1718
+ aiRemediationMode: ResourceAiRemediationMode.RequireApproval,
1719
+ });
1720
+ },
1721
+ );
1722
+
1723
+ test("the update only lands while the resource is still unconfigured", async () => {
1724
+ const harness: Harness = setUp();
1725
+
1726
+ await register();
1727
+
1728
+ const configuredAt: FindOperator<unknown> = defaultsWrite(harness).query[
1729
+ "aiAccessConfiguredAt"
1730
+ ] as FindOperator<unknown>;
1731
+
1732
+ // QueryHelper.isNull: a raw `IS NULL` on the column.
1733
+ expect(configuredAt).toBeInstanceOf(FindOperator);
1734
+ expect(configuredAt.type).toBe("raw");
1735
+ expect((configuredAt.getSql as (alias: string) => string)("column")).toBe(
1736
+ "(column IS NULL)",
1737
+ );
1738
+ });
1739
+
1740
+ test("never marks the resource configured", async () => {
1741
+ const harness: Harness = setUp();
1742
+
1743
+ await register({ posture: { allowWrites: true } });
1744
+
1745
+ expect(defaultsWrite(harness).data).not.toHaveProperty(
1746
+ "aiAccessConfiguredAt",
1747
+ );
1748
+ });
1749
+
1750
+ test("a read-only agent never turns fixes on", async () => {
1751
+ const harness: Harness = setUp();
1752
+
1753
+ await register({ posture: { allowWrites: false } });
1754
+
1755
+ expect(defaultsWrite(harness).data).not.toHaveProperty("aiRemediationMode");
1756
+ });
1757
+
1758
+ test.each([
1759
+ ResourceAiRemediationMode.RequireApproval,
1760
+ ResourceAiRemediationMode.Automatic,
1761
+ ResourceAiRemediationMode.BypassApproval,
1762
+ ])(
1763
+ "a mode already on the resource (%s) is never changed",
1764
+ async (mode: ResourceAiRemediationMode) => {
1765
+ const harness: Harness = setUp({
1766
+ resource: makeResourceRow({
1767
+ isAiInvestigationEnabled: true,
1768
+ aiRemediationMode: mode,
1769
+ }),
1770
+ });
1771
+
1772
+ await register({ posture: { allowWrites: true } });
1773
+
1774
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(0);
1775
+ },
1776
+ );
1777
+
1778
+ test("an unknown stored mode reads as Off", async () => {
1779
+ const harness: Harness = setUp({
1780
+ resource: makeResourceRow({ aiRemediationMode: "Sometimes" }),
1781
+ });
1782
+
1783
+ await register({ posture: { allowWrites: true } });
1784
+
1785
+ expect(defaultsWrite(harness).data["aiRemediationMode"]).toBe(
1786
+ ResourceAiRemediationMode.RequireApproval,
1787
+ );
1788
+ });
1789
+
1790
+ test("nothing is written when nothing needs changing", async () => {
1791
+ const harness: Harness = setUp({
1792
+ resource: makeResourceRow({ isAiInvestigationEnabled: true }),
1793
+ });
1794
+
1795
+ await register();
1796
+
1797
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(0);
1798
+ });
1799
+
1800
+ test("a resource someone configured is left exactly as chosen — investigation off and fixes Off included", async () => {
1801
+ const harness: Harness = setUp({
1802
+ resource: makeResourceRow({
1803
+ aiAccessConfiguredAt: msAgo(60 * 1000),
1804
+ isAiInvestigationEnabled: false,
1805
+ aiRemediationMode: ResourceAiRemediationMode.Disabled,
1806
+ }),
1807
+ });
1808
+
1809
+ await register({ posture: { allowWrites: true } });
1810
+
1811
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(0);
1812
+ expect(harness.resourceUpdateOneById.mock.calls).toHaveLength(0);
1813
+ });
1814
+
1815
+ test("they apply on a re-key too, with a feed item saying what changed", async () => {
1816
+ const harness: Harness = setUp({
1817
+ existing: makeAgent({ connectionStatus: "disconnected" }),
1818
+ resource: makeResourceRow({ isAiInvestigationEnabled: true }),
1819
+ });
1820
+
1821
+ await register({
1822
+ posture: { allowWrites: true, writeTargets: ["web-*", "api"] },
1823
+ });
1824
+
1825
+ expect(defaultsWrite(harness).data).toEqual({
1826
+ aiRemediationMode: ResourceAiRemediationMode.RequireApproval,
1827
+ });
1828
+ expect(harness.feed.mock.calls).toHaveLength(1);
1829
+
1830
+ const item: AnyObject = harness.feed.mock.calls[0]![0] as AnyObject;
1831
+ expect(String(item["feedInfoInMarkdown"])).toContain("Ask for approval");
1832
+ expect(String(item["feedInfoInMarkdown"])).toContain("to web-*, api");
1833
+ });
1834
+
1835
+ test("when an operator configured the resource a moment earlier, the conditional update lands nowhere and nothing is claimed", async () => {
1836
+ const harness: Harness = setUp({
1837
+ existing: makeAgent({ connectionStatus: "disconnected" }),
1838
+ resourceUpdateCount: 0,
1839
+ });
1840
+
1841
+ await register();
1842
+
1843
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(1);
1844
+ expect(harness.feed.mock.calls).toHaveLength(0);
1845
+ });
1846
+
1847
+ test("they never apply to a refused registration", async () => {
1848
+ const harness: Harness = setUp({ existing: makeAgent() });
1849
+
1850
+ await refusalOf(register({ posture: { allowWrites: true } }));
1851
+
1852
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(0);
1853
+ });
1854
+ });
1855
+
1856
+ /*
1857
+ * The key's hash is written before the defaults. If a failed defaults write
1858
+ * failed the registration, the agent would never receive the key its row
1859
+ * now demands, and — the row reading online — every retry would be refused
1860
+ * as previous_instance_online until the alive window lapsed.
1861
+ */
1862
+ describe("register: when applying the defaults fails", () => {
1863
+ function failTheDefaults(harness: Harness): void {
1864
+ (
1865
+ harness.resourceUpdateOneBy as unknown as {
1866
+ mockRejectedValue: (error: Error) => void;
1867
+ }
1868
+ ).mockRejectedValue(new Error("database is down"));
1869
+ }
1870
+
1871
+ test("a new agent still gets its key, and the row stores that key's hash", async () => {
1872
+ const harness: Harness = setUp();
1873
+ failTheDefaults(harness);
1874
+
1875
+ const result: ResourceAiAgentRegistrationResult = await register();
1876
+
1877
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(1);
1878
+ expect(result.admission).toBe("created");
1879
+ expect(createdRow(harness).keyHash).toBe(
1880
+ ResourceAiAgentServiceClass.hashKey(result.agentKey),
1881
+ );
1882
+ });
1883
+
1884
+ test("a re-key still hands out its key, and the next registration with it proves continuity while the row reads online", async () => {
1885
+ const harness: Harness = setUp({
1886
+ existing: makeAgent({ connectionStatus: "disconnected" }),
1887
+ });
1888
+ failTheDefaults(harness);
1889
+
1890
+ const result: ResourceAiAgentRegistrationResult = await register();
1891
+
1892
+ const storedHash: unknown = reKeyWrite(harness).data["keyHash"];
1893
+ expect(storedHash).toBe(
1894
+ ResourceAiAgentServiceClass.hashKey(result.agentKey),
1895
+ );
1896
+
1897
+ const rowAfter: ResourceAiAgent = makeAgent({
1898
+ keyHash: storedHash,
1899
+ connectionStatus: "connected",
1900
+ lastAliveAt: new Date(),
1901
+ });
1902
+ expect(ResourceAiAgentService.isOnline(rowAfter)).toBe(true);
1903
+ expect(
1904
+ ResourceAiAgentService.getReRegistrationAdmission({
1905
+ agent: rowAfter,
1906
+ previousAgentKey: result.agentKey,
1907
+ }),
1908
+ ).toBe("continuity");
1909
+ });
1910
+
1911
+ test("the connected feed item says only what is true: investigation stayed off, and fixes were not changed", async () => {
1912
+ const harness: Harness = setUp();
1913
+ failTheDefaults(harness);
1914
+
1915
+ await register({ posture: { allowWrites: true } });
1916
+
1917
+ expect(harness.feed.mock.calls).toHaveLength(1);
1918
+ const text: string = String(
1919
+ (harness.feed.mock.calls[0]![0] as AnyObject)["feedInfoInMarkdown"],
1920
+ );
1921
+ expect(text).toContain("The Docker AI agent connected");
1922
+ expect(text).toContain("Investigating through the agent is off");
1923
+ expect(text).not.toContain("Ask for approval");
1924
+ });
1925
+
1926
+ test("a re-key writes no defaults feed item", async () => {
1927
+ const harness: Harness = setUp({
1928
+ existing: makeAgent({ connectionStatus: "disconnected" }),
1929
+ });
1930
+ failTheDefaults(harness);
1931
+
1932
+ await register({ posture: { allowWrites: true } });
1933
+
1934
+ expect(harness.feed.mock.calls).toHaveLength(0);
1935
+ });
1936
+
1937
+ test("the failure is logged, naming the resource", async () => {
1938
+ const harness: Harness = setUp();
1939
+ failTheDefaults(harness);
1940
+
1941
+ await register();
1942
+
1943
+ const logged: string = JSON.stringify(
1944
+ (logger.error as unknown as SpyCalls).mock.calls,
1945
+ );
1946
+ expect(logged).toContain("first-connection defaults");
1947
+ expect(logged).toContain(RESOURCE_ID.toString());
1948
+ expect(logged).toContain("database is down");
1949
+ });
1950
+
1951
+ test("a failure before the key is stored still fails the registration", async () => {
1952
+ const harness: Harness = setUp({
1953
+ existing: makeAgent({ connectionStatus: "disconnected" }),
1954
+ });
1955
+ (
1956
+ harness.agentUpdateOneById as unknown as {
1957
+ mockRejectedValue: (error: Error) => void;
1958
+ }
1959
+ ).mockRejectedValue(new Error("database is down"));
1960
+
1961
+ await expect(register()).rejects.toThrow("database is down");
1962
+ expect(harness.resourceUpdateOneBy.mock.calls).toHaveLength(0);
1963
+ });
1964
+ });
1965
+
1966
+ describe("register: the resource's feed", () => {
1967
+ function feedItem(harness: Harness): AnyObject {
1968
+ expect(harness.feed.mock.calls).toHaveLength(1);
1969
+ return harness.feed.mock.calls[0]![0] as AnyObject;
1970
+ }
1971
+
1972
+ test("one item marks the first connection of a resource's agent", async () => {
1973
+ const harness: Harness = setUp();
1974
+
1975
+ await register();
1976
+
1977
+ const item: AnyObject = feedItem(harness);
1978
+ expect(String(item["dockerHostId"])).toBe(RESOURCE_ID.toString());
1979
+ expect(item["projectId"]).toBe(PROJECT_ID);
1980
+ expect(item["dockerHostFeedEventType"]).toBe("DockerHostUpdated");
1981
+ expect(String(item["feedInfoInMarkdown"])).toContain(
1982
+ "The Docker AI agent connected (read-only).",
1983
+ );
1984
+ expect(String(item["feedInfoInMarkdown"])).toContain(
1985
+ "AI can now use it to investigate this Docker host.",
1986
+ );
1987
+
1988
+ const more: string = String(item["moreInformationInMarkdown"]);
1989
+ expect(more).toContain(AGENT_ID.toString());
1990
+ expect(more).toContain("`web-1`");
1991
+ expect(more).toContain("27.3.1");
1992
+ expect(more).toContain("**Reachable**: yes");
1993
+ });
1994
+
1995
+ test("says where a writing agent may change things", async () => {
1996
+ const scoped: Harness = setUp();
1997
+ await register({
1998
+ posture: { allowWrites: true, writeTargets: ["web-*", "api"] },
1999
+ });
2000
+ expect(String(feedItem(scoped)["feedInfoInMarkdown"])).toContain(
2001
+ "(can make changes to web-*, api)",
2002
+ );
2003
+ jest.restoreAllMocks();
2004
+
2005
+ const anywhere: Harness = setUp();
2006
+ await register({ posture: { allowWrites: true, writeTargets: [] } });
2007
+ expect(String(feedItem(anywhere)["feedInfoInMarkdown"])).toContain(
2008
+ "(can make changes to anything but its protected targets)",
2009
+ );
2010
+ });
2011
+
2012
+ test("says fixes were set to Ask for approval, naming the agent's switch, when the defaults did that", async () => {
2013
+ const harness: Harness = setUp();
2014
+
2015
+ await register({ posture: { allowWrites: true } });
2016
+
2017
+ const text: string = String(feedItem(harness)["feedInfoInMarkdown"]);
2018
+ expect(text).toContain('"Ask for approval"');
2019
+ expect(text).toContain("ONEUPTIME_AI_ALLOW_WRITES=true");
2020
+ });
2021
+
2022
+ test("reports an unreachable resource with the agent's reason", async () => {
2023
+ const harness: Harness = setUp();
2024
+
2025
+ await register({
2026
+ posture: {
2027
+ reachable: false,
2028
+ reachError: "Cannot connect to the Docker daemon",
2029
+ },
2030
+ });
2031
+
2032
+ expect(String(feedItem(harness)["moreInformationInMarkdown"])).toContain(
2033
+ "**Reachable**: no (Cannot connect to the Docker daemon)",
2034
+ );
2035
+ });
2036
+
2037
+ test("on a resource where an operator turned investigation off, says so rather than promising investigations", async () => {
2038
+ const harness: Harness = setUp({
2039
+ resource: makeResourceRow({
2040
+ aiAccessConfiguredAt: msAgo(60 * 1000),
2041
+ isAiInvestigationEnabled: false,
2042
+ }),
2043
+ });
2044
+
2045
+ await register();
2046
+
2047
+ const text: string = String(feedItem(harness)["feedInfoInMarkdown"]);
2048
+ expect(text).toContain("Investigating through the agent is off");
2049
+ expect(text).not.toContain("AI can now use it");
2050
+ });
2051
+
2052
+ test("never carries the agent key", async () => {
2053
+ const harness: Harness = setUp();
2054
+
2055
+ const result: ResourceAiAgentRegistrationResult = await register();
2056
+
2057
+ expect(JSON.stringify(harness.feed.mock.calls)).not.toContain(
2058
+ result.agentKey,
2059
+ );
2060
+ });
2061
+ });
2062
+
2063
+ describe("getReRegistrationAdmission", () => {
2064
+ const NOW: Date = new Date("2026-09-28T10:00:00.000Z");
2065
+ const HASH: string = ResourceAiAgentServiceClass.hashKey(CURRENT_KEY);
2066
+
2067
+ function admission(
2068
+ agent: AnyObject,
2069
+ previousAgentKey?: string,
2070
+ ): string | null {
2071
+ return ResourceAiAgentService.getReRegistrationAdmission({
2072
+ agent: agent as never,
2073
+ previousAgentKey,
2074
+ now: NOW,
2075
+ });
2076
+ }
2077
+
2078
+ const ONLINE: AnyObject = {
2079
+ keyHash: HASH,
2080
+ connectionStatus: "connected",
2081
+ lastAliveAt: new Date(NOW.getTime() - 30 * 1000),
2082
+ };
2083
+
2084
+ test("reset beats everything", () => {
2085
+ expect(admission({ ...ONLINE, keyHash: null })).toBe("reset");
2086
+ expect(admission({ ...ONLINE, keyHash: "" })).toBe("reset");
2087
+ expect(admission({ ...ONLINE, keyHash: undefined })).toBe("reset");
2088
+ });
2089
+
2090
+ test("continuity needs the current key itself", () => {
2091
+ expect(admission(ONLINE, CURRENT_KEY)).toBe("continuity");
2092
+ expect(admission(ONLINE, "cd".repeat(32))).toBeNull();
2093
+ expect(admission(ONLINE, HASH)).toBeNull();
2094
+ expect(admission(ONLINE, "")).toBeNull();
2095
+ });
2096
+
2097
+ test("signed off, then offline, then refused", () => {
2098
+ expect(admission({ ...ONLINE, connectionStatus: "disconnected" })).toBe(
2099
+ "signed_off",
2100
+ );
2101
+ expect(
2102
+ admission({
2103
+ ...ONLINE,
2104
+ lastAliveAt: new Date(NOW.getTime() - ALIVE_WINDOW_MS - 1000),
2105
+ }),
2106
+ ).toBe("offline");
2107
+ expect(admission({ ...ONLINE, lastAliveAt: null })).toBe("offline");
2108
+ expect(admission(ONLINE)).toBeNull();
2109
+ });
2110
+ });
2111
+
2112
+ describe("getFirstConnectionDefaults", () => {
2113
+ function defaults(resource: AnyObject, allowWrites: boolean): AnyObject {
2114
+ return ResourceAiAgentService.getFirstConnectionDefaults({
2115
+ resource: resource as never,
2116
+ allowWrites,
2117
+ }) as unknown as AnyObject;
2118
+ }
2119
+
2120
+ test("configured resources get nothing", () => {
2121
+ expect(
2122
+ defaults(
2123
+ {
2124
+ aiAccessConfiguredAt: new Date(),
2125
+ isAiInvestigationEnabled: false,
2126
+ aiRemediationMode: ResourceAiRemediationMode.Disabled,
2127
+ },
2128
+ true,
2129
+ ),
2130
+ ).toEqual({ turnedOnInvestigation: false });
2131
+ });
2132
+
2133
+ test("unconfigured: investigation on when it is not, fixes Ask for approval only with writes and only from Off", () => {
2134
+ expect(defaults({ isAiInvestigationEnabled: false }, false)).toEqual({
2135
+ turnedOnInvestigation: true,
2136
+ });
2137
+ expect(defaults({}, false)).toEqual({ turnedOnInvestigation: true });
2138
+ expect(defaults({ isAiInvestigationEnabled: true }, false)).toEqual({
2139
+ turnedOnInvestigation: false,
2140
+ });
2141
+ expect(
2142
+ defaults(
2143
+ {
2144
+ isAiInvestigationEnabled: true,
2145
+ aiRemediationMode: ResourceAiRemediationMode.Disabled,
2146
+ },
2147
+ true,
2148
+ ),
2149
+ ).toEqual({
2150
+ turnedOnInvestigation: false,
2151
+ remediationMode: ResourceAiRemediationMode.RequireApproval,
2152
+ });
2153
+ expect(
2154
+ defaults(
2155
+ {
2156
+ isAiInvestigationEnabled: true,
2157
+ aiRemediationMode: ResourceAiRemediationMode.Automatic,
2158
+ },
2159
+ true,
2160
+ ),
2161
+ ).toEqual({ turnedOnInvestigation: false });
2162
+ });
2163
+
2164
+ test("didApplyDefaults is true only when something changes", () => {
2165
+ expect(
2166
+ ResourceAiAgentServiceClass.didApplyDefaults({
2167
+ turnedOnInvestigation: false,
2168
+ }),
2169
+ ).toBe(false);
2170
+ expect(
2171
+ ResourceAiAgentServiceClass.didApplyDefaults({
2172
+ turnedOnInvestigation: true,
2173
+ }),
2174
+ ).toBe(true);
2175
+ expect(
2176
+ ResourceAiAgentServiceClass.didApplyDefaults({
2177
+ turnedOnInvestigation: false,
2178
+ remediationMode: ResourceAiRemediationMode.RequireApproval,
2179
+ }),
2180
+ ).toBe(true);
2181
+ });
2182
+ });
2183
+
2184
+ describe("wording helpers", () => {
2185
+ test("describeWriteAccess", () => {
2186
+ expect(
2187
+ ResourceAiAgentServiceClass.describeWriteAccess({
2188
+ allowWrites: false,
2189
+ writeTargets: ["web"],
2190
+ }),
2191
+ ).toBe("read-only");
2192
+ expect(
2193
+ ResourceAiAgentServiceClass.describeWriteAccess({
2194
+ allowWrites: true,
2195
+ writeTargets: ["web"],
2196
+ }),
2197
+ ).toBe("can make changes to web");
2198
+ expect(
2199
+ ResourceAiAgentServiceClass.describeWriteAccess({
2200
+ allowWrites: true,
2201
+ writeTargets: [],
2202
+ }),
2203
+ ).toBe("can make changes to anything but its protected targets");
2204
+ });
2205
+
2206
+ test("normalizeAgentVersion", () => {
2207
+ expect(ResourceAiAgentServiceClass.normalizeAgentVersion(" 1.2 ")).toBe(
2208
+ "1.2",
2209
+ );
2210
+ expect(
2211
+ ResourceAiAgentServiceClass.normalizeAgentVersion(" "),
2212
+ ).toBeUndefined();
2213
+ expect(
2214
+ ResourceAiAgentServiceClass.normalizeAgentVersion(12),
2215
+ ).toBeUndefined();
2216
+ expect(
2217
+ ResourceAiAgentServiceClass.normalizeAgentVersion("x".repeat(101)),
2218
+ ).toHaveLength(ColumnLength.ShortText);
2219
+ });
2220
+ });
2221
+
2222
+ describe("ResourceAiAgentRegistrationRefusedException", () => {
2223
+ test("is a 403 carrying the reason and, when given, when to retry", () => {
2224
+ const reasons: Array<ResourceAiAgentRegistrationRefusalReason> = [
2225
+ "resource_type_invalid",
2226
+ "resource_identifier_invalid",
2227
+ "resource_not_found",
2228
+ "previous_instance_online",
2229
+ "agent_cap_reached",
2230
+ ];
2231
+
2232
+ for (const reason of reasons) {
2233
+ const refusal: ResourceAiAgentRegistrationRefusedException =
2234
+ new ResourceAiAgentRegistrationRefusedException({
2235
+ reason,
2236
+ message: "m",
2237
+ ...(isTransientResourceAiAgentRegistrationRefusal(reason)
2238
+ ? { retryAfterSeconds: 20 }
2239
+ : {}),
2240
+ });
2241
+
2242
+ expect(refusal).toBeInstanceOf(ForbiddenException);
2243
+ expect(refusal.code).toBe(403);
2244
+ expect(refusal.reason).toBe(reason);
2245
+ expect(refusal.message).toBe("m");
2246
+ expect(refusal.retryAfterSeconds).toBe(
2247
+ isTransientResourceAiAgentRegistrationRefusal(reason) ? 20 : undefined,
2248
+ );
2249
+ }
2250
+ });
2251
+ });