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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (435) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/BridgeHostInstance.d.ts +25 -0
  4. package/dist/agentSessions/BridgeHostInstance.d.ts.map +1 -0
  5. package/dist/agentSessions/BridgeHostInstance.js +30 -0
  6. package/dist/agentSessions/BridgeHostInstance.js.map +1 -0
  7. package/dist/agentSessions/HostInstance.d.ts +75 -11
  8. package/dist/agentSessions/HostInstance.d.ts.map +1 -1
  9. package/dist/agentSessions/HostInstance.js +99 -28
  10. package/dist/agentSessions/HostInstance.js.map +1 -1
  11. package/dist/agentSessions/ReturningVisitorRecap.d.ts.map +1 -1
  12. package/dist/agentSessions/ReturningVisitorRecap.js +7 -3
  13. package/dist/agentSessions/ReturningVisitorRecap.js.map +1 -1
  14. package/dist/agentSessions/SessionJanitor.d.ts +173 -25
  15. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  16. package/dist/agentSessions/SessionJanitor.js +452 -54
  17. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  18. package/dist/agentSessions/SessionManager.d.ts +43 -2
  19. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  20. package/dist/agentSessions/SessionManager.js +99 -8
  21. package/dist/agentSessions/SessionManager.js.map +1 -1
  22. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  23. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  24. package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
  25. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  26. package/dist/agentSessions/index.d.ts +2 -0
  27. package/dist/agentSessions/index.d.ts.map +1 -1
  28. package/dist/agentSessions/index.js +10 -3
  29. package/dist/agentSessions/index.js.map +1 -1
  30. package/dist/auth/actionAuthorization.d.ts +40 -0
  31. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  32. package/dist/auth/actionAuthorization.js +94 -0
  33. package/dist/auth/actionAuthorization.js.map +1 -0
  34. package/dist/auth/index.d.ts.map +1 -1
  35. package/dist/auth/index.js +3 -10
  36. package/dist/auth/index.js.map +1 -1
  37. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  38. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  39. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  40. package/dist/auth/newUsers.d.ts +10 -0
  41. package/dist/auth/newUsers.d.ts.map +1 -1
  42. package/dist/auth/newUsers.js +80 -11
  43. package/dist/auth/newUsers.js.map +1 -1
  44. package/dist/config.d.ts +4567 -71
  45. package/dist/config.d.ts.map +1 -1
  46. package/dist/config.js +246 -3
  47. package/dist/config.js.map +1 -1
  48. package/dist/context.d.ts +8 -0
  49. package/dist/context.d.ts.map +1 -1
  50. package/dist/context.js +25 -34
  51. package/dist/context.js.map +1 -1
  52. package/dist/generated/graphql-schemas/__mj.d.ts +1073 -10
  53. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  54. package/dist/generated/graphql-schemas/__mj.js +9188 -3649
  55. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  56. package/dist/generic/OrphanedConversationDetailReconciler.js +3 -3
  57. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -1
  58. package/dist/generic/ResolverBase.d.ts +62 -6
  59. package/dist/generic/ResolverBase.d.ts.map +1 -1
  60. package/dist/generic/ResolverBase.js +179 -35
  61. package/dist/generic/ResolverBase.js.map +1 -1
  62. package/dist/generic/RunViewResolver.d.ts +4 -0
  63. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  64. package/dist/generic/RunViewResolver.js +37 -1
  65. package/dist/generic/RunViewResolver.js.map +1 -1
  66. package/dist/index.d.ts +7 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +214 -159
  69. package/dist/index.js.map +1 -1
  70. package/dist/isolatedProvider.d.ts +20 -0
  71. package/dist/isolatedProvider.d.ts.map +1 -0
  72. package/dist/isolatedProvider.js +22 -0
  73. package/dist/isolatedProvider.js.map +1 -0
  74. package/dist/postgresqlCompat.d.ts +8 -0
  75. package/dist/postgresqlCompat.d.ts.map +1 -0
  76. package/dist/postgresqlCompat.js +19 -0
  77. package/dist/postgresqlCompat.js.map +1 -0
  78. package/dist/postgresqlPoolSettings.d.ts +108 -0
  79. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  80. package/dist/postgresqlPoolSettings.js +134 -0
  81. package/dist/postgresqlPoolSettings.js.map +1 -0
  82. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +44 -4
  83. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  84. package/dist/realtimeProxy/RealtimeProxyServer.js +121 -16
  85. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  86. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts +171 -0
  87. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts.map +1 -0
  88. package/dist/realtimeProxy/RealtimeRelayTunnel.js +532 -0
  89. package/dist/realtimeProxy/RealtimeRelayTunnel.js.map +1 -0
  90. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts +148 -0
  91. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts.map +1 -0
  92. package/dist/realtimeSessions/RealtimeSessionEventService.js +191 -0
  93. package/dist/realtimeSessions/RealtimeSessionEventService.js.map +1 -0
  94. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts +66 -0
  95. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -0
  96. package/dist/realtimeSessions/RealtimeSessionVerificationService.js +138 -0
  97. package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -0
  98. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts +38 -0
  99. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -0
  100. package/dist/realtimeSessions/RealtimeVerifyRouter.js +98 -0
  101. package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -0
  102. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts +61 -0
  103. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts.map +1 -0
  104. package/dist/realtimeSessions/SessionVerificationPortsBase.js +149 -0
  105. package/dist/realtimeSessions/SessionVerificationPortsBase.js.map +1 -0
  106. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts +57 -0
  107. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts.map +1 -0
  108. package/dist/realtimeSessions/SlidingWindowRateLimiter.js +69 -0
  109. package/dist/realtimeSessions/SlidingWindowRateLimiter.js.map +1 -0
  110. package/dist/realtimeSessions/index.d.ts +17 -0
  111. package/dist/realtimeSessions/index.d.ts.map +1 -0
  112. package/dist/realtimeSessions/index.js +17 -0
  113. package/dist/realtimeSessions/index.js.map +1 -0
  114. package/dist/realtimeSessions/sessionAccess.d.ts +86 -0
  115. package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -0
  116. package/dist/realtimeSessions/sessionAccess.js +90 -0
  117. package/dist/realtimeSessions/sessionAccess.js.map +1 -0
  118. package/dist/realtimeSessions/sessionConfigStamps.d.ts +70 -0
  119. package/dist/realtimeSessions/sessionConfigStamps.d.ts.map +1 -0
  120. package/dist/realtimeSessions/sessionConfigStamps.js +93 -0
  121. package/dist/realtimeSessions/sessionConfigStamps.js.map +1 -0
  122. package/dist/realtimeSessions/subscriptionAuthorization.d.ts +65 -0
  123. package/dist/realtimeSessions/subscriptionAuthorization.d.ts.map +1 -0
  124. package/dist/realtimeSessions/subscriptionAuthorization.js +71 -0
  125. package/dist/realtimeSessions/subscriptionAuthorization.js.map +1 -0
  126. package/dist/realtimeSessions/verificationCore.d.ts +243 -0
  127. package/dist/realtimeSessions/verificationCore.d.ts.map +1 -0
  128. package/dist/realtimeSessions/verificationCore.js +356 -0
  129. package/dist/realtimeSessions/verificationCore.js.map +1 -0
  130. package/dist/realtimeSessions/verificationPages.d.ts +66 -0
  131. package/dist/realtimeSessions/verificationPages.d.ts.map +1 -0
  132. package/dist/realtimeSessions/verificationPages.js +110 -0
  133. package/dist/realtimeSessions/verificationPages.js.map +1 -0
  134. package/dist/realtimeSessions/verificationPolicy.d.ts +147 -0
  135. package/dist/realtimeSessions/verificationPolicy.d.ts.map +1 -0
  136. package/dist/realtimeSessions/verificationPolicy.js +249 -0
  137. package/dist/realtimeSessions/verificationPolicy.js.map +1 -0
  138. package/dist/realtimeSessions/verificationWorkflow.d.ts +263 -0
  139. package/dist/realtimeSessions/verificationWorkflow.d.ts.map +1 -0
  140. package/dist/realtimeSessions/verificationWorkflow.js +435 -0
  141. package/dist/realtimeSessions/verificationWorkflow.js.map +1 -0
  142. package/dist/realtimeWidget/widgetGuestElevation.d.ts +20 -2
  143. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  144. package/dist/realtimeWidget/widgetGuestElevation.js +31 -2
  145. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  146. package/dist/resolvers/ActionResolver.d.ts +31 -5
  147. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  148. package/dist/resolvers/ActionResolver.js +75 -14
  149. package/dist/resolvers/ActionResolver.js.map +1 -1
  150. package/dist/resolvers/AdhocQueryResolver.d.ts +35 -17
  151. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  152. package/dist/resolvers/AdhocQueryResolver.js +92 -166
  153. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  154. package/dist/resolvers/CacheStatsResolver.d.ts +27 -0
  155. package/dist/resolvers/CacheStatsResolver.d.ts.map +1 -1
  156. package/dist/resolvers/CacheStatsResolver.js +113 -2
  157. package/dist/resolvers/CacheStatsResolver.js.map +1 -1
  158. package/dist/resolvers/ClientToolRequestResolver.d.ts +4 -2
  159. package/dist/resolvers/ClientToolRequestResolver.d.ts.map +1 -1
  160. package/dist/resolvers/ClientToolRequestResolver.js +28 -10
  161. package/dist/resolvers/ClientToolRequestResolver.js.map +1 -1
  162. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  163. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  164. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  165. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  166. package/dist/resolvers/FetchEntityVectorsResolver.d.ts +12 -1
  167. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  168. package/dist/resolvers/FetchEntityVectorsResolver.js +83 -2
  169. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  170. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  171. package/dist/resolvers/GetDataResolver.js +9 -0
  172. package/dist/resolvers/GetDataResolver.js.map +1 -1
  173. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  174. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  175. package/dist/resolvers/HumanHandoffResolver.js +330 -0
  176. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  177. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  178. package/dist/resolvers/MCPResolver.js +9 -4
  179. package/dist/resolvers/MCPResolver.js.map +1 -1
  180. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  181. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  182. package/dist/resolvers/MeetingResolver.js +381 -0
  183. package/dist/resolvers/MeetingResolver.js.map +1 -0
  184. package/dist/resolvers/RealtimeBridgeResolver.d.ts +88 -3
  185. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  186. package/dist/resolvers/RealtimeBridgeResolver.js +352 -12
  187. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  188. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +156 -108
  189. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  190. package/dist/resolvers/RealtimeClientSessionResolver.js +395 -293
  191. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  192. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts +61 -0
  193. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts.map +1 -0
  194. package/dist/resolvers/RealtimeSessionEventsResolver.js +130 -0
  195. package/dist/resolvers/RealtimeSessionEventsResolver.js.map +1 -0
  196. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts +85 -0
  197. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts.map +1 -0
  198. package/dist/resolvers/RealtimeSessionVerificationResolver.js +188 -0
  199. package/dist/resolvers/RealtimeSessionVerificationResolver.js.map +1 -0
  200. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  201. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  202. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  203. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  204. package/dist/resolvers/RunAIAgentResolver.d.ts +10 -0
  205. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  206. package/dist/resolvers/RunAIAgentResolver.js +68 -11
  207. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  208. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  209. package/dist/resolvers/RunTemplateResolver.js +12 -11
  210. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  211. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  212. package/dist/resolvers/SyncDataResolver.js +6 -2
  213. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  214. package/dist/resolvers/SyncRolesUsersResolver.d.ts +2 -0
  215. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  216. package/dist/resolvers/SyncRolesUsersResolver.js +23 -6
  217. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  218. package/dist/resolvers/UserAvatarResolver.d.ts +16 -0
  219. package/dist/resolvers/UserAvatarResolver.d.ts.map +1 -0
  220. package/dist/resolvers/UserAvatarResolver.js +124 -0
  221. package/dist/resolvers/UserAvatarResolver.js.map +1 -0
  222. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  223. package/dist/resolvers/UserResolver.js +2 -17
  224. package/dist/resolvers/UserResolver.js.map +1 -1
  225. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  226. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  227. package/dist/resolvers/adhocTimeout.js +12 -0
  228. package/dist/resolvers/adhocTimeout.js.map +1 -0
  229. package/dist/resolvers/agentVisionConsentAudit.d.ts +37 -0
  230. package/dist/resolvers/agentVisionConsentAudit.d.ts.map +1 -0
  231. package/dist/resolvers/agentVisionConsentAudit.js +63 -0
  232. package/dist/resolvers/agentVisionConsentAudit.js.map +1 -0
  233. package/dist/resolvers/avatarInputValidation.d.ts +40 -0
  234. package/dist/resolvers/avatarInputValidation.d.ts.map +1 -0
  235. package/dist/resolvers/avatarInputValidation.js +97 -0
  236. package/dist/resolvers/avatarInputValidation.js.map +1 -0
  237. package/dist/resolvers/currentUserRoles.d.ts +29 -0
  238. package/dist/resolvers/currentUserRoles.d.ts.map +1 -1
  239. package/dist/resolvers/currentUserRoles.js +30 -0
  240. package/dist/resolvers/currentUserRoles.js.map +1 -1
  241. package/dist/resolvers/meetings/index.d.ts +5 -0
  242. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  243. package/dist/resolvers/meetings/index.js +5 -0
  244. package/dist/resolvers/meetings/index.js.map +1 -0
  245. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  246. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  247. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  248. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  249. package/dist/resolvers/meetings/meetingMutations.d.ts +18 -0
  250. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  251. package/dist/resolvers/meetings/meetingMutations.js +439 -0
  252. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  253. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  254. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  255. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  256. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  257. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  258. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  259. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  260. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  261. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  262. package/dist/rest/ViewOperationsHandler.js +8 -5
  263. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  264. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  265. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  266. package/dist/services/TaskGraphActionRunner.js +27 -1
  267. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  268. package/dist/services/TaskGraphContinuationDeliverer.d.ts +4 -3
  269. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  270. package/dist/services/TaskGraphContinuationDeliverer.js +13 -7
  271. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  272. package/dist/services/WorkQueueHostService.d.ts +36 -0
  273. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  274. package/dist/services/WorkQueueHostService.js +97 -0
  275. package/dist/services/WorkQueueHostService.js.map +1 -0
  276. package/dist/services/workQueueConfig.d.ts +103 -0
  277. package/dist/services/workQueueConfig.d.ts.map +1 -0
  278. package/dist/services/workQueueConfig.js +48 -0
  279. package/dist/services/workQueueConfig.js.map +1 -0
  280. package/dist/sharedCache.d.ts +105 -0
  281. package/dist/sharedCache.d.ts.map +1 -0
  282. package/dist/sharedCache.js +276 -0
  283. package/dist/sharedCache.js.map +1 -0
  284. package/dist/types.d.ts +11 -0
  285. package/dist/types.d.ts.map +1 -1
  286. package/dist/types.js.map +1 -1
  287. package/package.json +97 -94
  288. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  289. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  290. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  291. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  292. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  293. package/src/__tests__/BridgeHostInstance.test.ts +232 -0
  294. package/src/__tests__/ClientToolRequestResolver.test.ts +116 -0
  295. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  296. package/src/__tests__/FetchEntityVectorsResolver.test.ts +285 -0
  297. package/src/__tests__/HostInstance.test.ts +84 -0
  298. package/src/__tests__/HumanHandoffResolver.test.ts +250 -0
  299. package/src/__tests__/MeetingResolver.test.ts +448 -0
  300. package/src/__tests__/RealtimeBridgeResolver.test.ts +651 -4
  301. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  302. package/src/__tests__/RealtimeClientSessionResolver.test.ts +682 -58
  303. package/src/__tests__/RealtimeProxyServer.test.ts +16 -1
  304. package/src/__tests__/RealtimeRelay.test.ts +876 -0
  305. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  306. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  307. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  308. package/src/__tests__/ResolverBase.clientOldValues.test.ts +6 -3
  309. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  310. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  311. package/src/__tests__/ReturningVisitorRecap.test.ts +115 -0
  312. package/src/__tests__/RunAIAgentResolver.branch.test.ts +169 -0
  313. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +4 -4
  314. package/src/__tests__/RunAIAgentResolver.stoppedPredecessor.test.ts +166 -0
  315. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  316. package/src/__tests__/RunViewResolver.cacheCheckForwarding.test.ts +63 -0
  317. package/src/__tests__/SessionJanitor.test.ts +845 -18
  318. package/src/__tests__/SessionManager.test.ts +213 -17
  319. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  320. package/src/__tests__/SyncRolesUsersResolver.userRoles.test.ts +97 -0
  321. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
  322. package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +50 -3
  323. package/src/__tests__/UserAvatarResolver.test.ts +255 -0
  324. package/src/__tests__/UserResolver.currentUser.test.ts +16 -1
  325. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  326. package/src/__tests__/actionAuthorization.test.ts +130 -0
  327. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  328. package/src/__tests__/agentVisionConsentAudit.test.ts +118 -0
  329. package/src/__tests__/avatarInputValidation.test.ts +160 -0
  330. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  331. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  332. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  333. package/src/__tests__/newUsers.template.test.ts +151 -0
  334. package/src/__tests__/newUsers.test.ts +74 -5
  335. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +17 -2
  336. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  337. package/src/__tests__/realtimeSessions/RealtimeSessionEventService.test.ts +201 -0
  338. package/src/__tests__/realtimeSessions/RealtimeSessionEventsResolver.test.ts +215 -0
  339. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationResolver.test.ts +102 -0
  340. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationService.test.ts +349 -0
  341. package/src/__tests__/realtimeSessions/RealtimeVerifyRouter.test.ts +131 -0
  342. package/src/__tests__/realtimeSessions/SlidingWindowRateLimiter.test.ts +56 -0
  343. package/src/__tests__/realtimeSessions/helpers.ts +4 -0
  344. package/src/__tests__/realtimeSessions/sessionAccess.test.ts +129 -0
  345. package/src/__tests__/realtimeSessions/sessionConfigStamps.test.ts +124 -0
  346. package/src/__tests__/realtimeSessions/verificationCore.test.ts +355 -0
  347. package/src/__tests__/realtimeSessions/verificationPages.test.ts +84 -0
  348. package/src/__tests__/realtimeSessions/verificationPolicy.test.ts +226 -0
  349. package/src/__tests__/realtimeSessions/verificationWorkflow.test.ts +633 -0
  350. package/src/__tests__/resolverBase.fls.test.ts +87 -4
  351. package/src/__tests__/serverExtensionReservedRoots.test.ts +2 -0
  352. package/src/__tests__/sharedCache.test.ts +310 -0
  353. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  354. package/src/__tests__/widgetGuestElevation.test.ts +44 -0
  355. package/src/agentSessions/BridgeHostInstance.ts +32 -0
  356. package/src/agentSessions/HostInstance.ts +124 -28
  357. package/src/agentSessions/ReturningVisitorRecap.ts +7 -3
  358. package/src/agentSessions/SessionJanitor.ts +543 -57
  359. package/src/agentSessions/SessionManager.ts +125 -25
  360. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  361. package/src/agentSessions/index.ts +10 -3
  362. package/src/auth/actionAuthorization.ts +116 -0
  363. package/src/auth/index.ts +3 -8
  364. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  365. package/src/auth/newUsers.ts +99 -11
  366. package/src/config.ts +255 -3
  367. package/src/context.ts +26 -34
  368. package/src/generated/graphql-schemas/__mj.ts +22701 -18838
  369. package/src/generic/OrphanedConversationDetailReconciler.ts +3 -3
  370. package/src/generic/ResolverBase.ts +210 -36
  371. package/src/generic/RunViewResolver.ts +37 -2
  372. package/src/index.ts +236 -170
  373. package/src/isolatedProvider.ts +22 -0
  374. package/src/postgresqlCompat.ts +21 -0
  375. package/src/postgresqlPoolSettings.ts +213 -0
  376. package/src/realtimeProxy/RealtimeProxyServer.ts +148 -16
  377. package/src/realtimeProxy/RealtimeRelayTunnel.ts +645 -0
  378. package/src/realtimeSessions/README.md +179 -0
  379. package/src/realtimeSessions/RealtimeSessionEventService.ts +274 -0
  380. package/src/realtimeSessions/RealtimeSessionVerificationService.ts +167 -0
  381. package/src/realtimeSessions/RealtimeVerifyRouter.ts +108 -0
  382. package/src/realtimeSessions/SessionVerificationPortsBase.ts +189 -0
  383. package/src/realtimeSessions/SlidingWindowRateLimiter.ts +92 -0
  384. package/src/realtimeSessions/index.ts +16 -0
  385. package/src/realtimeSessions/sessionAccess.ts +141 -0
  386. package/src/realtimeSessions/sessionConfigStamps.ts +123 -0
  387. package/src/realtimeSessions/subscriptionAuthorization.ts +116 -0
  388. package/src/realtimeSessions/verificationCore.ts +502 -0
  389. package/src/realtimeSessions/verificationPages.ts +149 -0
  390. package/src/realtimeSessions/verificationPolicy.ts +321 -0
  391. package/src/realtimeSessions/verificationWorkflow.ts +702 -0
  392. package/src/realtimeWidget/widgetGuestElevation.ts +34 -2
  393. package/src/resolvers/ActionResolver.ts +84 -21
  394. package/src/resolvers/AdhocQueryResolver.ts +105 -187
  395. package/src/resolvers/CacheStatsResolver.ts +89 -2
  396. package/src/resolvers/ClientToolRequestResolver.ts +25 -7
  397. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  398. package/src/resolvers/FetchEntityVectorsResolver.ts +97 -2
  399. package/src/resolvers/GetDataResolver.ts +10 -0
  400. package/src/resolvers/HumanHandoffResolver.ts +312 -0
  401. package/src/resolvers/MCPResolver.ts +8 -5
  402. package/src/resolvers/MeetingResolver.ts +438 -0
  403. package/src/resolvers/RealtimeBridgeResolver.ts +385 -19
  404. package/src/resolvers/RealtimeClientSessionResolver.ts +503 -344
  405. package/src/resolvers/RealtimeSessionEventsResolver.ts +133 -0
  406. package/src/resolvers/RealtimeSessionVerificationResolver.ts +174 -0
  407. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  408. package/src/resolvers/RunAIAgentResolver.ts +78 -11
  409. package/src/resolvers/RunTemplateResolver.ts +13 -12
  410. package/src/resolvers/SyncDataResolver.ts +6 -2
  411. package/src/resolvers/SyncRolesUsersResolver.ts +24 -6
  412. package/src/resolvers/UserAvatarResolver.ts +111 -0
  413. package/src/resolvers/UserResolver.ts +2 -16
  414. package/src/resolvers/adhocTimeout.ts +12 -0
  415. package/src/resolvers/agentVisionConsentAudit.ts +82 -0
  416. package/src/resolvers/avatarInputValidation.ts +123 -0
  417. package/src/resolvers/currentUserRoles.ts +32 -1
  418. package/src/resolvers/meetings/index.ts +4 -0
  419. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  420. package/src/resolvers/meetings/meetingMutations.ts +556 -0
  421. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  422. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  423. package/src/rest/ViewOperationsHandler.ts +8 -5
  424. package/src/services/TaskGraphActionRunner.ts +29 -1
  425. package/src/services/TaskGraphContinuationDeliverer.ts +13 -7
  426. package/src/services/WorkQueueHostService.ts +129 -0
  427. package/src/services/workQueueConfig.ts +52 -0
  428. package/src/sharedCache.ts +302 -0
  429. package/src/types.ts +12 -0
  430. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  431. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  432. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  433. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  434. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  435. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -8,8 +8,14 @@ import {
8
8
  LogError,
9
9
  LogStatus,
10
10
  } from '@memberjunction/core';
