@memberjunction/server 6.2.0-edge.2 → 6.2.0-edge.4

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 (435) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/BridgeHostInstance.d.ts +25 -0
  4. package/dist/agentSessions/BridgeHostInstance.d.ts.map +1 -0
  5. package/dist/agentSessions/BridgeHostInstance.js +30 -0
  6. package/dist/agentSessions/BridgeHostInstance.js.map +1 -0
  7. package/dist/agentSessions/HostInstance.d.ts +75 -11
  8. package/dist/agentSessions/HostInstance.d.ts.map +1 -1
  9. package/dist/agentSessions/HostInstance.js +99 -28
  10. package/dist/agentSessions/HostInstance.js.map +1 -1
  11. package/dist/agentSessions/ReturningVisitorRecap.d.ts.map +1 -1
  12. package/dist/agentSessions/ReturningVisitorRecap.js +7 -3
  13. package/dist/agentSessions/ReturningVisitorRecap.js.map +1 -1
  14. package/dist/agentSessions/SessionJanitor.d.ts +173 -25
  15. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  16. package/dist/agentSessions/SessionJanitor.js +452 -54
  17. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  18. package/dist/agentSessions/SessionManager.d.ts +43 -2
  19. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  20. package/dist/agentSessions/SessionManager.js +99 -8
  21. package/dist/agentSessions/SessionManager.js.map +1 -1
  22. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  23. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  24. package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
  25. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  26. package/dist/agentSessions/index.d.ts +2 -0
  27. package/dist/agentSessions/index.d.ts.map +1 -1
  28. package/dist/agentSessions/index.js +10 -3
  29. package/dist/agentSessions/index.js.map +1 -1
  30. package/dist/auth/actionAuthorization.d.ts +40 -0
  31. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  32. package/dist/auth/actionAuthorization.js +94 -0
  33. package/dist/auth/actionAuthorization.js.map +1 -0
  34. package/dist/auth/index.d.ts.map +1 -1
  35. package/dist/auth/index.js +3 -10
  36. package/dist/auth/index.js.map +1 -1
  37. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  38. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  39. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  40. package/dist/auth/newUsers.d.ts +10 -0
  41. package/dist/auth/newUsers.d.ts.map +1 -1
  42. package/dist/auth/newUsers.js +80 -11
  43. package/dist/auth/newUsers.js.map +1 -1
  44. package/dist/config.d.ts +4567 -71
  45. package/dist/config.d.ts.map +1 -1
  46. package/dist/config.js +246 -3
  47. package/dist/config.js.map +1 -1
  48. package/dist/context.d.ts +8 -0
  49. package/dist/context.d.ts.map +1 -1
  50. package/dist/context.js +25 -34
  51. package/dist/context.js.map +1 -1
  52. package/dist/generated/graphql-schemas/__mj.d.ts +1073 -10
  53. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  54. package/dist/generated/graphql-schemas/__mj.js +9188 -3649
  55. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  56. package/dist/generic/OrphanedConversationDetailReconciler.js +3 -3
  57. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -1
  58. package/dist/generic/ResolverBase.d.ts +62 -6
  59. package/dist/generic/ResolverBase.d.ts.map +1 -1
  60. package/dist/generic/ResolverBase.js +179 -35
  61. package/dist/generic/ResolverBase.js.map +1 -1
  62. package/dist/generic/RunViewResolver.d.ts +4 -0
  63. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  64. package/dist/generic/RunViewResolver.js +37 -1
  65. package/dist/generic/RunViewResolver.js.map +1 -1
  66. package/dist/index.d.ts +7 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +214 -159
  69. package/dist/index.js.map +1 -1
  70. package/dist/isolatedProvider.d.ts +20 -0
  71. package/dist/isolatedProvider.d.ts.map +1 -0
  72. package/dist/isolatedProvider.js +22 -0
  73. package/dist/isolatedProvider.js.map +1 -0
  74. package/dist/postgresqlCompat.d.ts +8 -0
  75. package/dist/postgresqlCompat.d.ts.map +1 -0
  76. package/dist/postgresqlCompat.js +19 -0
  77. package/dist/postgresqlCompat.js.map +1 -0
  78. package/dist/postgresqlPoolSettings.d.ts +108 -0
  79. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  80. package/dist/postgresqlPoolSettings.js +134 -0
  81. package/dist/postgresqlPoolSettings.js.map +1 -0
  82. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +44 -4
  83. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  84. package/dist/realtimeProxy/RealtimeProxyServer.js +121 -16
  85. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  86. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts +171 -0
  87. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts.map +1 -0
  88. package/dist/realtimeProxy/RealtimeRelayTunnel.js +532 -0
  89. package/dist/realtimeProxy/RealtimeRelayTunnel.js.map +1 -0
  90. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts +148 -0
  91. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts.map +1 -0
  92. package/dist/realtimeSessions/RealtimeSessionEventService.js +191 -0
  93. package/dist/realtimeSessions/RealtimeSessionEventService.js.map +1 -0
  94. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts +66 -0
  95. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -0
  96. package/dist/realtimeSessions/RealtimeSessionVerificationService.js +138 -0
  97. package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -0
  98. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts +38 -0
  99. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -0
  100. package/dist/realtimeSessions/RealtimeVerifyRouter.js +98 -0
  101. package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -0
  102. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts +61 -0
  103. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts.map +1 -0
  104. package/dist/realtimeSessions/SessionVerificationPortsBase.js +149 -0
  105. package/dist/realtimeSessions/SessionVerificationPortsBase.js.map +1 -0
  106. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts +57 -0
  107. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts.map +1 -0
  108. package/dist/realtimeSessions/SlidingWindowRateLimiter.js +69 -0
  109. package/dist/realtimeSessions/SlidingWindowRateLimiter.js.map +1 -0
  110. package/dist/realtimeSessions/index.d.ts +17 -0
  111. package/dist/realtimeSessions/index.d.ts.map +1 -0
  112. package/dist/realtimeSessions/index.js +17 -0
  113. package/dist/realtimeSessions/index.js.map +1 -0
  114. package/dist/realtimeSessions/sessionAccess.d.ts +86 -0
  115. package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -0
  116. package/dist/realtimeSessions/sessionAccess.js +90 -0
  117. package/dist/realtimeSessions/sessionAccess.js.map +1 -0
  118. package/dist/realtimeSessions/sessionConfigStamps.d.ts +70 -0
  119. package/dist/realtimeSessions/sessionConfigStamps.d.ts.map +1 -0
  120. package/dist/realtimeSessions/sessionConfigStamps.js +93 -0
  121. package/dist/realtimeSessions/sessionConfigStamps.js.map +1 -0
  122. package/dist/realtimeSessions/subscriptionAuthorization.d.ts +65 -0
  123. package/dist/realtimeSessions/subscriptionAuthorization.d.ts.map +1 -0
  124. package/dist/realtimeSessions/subscriptionAuthorization.js +71 -0
  125. package/dist/realtimeSessions/subscriptionAuthorization.js.map +1 -0
  126. package/dist/realtimeSessions/verificationCore.d.ts +243 -0
  127. package/dist/realtimeSessions/verificationCore.d.ts.map +1 -0
  128. package/dist/realtimeSessions/verificationCore.js +356 -0
  129. package/dist/realtimeSessions/verificationCore.js.map +1 -0
  130. package/dist/realtimeSessions/verificationPages.d.ts +66 -0
  131. package/dist/realtimeSessions/verificationPages.d.ts.map +1 -0
  132. package/dist/realtimeSessions/verificationPages.js +110 -0
  133. package/dist/realtimeSessions/verificationPages.js.map +1 -0
  134. package/dist/realtimeSessions/verificationPolicy.d.ts +147 -0
  135. package/dist/realtimeSessions/verificationPolicy.d.ts.map +1 -0
  136. package/dist/realtimeSessions/verificationPolicy.js +249 -0
  137. package/dist/realtimeSessions/verificationPolicy.js.map +1 -0
  138. package/dist/realtimeSessions/verificationWorkflow.d.ts +263 -0
  139. package/dist/realtimeSessions/verificationWorkflow.d.ts.map +1 -0
  140. package/dist/realtimeSessions/verificationWorkflow.js +435 -0
  141. package/dist/realtimeSessions/verificationWorkflow.js.map +1 -0
  142. package/dist/realtimeWidget/widgetGuestElevation.d.ts +20 -2
  143. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  144. package/dist/realtimeWidget/widgetGuestElevation.js +31 -2
  145. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  146. package/dist/resolvers/ActionResolver.d.ts +31 -5
  147. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  148. package/dist/resolvers/ActionResolver.js +75 -14
  149. package/dist/resolvers/ActionResolver.js.map +1 -1
  150. package/dist/resolvers/AdhocQueryResolver.d.ts +35 -17
  151. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  152. package/dist/resolvers/AdhocQueryResolver.js +92 -166
  153. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  154. package/dist/resolvers/CacheStatsResolver.d.ts +27 -0
  155. package/dist/resolvers/CacheStatsResolver.d.ts.map +1 -1
  156. package/dist/resolvers/CacheStatsResolver.js +113 -2
  157. package/dist/resolvers/CacheStatsResolver.js.map +1 -1
  158. package/dist/resolvers/ClientToolRequestResolver.d.ts +4 -2
  159. package/dist/resolvers/ClientToolRequestResolver.d.ts.map +1 -1
  160. package/dist/resolvers/ClientToolRequestResolver.js +28 -10
  161. package/dist/resolvers/ClientToolRequestResolver.js.map +1 -1
  162. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  163. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  164. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  165. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  166. package/dist/resolvers/FetchEntityVectorsResolver.d.ts +12 -1
  167. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  168. package/dist/resolvers/FetchEntityVectorsResolver.js +83 -2
  169. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  170. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  171. package/dist/resolvers/GetDataResolver.js +9 -0
  172. package/dist/resolvers/GetDataResolver.js.map +1 -1
  173. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  174. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  175. package/dist/resolvers/HumanHandoffResolver.js +330 -0
  176. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  177. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  178. package/dist/resolvers/MCPResolver.js +9 -4
  179. package/dist/resolvers/MCPResolver.js.map +1 -1
  180. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  181. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  182. package/dist/resolvers/MeetingResolver.js +381 -0
  183. package/dist/resolvers/MeetingResolver.js.map +1 -0
  184. package/dist/resolvers/RealtimeBridgeResolver.d.ts +88 -3
  185. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  186. package/dist/resolvers/RealtimeBridgeResolver.js +352 -12
  187. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  188. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +156 -108
  189. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  190. package/dist/resolvers/RealtimeClientSessionResolver.js +395 -293
  191. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  192. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts +61 -0
  193. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts.map +1 -0
  194. package/dist/resolvers/RealtimeSessionEventsResolver.js +130 -0
  195. package/dist/resolvers/RealtimeSessionEventsResolver.js.map +1 -0
  196. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts +85 -0
  197. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts.map +1 -0
  198. package/dist/resolvers/RealtimeSessionVerificationResolver.js +188 -0
  199. package/dist/resolvers/RealtimeSessionVerificationResolver.js.map +1 -0
  200. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  201. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  202. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  203. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  204. package/dist/resolvers/RunAIAgentResolver.d.ts +10 -0
  205. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  206. package/dist/resolvers/RunAIAgentResolver.js +68 -11
  207. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  208. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  209. package/dist/resolvers/RunTemplateResolver.js +12 -11
  210. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  211. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  212. package/dist/resolvers/SyncDataResolver.js +6 -2
  213. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  214. package/dist/resolvers/SyncRolesUsersResolver.d.ts +2 -0
  215. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  216. package/dist/resolvers/SyncRolesUsersResolver.js +23 -6
  217. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  218. package/dist/resolvers/UserAvatarResolver.d.ts +16 -0
  219. package/dist/resolvers/UserAvatarResolver.d.ts.map +1 -0
  220. package/dist/resolvers/UserAvatarResolver.js +124 -0
  221. package/dist/resolvers/UserAvatarResolver.js.map +1 -0
  222. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  223. package/dist/resolvers/UserResolver.js +2 -17
  224. package/dist/resolvers/UserResolver.js.map +1 -1
  225. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  226. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  227. package/dist/resolvers/adhocTimeout.js +12 -0
  228. package/dist/resolvers/adhocTimeout.js.map +1 -0
  229. package/dist/resolvers/agentVisionConsentAudit.d.ts +37 -0
  230. package/dist/resolvers/agentVisionConsentAudit.d.ts.map +1 -0
  231. package/dist/resolvers/agentVisionConsentAudit.js +63 -0
  232. package/dist/resolvers/agentVisionConsentAudit.js.map +1 -0
  233. package/dist/resolvers/avatarInputValidation.d.ts +40 -0
  234. package/dist/resolvers/avatarInputValidation.d.ts.map +1 -0
  235. package/dist/resolvers/avatarInputValidation.js +97 -0
  236. package/dist/resolvers/avatarInputValidation.js.map +1 -0
  237. package/dist/resolvers/currentUserRoles.d.ts +29 -0
  238. package/dist/resolvers/currentUserRoles.d.ts.map +1 -1
  239. package/dist/resolvers/currentUserRoles.js +30 -0
  240. package/dist/resolvers/currentUserRoles.js.map +1 -1
  241. package/dist/resolvers/meetings/index.d.ts +5 -0
  242. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  243. package/dist/resolvers/meetings/index.js +5 -0
  244. package/dist/resolvers/meetings/index.js.map +1 -0
  245. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  246. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  247. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  248. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  249. package/dist/resolvers/meetings/meetingMutations.d.ts +18 -0
  250. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  251. package/dist/resolvers/meetings/meetingMutations.js +439 -0
  252. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  253. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  254. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  255. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  256. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  257. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  258. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  259. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  260. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  261. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  262. package/dist/rest/ViewOperationsHandler.js +8 -5
  263. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  264. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  265. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  266. package/dist/services/TaskGraphActionRunner.js +27 -1
  267. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  268. package/dist/services/TaskGraphContinuationDeliverer.d.ts +4 -3
  269. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  270. package/dist/services/TaskGraphContinuationDeliverer.js +13 -7
  271. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  272. package/dist/services/WorkQueueHostService.d.ts +36 -0
  273. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  274. package/dist/services/WorkQueueHostService.js +97 -0
  275. package/dist/services/WorkQueueHostService.js.map +1 -0
  276. package/dist/services/workQueueConfig.d.ts +103 -0
  277. package/dist/services/workQueueConfig.d.ts.map +1 -0
  278. package/dist/services/workQueueConfig.js +48 -0
  279. package/dist/services/workQueueConfig.js.map +1 -0
  280. package/dist/sharedCache.d.ts +105 -0
  281. package/dist/sharedCache.d.ts.map +1 -0
  282. package/dist/sharedCache.js +276 -0
  283. package/dist/sharedCache.js.map +1 -0
  284. package/dist/types.d.ts +11 -0
  285. package/dist/types.d.ts.map +1 -1
  286. package/dist/types.js.map +1 -1
  287. package/package.json +97 -94
  288. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  289. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  290. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  291. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  292. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  293. package/src/__tests__/BridgeHostInstance.test.ts +232 -0
  294. package/src/__tests__/ClientToolRequestResolver.test.ts +116 -0
  295. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  296. package/src/__tests__/FetchEntityVectorsResolver.test.ts +285 -0
  297. package/src/__tests__/HostInstance.test.ts +84 -0
  298. package/src/__tests__/HumanHandoffResolver.test.ts +250 -0
  299. package/src/__tests__/MeetingResolver.test.ts +448 -0
  300. package/src/__tests__/RealtimeBridgeResolver.test.ts +651 -4
  301. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  302. package/src/__tests__/RealtimeClientSessionResolver.test.ts +682 -58
  303. package/src/__tests__/RealtimeProxyServer.test.ts +16 -1
  304. package/src/__tests__/RealtimeRelay.test.ts +876 -0
  305. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  306. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  307. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  308. package/src/__tests__/ResolverBase.clientOldValues.test.ts +6 -3
  309. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  310. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  311. package/src/__tests__/ReturningVisitorRecap.test.ts +115 -0
  312. package/src/__tests__/RunAIAgentResolver.branch.test.ts +169 -0
  313. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +4 -4
  314. package/src/__tests__/RunAIAgentResolver.stoppedPredecessor.test.ts +166 -0
  315. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  316. package/src/__tests__/RunViewResolver.cacheCheckForwarding.test.ts +63 -0
  317. package/src/__tests__/SessionJanitor.test.ts +845 -18
  318. package/src/__tests__/SessionManager.test.ts +213 -17
  319. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  320. package/src/__tests__/SyncRolesUsersResolver.userRoles.test.ts +97 -0
  321. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
  322. package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +50 -3
  323. package/src/__tests__/UserAvatarResolver.test.ts +255 -0
  324. package/src/__tests__/UserResolver.currentUser.test.ts +16 -1
  325. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  326. package/src/__tests__/actionAuthorization.test.ts +130 -0
  327. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  328. package/src/__tests__/agentVisionConsentAudit.test.ts +118 -0
  329. package/src/__tests__/avatarInputValidation.test.ts +160 -0
  330. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  331. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  332. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  333. package/src/__tests__/newUsers.template.test.ts +151 -0
  334. package/src/__tests__/newUsers.test.ts +74 -5
  335. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +17 -2
  336. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  337. package/src/__tests__/realtimeSessions/RealtimeSessionEventService.test.ts +201 -0
  338. package/src/__tests__/realtimeSessions/RealtimeSessionEventsResolver.test.ts +215 -0
  339. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationResolver.test.ts +102 -0
  340. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationService.test.ts +349 -0
  341. package/src/__tests__/realtimeSessions/RealtimeVerifyRouter.test.ts +131 -0
  342. package/src/__tests__/realtimeSessions/SlidingWindowRateLimiter.test.ts +56 -0
  343. package/src/__tests__/realtimeSessions/helpers.ts +4 -0
  344. package/src/__tests__/realtimeSessions/sessionAccess.test.ts +129 -0
  345. package/src/__tests__/realtimeSessions/sessionConfigStamps.test.ts +124 -0
  346. package/src/__tests__/realtimeSessions/verificationCore.test.ts +355 -0
  347. package/src/__tests__/realtimeSessions/verificationPages.test.ts +84 -0
  348. package/src/__tests__/realtimeSessions/verificationPolicy.test.ts +226 -0
  349. package/src/__tests__/realtimeSessions/verificationWorkflow.test.ts +633 -0
  350. package/src/__tests__/resolverBase.fls.test.ts +87 -4
  351. package/src/__tests__/serverExtensionReservedRoots.test.ts +2 -0
  352. package/src/__tests__/sharedCache.test.ts +310 -0
  353. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  354. package/src/__tests__/widgetGuestElevation.test.ts +44 -0
  355. package/src/agentSessions/BridgeHostInstance.ts +32 -0
  356. package/src/agentSessions/HostInstance.ts +124 -28
  357. package/src/agentSessions/ReturningVisitorRecap.ts +7 -3
  358. package/src/agentSessions/SessionJanitor.ts +543 -57
  359. package/src/agentSessions/SessionManager.ts +125 -25
  360. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  361. package/src/agentSessions/index.ts +10 -3
  362. package/src/auth/actionAuthorization.ts +116 -0
  363. package/src/auth/index.ts +3 -8
  364. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  365. package/src/auth/newUsers.ts +99 -11
  366. package/src/config.ts +255 -3
  367. package/src/context.ts +26 -34
  368. package/src/generated/graphql-schemas/__mj.ts +22701 -18838
  369. package/src/generic/OrphanedConversationDetailReconciler.ts +3 -3
  370. package/src/generic/ResolverBase.ts +210 -36
  371. package/src/generic/RunViewResolver.ts +37 -2
  372. package/src/index.ts +236 -170
  373. package/src/isolatedProvider.ts +22 -0
  374. package/src/postgresqlCompat.ts +21 -0
  375. package/src/postgresqlPoolSettings.ts +213 -0
  376. package/src/realtimeProxy/RealtimeProxyServer.ts +148 -16
  377. package/src/realtimeProxy/RealtimeRelayTunnel.ts +645 -0
  378. package/src/realtimeSessions/README.md +179 -0
  379. package/src/realtimeSessions/RealtimeSessionEventService.ts +274 -0
  380. package/src/realtimeSessions/RealtimeSessionVerificationService.ts +167 -0
  381. package/src/realtimeSessions/RealtimeVerifyRouter.ts +108 -0
  382. package/src/realtimeSessions/SessionVerificationPortsBase.ts +189 -0
  383. package/src/realtimeSessions/SlidingWindowRateLimiter.ts +92 -0
  384. package/src/realtimeSessions/index.ts +16 -0
  385. package/src/realtimeSessions/sessionAccess.ts +141 -0
  386. package/src/realtimeSessions/sessionConfigStamps.ts +123 -0
  387. package/src/realtimeSessions/subscriptionAuthorization.ts +116 -0
  388. package/src/realtimeSessions/verificationCore.ts +502 -0
  389. package/src/realtimeSessions/verificationPages.ts +149 -0
  390. package/src/realtimeSessions/verificationPolicy.ts +321 -0
  391. package/src/realtimeSessions/verificationWorkflow.ts +702 -0
  392. package/src/realtimeWidget/widgetGuestElevation.ts +34 -2
  393. package/src/resolvers/ActionResolver.ts +84 -21
  394. package/src/resolvers/AdhocQueryResolver.ts +105 -187
  395. package/src/resolvers/CacheStatsResolver.ts +89 -2
  396. package/src/resolvers/ClientToolRequestResolver.ts +25 -7
  397. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  398. package/src/resolvers/FetchEntityVectorsResolver.ts +97 -2
  399. package/src/resolvers/GetDataResolver.ts +10 -0
  400. package/src/resolvers/HumanHandoffResolver.ts +312 -0
  401. package/src/resolvers/MCPResolver.ts +8 -5
  402. package/src/resolvers/MeetingResolver.ts +438 -0
  403. package/src/resolvers/RealtimeBridgeResolver.ts +385 -19
  404. package/src/resolvers/RealtimeClientSessionResolver.ts +503 -344
  405. package/src/resolvers/RealtimeSessionEventsResolver.ts +133 -0
  406. package/src/resolvers/RealtimeSessionVerificationResolver.ts +174 -0
  407. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  408. package/src/resolvers/RunAIAgentResolver.ts +78 -11
  409. package/src/resolvers/RunTemplateResolver.ts +13 -12
  410. package/src/resolvers/SyncDataResolver.ts +6 -2
  411. package/src/resolvers/SyncRolesUsersResolver.ts +24 -6
  412. package/src/resolvers/UserAvatarResolver.ts +111 -0
  413. package/src/resolvers/UserResolver.ts +2 -16
  414. package/src/resolvers/adhocTimeout.ts +12 -0
  415. package/src/resolvers/agentVisionConsentAudit.ts +82 -0
  416. package/src/resolvers/avatarInputValidation.ts +123 -0
  417. package/src/resolvers/currentUserRoles.ts +32 -1
  418. package/src/resolvers/meetings/index.ts +4 -0
  419. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  420. package/src/resolvers/meetings/meetingMutations.ts +556 -0
  421. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  422. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  423. package/src/rest/ViewOperationsHandler.ts +8 -5
  424. package/src/services/TaskGraphActionRunner.ts +29 -1
  425. package/src/services/TaskGraphContinuationDeliverer.ts +13 -7
  426. package/src/services/WorkQueueHostService.ts +129 -0
  427. package/src/services/workQueueConfig.ts +52 -0
  428. package/src/sharedCache.ts +302 -0
  429. package/src/types.ts +12 -0
  430. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  431. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  432. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  433. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  434. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  435. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
