@memberjunction/server 6.2.0-edge.3 → 6.2.0-edge.5

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 (543) hide show
  1. package/README.md +20 -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 +236 -27
  15. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  16. package/dist/agentSessions/SessionJanitor.js +682 -54
  17. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  18. package/dist/agentSessions/SessionManager.d.ts +48 -3
  19. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  20. package/dist/agentSessions/SessionManager.js +112 -9
  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/apiRoot.d.ts +32 -0
  31. package/dist/apiRoot.d.ts.map +1 -0
  32. package/dist/apiRoot.js +67 -0
  33. package/dist/apiRoot.js.map +1 -0
  34. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -1
  35. package/dist/auth/AuthProviderCatalogRouter.js +25 -0
  36. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -1
  37. package/dist/auth/LoginScreenEngine.d.ts +22 -0
  38. package/dist/auth/LoginScreenEngine.d.ts.map +1 -0
  39. package/dist/auth/LoginScreenEngine.js +57 -0
  40. package/dist/auth/LoginScreenEngine.js.map +1 -0
  41. package/dist/auth/actionAuthorization.d.ts +40 -0
  42. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  43. package/dist/auth/actionAuthorization.js +94 -0
  44. package/dist/auth/actionAuthorization.js.map +1 -0
  45. package/dist/auth/index.d.ts +1 -0
  46. package/dist/auth/index.d.ts.map +1 -1
  47. package/dist/auth/index.js +4 -10
  48. package/dist/auth/index.js.map +1 -1
  49. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
  50. package/dist/auth/magicLink/MagicLinkRouter.js +3 -2
  51. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  52. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  53. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  54. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  55. package/dist/auth/newUsers.d.ts +10 -0
  56. package/dist/auth/newUsers.d.ts.map +1 -1
  57. package/dist/auth/newUsers.js +80 -11
  58. package/dist/auth/newUsers.js.map +1 -1
  59. package/dist/config.d.ts +2514 -22
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/config.js +166 -4
  62. package/dist/config.js.map +1 -1
  63. package/dist/context.d.ts +8 -0
  64. package/dist/context.d.ts.map +1 -1
  65. package/dist/context.js +14 -11
  66. package/dist/context.js.map +1 -1
  67. package/dist/coreRoutes.d.ts +29 -0
  68. package/dist/coreRoutes.d.ts.map +1 -0
  69. package/dist/coreRoutes.js +142 -0
  70. package/dist/coreRoutes.js.map +1 -0
  71. package/dist/generated/graphql-schemas/__mj.d.ts +504 -3
  72. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  73. package/dist/generated/graphql-schemas/__mj.js +2456 -7
  74. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  75. package/dist/generic/CacheInvalidationResolver.d.ts +43 -8
  76. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  77. package/dist/generic/CacheInvalidationResolver.js +59 -19
  78. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  79. package/dist/generic/OrphanedConversationDetailReconciler.js +3 -3
  80. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -1
  81. package/dist/generic/PushStatusResolver.js +1 -1
  82. package/dist/generic/PushStatusResolver.js.map +1 -1
  83. package/dist/generic/ResolverBase.d.ts +123 -48
  84. package/dist/generic/ResolverBase.d.ts.map +1 -1
  85. package/dist/generic/ResolverBase.js +293 -305
  86. package/dist/generic/ResolverBase.js.map +1 -1
  87. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  88. package/dist/generic/RunViewResolver.js +16 -0
  89. package/dist/generic/RunViewResolver.js.map +1 -1
  90. package/dist/globalCors.d.ts +33 -0
  91. package/dist/globalCors.d.ts.map +1 -0
  92. package/dist/globalCors.js +58 -0
  93. package/dist/globalCors.js.map +1 -0
  94. package/dist/index.d.ts +17 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +219 -278
  97. package/dist/index.js.map +1 -1
  98. package/dist/isolatedProvider.d.ts +20 -0
  99. package/dist/isolatedProvider.d.ts.map +1 -0
  100. package/dist/isolatedProvider.js +22 -0
  101. package/dist/isolatedProvider.js.map +1 -0
  102. package/dist/multiTenancy/index.d.ts.map +1 -1
  103. package/dist/multiTenancy/index.js +22 -2
  104. package/dist/multiTenancy/index.js.map +1 -1
  105. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +44 -4
  106. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  107. package/dist/realtimeProxy/RealtimeProxyServer.js +121 -16
  108. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  109. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts +171 -0
  110. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts.map +1 -0
  111. package/dist/realtimeProxy/RealtimeRelayTunnel.js +532 -0
  112. package/dist/realtimeProxy/RealtimeRelayTunnel.js.map +1 -0
  113. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts +148 -0
  114. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts.map +1 -0
  115. package/dist/realtimeSessions/RealtimeSessionEventService.js +191 -0
  116. package/dist/realtimeSessions/RealtimeSessionEventService.js.map +1 -0
  117. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts +66 -0
  118. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -0
  119. package/dist/realtimeSessions/RealtimeSessionVerificationService.js +139 -0
  120. package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -0
  121. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts +38 -0
  122. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -0
  123. package/dist/realtimeSessions/RealtimeVerifyRouter.js +99 -0
  124. package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -0
  125. package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts +100 -0
  126. package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts.map +1 -0
  127. package/dist/realtimeSessions/RecordingAssemblyScheduler.js +173 -0
  128. package/dist/realtimeSessions/RecordingAssemblyScheduler.js.map +1 -0
  129. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts +61 -0
  130. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts.map +1 -0
  131. package/dist/realtimeSessions/SessionVerificationPortsBase.js +149 -0
  132. package/dist/realtimeSessions/SessionVerificationPortsBase.js.map +1 -0
  133. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts +57 -0
  134. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts.map +1 -0
  135. package/dist/realtimeSessions/SlidingWindowRateLimiter.js +69 -0
  136. package/dist/realtimeSessions/SlidingWindowRateLimiter.js.map +1 -0
  137. package/dist/realtimeSessions/index.d.ts +19 -0
  138. package/dist/realtimeSessions/index.d.ts.map +1 -0
  139. package/dist/realtimeSessions/index.js +19 -0
  140. package/dist/realtimeSessions/index.js.map +1 -0
  141. package/dist/realtimeSessions/ownedRealtimeSession.d.ts +30 -0
  142. package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -0
  143. package/dist/realtimeSessions/ownedRealtimeSession.js +47 -0
  144. package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -0
  145. package/dist/realtimeSessions/sessionAccess.d.ts +92 -0
  146. package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -0
  147. package/dist/realtimeSessions/sessionAccess.js +94 -0
  148. package/dist/realtimeSessions/sessionAccess.js.map +1 -0
  149. package/dist/realtimeSessions/sessionConfigStamps.d.ts +70 -0
  150. package/dist/realtimeSessions/sessionConfigStamps.d.ts.map +1 -0
  151. package/dist/realtimeSessions/sessionConfigStamps.js +93 -0
  152. package/dist/realtimeSessions/sessionConfigStamps.js.map +1 -0
  153. package/dist/realtimeSessions/subscriptionAuthorization.d.ts +65 -0
  154. package/dist/realtimeSessions/subscriptionAuthorization.d.ts.map +1 -0
  155. package/dist/realtimeSessions/subscriptionAuthorization.js +71 -0
  156. package/dist/realtimeSessions/subscriptionAuthorization.js.map +1 -0
  157. package/dist/realtimeSessions/verificationCore.d.ts +243 -0
  158. package/dist/realtimeSessions/verificationCore.d.ts.map +1 -0
  159. package/dist/realtimeSessions/verificationCore.js +356 -0
  160. package/dist/realtimeSessions/verificationCore.js.map +1 -0
  161. package/dist/realtimeSessions/verificationPages.d.ts +66 -0
  162. package/dist/realtimeSessions/verificationPages.d.ts.map +1 -0
  163. package/dist/realtimeSessions/verificationPages.js +110 -0
  164. package/dist/realtimeSessions/verificationPages.js.map +1 -0
  165. package/dist/realtimeSessions/verificationPolicy.d.ts +147 -0
  166. package/dist/realtimeSessions/verificationPolicy.d.ts.map +1 -0
  167. package/dist/realtimeSessions/verificationPolicy.js +249 -0
  168. package/dist/realtimeSessions/verificationPolicy.js.map +1 -0
  169. package/dist/realtimeSessions/verificationWorkflow.d.ts +263 -0
  170. package/dist/realtimeSessions/verificationWorkflow.d.ts.map +1 -0
  171. package/dist/realtimeSessions/verificationWorkflow.js +435 -0
  172. package/dist/realtimeSessions/verificationWorkflow.js.map +1 -0
  173. package/dist/realtimeWidget/widgetGuestElevation.d.ts +20 -2
  174. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  175. package/dist/realtimeWidget/widgetGuestElevation.js +31 -2
  176. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  177. package/dist/resolvers/ActionResolver.d.ts +31 -5
  178. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  179. package/dist/resolvers/ActionResolver.js +75 -14
  180. package/dist/resolvers/ActionResolver.js.map +1 -1
  181. package/dist/resolvers/AdhocQueryResolver.d.ts +14 -1
  182. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  183. package/dist/resolvers/AdhocQueryResolver.js +21 -5
  184. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  185. package/dist/resolvers/ArtifactFileResolver.d.ts +5 -1
  186. package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -1
  187. package/dist/resolvers/ArtifactFileResolver.js +9 -7
  188. package/dist/resolvers/ArtifactFileResolver.js.map +1 -1
  189. package/dist/resolvers/CacheStatsResolver.d.ts +27 -0
  190. package/dist/resolvers/CacheStatsResolver.d.ts.map +1 -1
  191. package/dist/resolvers/CacheStatsResolver.js +113 -2
  192. package/dist/resolvers/CacheStatsResolver.js.map +1 -1
  193. package/dist/resolvers/ClientToolRequestResolver.d.ts +4 -2
  194. package/dist/resolvers/ClientToolRequestResolver.d.ts.map +1 -1
  195. package/dist/resolvers/ClientToolRequestResolver.js +28 -10
  196. package/dist/resolvers/ClientToolRequestResolver.js.map +1 -1
  197. package/dist/resolvers/ConversationRunEventsResolver.d.ts +8 -4
  198. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
  199. package/dist/resolvers/ConversationRunEventsResolver.js +6 -6
  200. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -1
  201. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  202. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  203. package/dist/resolvers/DatasetResolver.js +57 -8
  204. package/dist/resolvers/DatasetResolver.js.map +1 -1
  205. package/dist/resolvers/EntityRecordNameResolver.d.ts +2 -1
  206. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  207. package/dist/resolvers/EntityRecordNameResolver.js +3 -2
  208. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  209. package/dist/resolvers/FetchEntityVectorsResolver.d.ts +27 -1
  210. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  211. package/dist/resolvers/FetchEntityVectorsResolver.js +148 -2
  212. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  213. package/dist/resolvers/FileResolver.d.ts +82 -11
  214. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  215. package/dist/resolvers/FileResolver.js +279 -101
  216. package/dist/resolvers/FileResolver.js.map +1 -1
  217. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  218. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  219. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  220. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  221. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  222. package/dist/resolvers/GetDataResolver.js +6 -3
  223. package/dist/resolvers/GetDataResolver.js.map +1 -1
  224. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -1
  225. package/dist/resolvers/HumanHandoffResolver.js +7 -1
  226. package/dist/resolvers/HumanHandoffResolver.js.map +1 -1
  227. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  228. package/dist/resolvers/IntegrationDiscoveryResolver.js +4 -2
  229. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  230. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  231. package/dist/resolvers/MCPResolver.js +9 -4
  232. package/dist/resolvers/MCPResolver.js.map +1 -1
  233. package/dist/resolvers/QueryResolver.d.ts +2 -0
  234. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  235. package/dist/resolvers/QueryResolver.js +13 -0
  236. package/dist/resolvers/QueryResolver.js.map +1 -1
  237. package/dist/resolvers/RealtimeBridgeResolver.d.ts +54 -2
  238. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  239. package/dist/resolvers/RealtimeBridgeResolver.js +155 -10
  240. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  241. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +157 -33
  242. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  243. package/dist/resolvers/RealtimeClientSessionResolver.js +404 -116
  244. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  245. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts +61 -0
  246. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts.map +1 -0
  247. package/dist/resolvers/RealtimeSessionEventsResolver.js +130 -0
  248. package/dist/resolvers/RealtimeSessionEventsResolver.js.map +1 -0
  249. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts +85 -0
  250. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts.map +1 -0
  251. package/dist/resolvers/RealtimeSessionVerificationResolver.js +188 -0
  252. package/dist/resolvers/RealtimeSessionVerificationResolver.js.map +1 -0
  253. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  254. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  255. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  256. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  257. package/dist/resolvers/RunAIAgentResolver.d.ts +10 -0
  258. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  259. package/dist/resolvers/RunAIAgentResolver.js +100 -14
  260. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  261. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  262. package/dist/resolvers/RunTemplateResolver.js +12 -11
  263. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  264. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +12 -1
  265. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -1
  266. package/dist/resolvers/SearchKnowledgeStreamResolver.js +15 -2
  267. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
  268. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  269. package/dist/resolvers/SqlLoggingConfigResolver.js +2 -3
  270. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  271. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  272. package/dist/resolvers/SyncDataResolver.js +6 -2
  273. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  274. package/dist/resolvers/SyncRolesUsersResolver.d.ts +2 -0
  275. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  276. package/dist/resolvers/SyncRolesUsersResolver.js +23 -6
  277. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  278. package/dist/resolvers/TaskGraphFrameResolver.d.ts +2 -1
  279. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  280. package/dist/resolvers/TaskGraphFrameResolver.js +28 -1
  281. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  282. package/dist/resolvers/TestQuerySQLResolver.d.ts +24 -0
  283. package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
  284. package/dist/resolvers/TestQuerySQLResolver.js +65 -13
  285. package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
  286. package/dist/resolvers/TransactionGroupResolver.d.ts +19 -2
  287. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  288. package/dist/resolvers/TransactionGroupResolver.js +102 -24
  289. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  290. package/dist/resolvers/UserAvatarResolver.d.ts +16 -0
  291. package/dist/resolvers/UserAvatarResolver.d.ts.map +1 -0
  292. package/dist/resolvers/UserAvatarResolver.js +124 -0
  293. package/dist/resolvers/UserAvatarResolver.js.map +1 -0
  294. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  295. package/dist/resolvers/UserResolver.js +2 -17
  296. package/dist/resolvers/UserResolver.js.map +1 -1
  297. package/dist/resolvers/agent-run-data-guard.d.ts +31 -0
  298. package/dist/resolvers/agent-run-data-guard.d.ts.map +1 -0
  299. package/dist/resolvers/agent-run-data-guard.js +72 -0
  300. package/dist/resolvers/agent-run-data-guard.js.map +1 -0
  301. package/dist/resolvers/agentVisionConsentAudit.d.ts +37 -0
  302. package/dist/resolvers/agentVisionConsentAudit.d.ts.map +1 -0
  303. package/dist/resolvers/agentVisionConsentAudit.js +63 -0
  304. package/dist/resolvers/agentVisionConsentAudit.js.map +1 -0
  305. package/dist/resolvers/avatarInputValidation.d.ts +40 -0
  306. package/dist/resolvers/avatarInputValidation.d.ts.map +1 -0
  307. package/dist/resolvers/avatarInputValidation.js +97 -0
  308. package/dist/resolvers/avatarInputValidation.js.map +1 -0
  309. package/dist/resolvers/currentUserRoles.d.ts +29 -0
  310. package/dist/resolvers/currentUserRoles.d.ts.map +1 -1
  311. package/dist/resolvers/currentUserRoles.js +30 -0
  312. package/dist/resolvers/currentUserRoles.js.map +1 -1
  313. package/dist/resolvers/meetings/meetingMutations.d.ts +6 -0
  314. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -1
  315. package/dist/resolvers/meetings/meetingMutations.js +21 -0
  316. package/dist/resolvers/meetings/meetingMutations.js.map +1 -1
  317. package/dist/rest/RealtimeRecordingHandler.d.ts +49 -0
  318. package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -0
  319. package/dist/rest/RealtimeRecordingHandler.js +383 -0
  320. package/dist/rest/RealtimeRecordingHandler.js.map +1 -0
  321. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  322. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  323. package/dist/rest/ViewOperationsHandler.js +21 -12
  324. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  325. package/dist/rest/setupRESTEndpoints.d.ts +6 -0
  326. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  327. package/dist/rest/setupRESTEndpoints.js +6 -0
  328. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  329. package/dist/serverExtensionReservedRoots.d.ts +12 -10
  330. package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
  331. package/dist/serverExtensionReservedRoots.js +14 -17
  332. package/dist/serverExtensionReservedRoots.js.map +1 -1
  333. package/dist/services/MJStorageBlobStore.d.ts +8 -1
  334. package/dist/services/MJStorageBlobStore.d.ts.map +1 -1
  335. package/dist/services/MJStorageBlobStore.js +25 -19
  336. package/dist/services/MJStorageBlobStore.js.map +1 -1
  337. package/dist/services/StartTaskGraphDispatcher.js +2 -2
  338. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  339. package/dist/services/TaskGraphActionRunner.d.ts +5 -3
  340. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  341. package/dist/services/TaskGraphActionRunner.js +27 -5
  342. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  343. package/dist/services/TaskGraphAgentRunner.d.ts +14 -2
  344. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  345. package/dist/services/TaskGraphAgentRunner.js +157 -9
  346. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  347. package/dist/services/TaskGraphContinuationDeliverer.d.ts +57 -4
  348. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  349. package/dist/services/TaskGraphContinuationDeliverer.js +607 -18
  350. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  351. package/dist/sharedCache.d.ts +105 -0
  352. package/dist/sharedCache.d.ts.map +1 -0
  353. package/dist/sharedCache.js +279 -0
  354. package/dist/sharedCache.js.map +1 -0
  355. package/dist/types.d.ts +6 -0
  356. package/dist/types.d.ts.map +1 -1
  357. package/dist/types.js.map +1 -1
  358. package/package.json +99 -96
  359. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  360. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  361. package/src/__tests__/BridgeHostInstance.test.ts +232 -0
  362. package/src/__tests__/ClientToolRequestResolver.test.ts +116 -0
  363. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  364. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  365. package/src/__tests__/FetchEntityVectorsResolver.test.ts +433 -0
  366. package/src/__tests__/FileResolver.storageAccess.test.ts +562 -0
  367. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  368. package/src/__tests__/HostInstance.test.ts +84 -0
  369. package/src/__tests__/HumanHandoffResolver.test.ts +17 -0
  370. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  371. package/src/__tests__/LoginScreenEngine.test.ts +86 -0
  372. package/src/__tests__/MJStorageBlobStore.storageGate.test.ts +63 -0
  373. package/src/__tests__/MeetingResolver.test.ts +75 -1
  374. package/src/__tests__/RealtimeBridgeResolver.test.ts +415 -6
  375. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  376. package/src/__tests__/RealtimeClientSessionResolver.test.ts +682 -58
  377. package/src/__tests__/RealtimeProxyServer.test.ts +16 -1
  378. package/src/__tests__/RealtimeRelay.test.ts +876 -0
  379. package/src/__tests__/RecordingAssemblyScheduler.test.ts +178 -0
  380. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  381. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +133 -2
  382. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  383. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  384. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  385. package/src/__tests__/ResolverBase.clientOldValues.test.ts +212 -6
  386. package/src/__tests__/ResolverBase.filterEscaping.test.ts +5 -2
  387. package/src/__tests__/ReturningVisitorRecap.test.ts +115 -0
  388. package/src/__tests__/RunAIAgentResolver.branch.test.ts +170 -0
  389. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +6 -4
  390. package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +258 -0
  391. package/src/__tests__/RunAIAgentResolver.stoppedPredecessor.test.ts +166 -0
  392. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  393. package/src/__tests__/RunViewResolver.cacheCheckForwarding.test.ts +63 -0
  394. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  395. package/src/__tests__/SearchKnowledgeStreamResolver.test.ts +161 -0
  396. package/src/__tests__/SessionJanitor.test.ts +1203 -21
  397. package/src/__tests__/SessionManager.test.ts +240 -17
  398. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  399. package/src/__tests__/SyncRolesUsersResolver.userRoles.test.ts +97 -0
  400. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +25 -0
  401. package/src/__tests__/TaskGraphAgentRunner.test.ts +266 -0
  402. package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +403 -22
  403. package/src/__tests__/TaskGraphFrameResolver.test.ts +22 -0
  404. package/src/__tests__/TestQuerySQLResolver.test.ts +274 -0
  405. package/src/__tests__/TransactionGroupResolver.refusals.test.ts +98 -1
  406. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  407. package/src/__tests__/UserAvatarResolver.test.ts +255 -0
  408. package/src/__tests__/UserResolver.currentUser.test.ts +16 -1
  409. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  410. package/src/__tests__/actionAuthorization.test.ts +130 -0
  411. package/src/__tests__/agent-run-data-guard.test.ts +130 -0
  412. package/src/__tests__/agentVisionConsentAudit.test.ts +118 -0
  413. package/src/__tests__/apiRootBoot.test.ts +202 -0
  414. package/src/__tests__/avatarInputValidation.test.ts +160 -0
  415. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  416. package/src/__tests__/conversationRunEvents.test.ts +56 -14
  417. package/src/__tests__/globalCors.test.ts +217 -0
  418. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  419. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  420. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  421. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  422. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  423. package/src/__tests__/multiTenancy.test.ts +1 -1
  424. package/src/__tests__/newUsers.template.test.ts +151 -0
  425. package/src/__tests__/newUsers.test.ts +74 -5
  426. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +17 -2
  427. package/src/__tests__/ownedRealtimeSession.test.ts +39 -0
  428. package/src/__tests__/pushStatusResolver.filter.test.ts +7 -0
  429. package/src/__tests__/realtimeRecordingHandler.test.ts +652 -0
  430. package/src/__tests__/realtimeSessions/RealtimeSessionEventService.test.ts +201 -0
  431. package/src/__tests__/realtimeSessions/RealtimeSessionEventsResolver.test.ts +215 -0
  432. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationResolver.test.ts +102 -0
  433. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationService.test.ts +349 -0
  434. package/src/__tests__/realtimeSessions/RealtimeVerifyRouter.test.ts +131 -0
  435. package/src/__tests__/realtimeSessions/SlidingWindowRateLimiter.test.ts +56 -0
  436. package/src/__tests__/realtimeSessions/helpers.ts +4 -0
  437. package/src/__tests__/realtimeSessions/sessionAccess.test.ts +129 -0
  438. package/src/__tests__/realtimeSessions/sessionConfigStamps.test.ts +124 -0
  439. package/src/__tests__/realtimeSessions/verificationCore.test.ts +355 -0
  440. package/src/__tests__/realtimeSessions/verificationPages.test.ts +84 -0
  441. package/src/__tests__/realtimeSessions/verificationPolicy.test.ts +226 -0
  442. package/src/__tests__/realtimeSessions/verificationWorkflow.test.ts +633 -0
  443. package/src/__tests__/resolverBase.fls.test.ts +87 -4
  444. package/src/__tests__/serverExtensionHostOverlay.test.ts +66 -0
  445. package/src/__tests__/serverExtensionReservedRoots.test.ts +11 -96
  446. package/src/__tests__/sharedCache.test.ts +312 -0
  447. package/src/__tests__/widgetGuestElevation.test.ts +44 -0
  448. package/src/agentSessions/BridgeHostInstance.ts +32 -0
  449. package/src/agentSessions/HostInstance.ts +124 -28
  450. package/src/agentSessions/ReturningVisitorRecap.ts +7 -3
  451. package/src/agentSessions/SessionJanitor.ts +821 -59
  452. package/src/agentSessions/SessionManager.ts +138 -26
  453. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  454. package/src/agentSessions/index.ts +10 -3
  455. package/src/apiRoot.ts +78 -0
  456. package/src/auth/AuthProviderCatalogRouter.ts +26 -0
  457. package/src/auth/LoginScreenEngine.ts +50 -0
  458. package/src/auth/actionAuthorization.ts +116 -0
  459. package/src/auth/index.ts +4 -8
  460. package/src/auth/magicLink/MagicLinkRouter.ts +3 -2
  461. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  462. package/src/auth/newUsers.ts +99 -11
  463. package/src/config.ts +171 -5
  464. package/src/context.ts +15 -10
  465. package/src/coreRoutes.ts +166 -0
  466. package/src/generated/graphql-schemas/__mj.ts +1815 -57
  467. package/src/generic/CacheInvalidationResolver.ts +73 -18
  468. package/src/generic/OrphanedConversationDetailReconciler.ts +3 -3
  469. package/src/generic/PushStatusResolver.ts +1 -1
  470. package/src/generic/ResolverBase.ts +335 -326
  471. package/src/generic/RunViewResolver.ts +21 -0
  472. package/src/globalCors.ts +71 -0
  473. package/src/index.ts +231 -300
  474. package/src/isolatedProvider.ts +22 -0
  475. package/src/multiTenancy/index.ts +24 -2
  476. package/src/realtimeProxy/RealtimeProxyServer.ts +148 -16
  477. package/src/realtimeProxy/RealtimeRelayTunnel.ts +645 -0
  478. package/src/realtimeSessions/README.md +179 -0
  479. package/src/realtimeSessions/RealtimeSessionEventService.ts +274 -0
  480. package/src/realtimeSessions/RealtimeSessionVerificationService.ts +168 -0
  481. package/src/realtimeSessions/RealtimeVerifyRouter.ts +109 -0
  482. package/src/realtimeSessions/RecordingAssemblyScheduler.ts +234 -0
  483. package/src/realtimeSessions/SessionVerificationPortsBase.ts +189 -0
  484. package/src/realtimeSessions/SlidingWindowRateLimiter.ts +92 -0
  485. package/src/realtimeSessions/index.ts +18 -0
  486. package/src/realtimeSessions/ownedRealtimeSession.ts +55 -0
  487. package/src/realtimeSessions/sessionAccess.ts +145 -0
  488. package/src/realtimeSessions/sessionConfigStamps.ts +123 -0
  489. package/src/realtimeSessions/subscriptionAuthorization.ts +116 -0
  490. package/src/realtimeSessions/verificationCore.ts +502 -0
  491. package/src/realtimeSessions/verificationPages.ts +149 -0
  492. package/src/realtimeSessions/verificationPolicy.ts +321 -0
  493. package/src/realtimeSessions/verificationWorkflow.ts +702 -0
  494. package/src/realtimeWidget/widgetGuestElevation.ts +34 -2
  495. package/src/resolvers/ActionResolver.ts +84 -21
  496. package/src/resolvers/AdhocQueryResolver.ts +24 -6
  497. package/src/resolvers/ArtifactFileResolver.ts +9 -8
  498. package/src/resolvers/CacheStatsResolver.ts +89 -2
  499. package/src/resolvers/ClientToolRequestResolver.ts +25 -7
  500. package/src/resolvers/ConversationRunEventsResolver.ts +15 -11
  501. package/src/resolvers/DatasetResolver.ts +70 -10
  502. package/src/resolvers/EntityRecordNameResolver.ts +3 -2
  503. package/src/resolvers/FetchEntityVectorsResolver.ts +179 -2
  504. package/src/resolvers/FileResolver.ts +321 -106
  505. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  506. package/src/resolvers/GetDataResolver.ts +6 -3
  507. package/src/resolvers/HumanHandoffResolver.ts +8 -0
  508. package/src/resolvers/IntegrationDiscoveryResolver.ts +4 -2
  509. package/src/resolvers/MCPResolver.ts +8 -5
  510. package/src/resolvers/QueryResolver.ts +11 -0
  511. package/src/resolvers/RealtimeBridgeResolver.ts +163 -11
  512. package/src/resolvers/RealtimeClientSessionResolver.ts +511 -129
  513. package/src/resolvers/RealtimeSessionEventsResolver.ts +133 -0
  514. package/src/resolvers/RealtimeSessionVerificationResolver.ts +174 -0
  515. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  516. package/src/resolvers/RunAIAgentResolver.ts +116 -14
  517. package/src/resolvers/RunTemplateResolver.ts +13 -12
  518. package/src/resolvers/SearchKnowledgeStreamResolver.ts +19 -3
  519. package/src/resolvers/SqlLoggingConfigResolver.ts +2 -4
  520. package/src/resolvers/SyncDataResolver.ts +6 -2
  521. package/src/resolvers/SyncRolesUsersResolver.ts +24 -6
  522. package/src/resolvers/TaskGraphFrameResolver.ts +33 -1
  523. package/src/resolvers/TestQuerySQLResolver.ts +68 -13
  524. package/src/resolvers/TransactionGroupResolver.ts +131 -27
  525. package/src/resolvers/UserAvatarResolver.ts +111 -0
  526. package/src/resolvers/UserResolver.ts +2 -16
  527. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  528. package/src/resolvers/agent-run-data-guard.ts +89 -0
  529. package/src/resolvers/agentVisionConsentAudit.ts +82 -0
  530. package/src/resolvers/avatarInputValidation.ts +123 -0
  531. package/src/resolvers/currentUserRoles.ts +32 -1
  532. package/src/resolvers/meetings/meetingMutations.ts +21 -0
  533. package/src/rest/RealtimeRecordingHandler.ts +464 -0
  534. package/src/rest/ViewOperationsHandler.ts +29 -13
  535. package/src/rest/setupRESTEndpoints.ts +6 -0
  536. package/src/serverExtensionReservedRoots.ts +14 -17
  537. package/src/services/MJStorageBlobStore.ts +31 -30
  538. package/src/services/StartTaskGraphDispatcher.ts +2 -2
  539. package/src/services/TaskGraphActionRunner.ts +25 -5
  540. package/src/services/TaskGraphAgentRunner.ts +188 -11
  541. package/src/services/TaskGraphContinuationDeliverer.ts +713 -17
  542. package/src/sharedCache.ts +305 -0
  543. package/src/types.ts +7 -0