11
- import { MJAIAgentSessionEntity } from '@memberjunction/core-entities';
12
- import { GetHostInstanceID, GetHostNamePrefix } from './HostInstance.js';
11
+ import { MJAIAgentEntity, MJAIAgentSessionEntity } from '@memberjunction/core-entities';
12
+ import {
13
+ RecoverRealtimeRecordingFromSegments,
14
+ ResolveRecordingStorageAccountID,
15
+ type RecoverRealtimeRecordingInput,
16
+ type RecoverRealtimeRecordingResult,
17
+ } from '@memberjunction/ai-agents';
18
+ import { GetCurrentHostInstance, GetHostInstanceID, HostInstanceIdentity } from './HostInstance.js';
13
19
  import { SessionManager, SessionCloseReason } from './SessionManager.js';
14
20
 
15
21
  /** Entity name for session rows (kept in sync with {@link SessionManager}). */
@@ -18,50 +24,160 @@ const SESSION_ENTITY = 'MJ: AI Agent Sessions';
18
24
  /** Rows fetched per keyset page during a sweep. Small enough to bound memory on a large backlog. */
19
25
  const SWEEP_PAGE_SIZE = 200;
20
26
 
27
+ /** Entity name for agent rows (the recording storage account is configured on the agent). */
28
+ const AGENT_ENTITY = 'MJ: AI Agents';
29
+
30
+ /** A session whose recording recovery has failed this many times is given up on for the life of the process. */
31
+ const MAX_RECOVERY_ATTEMPTS = 8;
32
+
33
+ /** Recovery attempts (calls into the recovery routine) per sweep; bounds the I/O one tick can trigger. */
34
+ const MAX_RECOVERIES_PER_SWEEP = 5;
35
+
36
+ /**
37
+ * Abandoned (timed-out, still running) recovery attempts allowed at once. Each keeps its socket and any
38
+ * shard buffers it had read (up to the 384 MiB recovery cap) until it settles, so recovery pauses at this
39
+ * many instead of adding one more every timeout while storage keeps hanging.
40
+ */
41
+ const MAX_ABANDONED_RECOVERIES = 2;
42
+
43
+ /** Backoff after the k-th failed recovery is `2^k * base`, capped at the max. */
44
+ const RECOVERY_BACKOFF_BASE_MS = 5 * 60_000;
45
+ const RECOVERY_BACKOFF_MAX_MS = 6 * 60 * 60_000;
46
+
47
+ /**
48
+ * How long {@link SessionJanitor.Shutdown} waits for an in-flight recording recovery pass. A pass can
49
+ * spend minutes on large downloads/uploads, and graceful shutdown must not hang on it indefinitely.
50
+ */
51
+ const RECOVERY_SHUTDOWN_WAIT_MS = 10_000;
52
+
21
53
  /** Tuning knobs for the janitor. All durations in their named units. */