3
  // Mock CanRun (unused by the janitor path, but SessionManager imports it).
4
4
  vi.mock('@memberjunction/ai-engine-base', () => ({
@@ -6,11 +6,19 @@ vi.mock('@memberjunction/ai-engine-base', () => ({
6
6
  }));
7
7
 
8
8
  // Capture the ExtraFilter passed to each sweep page and return controllable rows.
9
- const runViewMock = vi.fn();
9
+ const { runViewMock, logErrorMock, logStatusMock, recoverMock, resolveAccountMock } = vi.hoisted(() => ({
10
+ runViewMock: vi.fn(),
11
+ logErrorMock: vi.fn(),
12
+ logStatusMock: vi.fn(),
13
+ recoverMock: vi.fn(),
14
+ resolveAccountMock: vi.fn(),
15
+ }));
10
16
  vi.mock('@memberjunction/core', async (importOriginal) => {
11
17
  const actual = await importOriginal<typeof import('@memberjunction/core')>();
12
18
  return {
13
19
  ...actual,
20
+ LogError: logErrorMock,
21
+ LogStatus: logStatusMock,
14
22
  RunView: {
15
23
  ...actual.RunView,
16
24
  FromMetadataProvider: () => ({ RunView: runViewMock }),
@@ -18,9 +26,32 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
18
26
  };
19
27
  });
20
28
 
21
- import { SessionJanitor } from '../agentSessions/SessionJanitor.js';
22
- import { GetHostInstanceID, GetHostNamePrefix } from '../agentSessions/HostInstance.js';
29
+ // Recording recovery lives in ai-agents; the janitor only decides WHEN to call it.
30
+ // Partial mock: SessionManager (imported by the janitor) needs the rest of ai-agents' real exports.
31
+ vi.mock('@memberjunction/ai-agents', async (importOriginal) => {
32
+ const actual = await importOriginal<typeof import('@memberjunction/ai-agents')>();
33
+ return {
34
+ ...actual,
35
+ RecoverRealtimeRecordingFromSegments: recoverMock,
36
+ ResolveRecordingStorageAccountID: resolveAccountMock,
37
+ };
38
+ });
39
+
40
+ import { SessionJanitor, type SessionJanitorConfig } from '../agentSessions/SessionJanitor.js';
41
+ import {
42
+ GetCurrentHostInstance,
43
+ GetHostInstanceID,
44
+ HostInstanceIdentity,
45
+ SetHostInstancePort,
46
+ } from '../agentSessions/HostInstance.js';
23
47
  import type { UserInfo, IMetadataProvider } from '@memberjunction/core';
48
+ import type { MJAIAgentSessionEntity } from '@memberjunction/core-entities';
49
+
50
+ /** The port this test process "serves" on, set the way `Serve` sets it, before anything reads the identity. */
51
+ const OUR_PORT = 4000;
52
+ /** The port of a second MJAPI on the same host. */
53
+ const OTHER_PORT = 4100;
54
+ SetHostInstancePort(OUR_PORT);
24
55
 
25
56
  interface FakeSession {
26
57
  ID: string;
@@ -94,6 +125,10 @@ function sessionSweepCalls(): Array<[{ EntityName: string; ExtraFilter: string;
94
125
 
95
126
  beforeEach(() => {
96
127
  runViewMock.mockReset();
128
+ logErrorMock.mockReset();
129
+ logStatusMock.mockReset();
130
+ recoverMock.mockReset();
131
+ resolveAccountMock.mockReset();
97
132
  sessionPages = [];
98
133
  runViewMock.mockImplementation(async (params: { EntityName: string }) => {
99
134
  if (params.EntityName === 'MJ: AI Agent Session Channels') {
@@ -105,29 +140,129 @@ beforeEach(() => {
105
140
  SessionJanitor.Instance.Stop();
106
141
  });
107
142
 
143
+ type SessionStatus = MJAIAgentSessionEntity['Status'];
144
+
145
+ /** A session row as the recovery sweep reads it. */
146
+ interface SessionRow {
147
+ ID: string;
148
+ Status: SessionStatus;
149
+ HostInstanceID: string;
150
+ }
151
+
152
+ /**
153
+ * Answer the session sweep's reads from `rows` the way the database answers the recovery filter: rows still open in
154
+ * `store` whose `HostInstanceID` starts with the filter's `LIKE` prefix and isn't the id it excludes. Channel reads
155
+ * return nothing.
156
+ */
157
+ function serveSessionReadsFrom(rows: SessionRow[], store: Map<string, SessionStatus>): void {
158
+ runViewMock.mockImplementation(async (params: { EntityName: string; ExtraFilter: string }) => {
159
+ if (params.EntityName !== 'MJ: AI Agent Sessions') {
160
+ return { Success: true, Results: [] };
161
+ }
162
+ const likePrefix = /HostInstanceID LIKE '(.*?)%'/.exec(params.ExtraFilter)?.[1] ?? '';
163
+ const excluded = /HostInstanceID <> '(.*?)'/.exec(params.ExtraFilter)?.[1];
164
+ const results = rows
165
+ .filter((r) => store.get(r.ID) !== 'Closed')
166
+ .filter((r) => r.HostInstanceID.startsWith(likePrefix) && r.HostInstanceID !== excluded)
167
+ .map((r) => ({ ...r, Status: store.get(r.ID) }));
168
+ return { Success: true, Results: results };
169
+ });
170
+ }
171
+
172
+ /** The status map a provider from {@link makeProvider} loads and saves through, built from `rows`. */
173
+ function storeOf(rows: SessionRow[]): Map<string, SessionStatus> {
174
+ return new Map(rows.map((r) => [r.ID, r.Status]));
175
+ }
176
+
108
177
  describe('SessionJanitor.RunStartupRecovery', () => {
109
- it('closes prior-boot orphans of this host but not current-host or other-host sessions', async () => {
110
- // The sweep SQL filters by host prefix + "<> current"; here we emulate the DB by having
111
- // runViewMock return only the rows that filter WOULD match (prior-boot orphans). We assert
112
- // (a) the filter is correct and (b) the returned orphans get closed.
113
- const orphan = { ID: 'orphan-1', Status: 'Active' };
114
- sessionPages = [{ Success: true, Results: [orphan] }];
115
-
116
- const store = new Map<string, 'Active' | 'Idle' | 'Closed'>([['orphan-1', 'Active']]);
178
+ const ours = GetCurrentHostInstance();
179
+
180
+ it('closes the sessions an earlier boot of this instance left, and nothing else (one MJAPI, as before)', async () => {
181
+ const rows: SessionRow[] = [
182
+ { ID: 'before-restart', Status: 'Active', HostInstanceID: `${ours.GetInstancePrefix()}31337:boot-before` },
183
+ // The OS gave the restarted process the same pid; the boot id still differs.
184
+ { ID: 'before-restart-same-pid', Status: 'Idle', HostInstanceID: `${ours.GetInstancePrefix()}${ours.ProcessID}:boot-before` },
185
+ { ID: 'this-boot', Status: 'Active', HostInstanceID: ours.GetHostInstanceID() },
186
+ { ID: 'other-host', Status: 'Active', HostInstanceID: `other-host:${OUR_PORT}:31337:boot-other` },
187
+ ];
188
+ const store = storeOf(rows);
189
+ serveSessionReadsFrom(rows, store);
117
190
  const { provider, closedIds, closedReasons } = makeProvider(store);
118
191
 
119
192
  const count = await SessionJanitor.Instance.RunStartupRecovery(provider, makeUser());
120
193
 
121
- expect(count).toBe(1);
122
- expect(closedIds).toContain('orphan-1');
123
- // Orphan recovery stamps the janitor close cause.
124
- expect(closedReasons.get('orphan-1')).toBe('Janitor');
194
+ expect(count).toBe(2);
195
+ expect([...closedIds].sort()).toEqual(['before-restart', 'before-restart-same-pid']);
196
+ expect(closedReasons.get('before-restart')).toBe('Janitor');
197
+ expect(store.get('this-boot')).toBe('Active');
198
+ expect(store.get('other-host')).toBe('Active');
125
199
 
126
- // Assert the recovery filter targets this host's *other* boots, never the current instance.
200
+ // The read is narrowed to this instance's other boots: its host and port, never this boot.
127
201
  const filter = sessionSweepCalls()[0][0].ExtraFilter as string;
128
202
  expect(filter).toContain("Status IN ('Active','Idle')");
129
- expect(filter).toContain(`HostInstanceID LIKE '${GetHostNamePrefix()}%'`);
203
+ expect(filter).toContain(`HostInstanceID LIKE '${ours.GetInstancePrefix()}%'`);
130
204
  expect(filter).toContain(`HostInstanceID <> '${GetHostInstanceID()}'`);
205
+ expect(ours.GetInstancePrefix()).toContain(`:${OUR_PORT}:`);
206
+ });
207
+
208
+ it('leaves rows stamped before the port was added (hostname:pid:bootId) to the staleness sweep', async () => {
209
+ // Its pid happens to equal our port, so the LIKE prefix reads it; the row check rejects it (it has no port).
210
+ const rows: SessionRow[] = [
211
+ { ID: 'pre-upgrade', Status: 'Active', HostInstanceID: `${ours.HostName}:${OUR_PORT}:boot-before-upgrade` },
212
+ ];
213
+ const store = storeOf(rows);
214
+ serveSessionReadsFrom(rows, store);
215
+ const { provider, closedIds } = makeProvider(store);
216
+
217
+ const count = await SessionJanitor.Instance.RunStartupRecovery(provider, makeUser());
218
+
219
+ expect(count).toBe(0);
220
+ expect(closedIds).toEqual([]);
221
+ });
222
+ });
223
+
224
+ describe('SessionJanitor.RunStartupRecovery with two MJAPIs on one host (#5309)', () => {
225
+ const ours = GetCurrentHostInstance();
226
+ const theirs = new HostInstanceIdentity(ours.HostName, String(OTHER_PORT), 40404, 'boot-theirs');
227
+
228
+ /** Both instances' live sessions, and one each left by an earlier boot. */
229
+ function sharedTable(): SessionRow[] {
230
+ return [
231
+ { ID: 'ours-live', Status: 'Active', HostInstanceID: ours.GetHostInstanceID() },
232
+ { ID: 'ours-stale', Status: 'Idle', HostInstanceID: `${ours.GetInstancePrefix()}31337:boot-ours-before` },
233
+ { ID: 'theirs-live', Status: 'Active', HostInstanceID: theirs.GetHostInstanceID() },
234
+ { ID: 'theirs-stale', Status: 'Active', HostInstanceID: `${theirs.GetInstancePrefix()}27182:boot-theirs-before` },
235
+ ];
236
+ }
237
+
238
+ it("closes its own stale sessions and leaves the other MJAPI's live sessions open", async () => {
239
+ const rows = sharedTable();
240
+ const store = storeOf(rows);
241
+ serveSessionReadsFrom(rows, store);
242
+ const { provider, closedIds } = makeProvider(store);
243
+
244
+ const count = await SessionJanitor.Instance.RunStartupRecovery(provider, makeUser());
245
+
246
+ expect(count).toBe(1);
247
+ expect(closedIds).toEqual(['ours-stale']);
248
+ expect(store.get('theirs-live')).toBe('Active');
249
+ expect(store.get('theirs-stale')).toBe('Active');
250
+ expect(store.get('ours-live')).toBe('Active');
251
+ });
252
+
253
+ it("the other MJAPI's recovery closes its stale sessions and leaves this one's live sessions open", async () => {
254
+ const rows = sharedTable();
255
+ const store = storeOf(rows);
256
+ serveSessionReadsFrom(rows, store);
257
+ const { provider, closedIds } = makeProvider(store);
258
+
259
+ const count = await SessionJanitor.Instance.RunStartupRecovery(provider, makeUser(), theirs);
260
+
261
+ expect(count).toBe(1);
262
+ expect(closedIds).toEqual(['theirs-stale']);
263
+ expect(store.get('ours-live')).toBe('Active');
264
+ expect(store.get('ours-stale')).toBe('Idle');
265
+ expect(store.get('theirs-live')).toBe('Active');
131
266
  });
132
267
  });
133
268
 
@@ -232,3 +367,695 @@ describe('SessionJanitor shutdown drain', () => {
232
367
  expect(closedReasons.get('mine-2')).toBe('Shutdown');
233
368
  });
234
369
  });
370
+
371
+ describe('SessionJanitor.RunRecordingRecoverySweep', () => {
372
+ const NOW = new Date('2026-10-07T12:00:00.000Z');
373
+ const MINUTE = 60_000;
374
+ const HOUR = 60 * MINUTE;
375
+ const STARTED_AT = new Date('2026-10-07T09:00:00.000Z');
376
+ const NO_SEGMENTS = { Outcome: 'NoSegments', FileID: null, SegmentCount: 0, MissingIndexes: [], ErrorMessage: null };
377
+ const FAILED = { Outcome: 'Failed', FileID: null, SegmentCount: 0, MissingIndexes: [], ErrorMessage: 'upload exploded' };
378
+ const MAX_ATTEMPTS_FOR_TEST = 8; // mirrors MAX_RECOVERY_ATTEMPTS
379
+ let nextSessionNumber = 0;
380
+
381
+ interface RecoveryHarness {
382
+ provider: IMetadataProvider;
383
+ /** Session id -> RecordingFileID the reload will report (default null). */
384
+ fileIds: Map<string, string | null>;
385
+ agentLoads: string[];
386
+ /** Agent ids whose Load() reports failure. */
387
+ failingAgents: Set<string>;
388
+ }
389
+
390
+ /** Session ids are unique per test: the janitor is a singleton whose backoff map outlives a test. */
391
+ function candidate(agentID = 'agent-1'): { ID: string; AgentID: string; RecordingStartedAt: Date } {
392
+ return { ID: `rec-session-${nextSessionNumber++}`, AgentID: agentID, RecordingStartedAt: STARTED_AT };
393
+ }
394
+
395
+ function makeRecoveryProvider(): RecoveryHarness {
396
+ const fileIds = new Map<string, string | null>();
397
+ const agentLoads: string[] = [];
398
+ const failingAgents = new Set<string>();
399
+ const provider = {
400
+ GetEntityObject: vi.fn(async (entityName: string) => {
401
+ const entity: { ID: string; RecordingFileID: string | null; Load: (id: string) => Promise<boolean> } = {
402
+ ID: '',
403
+ RecordingFileID: null,
404
+ Load: async (id: string) => {
405
+ entity.ID = id;
406
+ if (entityName === 'MJ: AI Agents') {
407
+ agentLoads.push(id);
408
+ return !failingAgents.has(id);
409
+ } else {
410
+ entity.RecordingFileID = fileIds.get(id) ?? null;
411
+ }
412
+ return true;
413
+ },
414
+ };
415
+ return entity;
416
+ }),
417
+ } as unknown as IMetadataProvider;
418
+ return { provider, fileIds, agentLoads, failingAgents };
419
+ }
420
+
421
+ function recoveryFilter(): string {
422
+ const call = sessionSweepCalls().find((c) => c[0].ExtraFilter.includes('RecordingStartedAt'));
423
+ if (!call) throw new Error('recording recovery sweep never queried sessions');
424
+ return call[0].ExtraFilter;
425
+ }
426
+
427
+ /** Runs one sweep whose candidate page holds exactly `rows`. */
428
+ async function sweepWith(provider: IMetadataProvider, rows: unknown[]): Promise<number> {
429
+ sessionPages = [{ Success: true, Results: rows }];
430
+ return SessionJanitor.Instance.RunRecordingRecoverySweep(provider, makeUser());
431
+ }
432
+
433
+ beforeEach(() => {
434
+ vi.useFakeTimers();
435
+ vi.setSystemTime(NOW);
436
+ SessionJanitor.Instance.Configure({ recordingRecoveryGraceMinutes: 10, recordingRecoveryLookbackHours: 168 });
437
+ resolveAccountMock.mockResolvedValue('account-1');
438
+ recoverMock.mockResolvedValue({ Outcome: 'Recovered', FileID: 'file-1', SegmentCount: 3, MissingIndexes: [], ErrorMessage: null });
439
+ });
440
+
441
+ afterEach(() => {
442
+ SessionJanitor.Instance.Stop();
443
+ vi.useRealTimers();
444
+ });
445
+
446
+ it('queries closed, recorded, unstored sessions between the lookback and grace cutoffs', async () => {
447
+ const { provider } = makeRecoveryProvider();
448
+ await sweepWith(provider, []);
449
+
450
+ const filter = recoveryFilter();
451
+ expect(filter).toContain("Status = 'Closed'");
452
+ expect(filter).toContain('RecordingStartedAt IS NOT NULL');
453
+ expect(filter).toContain('RecordingFileID IS NULL');
454
+ expect(filter).toContain(`ClosedAt < '${new Date(NOW.getTime() - 10 * MINUTE).toISOString()}'`);
455
+ expect(filter).toContain(`ClosedAt >= '${new Date(NOW.getTime() - 168 * HOUR).toISOString()}'`);
456
+ });
457
+
458
+ it('honours Configure() overrides for grace and lookback', async () => {
459
+ SessionJanitor.Instance.Configure({ recordingRecoveryGraceMinutes: 30, recordingRecoveryLookbackHours: 24 });
460
+ const { provider } = makeRecoveryProvider();
461
+ await sweepWith(provider, []);
462
+
463
+ const filter = recoveryFilter();
464
+ expect(filter).toContain(`ClosedAt < '${new Date(NOW.getTime() - 30 * MINUTE).toISOString()}'`);
465
+ expect(filter).toContain(`ClosedAt >= '${new Date(NOW.getTime() - 24 * HOUR).toISOString()}'`);
466
+ });
467
+
468
+ it('loads each agent once per sweep, resolves the account, recovers with StartedAt, and returns the count', async () => {
469
+ const [a, b, c] = [candidate('agent-1'), candidate('agent-1'), candidate('agent-2')];
470
+ const { provider, agentLoads } = makeRecoveryProvider();
471
+ const user = makeUser();
472
+ sessionPages = [{ Success: true, Results: [a, b, c] }];
473
+
474
+ const recovered = await SessionJanitor.Instance.RunRecordingRecoverySweep(provider, user);
475
+
476
+ expect(recovered).toBe(3);
477
+ expect(agentLoads).toEqual(['agent-1', 'agent-2']);
478
+ // The account is resolved once per agent, not once per session.
479
+ expect(resolveAccountMock).toHaveBeenCalledTimes(2);
480
+ expect(recoverMock).toHaveBeenNthCalledWith(1, {
481
+ SessionID: a.ID,
482
+ StorageAccountID: 'account-1',
483
+ StartedAt: STARTED_AT,
484
+ ContextUser: user,
485
+ Provider: provider,
486
+ });
487
+ });
488
+
489
+ it('skips a session whose reload shows a recording was stored meanwhile', async () => {
490
+ const stored = candidate();
491
+ const { provider, fileIds } = makeRecoveryProvider();
492
+ fileIds.set(stored.ID, 'file-from-late-upload');
493
+
494
+ expect(await sweepWith(provider, [stored])).toBe(0);
495
+ expect(recoverMock).not.toHaveBeenCalled();
496
+ });
497
+
498
+ it('skips and logs a candidate without RecordingStartedAt instead of recovering it with no t0', async () => {
499
+ const noStart = { ...candidate(), RecordingStartedAt: null };
500
+ const { provider } = makeRecoveryProvider();
501
+
502
+ expect(await sweepWith(provider, [noStart])).toBe(0);
503
+ expect(recoverMock).not.toHaveBeenCalled();
504
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${noStart.ID}.*RecordingStartedAt`)));
505
+ });
506
+
507
+ it('defers a session whose agent has no storage account: no recover call, backed off', async () => {
508
+ const noAccount = candidate();
509
+ resolveAccountMock.mockResolvedValue(null);
510
+ const { provider } = makeRecoveryProvider();
511
+
512
+ await sweepWith(provider, [noAccount]);
513
+ expect(recoverMock).not.toHaveBeenCalled();
514
+ expect(logStatusMock).toHaveBeenCalledWith(expect.stringContaining(noAccount.ID));
515
+
516
+ // Backed off: the next sweep must not even resolve the account again.
517
+ resolveAccountMock.mockClear();
518
+ await sweepWith(provider, [noAccount]);
519
+ expect(resolveAccountMock).not.toHaveBeenCalled();
520
+ });
521
+
522
+ it('never gives up on a no-storage session: it is not a failed attempt, so no LogError and one status line', async () => {
523
+ const noAccount = candidate();
524
+ resolveAccountMock.mockResolvedValue(null);
525
+ const { provider } = makeRecoveryProvider();
526
+
527
+ for (let i = 0; i < 12; i++) {
528
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
529
+ await sweepWith(provider, [noAccount]);
530
+ }
531
+
532
+ expect(recoverMock).not.toHaveBeenCalled();
533
+ expect(logErrorMock.mock.calls.filter((c) => String(c[0]).includes(noAccount.ID))).toHaveLength(0);
534
+ expect(logStatusMock.mock.calls.filter((c) => String(c[0]).includes(noAccount.ID))).toHaveLength(1);
535
+ });
536
+
537
+ it('recovers a deferred no-storage session once its agent gains a storage account', async () => {
538
+ const later = candidate();
539
+ resolveAccountMock.mockResolvedValue(null);
540
+ const { provider } = makeRecoveryProvider();
541
+ for (let i = 0; i < 9; i++) {
542
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
543
+ await sweepWith(provider, [later]);
544
+ }
545
+
546
+ resolveAccountMock.mockResolvedValue('account-1');
547
+ vi.setSystemTime(NOW.getTime() + 9 * 7 * HOUR);
548
+ expect(await sweepWith(provider, [later])).toBe(1);
549
+ expect(recoverMock).toHaveBeenCalledTimes(1);
550
+ });
551
+
552
+ it('logs a Superseded outcome without treating it as a failure', async () => {
553
+ const raced = candidate();
554
+ recoverMock.mockResolvedValue({ Outcome: 'Superseded', FileID: null, SegmentCount: 2, MissingIndexes: [], ErrorMessage: null });
555
+ const { provider } = makeRecoveryProvider();
556
+
557
+ expect(await sweepWith(provider, [raced])).toBe(0);
558
+
559
+ // Not backed off: a second sweep that still sees the row tries again.
560
+ await sweepWith(provider, [raced]);
561
+ expect(recoverMock).toHaveBeenCalledTimes(2);
562
+ });
563
+
564
+ it('logs NoSegments with session, agent and account ids and backs off', async () => {
565
+ const empty = candidate('agent-9');
566
+ recoverMock.mockResolvedValue(NO_SEGMENTS);
567
+ const { provider } = makeRecoveryProvider();
568
+
569
+ await sweepWith(provider, [empty]);
570
+
571
+ const message = logStatusMock.mock.calls.map((c) => String(c[0])).find((m) => m.includes(empty.ID));
572
+ expect(message).toContain('agent-9');
573
+ expect(message).toContain('account-1');
574
+ expect(logErrorMock).not.toHaveBeenCalled();
575
+ });
576
+
577
+ it('LogErrors a Failed outcome and does not retry before the backoff elapses, then retries after', async () => {
578
+ const flaky = candidate();
579
+ recoverMock.mockResolvedValue({ Outcome: 'Failed', FileID: null, SegmentCount: 0, MissingIndexes: [], ErrorMessage: 'upload exploded' });
580
+ const { provider } = makeRecoveryProvider();
581
+
582
+ await sweepWith(provider, [flaky]);
583
+ expect(recoverMock).toHaveBeenCalledTimes(1);
584
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringContaining('upload exploded'));
585
+
586
+ // After the 1st failure the wait is 2^1 x 5 = 10 minutes.
587
+ vi.setSystemTime(NOW.getTime() + 9 * MINUTE);
588
+ await sweepWith(provider, [flaky]);
589
+ expect(recoverMock).toHaveBeenCalledTimes(1);
590
+
591
+ vi.setSystemTime(NOW.getTime() + 10 * MINUTE + 1);
592
+ await sweepWith(provider, [flaky]);
593
+ expect(recoverMock).toHaveBeenCalledTimes(2);
594
+ });
595
+
596
+ it('caps the backoff at 6 hours', async () => {
597
+ const stubborn = candidate();
598
+ recoverMock.mockResolvedValue(NO_SEGMENTS);
599
+ const { provider } = makeRecoveryProvider();
600
+ const sweepAt = async (atMs: number): Promise<void> => {
601
+ vi.setSystemTime(atMs);
602
+ await sweepWith(provider, [stubborn]);
603
+ };
604
+
605
+ // Failures 1-7, each far enough apart to be eligible. The 7th (2^7 x 5 min = 640 min) is capped to 6 h.
606
+ const seventhFailureAt = NOW.getTime() + 6 * 7 * HOUR;
607
+ for (let i = 0; i < 7; i++) {
608
+ await sweepAt(NOW.getTime() + i * 7 * HOUR);
609
+ }
610
+ expect(recoverMock).toHaveBeenCalledTimes(7);
611
+
612
+ await sweepAt(seventhFailureAt + 6 * HOUR - MINUTE);
613
+ expect(recoverMock).toHaveBeenCalledTimes(7);
614
+ await sweepAt(seventhFailureAt + 6 * HOUR + 1);
615
+ expect(recoverMock).toHaveBeenCalledTimes(8);
616
+ });
617
+
618
+ it('gives up after 8 failures: never retried again, one give-up LogError for the session', async () => {
619
+ const doomed = candidate();
620
+ recoverMock.mockResolvedValue(FAILED);
621
+ const { provider } = makeRecoveryProvider();
622
+
623
+ for (let i = 0; i < 12; i++) {
624
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
625
+ await sweepWith(provider, [doomed]);
626
+ }
627
+
628
+ expect(recoverMock).toHaveBeenCalledTimes(8);
629
+ expect(logErrorMock.mock.calls.filter((c) => String(c[0]).includes(doomed.ID) && String(c[0]).includes('Giving up'))).toHaveLength(1);
630
+ });
631
+
632
+ it('stops checking a session that never has segments with a status line, not an error', async () => {
633
+ const empty = candidate();
634
+ recoverMock.mockResolvedValue(NO_SEGMENTS);
635
+ const { provider } = makeRecoveryProvider();
636
+
637
+ for (let i = 0; i < 12; i++) {
638
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
639
+ await sweepWith(provider, [empty]);
640
+ }
641
+
642
+ expect(recoverMock).toHaveBeenCalledTimes(8);
643
+ expect(logErrorMock.mock.calls.filter((c) => String(c[0]).includes(empty.ID))).toHaveLength(0);
644
+ expect(logStatusMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${empty.ID}.*no recoverable segments in 8 checks`)));
645
+ });
646
+
647
+ it('prunes backoff for sessions that left the candidate set', async () => {
648
+ const gone = candidate();
649
+ recoverMock.mockResolvedValue(NO_SEGMENTS);
650
+ const { provider } = makeRecoveryProvider();
651
+
652
+ await sweepWith(provider, [gone]);
653
+ expect(recoverMock).toHaveBeenCalledTimes(1);
654
+
655
+ await sweepWith(provider, []); // full pass without it: its backoff entry is dropped
656
+
657
+ await sweepWith(provider, [gone]); // reappears: tried immediately, not held back
658
+ expect(recoverMock).toHaveBeenCalledTimes(2);
659
+ });
660
+
661
+ it('attempts at most 5 recoveries per sweep', async () => {
662
+ const rows = Array.from({ length: 7 }, () => candidate());
663
+ const { provider } = makeRecoveryProvider();
664
+
665
+ expect(await sweepWith(provider, rows)).toBe(5);
666
+ expect(recoverMock).toHaveBeenCalledTimes(5);
667
+ });
668
+
669
+ it('stops paging once the per-sweep cap is reached', async () => {
670
+ const firstPage = Array.from({ length: 200 }, () => candidate());
671
+ const { provider } = makeRecoveryProvider();
672
+ sessionPages = [{ Success: true, Results: firstPage }, { Success: true, Results: [candidate()] }];
673
+
674
+ await SessionJanitor.Instance.RunRecordingRecoverySweep(provider, makeUser());
675
+
676
+ expect(recoverMock).toHaveBeenCalledTimes(5);
677
+ expect(sessionSweepCalls()).toHaveLength(1);
678
+ });
679
+
680
+ it('keeps backoff for candidates a capped sweep never reached', async () => {
681
+ const unreached = candidate();
682
+ const { provider } = makeRecoveryProvider();
683
+ recoverMock.mockResolvedValueOnce(NO_SEGMENTS);
684
+ await sweepWith(provider, [unreached]); // backs `unreached` off
685
+
686
+ // Next sweep hits the cap before reaching `unreached`, so it is never "seen".
687
+ const crowd = Array.from({ length: 6 }, () => candidate());
688
+ await sweepWith(provider, [...crowd, unreached]);
689
+ expect(recoverMock).toHaveBeenCalledTimes(6);
690
+
691
+ // Still backed off afterwards: the capped pass did not prune it.
692
+ await sweepWith(provider, [unreached]);
693
+ expect(recoverMock).toHaveBeenCalledTimes(6);
694
+ });
695
+
696
+ it('does not prune backoff when a page load fails, and a given-up session stays given up', async () => {
697
+ const doomed = candidate();
698
+ recoverMock.mockResolvedValue(FAILED);
699
+ const { provider } = makeRecoveryProvider();
700
+ for (let i = 0; i < MAX_ATTEMPTS_FOR_TEST; i++) {
701
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
702
+ await sweepWith(provider, [doomed]);
703
+ }
704
+ expect(recoverMock).toHaveBeenCalledTimes(MAX_ATTEMPTS_FOR_TEST);
705
+
706
+ // A DB hiccup: the page fails to load. This must not reset anyone's backoff.
707
+ vi.setSystemTime(NOW.getTime() + 100 * HOUR);
708
+ sessionPages = [{ Success: false, ErrorMessage: 'db down', Results: [] }];
709
+ await SessionJanitor.Instance.RunRecordingRecoverySweep(provider, makeUser());
710
+
711
+ await sweepWith(provider, [doomed]);
712
+ expect(recoverMock).toHaveBeenCalledTimes(MAX_ATTEMPTS_FOR_TEST);
713
+ expect(logErrorMock.mock.calls.filter((c) => String(c[0]).includes(doomed.ID) && String(c[0]).includes('Giving up'))).toHaveLength(1);
714
+ });
715
+
716
+ it('logs an unloadable agent distinctly from a missing storage account', async () => {
717
+ const orphaned = candidate('agent-gone');
718
+ const { provider, failingAgents } = makeRecoveryProvider();
719
+ failingAgents.add('agent-gone');
720
+
721
+ await sweepWith(provider, [orphaned]);
722
+
723
+ expect(recoverMock).not.toHaveBeenCalled();
724
+ const message = logStatusMock.mock.calls.map((c) => String(c[0])).find((m) => m.includes(orphaned.ID));
725
+ expect(message).toContain('could not be loaded');
726
+ expect(message).not.toContain('no recording storage account');
727
+ });
728
+
729
+ it('survives a candidate that throws, logging its session id and moving on', async () => {
730
+ const bad = candidate();
731
+ const good = candidate();
732
+ recoverMock.mockRejectedValueOnce(new Error('kaboom'));
733
+ const { provider } = makeRecoveryProvider();
734
+
735
+ expect(await sweepWith(provider, [bad, good])).toBe(1);
736
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringContaining(bad.ID));
737
+ });
738
+
739
+ describe('attempt timeout', () => {
740
+ /** A recovery call for `sessionID` that never settles until the returned release is called. */
741
+ function hangRecoveryFor(sessionID: string): () => void {
742
+ let release: () => void = () => undefined;
743
+ const gate = new Promise<void>((resolve) => { release = resolve; });
744
+ recoverMock.mockImplementation(async (input: { SessionID: string }) => {
745
+ if (input.SessionID === sessionID) {
746
+ await gate;
747
+ return NO_SEGMENTS;
748
+ }
749
+ return { Outcome: 'Recovered', FileID: 'file-1', SegmentCount: 3, MissingIndexes: [], ErrorMessage: null };
750
+ });
751
+ return release;
752
+ }
753
+
754
+ it('abandons an attempt that outlives the timeout, logs it as a failure, and recovers the next candidate', async () => {
755
+ const hung = candidate();
756
+ const next = candidate();
757
+ const release = hangRecoveryFor(hung.ID);
758
+ const { provider } = makeRecoveryProvider();
759
+ try {
760
+ const sweep = sweepWith(provider, [hung, next]);
761
+ await vi.advanceTimersByTimeAsync(10 * MINUTE);
762
+ expect(await sweep).toBe(1);
763
+
764
+ expect(recoverMock.mock.calls.map((c) => (c[0] as { SessionID: string }).SessionID)).toEqual([hung.ID, next.ID]);
765
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${hung.ID}.*did not finish within 10 minutes`)));
766
+ } finally {
767
+ release();
768
+ await vi.advanceTimersByTimeAsync(0);
769
+ }
770
+ });
771
+
772
+ it('starts no second attempt for a session while its abandoned attempt is still running', async () => {
773
+ const hung = candidate();
774
+ const release = hangRecoveryFor(hung.ID);
775
+ const { provider } = makeRecoveryProvider();
776
+ try {
777
+ const first = sweepWith(provider, [hung]);
778
+ await vi.advanceTimersByTimeAsync(10 * MINUTE);
779
+ await first;
780
+
781
+ // Past the 10-minute backoff of the first failure, but the abandoned call is still in flight.
782
+ vi.setSystemTime(Date.now() + 11 * MINUTE);
783
+ await sweepWith(provider, [hung]);
784
+ expect(recoverMock).toHaveBeenCalledTimes(1);
785
+
786
+ release();
787
+ await vi.advanceTimersByTimeAsync(0);
788
+ vi.setSystemTime(Date.now() + 11 * MINUTE);
789
+ await sweepWith(provider, [hung]);
790
+ expect(recoverMock).toHaveBeenCalledTimes(2);
791
+ } finally {
792
+ release();
793
+ await vi.advanceTimersByTimeAsync(0);
794
+ }
795
+ });
796
+
797
+ it('pauses recovery while 2 abandoned attempts are still running, logs it once, and resumes when one settles', async () => {
798
+ const [a, b, c, d] = [candidate(), candidate(), candidate(), candidate()];
799
+ const gates = new Map<string, () => void>();
800
+ recoverMock.mockImplementation(async (input: { SessionID: string }) => {
801
+ if (input.SessionID === a.ID || input.SessionID === b.ID || input.SessionID === c.ID) {
802
+ await new Promise<void>((resolve) => gates.set(input.SessionID, resolve));
803
+ return NO_SEGMENTS;
804
+ }
805
+ return { Outcome: 'Recovered', FileID: 'file-1', SegmentCount: 1, MissingIndexes: [], ErrorMessage: null };
806
+ });
807
+ const attempted = (): string[] => recoverMock.mock.calls.map((call) => (call[0] as { SessionID: string }).SessionID);
808
+ const { provider } = makeRecoveryProvider();
809
+ try {
810
+ const first = sweepWith(provider, [a, b, c, d]);
811
+ await vi.advanceTimersByTimeAsync(20 * MINUTE);
812
+ await first;
813
+ expect(attempted()).toEqual([a.ID, b.ID]); // cap reached after the 2nd abandonment: C and D wait
814
+
815
+ await sweepWith(provider, [a, b, c, d]);
816
+ await sweepWith(provider, [a, b, c, d]);
817
+ expect(attempted()).toEqual([a.ID, b.ID]);
818
+ expect(logErrorMock.mock.calls.filter((call) => String(call[0]).includes('paused'))).toHaveLength(1);
819
+
820
+ gates.get(a.ID)?.();
821
+ await vi.advanceTimersByTimeAsync(0);
822
+ const resumed = sweepWith(provider, [d, c]);
823
+ await vi.advanceTimersByTimeAsync(10 * MINUTE);
824
+ expect(await resumed).toBe(1);
825
+ expect(attempted()).toEqual([a.ID, b.ID, d.ID, c.ID]);
826
+ } finally {
827
+ for (const release of gates.values()) release();
828
+ await vi.advanceTimersByTimeAsync(0);
829
+ }
830
+ });
831
+
832
+ it('honours a configured attempt timeout', async () => {
833
+ SessionJanitor.Instance.Configure({ recordingRecoveryAttemptTimeoutMinutes: 2 });
834
+ const hung = candidate();
835
+ const release = hangRecoveryFor(hung.ID);
836
+ const { provider } = makeRecoveryProvider();
837
+ try {
838
+ const sweep = sweepWith(provider, [hung]);
839
+ await vi.advanceTimersByTimeAsync(2 * MINUTE);
840
+ expect(await sweep).toBe(0);
841
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(/did not finish within 2 minutes/));
842
+ } finally {
843
+ SessionJanitor.Instance.Configure({ recordingRecoveryAttemptTimeoutMinutes: 10 });
844
+ release();
845
+ await vi.advanceTimersByTimeAsync(0);
846
+ }
847
+ });
848
+ });
849
+
850
+ describe('periodic tick', () => {
851
+ it('a recovery call that never settles does not stop later ticks from recovering other sessions', async () => {
852
+ const hung = candidate();
853
+ const later = candidate();
854
+ let release: () => void = () => undefined;
855
+ const gate = new Promise<void>((resolve) => { release = resolve; });
856
+ recoverMock.mockImplementation(async (input: { SessionID: string }) => {
857
+ if (input.SessionID === hung.ID) {
858
+ await gate;
859
+ }
860
+ return { Outcome: 'Recovered', FileID: 'file-1', SegmentCount: 1, MissingIndexes: [], ErrorMessage: null };
861
+ });
862
+ const { provider } = makeRecoveryProvider();
863
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
864
+ runViewMock.mockImplementation(async (params: { EntityName: string; ExtraFilter?: string }) => {
865
+ if (params.ExtraFilter?.includes('RecordingStartedAt')) {
866
+ return { Success: true, Results: recoverMock.mock.calls.length === 0 ? [hung] : [later] };
867
+ }
868
+ return { Success: true, Results: [] };
869
+ });
870
+ try {
871
+ await vi.advanceTimersByTimeAsync(60_000); // tick 1: the hung attempt starts
872
+ await vi.advanceTimersByTimeAsync(12 * MINUTE); // timeout elapses; later ticks run new passes
873
+
874
+ expect(recoverMock.mock.calls.map((c) => (c[0] as { SessionID: string }).SessionID)).toContain(later.ID);
875
+ } finally {
876
+ release();
877
+ await vi.advanceTimersByTimeAsync(0);
878
+ }
879
+ });
880
+
881
+ it('runs recovery after the close sweeps', async () => {
882
+ const { provider } = makeRecoveryProvider();
883
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
884
+ runViewMock.mockClear();
885
+
886
+ await vi.advanceTimersByTimeAsync(60_000);
887
+
888
+ const filters = sessionSweepCalls().map((c) => c[0].ExtraFilter);
889
+ expect(filters).toHaveLength(3);
890
+ expect(filters[0]).toContain('LastActiveAt <');
891
+ expect(filters[1]).toContain('maxSessionDeadlineIso');
892
+ expect(filters[2]).toContain('RecordingStartedAt');
893
+ });
894
+
895
+ it('a slow recovery does not block the close sweeps on later ticks and is not started twice', async () => {
896
+ const { provider } = makeRecoveryProvider();
897
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
898
+ runViewMock.mockClear();
899
+ let releaseRecovery: () => void = () => undefined;
900
+ const recoveryGate = new Promise<void>((resolve) => { releaseRecovery = resolve; });
901
+ runViewMock.mockImplementation(async (params: { ExtraFilter?: string }) => {
902
+ if (params.ExtraFilter?.includes('RecordingStartedAt')) await recoveryGate; // recovery hangs
903
+ return { Success: true, Results: [] };
904
+ });
905
+
906
+ await vi.advanceTimersByTimeAsync(60_000); // tick 1: close sweeps + recovery starts, hangs
907
+ await vi.advanceTimersByTimeAsync(60_000); // tick 2: close sweeps still run; no second recovery
908
+
909
+ const filters = sessionSweepCalls().map((c) => c[0].ExtraFilter);
910
+ expect(filters.filter((f) => f.includes('LastActiveAt <'))).toHaveLength(2);
911
+ expect(filters.filter((f) => f.includes('maxSessionDeadlineIso'))).toHaveLength(2);
912
+ expect(filters.filter((f) => f.includes('RecordingStartedAt'))).toHaveLength(1);
913
+
914
+ releaseRecovery();
915
+ await vi.advanceTimersByTimeAsync(60_000); // tick 3: recovery finished, so it starts again
916
+ expect(sessionSweepCalls().map((c) => c[0].ExtraFilter).filter((f) => f.includes('RecordingStartedAt'))).toHaveLength(2);
917
+ });
918
+
919
+ /** Makes the recovery sweep's candidate query hang until the returned release function is called. */
920
+ function hangRecoveryQuery(): () => void {
921
+ let release: () => void = () => undefined;
922
+ const gate = new Promise<void>((resolve) => { release = resolve; });
923
+ runViewMock.mockImplementation(async (params: { ExtraFilter?: string }) => {
924
+ if (params.ExtraFilter?.includes('RecordingStartedAt')) await gate;
925
+ return { Success: true, Results: [] };
926
+ });
927
+ return release;
928
+ }
929
+
930
+ function recoveryQueries(): number {
931
+ return sessionSweepCalls().filter((c) => c[0].ExtraFilter.includes('RecordingStartedAt')).length;
932
+ }
933
+
934
+ it('starts no recovery pass after Stop(), even from a tick that was already running', async () => {
935
+ const { provider } = makeRecoveryProvider();
936
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
937
+ runViewMock.mockClear();
938
+ let releaseClose: () => void = () => undefined;
939
+ const closeGate = new Promise<void>((resolve) => { releaseClose = resolve; });
940
+ runViewMock.mockImplementation(async (params: { ExtraFilter?: string }) => {
941
+ if (params.ExtraFilter?.includes('LastActiveAt <')) await closeGate; // tick parks in the close sweeps
942
+ return { Success: true, Results: [] };
943
+ });
944
+
945
+ await vi.advanceTimersByTimeAsync(60_000);
946
+ SessionJanitor.Instance.Stop();
947
+ releaseClose();
948
+ await vi.advanceTimersByTimeAsync(0);
949
+
950
+ expect(sessionSweepCalls().some((c) => c[0].ExtraFilter.includes('maxSessionDeadlineIso'))).toBe(true);
951
+ expect(recoveryQueries()).toBe(0);
952
+ });
953
+
954
+ it('Shutdown() waits for an in-flight recovery pass to finish', async () => {
955
+ const { provider } = makeRecoveryProvider();
956
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
957
+ runViewMock.mockClear();
958
+ const releaseRecovery = hangRecoveryQuery();
959
+ try {
960
+ await vi.advanceTimersByTimeAsync(60_000); // recovery pass starts and hangs
961
+ expect(recoveryQueries()).toBe(1);
962
+
963
+ let finished = false;
964
+ const shutdown = SessionJanitor.Instance.Shutdown().then(() => { finished = true; });
965
+ await vi.advanceTimersByTimeAsync(1_000);
966
+ expect(finished).toBe(false);
967
+
968
+ releaseRecovery();
969
+ await shutdown;
970
+ expect(finished).toBe(true);
971
+ expect(logErrorMock).not.toHaveBeenCalled();
972
+ } finally {
973
+ releaseRecovery(); // never leave a parked pass on the singleton for later tests
974
+ await vi.advanceTimersByTimeAsync(0);
975
+ }
976
+ });
977
+
978
+ it('Shutdown() stops waiting for a stuck recovery pass after 10 s and logs that once', async () => {
979
+ const { provider } = makeRecoveryProvider();
980
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
981
+ runViewMock.mockClear();
982
+ const releaseRecovery = hangRecoveryQuery();
983
+ try {
984
+ await vi.advanceTimersByTimeAsync(60_000);
985
+
986
+ let finished = false;
987
+ const shutdown = SessionJanitor.Instance.Shutdown().then(() => { finished = true; });
988
+ await vi.advanceTimersByTimeAsync(9_999);
989
+ expect(finished).toBe(false);
990
+ await vi.advanceTimersByTimeAsync(1);
991
+ await shutdown;
992
+
993
+ expect(finished).toBe(true);
994
+ expect(logErrorMock).toHaveBeenCalledTimes(1);
995
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(/shutdown.*recording recovery.*10000 ms/i));
996
+ } finally {
997
+ releaseRecovery(); // let the parked pass end so the singleton can start passes in later tests
998
+ await vi.advanceTimersByTimeAsync(0);
999
+ }
1000
+ });
1001
+
1002
+ it('a recovery failure is logged with context and does not disturb the close sweeps', async () => {
1003
+ const { provider } = makeRecoveryProvider();
1004
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
1005
+ runViewMock.mockClear();
1006
+ runViewMock.mockImplementation(async (params: { ExtraFilter?: string }) => {
1007
+ if (params.ExtraFilter?.includes('RecordingStartedAt')) throw new Error('recovery query blew up');
1008
+ return { Success: true, Results: [] };
1009
+ });
1010
+
1011
+ await vi.advanceTimersByTimeAsync(60_000);
1012
+
1013
+ const filters = sessionSweepCalls().map((c) => c[0].ExtraFilter);
1014
+ expect(filters[0]).toContain('LastActiveAt <');
1015
+ expect(filters[1]).toContain('maxSessionDeadlineIso');
1016
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(/recording recovery.*recovery query blew up/i));
1017
+ });
1018
+ });
1019
+ });
1020
+
1021
+ describe('SessionJanitor.Configure', () => {
1022
+ const DEFAULTS: SessionJanitorConfig = {
1023
+ closeThresholdMinutes: 15, sweepIntervalMs: 60_000, recordingRecoveryGraceMinutes: 10, recordingRecoveryLookbackHours: 168,
1024
+ recordingRecoveryAttemptTimeoutMinutes: 10,
1025
+ };
1026
+
1027
+ afterEach(() => {
1028
+ SessionJanitor.Instance.Configure(DEFAULTS);
1029
+ vi.useRealTimers();
1030
+ });
1031
+
1032
+ it.each([
1033
+ ['recordingRecoveryGraceMinutes', Number.NaN],
1034
+ ['recordingRecoveryGraceMinutes', 0],
1035
+ ['recordingRecoveryLookbackHours', -1],
1036
+ ['closeThresholdMinutes', 0],
1037
+ ['sweepIntervalMs', Number.POSITIVE_INFINITY],
1038
+ ['recordingRecoveryAttemptTimeoutMinutes', 0],
1039
+ ] as const)('rejects %s = %s, naming the field and the value', (field, value) => {
1040
+ const config: Partial<SessionJanitorConfig> = { [field]: value };
1041
+ expect(() => SessionJanitor.Instance.Configure(config)).toThrow(new RegExp(`${field}.*${String(value)}`));
1042
+ });
1043
+
1044
+ it('applies nothing from a call that has an invalid value', async () => {
1045
+ vi.useFakeTimers();
1046
+ vi.setSystemTime(new Date('2026-10-07T12:00:00.000Z'));
1047
+ SessionJanitor.Instance.Configure({ closeThresholdMinutes: 20 });
1048
+ expect(() => SessionJanitor.Instance.Configure({ closeThresholdMinutes: 30, sweepIntervalMs: -5 })).toThrow(/sweepIntervalMs/);
1049
+
1050
+ const { provider } = makeProvider(new Map());
1051
+ await SessionJanitor.Instance.RunStalenessSweep(provider, makeUser());
1052
+ const cutoff = new Date(Date.now() - 20 * 60_000).toISOString();
1053
+ expect(sessionSweepCalls()[0][0].ExtraFilter).toContain(`LastActiveAt < '${cutoff}'`);
1054
+ });
1055
+
1056
+ it('rejects an invalid sweep interval passed to Start() before doing any work', async () => {
1057
+ const { provider } = makeProvider(new Map());
1058
+ await expect(SessionJanitor.Instance.Start(provider, makeUser(), 0)).rejects.toThrow(/sweepIntervalMs.*0/);
1059
+ expect(sessionSweepCalls()).toHaveLength(0);
1060
+ });
1061
+ });