@@ -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,27 @@ 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, assembleMock, ledgerMock } = vi.hoisted(() => ({
10
+ runViewMock: vi.fn(),
11
+ logErrorMock: vi.fn(),
12
+ logStatusMock: vi.fn(),
13
+ recoverMock: vi.fn(),
14
+ resolveAccountMock: vi.fn(),
15
+ assembleMock: vi.fn(),
16
+ /** The one fake shard ledger every `new SqlRealtimeRecordingLedger(...)` returns. */
17
+ ledgerMock: {
18
+ ClaimAssembly: vi.fn(),
19
+ PurgeClosedBefore: vi.fn(),
20
+ LoadShardMetadata: vi.fn(),
21
+ StampTerminal: vi.fn(),
22
+ },
23
+ }));
10
24
  vi.mock('@memberjunction/core', async (importOriginal) => {
11
25
  const actual = await importOriginal<typeof import('@memberjunction/core')>();
12
26
  return {
13
27
  ...actual,
28
+ LogError: logErrorMock,
29
+ LogStatus: logStatusMock,
14
30
  RunView: {
15
31
  ...actual.RunView,
16
32
  FromMetadataProvider: () => ({ RunView: runViewMock }),
@@ -18,9 +34,44 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
18
34
  };
19
35
  });
20
36
 
21
- import { SessionJanitor } from '../agentSessions/SessionJanitor.js';
22
- import { GetHostInstanceID, GetHostNamePrefix } from '../agentSessions/HostInstance.js';
23
- import type { UserInfo, IMetadataProvider } from '@memberjunction/core';
37
+ // Recording recovery lives in ai-agents; the janitor only decides WHEN to call it.
38
+ // Partial mock: SessionManager (imported by the janitor) needs the rest of ai-agents' real exports.
39
+ vi.mock('@memberjunction/ai-agents', async (importOriginal) => {
40
+ const actual = await importOriginal<typeof import('@memberjunction/ai-agents')>();
41
+ return {
42
+ ...actual,
43
+ RecoverRealtimeRecordingFromSegments: recoverMock,
44
+ ResolveRecordingStorageAccountID: resolveAccountMock,
45
+ AssembleRealtimeRecordingFromLedger: assembleMock,
46
+ SqlRealtimeRecordingLedger: vi.fn(function FakeLedger() { return ledgerMock; }),
47
+ };
48
+ });
49
+
50
+ import { SessionJanitor, type SessionJanitorConfig } from '../agentSessions/SessionJanitor.js';
51
+ import {
52
+ GetCurrentHostInstance,
53
+ GetHostInstanceID,
54
+ HostInstanceIdentity,
55
+ SetHostInstancePort,
56
+ } from '../agentSessions/HostInstance.js';
57
+ import type { UserInfo, IMetadataProvider, DatabaseProviderBase } from '@memberjunction/core';
58
+ import type { MJAIAgentSessionEntity } from '@memberjunction/core-entities';
59
+
60
+ /** The port this test process "serves" on, set the way `Serve` sets it, before anything reads the identity. */
61
+ const OUR_PORT = 4000;
62
+ /** The port of a second MJAPI on the same host. */
63
+ const OTHER_PORT = 4100;
64
+ SetHostInstancePort(OUR_PORT);
65
+
66
+ /**
67
+ * The janitor builds the shard-ledger view name from the provider's core schema and dialect quoting;
68
+ * fakes carry SQL Server's so filter assertions read naturally.
69
+ */
70
+ const LEDGER_SCHEMA_PROVIDER_FIELDS = {
71
+ MJCoreSchemaName: '__mj',
72
+ QuoteSchemaAndView: (schema: string, view: string) => `[${schema}].[${view}]`,
73
+ };
74
+ const LEDGER_VIEW = '[__mj].[vwAIAgentSessionRecordingShards]';
24
75
 
25
76
  interface FakeSession {
26
77
  ID: string;
@@ -42,7 +93,7 @@ function makeUser(): UserInfo {
42
93
  * inside CloseSession uses runViewMock, which we default to an empty channel list.
43
94
  */
44
95
  function makeProvider(store: Map<string, 'Active' | 'Idle' | 'Closed'>): {
45
- provider: IMetadataProvider;
96
+ provider: DatabaseProviderBase;
46
97
  closedIds: string[];
47
98
  closedReasons: Map<string, string | null>;
48
99
  } {
@@ -74,7 +125,8 @@ function makeProvider(store: Map<string, 'Active' | 'Idle' | 'Closed'>): {
74
125
  };
75
126
  return entity as unknown as Awaited<ReturnType<IMetadataProvider['GetEntityObject']>>;
76
127
  }),
77
- } as unknown as IMetadataProvider;
128
+ ...LEDGER_SCHEMA_PROVIDER_FIELDS,
129
+ } as unknown as DatabaseProviderBase;
78
130
  return { provider, closedIds, closedReasons };