22
54
  export interface SessionJanitorConfig {
23
55
  /** A non-`Closed` session whose `LastActiveAt` is older than this is force-closed by the global sweep. */
24
56
  closeThresholdMinutes: number;
25
- /** How often the periodic staleness sweep runs once {@link SessionJanitor.Start} is called. */
57
+ /** How often the periodic tick (close sweeps, then recording recovery) runs once {@link SessionJanitor.Start} is called. */
26
58
  sweepIntervalMs: number;
59
+ /**
60
+ * How long after `ClosedAt` a recording-less session waits before recording recovery rebuilds it.
61
+ * Sessions are closed BEFORE the browser uploads the recording (#5195), so a fresh close without a
62
+ * recording is normal; recovery must not race the upload that is about to arrive.
63
+ */
64
+ recordingRecoveryGraceMinutes: number;
65
+ /** Sessions closed longer ago than this are no longer considered by recording recovery. */
66
+ recordingRecoveryLookbackHours: number;
67
+ /**
68
+ * How long one session's recovery may run before the sweep abandons it and moves on. Storage clients
69
+ * can wait forever on a connection that stops answering (the S3 driver sets no request timeout), and
70
+ * one such call must not hold the single recovery pass for the life of the process.
71
+ */
72
+ recordingRecoveryAttemptTimeoutMinutes: number;
27
73
  }
28
74
 
29
75
  const DEFAULT_CONFIG: SessionJanitorConfig = {
30
76
  closeThresholdMinutes: 15,
31
77
  sweepIntervalMs: 60_000,
78
+ recordingRecoveryGraceMinutes: 10,
79
+ recordingRecoveryLookbackHours: 168,
80
+ recordingRecoveryAttemptTimeoutMinutes: 10,
32
81
  };
33
82
 
83
+ /** Every knob {@link SessionJanitor.Configure} accepts; each must be a finite number greater than zero. */
84
+ const CONFIG_KEYS: ReadonlyArray<keyof SessionJanitorConfig> = [
85
+ 'closeThresholdMinutes',
86
+ 'sweepIntervalMs',
87
+ 'recordingRecoveryGraceMinutes',
88
+ 'recordingRecoveryLookbackHours',
89
+ 'recordingRecoveryAttemptTimeoutMinutes',
90
+ ];
91
+
92
+ /** Per-session recovery retry state (process-local). */
93
+ interface RecoveryBackoff {
94
+ Failures: number;
95
+ /** Epoch ms before which the session is not retried; `Infinity` once given up. */
96
+ NextAttemptAt: number;
97
+ /** True while the session waits on configuration (no storage account), which is not a failure. */
98
+ Deferred: boolean;
99
+ }
100
+
101
+ /** Per-sweep working state shared by every candidate in one recovery pass. */
102
+ interface RecoverySweepContext {
103
+ provider: IMetadataProvider;
104
+ systemUser: UserInfo;
105
+ /** Per-agent storage resolution this sweep, by lowercase agent id, so each agent costs one load + one resolve. */
106
+ storage: Map<string, RecoveryStorage>;
107
+ }
108
+
109
+ /** Outcome of resolving where an agent's recordings live. */
110
+ type RecoveryStorage =
111
+ | { Kind: 'Resolved'; AccountID: string }
112
+ | { Kind: 'NoAccount' }
113
+ | { Kind: 'AgentUnavailable' };
114
+
115
+ /** What one candidate cost the sweep: `Attempted` = the recovery routine was called (counts against the cap). */
116
+ interface RecoveryVisit {
117
+ Attempted: boolean;
118
+ Recovered: boolean;
119
+ }
120
+
34
121
  /**
35
122
  * `BaseSingleton` background reconciler that keeps the durable `AIAgentSession` state from drifting
36
123
  * away from volatile process reality. A crash/redeploy vaporizes a host's in-memory sockets but
37
124
  * leaves its session rows reading `Active`/`Idle` forever; the janitor force-closes those orphans.
38
125
  *
39
- * Two sweeps, both writing through {@link SessionManager.CloseSession} so Record Changes captures
126
+ * Three close sweeps, all writing through {@link SessionManager.CloseSession} so Record Changes captures
40
127
  * each transition and channel rows are disconnected consistently:
41
128
  *
42
- * 1. **Own-host recovery ({@link RunStartupRecovery})** — run once at boot. Closes any `Active`/`Idle`
43
- * session whose `HostInstanceID` belongs to a *previous* boot of *this* host (same hostname prefix,
44
- * different `bootId`). Primary defense against the "Active forever" leak after a restart.
45
- * 2. **Global staleness sweep ({@link RunStalenessSweep})** — run periodically on every instance.
46
- * Closes any `Active`/`Idle` session whose `LastActiveAt` is older than `closeThresholdMinutes`,
47
- * regardless of host. Catches sessions whose owner died without a clean reboot (OOM, scaled-down pod).
129
+ * 1. **Own-instance recovery ({@link RunStartupRecovery})** — run once at boot. Closes any `Active`/`Idle`
130
+ * session whose `HostInstanceID` belongs to an *earlier* boot of *this* MJAPI instance (same host and port,
131
+ * different boot; see {@link HostInstanceIdentity}). Another MJAPI on the same host serves on another port,
132
+ * so its live sessions are left alone. Primary defense against the "Active forever" leak after a restart.
133
+ * 2. **Global staleness sweep ({@link RunStalenessSweep})** — run on every periodic tick, on every
134
+ * instance. Closes any `Active`/`Idle` session whose `LastActiveAt` is older than
135
+ * `closeThresholdMinutes`, regardless of host. Catches sessions whose owner died without a clean
136
+ * reboot (OOM, scaled-down pod).
137
+ * 3. **Max-duration sweep ({@link RunMaxDurationSweep})** — run on every periodic tick after the
138
+ * staleness sweep. Closes any session past its stored hard deadline (the public voice cost cap).
139
+ *
140
+ * The close sweeps stamp `CloseReason = 'Janitor'`. A shutdown-time path — {@link RunShutdownDrain},
141
+ * invoked from {@link Shutdown} during the graceful ShutdownRegistry drain — closes this exact host
142
+ * instance's own live sessions with `CloseReason = 'Shutdown'`.
48
143
  *
49
- * Both sweeps stamp `CloseReason = 'Janitor'`. A third, shutdown-time path —
50
- * {@link RunShutdownDrain}, invoked from {@link Shutdown} during the graceful ShutdownRegistry
51
- * drain — closes this exact host instance's own live sessions with `CloseReason = 'Shutdown'`.
144
+ * Separately from closing, {@link RunRecordingRecoverySweep} runs after the close sweeps on every
145
+ * periodic tick, without blocking them, and rebuilds the recording of already-`Closed` sessions whose
146
+ * end-of-call upload never arrived (it closes nothing).
52
147
  *
53
- * Both sweeps are **idempotent and safe to run concurrently** on every instance: closing an
54
- * already-`Closed` session is a no-op, and the close path is last-writer-wins. Both page with keyset
148
+ * The close sweeps are **idempotent and safe to run concurrently** on every instance: closing an
149
+ * already-`Closed` session is a no-op, and the close path is last-writer-wins. Recording recovery is not
150
+ * strictly so: two instances can recover the same session at once (there is no claim column). Each
151
+ * attempt uploads its own file and defers to an already-stamped recording on a best-effort basis, so
152
+ * the cost is duplicate work and an extra file, not a lost recording. The extra file is an orphan when the
153
+ * second attempt's re-check sees the first stamp; when both re-checks pass first, both files are stamped in
154
+ * turn and both are linked to the session (the later stamp wins `RecordingFileID`). Every sweep pages with keyset
55
155
  * (`AfterKey`) pagination per the deep-pagination guide so a large backlog can't blow up memory.
56
156
  */