79
131
  }
80
132
 
@@ -94,6 +146,15 @@ function sessionSweepCalls(): Array<[{ EntityName: string; ExtraFilter: string;
94
146
 
95
147
  beforeEach(() => {
96
148
  runViewMock.mockReset();
149
+ logErrorMock.mockReset();
150
+ logStatusMock.mockReset();
151
+ recoverMock.mockReset();
152
+ resolveAccountMock.mockReset();
153
+ assembleMock.mockReset();
154
+ for (const method of Object.values(ledgerMock)) method.mockReset();
155
+ // Quiet ledger by default: nothing to purge, every claim lost.
156
+ ledgerMock.PurgeClosedBefore.mockResolvedValue(0);
157
+ ledgerMock.ClaimAssembly.mockResolvedValue(null);
97
158
  sessionPages = [];
98
159
  runViewMock.mockImplementation(async (params: { EntityName: string }) => {
99
160
  if (params.EntityName === 'MJ: AI Agent Session Channels') {
@@ -105,29 +166,129 @@ beforeEach(() => {
105
166
  SessionJanitor.Instance.Stop();
106
167
  });
107
168
 
169
+ type SessionStatus = MJAIAgentSessionEntity['Status'];
170
+
171
+ /** A session row as the recovery sweep reads it. */
172
+ interface SessionRow {
173
+ ID: string;
174
+ Status: SessionStatus;
175
+ HostInstanceID: string;
176
+ }
177
+
178
+ /**
179
+ * Answer the session sweep's reads from `rows` the way the database answers the recovery filter: rows still open in
180
+ * `store` whose `HostInstanceID` starts with the filter's `LIKE` prefix and isn't the id it excludes. Channel reads
181
+ * return nothing.
182
+ */
183
+ function serveSessionReadsFrom(rows: SessionRow[], store: Map<string, SessionStatus>): void {
184
+ runViewMock.mockImplementation(async (params: { EntityName: string; ExtraFilter: string }) => {
185
+ if (params.EntityName !== 'MJ: AI Agent Sessions') {
186
+ return { Success: true, Results: [] };
187
+ }
188
+ const likePrefix = /HostInstanceID LIKE '(.*?)%'/.exec(params.ExtraFilter)?.[1] ?? '';
189
+ const excluded = /HostInstanceID <> '(.*?)'/.exec(params.ExtraFilter)?.[1];
190
+ const results = rows
191
+ .filter((r) => store.get(r.ID) !== 'Closed')
192
+ .filter((r) => r.HostInstanceID.startsWith(likePrefix) && r.HostInstanceID !== excluded)
193
+ .map((r) => ({ ...r, Status: store.get(r.ID) }));
194
+ return { Success: true, Results: results };
195
+ });
196
+ }
197
+
198
+ /** The status map a provider from {@link makeProvider} loads and saves through, built from `rows`. */
199
+ function storeOf(rows: SessionRow[]): Map<string, SessionStatus> {
200
+ return new Map(rows.map((r) => [r.ID, r.Status]));
201
+ }
202
+
108
203
  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']]);
204
+ const ours = GetCurrentHostInstance();
205
+
206
+ it('closes the sessions an earlier boot of this instance left, and nothing else (one MJAPI, as before)', async () => {
207
+ const rows: SessionRow[] = [
208
+ { ID: 'before-restart', Status: 'Active', HostInstanceID: `${ours.GetInstancePrefix()}31337:boot-before` },
209
+ // The OS gave the restarted process the same pid; the boot id still differs.
210
+ { ID: 'before-restart-same-pid', Status: 'Idle', HostInstanceID: `${ours.GetInstancePrefix()}${ours.ProcessID}:boot-before` },
211
+ { ID: 'this-boot', Status: 'Active', HostInstanceID: ours.GetHostInstanceID() },
212
+ { ID: 'other-host', Status: 'Active', HostInstanceID: `other-host:${OUR_PORT}:31337:boot-other` },
213
+ ];
214
+ const store = storeOf(rows);
215
+ serveSessionReadsFrom(rows, store);
117
216
  const { provider, closedIds, closedReasons } = makeProvider(store);
118
217
 
119
218
  const count = await SessionJanitor.Instance.RunStartupRecovery(provider, makeUser());
120
219
 
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');
220
+ expect(count).toBe(2);
221
+ expect([...closedIds].sort()).toEqual(['before-restart', 'before-restart-same-pid']);
222
+ expect(closedReasons.get('before-restart')).toBe('Janitor');
223
+ expect(store.get('this-boot')).toBe('Active');
224
+ expect(store.get('other-host')).toBe('Active');
125
225
 
126
- // Assert the recovery filter targets this host's *other* boots, never the current instance.
226
+ // The read is narrowed to this instance's other boots: its host and port, never this boot.
127
227
  const filter = sessionSweepCalls()[0][0].ExtraFilter as string;
128
228
  expect(filter).toContain("Status IN ('Active','Idle')");
129
- expect(filter).toContain(`HostInstanceID LIKE '${GetHostNamePrefix()}%'`);
229
+ expect(filter).toContain(`HostInstanceID LIKE '${ours.GetInstancePrefix()}%'`);
130
230
  expect(filter).toContain(`HostInstanceID <> '${GetHostInstanceID()}'`);
231
+ expect(ours.GetInstancePrefix()).toContain(`:${OUR_PORT}:`);
232
+ });
233
+
234
+ it('leaves rows stamped before the port was added (hostname:pid:bootId) to the staleness sweep', async () => {
235
+ // Its pid happens to equal our port, so the LIKE prefix reads it; the row check rejects it (it has no port).
236
+ const rows: SessionRow[] = [
237
+ { ID: 'pre-upgrade', Status: 'Active', HostInstanceID: `${ours.HostName}:${OUR_PORT}:boot-before-upgrade` },
238
+ ];
239
+ const store = storeOf(rows);
240
+ serveSessionReadsFrom(rows, store);
241
+ const { provider, closedIds } = makeProvider(store);
242
+
243
+ const count = await SessionJanitor.Instance.RunStartupRecovery(provider, makeUser());
244
+
245
+ expect(count).toBe(0);
246
+ expect(closedIds).toEqual([]);
247
+ });
248
+ });
249
+
250
+ describe('SessionJanitor.RunStartupRecovery with two MJAPIs on one host (#5309)', () => {
251
+ const ours = GetCurrentHostInstance();
252
+ const theirs = new HostInstanceIdentity(ours.HostName, String(OTHER_PORT), 40404, 'boot-theirs');
253
+
254
+ /** Both instances' live sessions, and one each left by an earlier boot. */
255
+ function sharedTable(): SessionRow[] {
256
+ return [
257
+ { ID: 'ours-live', Status: 'Active', HostInstanceID: ours.GetHostInstanceID() },
258
+ { ID: 'ours-stale', Status: 'Idle', HostInstanceID: `${ours.GetInstancePrefix()}31337:boot-ours-before` },
259
+ { ID: 'theirs-live', Status: 'Active', HostInstanceID: theirs.GetHostInstanceID() },
260
+ { ID: 'theirs-stale', Status: 'Active', HostInstanceID: `${theirs.GetInstancePrefix()}27182:boot-theirs-before` },
261
+ ];
262
+ }
263
+
264
+ it("closes its own stale sessions and leaves the other MJAPI's live sessions open", async () => {
265
+ const rows = sharedTable();
266
+ const store = storeOf(rows);
267
+ serveSessionReadsFrom(rows, store);
268
+ const { provider, closedIds } = makeProvider(store);
269
+
270
+ const count = await SessionJanitor.Instance.RunStartupRecovery(provider, makeUser());
271
+
272
+ expect(count).toBe(1);
273
+ expect(closedIds).toEqual(['ours-stale']);
274
+ expect(store.get('theirs-live')).toBe('Active');
275
+ expect(store.get('theirs-stale')).toBe('Active');
276
+ expect(store.get('ours-live')).toBe('Active');
277
+ });
278
+
279
+ it("the other MJAPI's recovery closes its stale sessions and leaves this one's live sessions open", async () => {
280
+ const rows = sharedTable();
281
+ const store = storeOf(rows);
282
+ serveSessionReadsFrom(rows, store);
283
+ const { provider, closedIds } = makeProvider(store);
284
+
285
+ const count = await SessionJanitor.Instance.RunStartupRecovery(provider, makeUser(), theirs);
286
+
287
+ expect(count).toBe(1);
288
+ expect(closedIds).toEqual(['theirs-stale']);
289
+ expect(store.get('ours-live')).toBe('Active');
290
+ expect(store.get('ours-stale')).toBe('Idle');
291
+ expect(store.get('theirs-live')).toBe('Active');
131
292
  });
132
293
  });
133
294
 
@@ -232,3 +393,1024 @@ describe('SessionJanitor shutdown drain', () => {
232
393
  expect(closedReasons.get('mine-2')).toBe('Shutdown');
233
394
  });
234
395
  });
396
+
397
+ describe('SessionJanitor.RunRecordingRecoverySweep', () => {
398
+ const NOW = new Date('2026-10-07T12:00:00.000Z');
399
+ const MINUTE = 60_000;
400
+ const HOUR = 60 * MINUTE;
401
+ const STARTED_AT = new Date('2026-10-07T09:00:00.000Z');
402
+ const NO_SEGMENTS = { Outcome: 'NoSegments', FileID: null, SegmentCount: 0, MissingIndexes: [], ErrorMessage: null };
403
+ const FAILED = { Outcome: 'Failed', FileID: null, SegmentCount: 0, MissingIndexes: [], ErrorMessage: 'upload exploded' };
404
+ const MAX_ATTEMPTS_FOR_TEST = 8; // mirrors MAX_RECOVERY_ATTEMPTS
405
+ let nextSessionNumber = 0;
406
+
407
+ interface RecoveryHarness {
408
+ provider: DatabaseProviderBase;
409
+ /** Session id -> RecordingFileID the reload will report (default null). */
410
+ fileIds: Map<string, string | null>;
411
+ agentLoads: string[];
412
+ /** Agent ids whose Load() reports failure. */
413
+ failingAgents: Set<string>;
414
+ }
415
+
416
+ /** Session ids are unique per test: the janitor is a singleton whose backoff map outlives a test. */
417
+ function candidate(agentID = 'agent-1'): { ID: string; AgentID: string; RecordingStartedAt: Date } {
418
+ return { ID: `rec-session-${nextSessionNumber++}`, AgentID: agentID, RecordingStartedAt: STARTED_AT };
419
+ }
420
+
421
+ function makeRecoveryProvider(): RecoveryHarness {
422
+ const fileIds = new Map<string, string | null>();
423
+ const agentLoads: string[] = [];
424
+ const failingAgents = new Set<string>();
425
+ const provider = {
426
+ GetEntityObject: vi.fn(async (entityName: string) => {
427
+ const entity: { ID: string; RecordingFileID: string | null; Load: (id: string) => Promise<boolean> } = {
428
+ ID: '',
429
+ RecordingFileID: null,
430
+ Load: async (id: string) => {
431
+ entity.ID = id;
432
+ if (entityName === 'MJ: AI Agents') {
433
+ agentLoads.push(id);
434
+ return !failingAgents.has(id);
435
+ } else {
436
+ entity.RecordingFileID = fileIds.get(id) ?? null;
437
+ }
438
+ return true;
439
+ },
440
+ };
441
+ return entity;
442
+ }),
443
+ ...LEDGER_SCHEMA_PROVIDER_FIELDS,
444
+ } as unknown as DatabaseProviderBase;
445
+ return { provider, fileIds, agentLoads, failingAgents };
446
+ }
447
+
448
+ function recoveryFilter(): string {
449
+ const call = sessionSweepCalls().find((c) => c[0].ExtraFilter.includes('RecordingStartedAt'));
450
+ if (!call) throw new Error('recording recovery sweep never queried sessions');
451
+ return call[0].ExtraFilter;
452
+ }
453
+
454
+ /** Runs one sweep whose candidate page holds exactly `rows`. */
455
+ async function sweepWith(provider: DatabaseProviderBase, rows: unknown[]): Promise<number> {
456
+ sessionPages = [{ Success: true, Results: rows }];
457
+ return SessionJanitor.Instance.RunRecordingRecoverySweep(provider, makeUser());
458
+ }
459
+
460
+ beforeEach(() => {
461
+ vi.useFakeTimers();
462
+ vi.setSystemTime(NOW);
463
+ SessionJanitor.Instance.Configure({ recordingRecoveryGraceMinutes: 10, recordingRecoveryLookbackHours: 168 });
464
+ resolveAccountMock.mockResolvedValue('account-1');
465
+ recoverMock.mockResolvedValue({ Outcome: 'Recovered', FileID: 'file-1', SegmentCount: 3, MissingIndexes: [], ErrorMessage: null });
466
+ });
467
+
468
+ afterEach(() => {
469
+ SessionJanitor.Instance.Stop();
470
+ vi.useRealTimers();
471
+ });
472
+
473
+ it('queries closed, recorded, unstored sessions between the lookback and grace cutoffs', async () => {
474
+ const { provider } = makeRecoveryProvider();
475
+ await sweepWith(provider, []);
476
+
477
+ const filter = recoveryFilter();
478
+ expect(filter).toContain("Status = 'Closed'");
479
+ expect(filter).toContain('RecordingStartedAt IS NOT NULL');
480
+ expect(filter).toContain('RecordingFileID IS NULL');
481
+ expect(filter).toContain(`ClosedAt < '${new Date(NOW.getTime() - 10 * MINUTE).toISOString()}'`);
482
+ expect(filter).toContain(`ClosedAt >= '${new Date(NOW.getTime() - 168 * HOUR).toISOString()}'`);
483
+ });
484
+
485
+ it('leaves sessions on the shard ledger to the ledger sweep', async () => {
486
+ const { provider } = makeRecoveryProvider();
487
+ await sweepWith(provider, []);
488
+
489
+ const filter = recoveryFilter();
490
+ expect(filter).toContain(`RecordingStatus IS NULL AND ID NOT IN (SELECT AgentSessionID FROM ${LEDGER_VIEW})`);
491
+ });
492
+
493
+ it('honours Configure() overrides for grace and lookback', async () => {
494
+ SessionJanitor.Instance.Configure({ recordingRecoveryGraceMinutes: 30, recordingRecoveryLookbackHours: 24 });
495
+ const { provider } = makeRecoveryProvider();
496
+ await sweepWith(provider, []);
497
+
498
+ const filter = recoveryFilter();
499
+ expect(filter).toContain(`ClosedAt < '${new Date(NOW.getTime() - 30 * MINUTE).toISOString()}'`);
500
+ expect(filter).toContain(`ClosedAt >= '${new Date(NOW.getTime() - 24 * HOUR).toISOString()}'`);
501
+ });
502
+
503
+ it('loads each agent once per sweep, resolves the account, recovers with StartedAt, and returns the count', async () => {
504
+ const [a, b, c] = [candidate('agent-1'), candidate('agent-1'), candidate('agent-2')];
505
+ const { provider, agentLoads } = makeRecoveryProvider();
506
+ const user = makeUser();
507
+ sessionPages = [{ Success: true, Results: [a, b, c] }];
508
+
509
+ const recovered = await SessionJanitor.Instance.RunRecordingRecoverySweep(provider, user);
510
+
511
+ expect(recovered).toBe(3);
512
+ expect(agentLoads).toEqual(['agent-1', 'agent-2']);
513
+ // The account is resolved once per agent, not once per session.
514
+ expect(resolveAccountMock).toHaveBeenCalledTimes(2);
515
+ expect(recoverMock).toHaveBeenNthCalledWith(1, {
516
+ SessionID: a.ID,
517
+ StorageAccountID: 'account-1',
518
+ StartedAt: STARTED_AT,
519
+ ContextUser: user,
520
+ Provider: provider,
521
+ });
522
+ });
523
+
524
+ it('skips a session whose reload shows a recording was stored meanwhile', async () => {
525
+ const stored = candidate();
526
+ const { provider, fileIds } = makeRecoveryProvider();
527
+ fileIds.set(stored.ID, 'file-from-late-upload');
528
+
529
+ expect(await sweepWith(provider, [stored])).toBe(0);
530
+ expect(recoverMock).not.toHaveBeenCalled();
531
+ });
532
+
533
+ it('skips and logs a candidate without RecordingStartedAt instead of recovering it with no t0', async () => {
534
+ const noStart = { ...candidate(), RecordingStartedAt: null };
535
+ const { provider } = makeRecoveryProvider();
536
+
537
+ expect(await sweepWith(provider, [noStart])).toBe(0);
538
+ expect(recoverMock).not.toHaveBeenCalled();
539
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${noStart.ID}.*RecordingStartedAt`)));
540
+ });
541
+
542
+ it('defers a session whose agent has no storage account: no recover call, backed off', async () => {
543
+ const noAccount = candidate();
544
+ resolveAccountMock.mockResolvedValue(null);
545
+ const { provider } = makeRecoveryProvider();
546
+
547
+ await sweepWith(provider, [noAccount]);
548
+ expect(recoverMock).not.toHaveBeenCalled();
549
+ expect(logStatusMock).toHaveBeenCalledWith(expect.stringContaining(noAccount.ID));
550
+
551
+ // Backed off: the next sweep must not even resolve the account again.
552
+ resolveAccountMock.mockClear();
553
+ await sweepWith(provider, [noAccount]);
554
+ expect(resolveAccountMock).not.toHaveBeenCalled();
555
+ });
556
+
557
+ it('never gives up on a no-storage session: it is not a failed attempt, so no LogError and one status line', async () => {
558
+ const noAccount = candidate();
559
+ resolveAccountMock.mockResolvedValue(null);
560
+ const { provider } = makeRecoveryProvider();
561
+
562
+ for (let i = 0; i < 12; i++) {
563
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
564
+ await sweepWith(provider, [noAccount]);
565
+ }
566
+
567
+ expect(recoverMock).not.toHaveBeenCalled();
568
+ expect(logErrorMock.mock.calls.filter((c) => String(c[0]).includes(noAccount.ID))).toHaveLength(0);
569
+ expect(logStatusMock.mock.calls.filter((c) => String(c[0]).includes(noAccount.ID))).toHaveLength(1);
570
+ });
571
+
572
+ it('recovers a deferred no-storage session once its agent gains a storage account', async () => {
573
+ const later = candidate();
574
+ resolveAccountMock.mockResolvedValue(null);
575
+ const { provider } = makeRecoveryProvider();
576
+ for (let i = 0; i < 9; i++) {
577
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
578
+ await sweepWith(provider, [later]);
579
+ }
580
+
581
+ resolveAccountMock.mockResolvedValue('account-1');
582
+ vi.setSystemTime(NOW.getTime() + 9 * 7 * HOUR);
583
+ expect(await sweepWith(provider, [later])).toBe(1);
584
+ expect(recoverMock).toHaveBeenCalledTimes(1);
585
+ });
586
+
587
+ it('logs a Superseded outcome without treating it as a failure', async () => {
588
+ const raced = candidate();
589
+ recoverMock.mockResolvedValue({ Outcome: 'Superseded', FileID: null, SegmentCount: 2, MissingIndexes: [], ErrorMessage: null });
590
+ const { provider } = makeRecoveryProvider();
591
+
592
+ expect(await sweepWith(provider, [raced])).toBe(0);
593
+
594
+ // Not backed off: a second sweep that still sees the row tries again.
595
+ await sweepWith(provider, [raced]);
596
+ expect(recoverMock).toHaveBeenCalledTimes(2);
597
+ });
598
+
599
+ it('logs NoSegments with session, agent and account ids and backs off', async () => {
600
+ const empty = candidate('agent-9');
601
+ recoverMock.mockResolvedValue(NO_SEGMENTS);
602
+ const { provider } = makeRecoveryProvider();
603
+
604
+ await sweepWith(provider, [empty]);
605
+
606
+ const message = logStatusMock.mock.calls.map((c) => String(c[0])).find((m) => m.includes(empty.ID));
607
+ expect(message).toContain('agent-9');
608
+ expect(message).toContain('account-1');
609
+ expect(logErrorMock).not.toHaveBeenCalled();
610
+ });
611
+
612
+ it('LogErrors a Failed outcome and does not retry before the backoff elapses, then retries after', async () => {
613
+ const flaky = candidate();
614
+ recoverMock.mockResolvedValue({ Outcome: 'Failed', FileID: null, SegmentCount: 0, MissingIndexes: [], ErrorMessage: 'upload exploded' });
615
+ const { provider } = makeRecoveryProvider();
616
+
617
+ await sweepWith(provider, [flaky]);
618
+ expect(recoverMock).toHaveBeenCalledTimes(1);
619
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringContaining('upload exploded'));
620
+
621
+ // After the 1st failure the wait is 2^1 x 5 = 10 minutes.
622
+ vi.setSystemTime(NOW.getTime() + 9 * MINUTE);
623
+ await sweepWith(provider, [flaky]);
624
+ expect(recoverMock).toHaveBeenCalledTimes(1);
625
+
626
+ vi.setSystemTime(NOW.getTime() + 10 * MINUTE + 1);
627
+ await sweepWith(provider, [flaky]);
628
+ expect(recoverMock).toHaveBeenCalledTimes(2);
629
+ });
630
+
631
+ it('caps the backoff at 6 hours', async () => {
632
+ const stubborn = candidate();
633
+ recoverMock.mockResolvedValue(NO_SEGMENTS);
634
+ const { provider } = makeRecoveryProvider();
635
+ const sweepAt = async (atMs: number): Promise<void> => {
636
+ vi.setSystemTime(atMs);
637
+ await sweepWith(provider, [stubborn]);
638
+ };
639
+
640
+ // Failures 1-7, each far enough apart to be eligible. The 7th (2^7 x 5 min = 640 min) is capped to 6 h.
641
+ const seventhFailureAt = NOW.getTime() + 6 * 7 * HOUR;
642
+ for (let i = 0; i < 7; i++) {
643
+ await sweepAt(NOW.getTime() + i * 7 * HOUR);
644
+ }
645
+ expect(recoverMock).toHaveBeenCalledTimes(7);
646
+
647
+ await sweepAt(seventhFailureAt + 6 * HOUR - MINUTE);
648
+ expect(recoverMock).toHaveBeenCalledTimes(7);
649
+ await sweepAt(seventhFailureAt + 6 * HOUR + 1);
650
+ expect(recoverMock).toHaveBeenCalledTimes(8);
651
+ });
652
+
653
+ it('gives up after 8 failures: never retried again, one give-up LogError for the session', async () => {
654
+ const doomed = candidate();
655
+ recoverMock.mockResolvedValue(FAILED);
656
+ const { provider } = makeRecoveryProvider();
657
+
658
+ for (let i = 0; i < 12; i++) {
659
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
660
+ await sweepWith(provider, [doomed]);
661
+ }
662
+
663
+ expect(recoverMock).toHaveBeenCalledTimes(8);
664
+ expect(logErrorMock.mock.calls.filter((c) => String(c[0]).includes(doomed.ID) && String(c[0]).includes('Giving up'))).toHaveLength(1);
665
+ });
666
+
667
+ it('stops checking a session that never has segments with a status line, not an error', async () => {
668
+ const empty = candidate();
669
+ recoverMock.mockResolvedValue(NO_SEGMENTS);
670
+ const { provider } = makeRecoveryProvider();
671
+
672
+ for (let i = 0; i < 12; i++) {
673
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
674
+ await sweepWith(provider, [empty]);
675
+ }
676
+
677
+ expect(recoverMock).toHaveBeenCalledTimes(8);
678
+ expect(logErrorMock.mock.calls.filter((c) => String(c[0]).includes(empty.ID))).toHaveLength(0);
679
+ expect(logStatusMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${empty.ID}.*no recoverable segments in 8 checks`)));
680
+ });
681
+
682
+ it('prunes backoff for sessions that left the candidate set', async () => {
683
+ const gone = candidate();
684
+ recoverMock.mockResolvedValue(NO_SEGMENTS);
685
+ const { provider } = makeRecoveryProvider();
686
+
687
+ await sweepWith(provider, [gone]);
688
+ expect(recoverMock).toHaveBeenCalledTimes(1);
689
+
690
+ await sweepWith(provider, []); // full pass without it: its backoff entry is dropped
691
+
692
+ await sweepWith(provider, [gone]); // reappears: tried immediately, not held back
693
+ expect(recoverMock).toHaveBeenCalledTimes(2);
694
+ });
695
+
696
+ it('attempts at most 5 recoveries per sweep', async () => {
697
+ const rows = Array.from({ length: 7 }, () => candidate());
698
+ const { provider } = makeRecoveryProvider();
699
+
700
+ expect(await sweepWith(provider, rows)).toBe(5);
701
+ expect(recoverMock).toHaveBeenCalledTimes(5);
702
+ });
703
+
704
+ it('stops paging once the per-sweep cap is reached', async () => {
705
+ const firstPage = Array.from({ length: 200 }, () => candidate());
706
+ const { provider } = makeRecoveryProvider();
707
+ sessionPages = [{ Success: true, Results: firstPage }, { Success: true, Results: [candidate()] }];
708
+
709
+ await SessionJanitor.Instance.RunRecordingRecoverySweep(provider, makeUser());
710
+
711
+ expect(recoverMock).toHaveBeenCalledTimes(5);
712
+ expect(sessionSweepCalls()).toHaveLength(1);
713
+ });
714
+
715
+ it('keeps backoff for candidates a capped sweep never reached', async () => {
716
+ const unreached = candidate();
717
+ const { provider } = makeRecoveryProvider();
718
+ recoverMock.mockResolvedValueOnce(NO_SEGMENTS);
719
+ await sweepWith(provider, [unreached]); // backs `unreached` off
720
+
721
+ // Next sweep hits the cap before reaching `unreached`, so it is never "seen".
722
+ const crowd = Array.from({ length: 6 }, () => candidate());
723
+ await sweepWith(provider, [...crowd, unreached]);
724
+ expect(recoverMock).toHaveBeenCalledTimes(6);
725
+
726
+ // Still backed off afterwards: the capped pass did not prune it.
727
+ await sweepWith(provider, [unreached]);
728
+ expect(recoverMock).toHaveBeenCalledTimes(6);
729
+ });
730
+
731
+ it('does not prune backoff when a page load fails, and a given-up session stays given up', async () => {
732
+ const doomed = candidate();
733
+ recoverMock.mockResolvedValue(FAILED);
734
+ const { provider } = makeRecoveryProvider();
735
+ for (let i = 0; i < MAX_ATTEMPTS_FOR_TEST; i++) {
736
+ vi.setSystemTime(NOW.getTime() + i * 7 * HOUR);
737
+ await sweepWith(provider, [doomed]);
738
+ }
739
+ expect(recoverMock).toHaveBeenCalledTimes(MAX_ATTEMPTS_FOR_TEST);
740
+
741
+ // A DB hiccup: the page fails to load. This must not reset anyone's backoff.
742
+ vi.setSystemTime(NOW.getTime() + 100 * HOUR);
743
+ sessionPages = [{ Success: false, ErrorMessage: 'db down', Results: [] }];
744
+ await SessionJanitor.Instance.RunRecordingRecoverySweep(provider, makeUser());
745
+
746
+ await sweepWith(provider, [doomed]);
747
+ expect(recoverMock).toHaveBeenCalledTimes(MAX_ATTEMPTS_FOR_TEST);
748
+ expect(logErrorMock.mock.calls.filter((c) => String(c[0]).includes(doomed.ID) && String(c[0]).includes('Giving up'))).toHaveLength(1);
749
+ });
750
+
751
+ it('logs an unloadable agent distinctly from a missing storage account', async () => {
752
+ const orphaned = candidate('agent-gone');
753
+ const { provider, failingAgents } = makeRecoveryProvider();
754
+ failingAgents.add('agent-gone');
755
+
756
+ await sweepWith(provider, [orphaned]);
757
+
758
+ expect(recoverMock).not.toHaveBeenCalled();
759
+ const message = logStatusMock.mock.calls.map((c) => String(c[0])).find((m) => m.includes(orphaned.ID));
760
+ expect(message).toContain('could not be loaded');
761
+ expect(message).not.toContain('no recording storage account');
762
+ });
763
+
764
+ it('survives a candidate that throws, logging its session id and moving on', async () => {
765
+ const bad = candidate();
766
+ const good = candidate();
767
+ recoverMock.mockRejectedValueOnce(new Error('kaboom'));
768
+ const { provider } = makeRecoveryProvider();
769
+
770
+ expect(await sweepWith(provider, [bad, good])).toBe(1);
771
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringContaining(bad.ID));
772
+ });
773
+
774
+ describe('attempt timeout', () => {
775
+ /** A recovery call for `sessionID` that never settles until the returned release is called. */
776
+ function hangRecoveryFor(sessionID: string): () => void {
777
+ let release: () => void = () => undefined;
778
+ const gate = new Promise<void>((resolve) => { release = resolve; });
779
+ recoverMock.mockImplementation(async (input: { SessionID: string }) => {
780
+ if (input.SessionID === sessionID) {
781
+ await gate;
782
+ return NO_SEGMENTS;
783
+ }
784
+ return { Outcome: 'Recovered', FileID: 'file-1', SegmentCount: 3, MissingIndexes: [], ErrorMessage: null };
785
+ });
786
+ return release;
787
+ }
788
+
789
+ it('abandons an attempt that outlives the timeout, logs it as a failure, and recovers the next candidate', async () => {
790
+ const hung = candidate();
791
+ const next = candidate();
792
+ const release = hangRecoveryFor(hung.ID);
793
+ const { provider } = makeRecoveryProvider();
794
+ try {
795
+ const sweep = sweepWith(provider, [hung, next]);
796
+ await vi.advanceTimersByTimeAsync(10 * MINUTE);
797
+ expect(await sweep).toBe(1);
798
+
799
+ expect(recoverMock.mock.calls.map((c) => (c[0] as { SessionID: string }).SessionID)).toEqual([hung.ID, next.ID]);
800
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${hung.ID}.*did not finish within 10 minutes`)));
801
+ } finally {
802
+ release();
803
+ await vi.advanceTimersByTimeAsync(0);
804
+ }
805
+ });
806
+
807
+ it('starts no second attempt for a session while its abandoned attempt is still running', async () => {
808
+ const hung = candidate();
809
+ const release = hangRecoveryFor(hung.ID);
810
+ const { provider } = makeRecoveryProvider();
811
+ try {
812
+ const first = sweepWith(provider, [hung]);
813
+ await vi.advanceTimersByTimeAsync(10 * MINUTE);
814
+ await first;
815
+
816
+ // Past the 10-minute backoff of the first failure, but the abandoned call is still in flight.
817
+ vi.setSystemTime(Date.now() + 11 * MINUTE);
818
+ await sweepWith(provider, [hung]);
819
+ expect(recoverMock).toHaveBeenCalledTimes(1);
820
+
821
+ release();
822
+ await vi.advanceTimersByTimeAsync(0);
823
+ vi.setSystemTime(Date.now() + 11 * MINUTE);
824
+ await sweepWith(provider, [hung]);
825
+ expect(recoverMock).toHaveBeenCalledTimes(2);
826
+ } finally {
827
+ release();
828
+ await vi.advanceTimersByTimeAsync(0);
829
+ }
830
+ });
831
+
832
+ it('pauses recovery while 2 abandoned attempts are still running, logs it once, and resumes when one settles', async () => {
833
+ const [a, b, c, d] = [candidate(), candidate(), candidate(), candidate()];
834
+ const gates = new Map<string, () => void>();
835
+ recoverMock.mockImplementation(async (input: { SessionID: string }) => {
836
+ if (input.SessionID === a.ID || input.SessionID === b.ID || input.SessionID === c.ID) {
837
+ await new Promise<void>((resolve) => gates.set(input.SessionID, resolve));
838
+ return NO_SEGMENTS;
839
+ }
840
+ return { Outcome: 'Recovered', FileID: 'file-1', SegmentCount: 1, MissingIndexes: [], ErrorMessage: null };
841
+ });
842
+ const attempted = (): string[] => recoverMock.mock.calls.map((call) => (call[0] as { SessionID: string }).SessionID);
843
+ const { provider } = makeRecoveryProvider();
844
+ try {
845
+ const first = sweepWith(provider, [a, b, c, d]);
846
+ await vi.advanceTimersByTimeAsync(20 * MINUTE);
847
+ await first;
848
+ expect(attempted()).toEqual([a.ID, b.ID]); // cap reached after the 2nd abandonment: C and D wait
849
+
850
+ await sweepWith(provider, [a, b, c, d]);
851
+ await sweepWith(provider, [a, b, c, d]);
852
+ expect(attempted()).toEqual([a.ID, b.ID]);
853
+ expect(logErrorMock.mock.calls.filter((call) => String(call[0]).includes('paused'))).toHaveLength(1);
854
+
855
+ gates.get(a.ID)?.();
856
+ await vi.advanceTimersByTimeAsync(0);
857
+ const resumed = sweepWith(provider, [d, c]);
858
+ await vi.advanceTimersByTimeAsync(10 * MINUTE);
859
+ expect(await resumed).toBe(1);
860
+ expect(attempted()).toEqual([a.ID, b.ID, d.ID, c.ID]);
861
+ } finally {
862
+ for (const release of gates.values()) release();
863
+ await vi.advanceTimersByTimeAsync(0);
864
+ }
865
+ });
866
+
867
+ it('honours a configured attempt timeout', async () => {
868
+ SessionJanitor.Instance.Configure({ recordingRecoveryAttemptTimeoutMinutes: 2 });
869
+ const hung = candidate();
870
+ const release = hangRecoveryFor(hung.ID);
871
+ const { provider } = makeRecoveryProvider();
872
+ try {
873
+ const sweep = sweepWith(provider, [hung]);
874
+ await vi.advanceTimersByTimeAsync(2 * MINUTE);
875
+ expect(await sweep).toBe(0);
876
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(/did not finish within 2 minutes/));
877
+ } finally {
878
+ SessionJanitor.Instance.Configure({ recordingRecoveryAttemptTimeoutMinutes: 10 });
879
+ release();
880
+ await vi.advanceTimersByTimeAsync(0);
881
+ }
882
+ });
883
+ });
884
+
885
+ describe('periodic tick', () => {
886
+ it('a recovery call that never settles does not stop later ticks from recovering other sessions', async () => {
887
+ const hung = candidate();
888
+ const later = candidate();
889
+ let release: () => void = () => undefined;
890
+ const gate = new Promise<void>((resolve) => { release = resolve; });
891
+ recoverMock.mockImplementation(async (input: { SessionID: string }) => {
892
+ if (input.SessionID === hung.ID) {
893
+ await gate;
894
+ }
895
+ return { Outcome: 'Recovered', FileID: 'file-1', SegmentCount: 1, MissingIndexes: [], ErrorMessage: null };
896
+ });
897
+ const { provider } = makeRecoveryProvider();
898
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
899
+ runViewMock.mockImplementation(async (params: { EntityName: string; ExtraFilter?: string }) => {
900
+ if (params.ExtraFilter?.includes('RecordingStartedAt')) {
901
+ return { Success: true, Results: recoverMock.mock.calls.length === 0 ? [hung] : [later] };
902
+ }
903
+ return { Success: true, Results: [] };
904
+ });
905
+ try {
906
+ await vi.advanceTimersByTimeAsync(60_000); // tick 1: the hung attempt starts
907
+ await vi.advanceTimersByTimeAsync(12 * MINUTE); // timeout elapses; later ticks run new passes
908
+
909
+ expect(recoverMock.mock.calls.map((c) => (c[0] as { SessionID: string }).SessionID)).toContain(later.ID);
910
+ } finally {
911
+ release();
912
+ await vi.advanceTimersByTimeAsync(0);
913
+ }
914
+ });
915
+
916
+ it('runs recovery after the close sweeps: ledger assembly first, then legacy segment recovery', async () => {
917
+ const { provider } = makeRecoveryProvider();
918
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
919
+ runViewMock.mockClear();
920
+
921
+ await vi.advanceTimersByTimeAsync(60_000);
922
+
923
+ const filters = sessionSweepCalls().map((c) => c[0].ExtraFilter);
924
+ expect(filters).toHaveLength(4);
925
+ expect(filters[0]).toContain('LastActiveAt <');
926
+ expect(filters[1]).toContain('maxSessionDeadlineIso');
927
+ expect(filters[2]).toContain("RecordingStatus IN ('Pending','Assembling')");
928
+ expect(filters[3]).toContain('RecordingStartedAt');
929
+ });
930
+
931
+ it('purges ledger shards past the lookback window once per pass', async () => {
932
+ const { provider } = makeRecoveryProvider();
933
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
934
+ ledgerMock.PurgeClosedBefore.mockResolvedValueOnce(42);
935
+
936
+ await vi.advanceTimersByTimeAsync(60_000);
937
+
938
+ expect(ledgerMock.PurgeClosedBefore).toHaveBeenCalledTimes(1);
939
+ const [cutoff, maxRows] = ledgerMock.PurgeClosedBefore.mock.calls[0] as [Date, number];
940
+ expect(cutoff.toISOString()).toBe(new Date(Date.now() - 168 * HOUR).toISOString()); // now = the tick
941
+ expect(maxRows).toBe(500);
942
+ expect(logStatusMock).toHaveBeenCalledWith(expect.stringMatching(/purged 42 recording shard/i));
943
+ });
944
+
945
+ it('a Stop() during the pass ends it before the next step', async () => {
946
+ const { provider } = makeRecoveryProvider();
947
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
948
+ runViewMock.mockClear();
949
+ let releasePurge: () => void = () => undefined;
950
+ ledgerMock.PurgeClosedBefore.mockImplementationOnce(
951
+ () => new Promise<number>((resolve) => { releasePurge = () => resolve(0); }),
952
+ );
953
+
954
+ await vi.advanceTimersByTimeAsync(60_000); // the pass parks in the purge
955
+ SessionJanitor.Instance.Stop();
956
+ releasePurge();
957
+ await vi.advanceTimersByTimeAsync(0);
958
+
959
+ expect(sessionSweepCalls().filter((c) => c[0].ExtraFilter.includes('RecordingStatus'))).toHaveLength(0);
960
+ });
961
+
962
+ it('a failed purge is logged with context and the sweeps still run', async () => {
963
+ const { provider } = makeRecoveryProvider();
964
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
965
+ runViewMock.mockClear();
966
+ ledgerMock.PurgeClosedBefore.mockRejectedValueOnce(new Error('purge deadlock'));
967
+
968
+ await vi.advanceTimersByTimeAsync(60_000);
969
+
970
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(/purge.*purge deadlock/i));
971
+ expect(sessionSweepCalls().some((c) => c[0].ExtraFilter.includes('RecordingStartedAt'))).toBe(true);
972
+ });
973
+
974
+ it('a failed ledger sweep is logged and legacy recovery still runs', async () => {
975
+ const { provider } = makeRecoveryProvider();
976
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
977
+ runViewMock.mockClear();
978
+ runViewMock.mockImplementation(async (params: { ExtraFilter?: string }) => {
979
+ if (params.ExtraFilter?.includes("RecordingStatus IN ('Pending','Assembling')")) throw new Error('ledger query blew up');
980
+ return { Success: true, Results: [] };
981
+ });
982
+
983
+ await vi.advanceTimersByTimeAsync(60_000);
984
+
985
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(/ledger.*ledger query blew up/i));
986
+ expect(sessionSweepCalls().some((c) => c[0].ExtraFilter.includes('RecordingStartedAt'))).toBe(true);
987
+ });
988
+
989
+ it('a slow recovery does not block the close sweeps on later ticks and is not started twice', async () => {
990
+ const { provider } = makeRecoveryProvider();
991
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
992
+ runViewMock.mockClear();
993
+ let releaseRecovery: () => void = () => undefined;
994
+ const recoveryGate = new Promise<void>((resolve) => { releaseRecovery = resolve; });
995
+ runViewMock.mockImplementation(async (params: { ExtraFilter?: string }) => {
996
+ if (params.ExtraFilter?.includes('RecordingStartedAt')) await recoveryGate; // recovery hangs
997
+ return { Success: true, Results: [] };
998
+ });
999
+
1000
+ await vi.advanceTimersByTimeAsync(60_000); // tick 1: close sweeps + recovery starts, hangs
1001
+ await vi.advanceTimersByTimeAsync(60_000); // tick 2: close sweeps still run; no second recovery
1002
+
1003
+ const filters = sessionSweepCalls().map((c) => c[0].ExtraFilter);
1004
+ expect(filters.filter((f) => f.includes('LastActiveAt <'))).toHaveLength(2);
1005
+ expect(filters.filter((f) => f.includes('maxSessionDeadlineIso'))).toHaveLength(2);
1006
+ expect(filters.filter((f) => f.includes('RecordingStartedAt'))).toHaveLength(1);
1007
+
1008
+ releaseRecovery();
1009
+ await vi.advanceTimersByTimeAsync(60_000); // tick 3: recovery finished, so it starts again
1010
+ expect(sessionSweepCalls().map((c) => c[0].ExtraFilter).filter((f) => f.includes('RecordingStartedAt'))).toHaveLength(2);
1011
+ });
1012
+
1013
+ /** Makes the recovery sweep's candidate query hang until the returned release function is called. */
1014
+ function hangRecoveryQuery(): () => void {
1015
+ let release: () => void = () => undefined;
1016
+ const gate = new Promise<void>((resolve) => { release = resolve; });
1017
+ runViewMock.mockImplementation(async (params: { ExtraFilter?: string }) => {
1018
+ if (params.ExtraFilter?.includes('RecordingStartedAt')) await gate;
1019
+ return { Success: true, Results: [] };
1020
+ });
1021
+ return release;
1022
+ }
1023
+
1024
+ function recoveryQueries(): number {
1025
+ return sessionSweepCalls().filter((c) => c[0].ExtraFilter.includes('RecordingStartedAt')).length;
1026
+ }
1027
+
1028
+ it('starts no recovery pass after Stop(), even from a tick that was already running', async () => {
1029
+ const { provider } = makeRecoveryProvider();
1030
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
1031
+ runViewMock.mockClear();
1032
+ let releaseClose: () => void = () => undefined;
1033
+ const closeGate = new Promise<void>((resolve) => { releaseClose = resolve; });
1034
+ runViewMock.mockImplementation(async (params: { ExtraFilter?: string }) => {
1035
+ if (params.ExtraFilter?.includes('LastActiveAt <')) await closeGate; // tick parks in the close sweeps
1036
+ return { Success: true, Results: [] };
1037
+ });
1038
+
1039
+ await vi.advanceTimersByTimeAsync(60_000);
1040
+ SessionJanitor.Instance.Stop();
1041
+ releaseClose();
1042
+ await vi.advanceTimersByTimeAsync(0);
1043
+
1044
+ expect(sessionSweepCalls().some((c) => c[0].ExtraFilter.includes('maxSessionDeadlineIso'))).toBe(true);
1045
+ expect(recoveryQueries()).toBe(0);
1046
+ });
1047
+
1048
+ it('Shutdown() waits for an in-flight recovery pass to finish', async () => {
1049
+ const { provider } = makeRecoveryProvider();
1050
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
1051
+ runViewMock.mockClear();
1052
+ const releaseRecovery = hangRecoveryQuery();
1053
+ try {
1054
+ await vi.advanceTimersByTimeAsync(60_000); // recovery pass starts and hangs
1055
+ expect(recoveryQueries()).toBe(1);
1056
+
1057
+ let finished = false;
1058
+ const shutdown = SessionJanitor.Instance.Shutdown().then(() => { finished = true; });
1059
+ await vi.advanceTimersByTimeAsync(1_000);
1060
+ expect(finished).toBe(false);
1061
+
1062
+ releaseRecovery();
1063
+ await shutdown;
1064
+ expect(finished).toBe(true);
1065
+ expect(logErrorMock).not.toHaveBeenCalled();
1066
+ } finally {
1067
+ releaseRecovery(); // never leave a parked pass on the singleton for later tests
1068
+ await vi.advanceTimersByTimeAsync(0);
1069
+ }
1070
+ });
1071
+
1072
+ it('Shutdown() stops waiting for a stuck recovery pass after 10 s and logs that once', async () => {
1073
+ const { provider } = makeRecoveryProvider();
1074
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
1075
+ runViewMock.mockClear();
1076
+ const releaseRecovery = hangRecoveryQuery();
1077
+ try {
1078
+ await vi.advanceTimersByTimeAsync(60_000);
1079
+
1080
+ let finished = false;
1081
+ const shutdown = SessionJanitor.Instance.Shutdown().then(() => { finished = true; });
1082
+ await vi.advanceTimersByTimeAsync(9_999);
1083
+ expect(finished).toBe(false);
1084
+ await vi.advanceTimersByTimeAsync(1);
1085
+ await shutdown;
1086
+
1087
+ expect(finished).toBe(true);
1088
+ expect(logErrorMock).toHaveBeenCalledTimes(1);
1089
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(/shutdown.*recording recovery.*10000 ms/i));
1090
+ } finally {
1091
+ releaseRecovery(); // let the parked pass end so the singleton can start passes in later tests
1092
+ await vi.advanceTimersByTimeAsync(0);
1093
+ }
1094
+ });
1095
+
1096
+ it('a recovery failure is logged with context and does not disturb the close sweeps', async () => {
1097
+ const { provider } = makeRecoveryProvider();
1098
+ await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
1099
+ runViewMock.mockClear();
1100
+ runViewMock.mockImplementation(async (params: { ExtraFilter?: string }) => {
1101
+ if (params.ExtraFilter?.includes('RecordingStartedAt')) throw new Error('recovery query blew up');
1102
+ return { Success: true, Results: [] };
1103
+ });
1104
+
1105
+ await vi.advanceTimersByTimeAsync(60_000);
1106
+
1107
+ const filters = sessionSweepCalls().map((c) => c[0].ExtraFilter);
1108
+ expect(filters[0]).toContain('LastActiveAt <');
1109
+ expect(filters[1]).toContain('maxSessionDeadlineIso');
1110
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(/recording recovery.*recovery query blew up/i));
1111
+ });
1112
+ });
1113
+ });
1114
+
1115
+ describe('SessionJanitor.RunRecordingLedgerSweep', () => {
1116
+ const NOW = new Date('2026-10-12T12:00:00.000Z');
1117
+ const MINUTE = 60_000;
1118
+ const HOUR = 60 * MINUTE;
1119
+ const STALE_CUTOFF = new Date(NOW.getTime() - 10 * MINUTE); // recordingRecoveryAttemptTimeoutMinutes
1120
+ let nextSessionNumber = 0;
1121
+
1122
+ type LedgerCandidate = {
1123
+ ID: string;
1124
+ AgentID: string;
1125
+ RecordingStatus: 'Pending' | 'Assembling' | null;
1126
+ RecordingExpectedShards: number | null;
1127
+ RecordingCapturedSamples: number | null;
1128
+ __mj_UpdatedAt: Date;
1129
+ };
1130
+
1131
+ /** Session ids are unique per test: the janitor is a singleton whose backoff outlives a test. */
1132
+ function ledgerCandidate(overrides: Partial<LedgerCandidate> = {}): LedgerCandidate {
1133
+ return {
1134
+ ID: `ledger-session-${nextSessionNumber++}`,
1135
+ AgentID: 'agent-1',
1136
+ RecordingStatus: 'Pending',
1137
+ RecordingExpectedShards: null,
1138
+ RecordingCapturedSamples: null,
1139
+ __mj_UpdatedAt: new Date(NOW.getTime() - HOUR),
1140
+ ...overrides,
1141
+ };
1142
+ }
1143
+
1144
+ function ledgerProvider(): DatabaseProviderBase {
1145
+ return { GetEntityObject: vi.fn(), ...LEDGER_SCHEMA_PROVIDER_FIELDS } as unknown as DatabaseProviderBase;
1146
+ }
1147
+
1148
+ /** Runs one ledger sweep whose candidate page holds exactly `rows`. */
1149
+ async function ledgerSweepWith(rows: LedgerCandidate[], provider = ledgerProvider()): Promise<number> {
1150
+ sessionPages = [{ Success: true, Results: rows }];
1151
+ return SessionJanitor.Instance.RunRecordingLedgerSweep(provider, makeUser());
1152
+ }
1153
+
1154
+ function ledgerFilter(): string {
1155
+ const call = sessionSweepCalls().find((c) => c[0].ExtraFilter.includes('RecordingStatus'));
1156
+ if (!call) throw new Error('ledger sweep never queried sessions');
1157
+ return call[0].ExtraFilter;
1158
+ }
1159
+
1160
+ function claimedIDs(): string[] {
1161
+ return ledgerMock.ClaimAssembly.mock.calls.map((c) => c[0] as string);
1162
+ }
1163
+
1164
+ beforeEach(() => {
1165
+ vi.useFakeTimers();
1166
+ vi.setSystemTime(NOW);
1167
+ SessionJanitor.Instance.Configure({ recordingRecoveryGraceMinutes: 10, recordingRecoveryLookbackHours: 168, recordingRecoveryAttemptTimeoutMinutes: 10 });
1168
+ ledgerMock.ClaimAssembly.mockResolvedValue(7);
1169
+ assembleMock.mockResolvedValue('Partial');
1170
+ });
1171
+
1172
+ afterEach(() => {
1173
+ SessionJanitor.Instance.Stop();
1174
+ vi.useRealTimers();
1175
+ });
1176
+
1177
+ it('queries closed sessions past the grace and inside the lookback that are Pending, Assembling, or lost their status with shards on the ledger', async () => {
1178
+ await ledgerSweepWith([]);
1179
+
1180
+ const filter = ledgerFilter();
1181
+ expect(filter).toContain("Status = 'Closed'");
1182
+ expect(filter).toContain(`ClosedAt < '${new Date(NOW.getTime() - 10 * MINUTE).toISOString()}'`);
1183
+ expect(filter).toContain(`ClosedAt >= '${new Date(NOW.getTime() - 168 * HOUR).toISOString()}'`);
1184
+ expect(filter).toContain("RecordingStatus IN ('Pending','Assembling')");
1185
+ expect(filter).toContain(`RecordingStatus IS NULL AND ID IN (SELECT AgentSessionID FROM ${LEDGER_VIEW})`);
1186
+ expect(filter).not.toContain('RecordingStartedAt');
1187
+ });
1188
+
1189
+ it('assembles a closed Pending session with no terminal signal as Partial: stale-aware claim, then a forced assembly', async () => {
1190
+ const session = ledgerCandidate();
1191
+
1192
+ expect(await ledgerSweepWith([session])).toBe(1);
1193
+
1194
+ expect(ledgerMock.ClaimAssembly).toHaveBeenCalledWith(session.ID, STALE_CUTOFF);
1195
+ expect(assembleMock).toHaveBeenCalledTimes(1);
1196
+ expect(assembleMock).toHaveBeenCalledWith(expect.objectContaining({
1197
+ SessionID: session.ID, Version: 7, Ledger: ledgerMock, ForcePartial: true,
1198
+ }));
1199
+ expect(logStatusMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${session.ID}.*Partial`)));
1200
+ });
1201
+
1202
+ it('re-claims an Assembling session whose assembler went quiet past the attempt timeout, but not a fresh one', async () => {
1203
+ const stale = ledgerCandidate({ RecordingStatus: 'Assembling', __mj_UpdatedAt: new Date(STALE_CUTOFF.getTime() - 1) });
1204
+ const fresh = ledgerCandidate({ RecordingStatus: 'Assembling', __mj_UpdatedAt: new Date(NOW.getTime() - MINUTE) });
1205
+
1206
+ expect(await ledgerSweepWith([fresh, stale])).toBe(1);
1207
+
1208
+ expect(claimedIDs()).toEqual([stale.ID]);
1209
+ expect(ledgerMock.ClaimAssembly).toHaveBeenCalledWith(stale.ID, STALE_CUTOFF);
1210
+ });
1211
+
1212
+ it('skips a session whose claim another assembler won, without backing it off', async () => {
1213
+ const session = ledgerCandidate();
1214
+ ledgerMock.ClaimAssembly.mockResolvedValueOnce(null);
1215
+
1216
+ expect(await ledgerSweepWith([session])).toBe(0);
1217
+ expect(assembleMock).not.toHaveBeenCalled();
1218
+ expect(logErrorMock).not.toHaveBeenCalled();
1219
+
1220
+ await ledgerSweepWith([session]); // not backed off: the next sweep tries the claim again
1221
+ expect(claimedIDs()).toEqual([session.ID, session.ID]);
1222
+ });
1223
+
1224
+ it('backs off a session whose assembly hit a transient error (Retry), and retries after the backoff', async () => {
1225
+ const session = ledgerCandidate();
1226
+ assembleMock.mockResolvedValueOnce('Retry');
1227
+
1228
+ expect(await ledgerSweepWith([session])).toBe(0);
1229
+ await ledgerSweepWith([session]);
1230
+ expect(claimedIDs()).toEqual([session.ID]);
1231
+
1232
+ vi.setSystemTime(NOW.getTime() + 11 * MINUTE); // first backoff is 2^1 * 5 min
1233
+ await ledgerSweepWith([session]);
1234
+ expect(claimedIDs()).toEqual([session.ID, session.ID]);
1235
+ });
1236
+
1237
+ it('gives up on a session that keeps hitting Retry after 8 attempts, with one LogError', async () => {
1238
+ const session = ledgerCandidate();
1239
+ assembleMock.mockResolvedValue('Retry');
1240
+
1241
+ for (let i = 0; i < 12; i++) {
1242
+ await ledgerSweepWith([session]);
1243
+ vi.setSystemTime(Date.now() + 7 * HOUR); // past the longest (6 h) backoff
1244
+ }
1245
+
1246
+ expect(assembleMock).toHaveBeenCalledTimes(8);
1247
+ expect(logErrorMock.mock.calls.filter((c) => /Giving up/.test(String(c[0])) && String(c[0]).includes(session.ID))).toHaveLength(1);
1248
+ });
1249
+
1250
+ it('LogErrors a Failed assembly with the session id and counts it as a failure', async () => {
1251
+ const session = ledgerCandidate();
1252
+ assembleMock.mockResolvedValueOnce('Failed');
1253
+
1254
+ expect(await ledgerSweepWith([session])).toBe(0);
1255
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${session.ID}.*Failed`)));
1256
+ await ledgerSweepWith([session]);
1257
+ expect(claimedIDs()).toEqual([session.ID]); // backed off
1258
+ });
1259
+
1260
+ it('logs a Superseded assembly without treating it as a failure', async () => {
1261
+ const session = ledgerCandidate();
1262
+ assembleMock.mockResolvedValueOnce('Superseded');
1263
+
1264
+ expect(await ledgerSweepWith([session])).toBe(0);
1265
+ expect(logErrorMock).not.toHaveBeenCalled();
1266
+ expect(logStatusMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${session.ID}.*Superseded`)));
1267
+ await ledgerSweepWith([session]);
1268
+ expect(claimedIDs()).toEqual([session.ID, session.ID]);
1269
+ });
1270
+
1271
+ it('LogErrors an unexpected Deferred from a forced assembly and backs off', async () => {
1272
+ const session = ledgerCandidate();
1273
+ assembleMock.mockResolvedValueOnce('Deferred');
1274
+
1275
+ await ledgerSweepWith([session]);
1276
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${session.ID}.*Deferred`)));
1277
+ await ledgerSweepWith([session]);
1278
+ expect(claimedIDs()).toEqual([session.ID]);
1279
+ });
1280
+
1281
+ it('logs and backs off a claim that throws, then moves on to the next candidate', async () => {
1282
+ const [bad, good] = [ledgerCandidate(), ledgerCandidate()];
1283
+ ledgerMock.ClaimAssembly.mockRejectedValueOnce(new Error('claim deadlock'));
1284
+
1285
+ expect(await ledgerSweepWith([bad, good])).toBe(1);
1286
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${bad.ID}.*claim deadlock`)));
1287
+ });
1288
+
1289
+ it('restores the status a racing session save wiped with a (0, 0) stamp, never counts invented from the ledger, before claiming', async () => {
1290
+ // No terminal counts survive: stamping 0/0 sets NULL -> Pending, and the assembly (shard count != 0)
1291
+ // is Partial and keeps the shards, instead of a made-up terminal labelling it Complete.
1292
+ const session = ledgerCandidate({ RecordingStatus: null });
1293
+ ledgerMock.StampTerminal.mockResolvedValueOnce({ Outcome: 'Stamped', RecordingStatus: 'Pending' });
1294
+
1295
+ expect(await ledgerSweepWith([session])).toBe(1);
1296
+
1297
+ expect(ledgerMock.StampTerminal).toHaveBeenCalledWith(session.ID, 0, 0);
1298
+ expect(ledgerMock.LoadShardMetadata).not.toHaveBeenCalled();
1299
+ expect(ledgerMock.StampTerminal.mock.invocationCallOrder[0]).toBeLessThan(ledgerMock.ClaimAssembly.mock.invocationCallOrder[0]);
1300
+ expect(assembleMock).toHaveBeenCalledWith(expect.objectContaining({ SessionID: session.ID, ForcePartial: true }));
1301
+ });
1302
+
1303
+ it('restores a lost status with the counts the session still stores', async () => {
1304
+ const session = ledgerCandidate({ RecordingStatus: null, RecordingExpectedShards: 9, RecordingCapturedSamples: 2_000_000 });
1305
+ ledgerMock.StampTerminal.mockResolvedValueOnce({ Outcome: 'Stamped', RecordingStatus: 'Pending' });
1306
+
1307
+ await ledgerSweepWith([session]);
1308
+
1309
+ expect(ledgerMock.StampTerminal).toHaveBeenCalledWith(session.ID, 9, 2_000_000);
1310
+ });
1311
+
1312
+ it('stamps a BIGINT capture count that the candidate page delivers as a string as a number', async () => {
1313
+ // A RunView entity page keeps the driver's value for a BIGINT column: the SQL Server driver
1314
+ // returns it as a string, and the ledger's integer guard refused it (#5294 gauntlet, row S13 J3).
1315
+ const session = ledgerCandidate({ RecordingStatus: null, RecordingExpectedShards: 5, RecordingCapturedSamples: '1137600' as unknown as number });
1316
+ ledgerMock.StampTerminal.mockResolvedValueOnce({ Outcome: 'Stamped', RecordingStatus: 'Pending' });
1317
+
1318
+ await ledgerSweepWith([session]);
1319
+
1320
+ expect(ledgerMock.StampTerminal).toHaveBeenCalledWith(session.ID, 5, 1_137_600);
1321
+ });
1322
+
1323
+ it('LogErrors and backs off a lost status the stamp could not restore', async () => {
1324
+ const session = ledgerCandidate({ RecordingStatus: null });
1325
+ ledgerMock.StampTerminal.mockResolvedValueOnce({ Outcome: 'Ignored', RecordingStatus: null });
1326
+
1327
+ expect(await ledgerSweepWith([session])).toBe(0);
1328
+ expect(ledgerMock.ClaimAssembly).not.toHaveBeenCalled();
1329
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringContaining(session.ID));
1330
+ await ledgerSweepWith([session]);
1331
+ expect(ledgerMock.StampTerminal).toHaveBeenCalledTimes(1); // backed off
1332
+ });
1333
+
1334
+ it('counts failed status restores toward the per-sweep cap', async () => {
1335
+ const sessions = Array.from({ length: 7 }, () => ledgerCandidate({ RecordingStatus: null }));
1336
+ ledgerMock.StampTerminal.mockRejectedValue(new Error('stamp deadlock'));
1337
+
1338
+ await ledgerSweepWith(sessions);
1339
+
1340
+ expect(ledgerMock.StampTerminal).toHaveBeenCalledTimes(5);
1341
+ });
1342
+
1343
+ it('attempts at most 5 assemblies per sweep', async () => {
1344
+ const sessions = Array.from({ length: 7 }, () => ledgerCandidate());
1345
+
1346
+ expect(await ledgerSweepWith(sessions)).toBe(5);
1347
+ expect(assembleMock).toHaveBeenCalledTimes(5);
1348
+ });
1349
+
1350
+ it('abandons an assembly that outlives the attempt timeout, backs it off, and continues with the next candidate', async () => {
1351
+ const [hung, next] = [ledgerCandidate(), ledgerCandidate()];
1352
+ let release: () => void = () => undefined;
1353
+ assembleMock.mockImplementation(async (input: { SessionID: string }) => {
1354
+ if (input.SessionID === hung.ID) {
1355
+ await new Promise<void>((resolve) => { release = resolve; });
1356
+ return 'Retry';
1357
+ }
1358
+ return 'Complete';
1359
+ });
1360
+ try {
1361
+ const sweep = ledgerSweepWith([hung, next]);
1362
+ await vi.advanceTimersByTimeAsync(10 * MINUTE);
1363
+ expect(await sweep).toBe(1);
1364
+ expect(logErrorMock).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${hung.ID}.*did not finish within 10 minutes`)));
1365
+
1366
+ vi.setSystemTime(Date.now() + 11 * MINUTE); // past the backoff, but the abandoned call is still running
1367
+ await ledgerSweepWith([hung]);
1368
+ expect(assembleMock.mock.calls.map((c) => (c[0] as { SessionID: string }).SessionID)).toEqual([hung.ID, next.ID]);
1369
+ } finally {
1370
+ release();
1371
+ await vi.advanceTimersByTimeAsync(0);
1372
+ }
1373
+ });
1374
+ });
1375
+
1376
+ describe('SessionJanitor.Configure', () => {
1377
+ const DEFAULTS: SessionJanitorConfig = {
1378
+ closeThresholdMinutes: 15, sweepIntervalMs: 60_000, recordingRecoveryGraceMinutes: 10, recordingRecoveryLookbackHours: 168,
1379
+ recordingRecoveryAttemptTimeoutMinutes: 10,
1380
+ };
1381
+
1382
+ afterEach(() => {
1383
+ SessionJanitor.Instance.Configure(DEFAULTS);
1384
+ vi.useRealTimers();
1385
+ });
1386
+
1387
+ it.each([
1388
+ ['recordingRecoveryGraceMinutes', Number.NaN],
1389
+ ['recordingRecoveryGraceMinutes', 0],
1390
+ ['recordingRecoveryLookbackHours', -1],
1391
+ ['closeThresholdMinutes', 0],
1392
+ ['sweepIntervalMs', Number.POSITIVE_INFINITY],
1393
+ ['recordingRecoveryAttemptTimeoutMinutes', 0],
1394
+ ] as const)('rejects %s = %s, naming the field and the value', (field, value) => {
1395
+ const config: Partial<SessionJanitorConfig> = { [field]: value };
1396
+ expect(() => SessionJanitor.Instance.Configure(config)).toThrow(new RegExp(`${field}.*${String(value)}`));
1397
+ });
1398
+
1399
+ it('applies nothing from a call that has an invalid value', async () => {
1400
+ vi.useFakeTimers();
1401
+ vi.setSystemTime(new Date('2026-10-07T12:00:00.000Z'));
1402
+ SessionJanitor.Instance.Configure({ closeThresholdMinutes: 20 });
1403
+ expect(() => SessionJanitor.Instance.Configure({ closeThresholdMinutes: 30, sweepIntervalMs: -5 })).toThrow(/sweepIntervalMs/);
1404
+
1405
+ const { provider } = makeProvider(new Map());
1406
+ await SessionJanitor.Instance.RunStalenessSweep(provider, makeUser());
1407
+ const cutoff = new Date(Date.now() - 20 * 60_000).toISOString();
1408
+ expect(sessionSweepCalls()[0][0].ExtraFilter).toContain(`LastActiveAt < '${cutoff}'`);
1409
+ });
1410
+
1411
+ it('rejects an invalid sweep interval passed to Start() before doing any work', async () => {
1412
+ const { provider } = makeProvider(new Map());
1413
+ await expect(SessionJanitor.Instance.Start(provider, makeUser(), 0)).rejects.toThrow(/sweepIntervalMs.*0/);
1414
+ expect(sessionSweepCalls()).toHaveLength(0);
1415
+ });
1416
+ });