57
157
  export class SessionJanitor extends BaseSingleton<SessionJanitor> implements IShutdownable {
58
158
  private _config: SessionJanitorConfig = DEFAULT_CONFIG;
59
159
  private _sweepTimer: ReturnType<typeof setInterval> | null = null;
60
160
  private _sweepRunning = false;
161
+ /**
162
+ * The in-flight recording recovery pass, or null. Separate from {@link _sweepRunning}: slow recovery
163
+ * must never block the close sweeps. Kept as a promise so {@link Shutdown} can wait for it.
164
+ */
165
+ private _recoveryPass: Promise<void> | null = null;
166
+ /** Set by {@link Stop}, cleared by {@link Start}: no new recovery pass starts while stopped. */
167
+ private _stopped = false;
61
168
  private _registered = false;
62
169
  private _provider: IMetadataProvider | null = null;
63
170
  private _systemUser: UserInfo | null = null;
64
171
  private readonly sessionManager = new SessionManager();
172
+ /** Recovery retry state keyed by lowercase session id. Process-local: a restart gets fresh attempts. */
173
+ private readonly recoveryBackoff = new Map<string, RecoveryBackoff>();
174
+ /**
175
+ * Lowercase ids of sessions whose recovery attempt timed out but has not settled. Skipped by later
176
+ * sweeps so a second attempt never runs alongside the abandoned one; removed when it settles.
177
+ */
178
+ private readonly abandonedRecoveries = new Set<string>();
179
+ /** True once the "recovery paused" error has been logged for the current pause; reset when it lifts. */
180
+ private _recoveryPauseLogged = false;
65
181
 
66
182
  protected constructor() {
67
183
  super();
@@ -75,29 +191,53 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
75
191
  /** Identifier surfaced in graceful-shutdown logs. */
76
192
  public readonly ShutdownName = 'SessionJanitor';
77
193
 
78
- /** Overrides default tuning. Safe to call before or after {@link Start}. */
194
+ /**
195
+ * Overrides default tuning. Safe to call before or after {@link Start}. Every value given must be a
196
+ * finite number greater than zero: a NaN or non-positive duration would otherwise build an invalid
197
+ * cutoff date and make every later sweep throw. All-or-nothing: on an invalid value it throws, naming
198
+ * the field and value, and applies nothing. `undefined` values are ignored.
199
+ */
79
200
  public Configure(config: Partial<SessionJanitorConfig>): void {
80
- this._config = { ...this._config, ...config };
201
+ const next: SessionJanitorConfig = { ...this._config };
202
+ for (const key of CONFIG_KEYS) {
203
+ const value = config[key];
204
+ if (value === undefined) {
205
+ continue;
206
+ }
207
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
208
+ throw new Error(`SessionJanitor.Configure: ${key} must be a finite number greater than 0, got ${String(value)}`);
209
+ }
210
+ next[key] = value;
211
+ }
212
+ this._config = next;
81
213
  }
82
214
 
83
215
  /**
84
- * Run own-host startup recovery once, then schedule the periodic staleness sweep. Idempotent:
85
- * a second call does not stack a second timer. Captures the provider + system user so the timer
86
- * callback can run without re-supplying them.
216
+ * Run own-host startup recovery once, then schedule the periodic tick (staleness and max-duration
217
+ * close sweeps, then recording recovery). Idempotent: a second call does not stack a second timer.
218
+ * Captures the provider + system user so the timer callback can run without re-supplying them, and
219
+ * re-enables recording recovery after a {@link Stop}. Throws, before doing any work, when
220
+ * `intervalMs` is invalid (see {@link Configure}).
87
221
  */
88
222
  public async Start(provider: IMetadataProvider, systemUser: UserInfo, intervalMs?: number): Promise<void> {
89
- this._provider = provider;
90
- this._systemUser = systemUser;
91
223
  if (intervalMs != null) {
92
- this._config = { ...this._config, sweepIntervalMs: intervalMs };
224
+ this.Configure({ sweepIntervalMs: intervalMs });
93
225
  }
226
+ this._provider = provider;
227
+ this._systemUser = systemUser;
228
+ this._stopped = false;
94
229
  this.ensureRegistered();
95
230
  await this.RunStartupRecovery(provider, systemUser);
96
231
  this.scheduleSweep();
97
232
  }
98
233
 
99
- /** Stops the periodic sweep timer. Idempotent. Part of {@link IShutdownable}. */
234
+ /**
235
+ * Stops the periodic tick and keeps any new recording recovery pass from starting until the next
236
+ * {@link Start}, including one a tick already in progress would have started. A pass that is already
237
+ * running is not interrupted ({@link Shutdown} waits for it). Idempotent. Part of {@link IShutdownable}.
238
+ */
100
239
  public Stop(): void {
240
+ this._stopped = true;
101
241
  if (this._sweepTimer) {
102
242
  clearInterval(this._sweepTimer);
103
243
  this._sweepTimer = null;
@@ -105,8 +245,9 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
105
245
  }
106
246
 
107
247
  /**
108
- * {@link IShutdownable}: clear the timer, then drain this host's own live sessions so a
109
- * graceful stop never strands `Active`/`Idle` rows for the next boot's janitor to mop up.
248
+ * {@link IShutdownable}: stop the timer, then drain this host's own live sessions so a
249
+ * graceful stop never strands `Active`/`Idle` rows for the next boot's janitor to mop up, then wait
250
+ * (at most {@link RECOVERY_SHUTDOWN_WAIT_MS}) for an in-flight recording recovery pass.
110
251
  * Drained sessions are stamped `CloseReason = 'Shutdown'` (vs. `'Janitor'` for crash orphans),
111
252
  * so the dashboards can tell a clean redeploy from a reconciled crash. Never throws; the drain
112
253
  * is skipped when {@link Start} was never called (no captured provider/user).
@@ -120,6 +261,7 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
120
261
  LogError(`SessionJanitor shutdown drain failed: ${err instanceof Error ? err.message : String(err)}`);
121
262
  }
122
263
  }
264
+ await this.waitForRecoveryPass();
123
265
  }
124
266
 
125
267
  /**
@@ -139,19 +281,34 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
139
281
  }
140
282
 
141
283
  /**
142
- * Force-close any `Active`/`Idle` session left behind by a *previous boot of this host*
143
- * (matching hostname prefix, differing `bootId`). Returns the number of sessions closed.
284
+ * Force-close any `Active`/`Idle` session left behind by an earlier boot of this MJAPI instance: its
285
+ * `HostInstanceID` has this instance's prefix (`hostname:port:`) and another boot. The sessions of another
286
+ * MJAPI on the same host carry another port and stay open. Returns the number of sessions closed.
287
+ *
288
+ * @param provider The metadata provider for the reads and writes.
289
+ * @param systemUser The user the closes run as.
290
+ * @param hostInstance The instance whose earlier boots to close. Defaults to this process's
291
+ * ({@link GetCurrentHostInstance}).
144
292
  */
145
- public async RunStartupRecovery(provider: IMetadataProvider, systemUser: UserInfo): Promise<number> {
146
- const prefix = GetHostNamePrefix().replace(/'/g, "''");
147
- const current = GetHostInstanceID().replace(/'/g, "''");
293
+ public async RunStartupRecovery(
294
+ provider: IMetadataProvider,
295
+ systemUser: UserInfo,
296
+ hostInstance: HostInstanceIdentity = GetCurrentHostInstance(),
297
+ ): Promise<number> {
298
+ const prefix = hostInstance.GetInstancePrefix().replace(/'/g, "''");
299
+ const current = hostInstance.GetHostInstanceID().replace(/'/g, "''");
148
300
  const filter =
149
301
  `Status IN ('Active','Idle') ` +
150
302
  `AND HostInstanceID LIKE '${prefix}%' ` +
151
303
  `AND HostInstanceID <> '${current}'`;
152
- const closed = await this.sweepAndClose(filter, provider, systemUser, 'Janitor');
304
+ // The LIKE narrows the read; the exact check decides, since LIKE reads a `_` in a host name as any character.
305
+ const isPriorBoot = (session: MJAIAgentSessionEntity): boolean => hostInstance.IsPriorBoot(session.HostInstanceID);
306
+ const closed = await this.sweepAndClose(filter, provider, systemUser, 'Janitor', isPriorBoot);
153
307
  if (closed > 0) {
154
- LogStatus(`[SessionJanitor] Startup recovery closed ${closed} orphaned session(s) from a prior boot of this host`);
308
+ LogStatus(
309
+ `[SessionJanitor] Startup recovery closed ${closed} orphaned session(s) from an earlier boot of this instance ` +
310
+ `(${hostInstance.GetInstancePrefix()})`,
311
+ );
155
312
  }
156
313
  return closed;
157
314
  }
@@ -196,6 +353,62 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
196
353
  return closed;
197
354
  }
198
355
 
356
+ /**
357
+ * Rebuilds the recording of sessions whose end-of-call upload never arrived (browser died, tab
358
+ * closed). A session is a candidate when it is `Closed`, has `RecordingStartedAt` (consent is implied:
359
+ * recording only starts after the guest agreed), still has no `RecordingFileID`, and closed between
360
+ * the lookback window and the grace period (see {@link SessionJanitorConfig.recordingRecoveryGraceMinutes}).
361
+ *
362
+ * Work is bounded: candidates are handled sequentially, at most {@link MAX_RECOVERIES_PER_SWEEP}
363
+ * recovery attempts per call, and a failing session backs off exponentially (`min(2^k * 5 min, 6 h)`)
364
+ * and is abandoned after {@link MAX_RECOVERY_ATTEMPTS} failures with one `LogError`. Backoff entries of
365
+ * sessions that left the candidate set are pruned, but only after a pass that saw every candidate, so
366
+ * a capped pass never forgets sessions it did not reach.
367
+ *
368
+ * Never throws for a single bad session. Returns the number of recordings recovered.
369
+ */
370
+ public async RunRecordingRecoverySweep(provider: IMetadataProvider, systemUser: UserInfo): Promise<number> {
371
+ if (this.isRecoveryPausedForAbandonedAttempts()) {
372
+ return 0;
373
+ }
374
+ const nowMs = Date.now();
375
+ const graceCutoff = new Date(nowMs - this._config.recordingRecoveryGraceMinutes * 60_000).toISOString();
376
+ const lookbackCutoff = new Date(nowMs - this._config.recordingRecoveryLookbackHours * 3_600_000).toISOString();
377
+ const filter =
378
+ `Status = 'Closed' AND RecordingStartedAt IS NOT NULL AND RecordingFileID IS NULL ` +
379
+ `AND ClosedAt < '${graceCutoff}' AND ClosedAt >= '${lookbackCutoff}'`;
380
+
381
+ const context: RecoverySweepContext = { provider, systemUser, storage: new Map() };
382
+ const seen = new Set<string>();
383
+ let attempts = 0;
384
+ let recovered = 0;
385
+
386
+ const walk = await this.forEachSessionPage(filter, provider, systemUser, async page => {
387
+ for (const session of page) {
388
+ seen.add(session.ID.toLowerCase());
389
+ if (this.isRecoveryBackedOff(session.ID) || this.abandonedRecoveries.has(session.ID.toLowerCase())) {
390
+ continue;
391
+ }
392
+ if (attempts >= MAX_RECOVERIES_PER_SWEEP || this.abandonedRecoveries.size >= MAX_ABANDONED_RECOVERIES) {
393
+ return false; // per-sweep cap, or too many abandoned attempts still running: stop paging
394
+ }
395
+ const visit = await this.recoverSessionRecording(session, context);
396
+ attempts += visit.Attempted ? 1 : 0;
397
+ recovered += visit.Recovered ? 1 : 0;
398
+ }
399
+ });
400
+
401
+ // Only a walk that reached the end saw every candidate. A capped or failed-load walk has an
402
+ // incomplete `seen`; pruning on it would wipe backoff and give-up state for unseen sessions.
403
+ if (walk === 'complete') {
404
+ this.pruneRecoveryBackoff(seen);
405
+ }
406
+ if (recovered > 0) {
407
+ LogStatus(`[SessionJanitor] Recording recovery rebuilt ${recovered} recording(s)`);
408
+ }
409
+ return recovered;
410
+ }
411
+
199
412
  /** Parses the absolute deadline (ms) from a session's `Config_` JSON, or null when absent/malformed. */
200
413
  private parseSessionDeadlineMs(configJson: string | null | undefined): number | null {
201
414
  if (!configJson) {
@@ -223,7 +436,7 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
223
436
  }
224
437
  }
225
438
 
226
- /** Schedule the periodic staleness sweep, exactly once. Timer is unref'd so it never blocks exit. */
439
+ /** Schedule the periodic sweep tick (close sweeps + recording recovery), exactly once. Timer is unref'd so it never blocks exit. */
227
440
  private scheduleSweep(): void {
228
441
  if (this._sweepTimer) {
229
442
  return;
@@ -232,7 +445,16 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
232
445
  this._sweepTimer.unref?.();
233
446
  }
234
447
 
235
- /** Timer tick: run the staleness sweep under the captured provider/user, guarding overlap. */
448
+ /**
449
+ * Timer tick: run the close sweeps (staleness, then max-duration) under the captured
450
+ * provider/user, guarding overlap, then kick off recording recovery.
451
+ *
452
+ * Recovery is started WITHOUT awaiting and has its own overlap guard: it can spend minutes on
453
+ * large downloads/uploads, and awaiting it under the close sweeps' guard would delay the
454
+ * max-duration cost cap and make every later tick return early. Each tick skips recovery while a pass
455
+ * is still running; because every session's attempt is bounded by
456
+ * `recordingRecoveryAttemptTimeoutMinutes`, a hung storage call delays later passes, never stops them.
457
+ */
236
458
  private async periodicSweep(): Promise<void> {
237
459
  if (this._sweepRunning || !this._provider || !this._systemUser) {
238
460
  return;
@@ -248,6 +470,249 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
248
470
  } finally {
249
471
  this._sweepRunning = false;
250
472
  }
473
+ this.startRecoveryPass(this._provider, this._systemUser);
474
+ }
475
+
476
+ /**
477
+ * Fire-and-forget recording recovery, skipped when the previous pass is still running or the janitor
478
+ * has been stopped (a tick that was mid-close-sweep when {@link Stop} ran must not start one).
479
+ */
480
+ private startRecoveryPass(provider: IMetadataProvider, systemUser: UserInfo): void {
481
+ if (this._stopped || this._recoveryPass) {
482
+ return;
483
+ }
484
+ this._recoveryPass = this.RunRecordingRecoverySweep(provider, systemUser)
485
+ .then(
486
+ () => undefined,
487
+ err => {
488
+ LogError(`SessionJanitor recording recovery sweep failed: ${err instanceof Error ? err.message : String(err)}`);
489
+ },
490
+ )
491
+ .finally(() => {
492
+ this._recoveryPass = null;
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Waits for the in-flight recovery pass, if any, for at most {@link RECOVERY_SHUTDOWN_WAIT_MS}; logs
498
+ * once when it gives up. Never throws: the pass promise already logs and absorbs its own errors.
499
+ */
500
+ private async waitForRecoveryPass(): Promise<void> {
501
+ const pass = this._recoveryPass;
502
+ if (!pass) {
503
+ return;
504
+ }
505
+ let timer: ReturnType<typeof setTimeout> | undefined;
506
+ const timedOut = new Promise<'timeout'>(resolve => {
507
+ timer = setTimeout(() => resolve('timeout'), RECOVERY_SHUTDOWN_WAIT_MS);
508
+ });
509
+ try {
510
+ const outcome = await Promise.race([pass.then(() => 'finished' as const), timedOut]);
511
+ if (outcome === 'timeout') {
512
+ LogError(
513
+ `SessionJanitor shutdown (host instance ${GetHostInstanceID()}): recording recovery pass still running after ` +
514
+ `${RECOVERY_SHUTDOWN_WAIT_MS} ms; shutting down without it. A session it had not yet stamped is retried by a later sweep.`,
515
+ );
516
+ }
517
+ } finally {
518
+ clearTimeout(timer);
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Recovers one candidate's recording and updates its backoff. Never throws: any exception is logged
524
+ * with the session id and counted as a failure.
525
+ */
526
+ private async recoverSessionRecording(candidate: MJAIAgentSessionEntity, context: RecoverySweepContext): Promise<RecoveryVisit> {
527
+ let attempted = false;
528
+ // The sweep filter requires RecordingStartedAt, so this only fires on drifted data; recovery would
529
+ // otherwise stamp a recording with no t0. Counted as a failure so it backs off rather than logging every tick.
530
+ const startedAt = candidate.RecordingStartedAt;
531
+ if (!startedAt) {
532
+ LogError(`[SessionJanitor] Recording recovery skipped for session ${candidate.ID}: RecordingStartedAt is empty`);
533
+ return this.recordRecoveryFailure(candidate.ID, false);
534
+ }
535
+ try {
536
+ // Re-read: the late upload may have landed since the candidate page was fetched.
537
+ const session = await context.provider.GetEntityObject<MJAIAgentSessionEntity>(SESSION_ENTITY, context.systemUser);
538
+ if (!(await session.Load(candidate.ID))) {
539
+ LogError(`[SessionJanitor] Recording recovery could not reload session ${candidate.ID}`);
540
+ return this.recordRecoveryFailure(candidate.ID, false);
541
+ }
542
+ if (session.RecordingFileID) {
543
+ return { Attempted: false, Recovered: false };
544
+ }
545
+ const storage = await this.resolveRecoveryStorage(candidate.AgentID, context);
546
+ if (storage.Kind === 'NoAccount') {
547
+ return this.deferRecovery(candidate.ID, `no recording storage account for agent ${candidate.AgentID}`);
548
+ }
549
+ if (storage.Kind === 'AgentUnavailable') {
550
+ LogStatus(`[SessionJanitor] Recording recovery skipped for session ${candidate.ID}: agent ${candidate.AgentID} could not be loaded`);
551
+ return this.recordRecoveryFailure(candidate.ID, false);
552
+ }
553
+ const accountID = storage.AccountID;
554
+ attempted = true;
555
+ const result = await this.recoverWithinTimeout({
556
+ SessionID: candidate.ID,
557
+ StorageAccountID: accountID,
558
+ StartedAt: startedAt,
559
+ ContextUser: context.systemUser,
560
+ Provider: context.provider,
561
+ });
562
+ if (result === 'TimedOut') {
563
+ LogError(
564
+ `[SessionJanitor] Recording recovery for session ${candidate.ID} did not finish within ` +
565
+ `${this._config.recordingRecoveryAttemptTimeoutMinutes} minutes; abandoning this attempt (its storage call may ` +
566
+ `still complete) and continuing the sweep. The session is not retried until that call settles.`,
567
+ );
568
+ return this.recordRecoveryFailure(candidate.ID, true);
569
+ }
570
+ switch (result.Outcome) {
571
+ case 'Recovered':
572
+ this.recoveryBackoff.delete(candidate.ID.toLowerCase());
573
+ LogStatus(`[SessionJanitor] Recovered recording for session ${candidate.ID} from ${result.SegmentCount} segment(s) (file ${result.FileID})`);
574
+ return { Attempted: true, Recovered: true };
575
+ case 'Superseded':
576
+ this.recoveryBackoff.delete(candidate.ID.toLowerCase());
577
+ LogStatus(`[SessionJanitor] Session ${candidate.ID} was given a recording by another writer; recovery left it alone`);
578
+ return { Attempted: true, Recovered: false };
579
+ case 'NoSegments':
580
+ LogStatus(`[SessionJanitor] No recoverable segments for session ${candidate.ID} (agent ${candidate.AgentID}, storage account ${accountID})`);
581
+ // Retried with backoff: a storage outage can also read as an empty folder (drivers that swallow
582
+ // list errors), but a session that never has shards is not an error when checking stops.
583
+ return this.recordRecoveryFailure(candidate.ID, true, 'NoSegments');
584
+ default:
585
+ LogError(`[SessionJanitor] Recording recovery failed for session ${candidate.ID}: ${result.ErrorMessage ?? 'unknown error'}`);
586
+ return this.recordRecoveryFailure(candidate.ID, true);
587
+ }
588
+ } catch (err) {
589
+ LogError(`[SessionJanitor] Recording recovery threw for session ${candidate.ID}: ${err instanceof Error ? err.message : String(err)}`);
590
+ return this.recordRecoveryFailure(candidate.ID, attempted);
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Runs one session's recovery, giving up on waiting after `recordingRecoveryAttemptTimeoutMinutes`.
596
+ * A call cannot be cancelled, so a timed-out one keeps running: the session is marked abandoned until
597
+ * it settles (later sweeps skip it), and its late outcome is logged then.
598
+ */
599
+ private async recoverWithinTimeout(input: RecoverRealtimeRecordingInput): Promise<RecoverRealtimeRecordingResult | 'TimedOut'> {
600
+ const key = input.SessionID.toLowerCase();
601
+ const attempt = RecoverRealtimeRecordingFromSegments(input);
602
+ let timer: ReturnType<typeof setTimeout> | undefined;
603
+ const timedOut = new Promise<'TimedOut'>(resolve => {
604
+ timer = setTimeout(() => resolve('TimedOut'), this._config.recordingRecoveryAttemptTimeoutMinutes * 60_000);
605
+ timer.unref?.();
606
+ });
607
+ try {
608
+ const outcome = await Promise.race([attempt, timedOut]);
609
+ if (outcome === 'TimedOut') {
610
+ this.abandonedRecoveries.add(key);
611
+ attempt.then(
612
+ late => LogStatus(`[SessionJanitor] Abandoned recording recovery for session ${input.SessionID} finished late: ${late.Outcome}`),
613
+ err => LogError(`[SessionJanitor] Abandoned recording recovery for session ${input.SessionID} failed late: ${err instanceof Error ? err.message : String(err)}`),
614
+ ).finally(() => this.abandonedRecoveries.delete(key));
615
+ }
616
+ return outcome;
617
+ } finally {
618
+ clearTimeout(timer);
619
+ }
620
+ }
621
+
622
+ /**
623
+ * True while {@link MAX_ABANDONED_RECOVERIES} timed-out attempts are still running: no new attempt
624
+ * starts until one settles. Logs one `LogError` per pause.
625
+ */
626
+ private isRecoveryPausedForAbandonedAttempts(): boolean {
627
+ if (this.abandonedRecoveries.size < MAX_ABANDONED_RECOVERIES) {
628
+ this._recoveryPauseLogged = false;
629
+ return false;
630
+ }
631
+ if (!this._recoveryPauseLogged) {
632
+ LogError(
633
+ `[SessionJanitor] Recording recovery paused: ${this.abandonedRecoveries.size} abandoned attempts are still waiting on storage ` +
634
+ `(sessions ${[...this.abandonedRecoveries].join(', ')}); no new attempt starts until one of them settles`,
635
+ );
636
+ this._recoveryPauseLogged = true;
637
+ }
638
+ return true;
639
+ }
640
+
641
+ /**
642
+ * Defers a session that cannot be recovered until configuration changes (its agent has no recording
643
+ * storage account). That is not a failed attempt, so it never counts toward the give-up: the session
644
+ * is re-checked every {@link RECOVERY_BACKOFF_MAX_MS} and logged only the first time.
645
+ */
646
+ private deferRecovery(sessionID: string, reason: string): RecoveryVisit {
647
+ const key = sessionID.toLowerCase();
648
+ const state = this.recoveryBackoff.get(key);
649
+ if (!state?.Deferred) {
650
+ LogStatus(`[SessionJanitor] Recording recovery skipped for session ${sessionID}: ${reason}; re-checked every ${RECOVERY_BACKOFF_MAX_MS / 3_600_000} h`);
651
+ }
652
+ this.recoveryBackoff.set(key, { Failures: state?.Failures ?? 0, NextAttemptAt: Date.now() + RECOVERY_BACKOFF_MAX_MS, Deferred: true });
653
+ return { Attempted: false, Recovered: false };
654
+ }
655
+
656
+ /**
657
+ * Loads the agent carrying a session's recording storage config and resolves its storage account,
658
+ * memoized per agent for the sweep: the resolve may force a storage-engine reload, so it must not
659
+ * repeat for every session of the same agent.
660
+ */
661
+ private async resolveRecoveryStorage(agentID: string, context: RecoverySweepContext): Promise<RecoveryStorage> {
662
+ const key = agentID.toLowerCase();
663
+ const cached = context.storage.get(key);
664
+ if (cached) {
665
+ return cached;
666
+ }
667
+ const agent = await context.provider.GetEntityObject<MJAIAgentEntity>(AGENT_ENTITY, context.systemUser);
668
+ let storage: RecoveryStorage;
669
+ if (!(await agent.Load(agentID))) {
670
+ LogError(`[SessionJanitor] Recording recovery could not load agent ${agentID}`);
671
+ storage = { Kind: 'AgentUnavailable' };
672
+ } else {
673
+ const accountID = await ResolveRecordingStorageAccountID(agent, context.systemUser, context.provider);
674
+ storage = accountID ? { Kind: 'Resolved', AccountID: accountID } : { Kind: 'NoAccount' };
675
+ }
676
+ context.storage.set(key, storage);
677
+ return storage;
678
+ }
679
+
680
+ /** True while a session's backoff (or give-up) window has not elapsed. */
681
+ private isRecoveryBackedOff(sessionID: string): boolean {
682
+ const state = this.recoveryBackoff.get(sessionID.toLowerCase());
683
+ return state != null && Date.now() < state.NextAttemptAt;
684
+ }
685
+
686
+ /**
687
+ * Counts one more unsuccessful recovery for a session and schedules its next attempt
688
+ * (`min(2^k * 5 min, 6 h)`); at {@link MAX_RECOVERY_ATTEMPTS} it stops checking the session for this
689
+ * process with a single log line: a `LogError` when the last attempt failed, a `LogStatus` when it
690
+ * simply found no shards (nothing to recover is not an error).
691
+ */
692
+ private recordRecoveryFailure(sessionID: string, attempted: boolean, kind: 'Failed' | 'NoSegments' = 'Failed'): RecoveryVisit {
693
+ const key = sessionID.toLowerCase();
694
+ const failures = (this.recoveryBackoff.get(key)?.Failures ?? 0) + 1;
695
+ if (failures >= MAX_RECOVERY_ATTEMPTS) {
696
+ this.recoveryBackoff.set(key, { Failures: failures, NextAttemptAt: Infinity, Deferred: false });
697
+ if (kind === 'NoSegments') {
698
+ LogStatus(`[SessionJanitor] Session ${sessionID} had no recoverable segments in ${failures} checks; not checking it again until restart`);
699
+ } else {
700
+ LogError(`[SessionJanitor] Giving up on recording recovery for session ${sessionID} after ${failures} failed attempts`);
701
+ }
702
+ } else {
703
+ const waitMs = Math.min(2 ** failures * RECOVERY_BACKOFF_BASE_MS, RECOVERY_BACKOFF_MAX_MS);
704
+ this.recoveryBackoff.set(key, { Failures: failures, NextAttemptAt: Date.now() + waitMs, Deferred: false });
705
+ }
706
+ return { Attempted: attempted, Recovered: false };
707
+ }
708
+
709
+ /** Drops backoff state for sessions that were not in the latest complete candidate set. */
710
+ private pruneRecoveryBackoff(candidateKeys: ReadonlySet<string>): void {
711
+ for (const key of this.recoveryBackoff.keys()) {
712
+ if (!candidateKeys.has(key)) {
713
+ this.recoveryBackoff.delete(key);
714
+ }
715
+ }
251
716
  }
252
717
 
253
718
  /**
@@ -264,29 +729,11 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
264
729
  shouldClose?: (session: MJAIAgentSessionEntity) => boolean,
265
730
  ): Promise<number> {
266
731
  let closedCount = 0;
267
- let afterKey: CompositeKey | undefined;
268
-
269
- // eslint-disable-next-line no-constant-condition
270
- while (true) {
271
- const fetched = await this.fetchPage(filter, afterKey, provider, systemUser);
272
- if (fetched == null) {
273
- break; // load failure already logged
274
- }
275
- if (fetched.length === 0) {
276
- break;
277
- }
278
- // Advance the keyset BEFORE the optional JS predicate narrows the page (we must page by the
279
- // SQL-matched set, not the post-filtered subset, or pagination would stall/skip).
280
- const lastFetchedId = fetched[fetched.length - 1].ID;
281
- const fetchedCount = fetched.length;
732
+ await this.forEachSessionPage(filter, provider, systemUser, async fetched => {
282
733
  // Optional in-JS narrowing (e.g. exact deadline check the SQL pre-filter can't express).
283
734
  const page = shouldClose ? fetched.filter(shouldClose) : fetched;
284
735
  if (page.length === 0) {
285
- if (fetchedCount < SWEEP_PAGE_SIZE) {
286
- break;
287
- }
288
- afterKey = CompositeKey.FromID(lastFetchedId); // first-pk-ok: keyset AfterKey on SESSION_ENTITY = MJ: AI Agent Sessions, a core entity keyed by ID
289
- continue;
736
+ return;
290
737
  }
291
738
  // Batch-load every channel for this whole page of sessions in ONE query, then hand each
292
739
  // session its own slice to CloseSession — avoids the N+1 channel read (one RunView per
@@ -308,12 +755,51 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
308
755
  closedCount++;
309
756
  }
310
757
  }
758
+ });
759
+ return closedCount;
760
+ }
761
+
762
+ /**
763
+ * Keyset-page (`AfterKey`) through every session matching `filter`, handing each full fetched page
764
+ * to `visit`. Stops on a load failure (already logged), an empty page, a partial page, or when
765
+ * `visit` returns `false`.
766
+ *
767
+ * `visit` receives the page exactly as the SQL filter matched it. The keyset advances from the
768
+ * LAST FETCHED row, so any narrowing `visit` does in JS can never stall or skip pagination.
769
+ *
770
+ * Returns how the walk ended: `'complete'` (reached the end of the matching set), `'stopped'`
771
+ * (`visit` returned `false`) or `'failed'` (a page failed to load). Callers that reason about the
772
+ * WHOLE set (e.g. pruning state for absent rows) must act only on `'complete'`.
773
+ */
774
+ private async forEachSessionPage(
775
+ filter: string,
776
+ provider: IMetadataProvider,
777
+ systemUser: UserInfo,
778
+ visit: (page: MJAIAgentSessionEntity[]) => Promise<boolean | void>,
779
+ ): Promise<'complete' | 'stopped' | 'failed'> {
780
+ let afterKey: CompositeKey | undefined;
781
+
782
+ // eslint-disable-next-line no-constant-condition
783
+ while (true) {
784
+ const fetched = await this.fetchPage(filter, afterKey, provider, systemUser);
785
+ if (fetched == null) {
786
+ return 'failed'; // load failure already logged
787
+ }
788
+ if (fetched.length === 0) {
789
+ return 'complete';
790
+ }
791
+ // Advance from the last FETCHED row (before `visit` narrows anything): we must page by the
792
+ // SQL-matched set, not a post-filtered subset, or pagination would stall/skip.
793
+ const lastFetchedId = fetched[fetched.length - 1].ID;
794
+ const fetchedCount = fetched.length;
795
+ if ((await visit(fetched)) === false) {
796
+ return 'stopped';
797
+ }
311
798
  if (fetchedCount < SWEEP_PAGE_SIZE) {
312
- break; // partial page (by the SQL-matched set) => end of data
799
+ return 'complete'; // partial page (by the SQL-matched set) => end of data
313
800
  }
314
801
  afterKey = CompositeKey.FromID(lastFetchedId); // first-pk-ok: keyset AfterKey on SESSION_ENTITY = MJ: AI Agent Sessions, a core entity keyed by ID
315
802
  }
316
- return closedCount;
317
803
  }
318
804
 
319
805
  /** Fetch one keyset page of matching sessions, or null on a load failure (logged). */