@memberjunction/server 5.42.0 → 5.44.0

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 (101) hide show
  1. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  2. package/dist/agentSessions/SessionJanitor.js +5 -1
  3. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  4. package/dist/agentSessions/SessionManager.d.ts +15 -2
  5. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  6. package/dist/agentSessions/SessionManager.js +53 -11
  7. package/dist/agentSessions/SessionManager.js.map +1 -1
  8. package/dist/agentSessions/index.d.ts.map +1 -1
  9. package/dist/agentSessions/index.js +3 -1
  10. package/dist/agentSessions/index.js.map +1 -1
  11. package/dist/generated/generated.d.ts +1180 -51
  12. package/dist/generated/generated.d.ts.map +1 -1
  13. package/dist/generated/generated.js +35564 -29262
  14. package/dist/generated/generated.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +14 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
  20. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
  21. package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
  22. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
  23. package/dist/resolvers/FileResolver.d.ts +74 -0
  24. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  25. package/dist/resolvers/FileResolver.js +211 -2
  26. package/dist/resolvers/FileResolver.js.map +1 -1
  27. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  28. package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
  29. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  30. package/dist/resolvers/QueryResolver.d.ts +6 -4
  31. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  32. package/dist/resolvers/QueryResolver.js +29 -14
  33. package/dist/resolvers/QueryResolver.js.map +1 -1
  34. package/dist/resolvers/RealtimeBridgeResolver.d.ts +17 -1
  35. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  36. package/dist/resolvers/RealtimeBridgeResolver.js +78 -7
  37. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  38. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
  39. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  40. package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
  41. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  42. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  43. package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
  44. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  45. package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
  46. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RunAIAgentResolver.js +37 -15
  48. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  49. package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
  50. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RunTemplateResolver.js +8 -0
  52. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  53. package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
  54. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
  55. package/dist/resolvers/meetingRecordingRegistration.js +311 -0
  56. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
  57. package/dist/resolvers/peaksSidecar.d.ts +30 -0
  58. package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
  59. package/dist/resolvers/peaksSidecar.js +51 -0
  60. package/dist/resolvers/peaksSidecar.js.map +1 -0
  61. package/dist/rest/MediaAccessKeys.d.ts +68 -0
  62. package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
  63. package/dist/rest/MediaAccessKeys.js +96 -0
  64. package/dist/rest/MediaAccessKeys.js.map +1 -0
  65. package/dist/rest/MediaStreamHandler.d.ts +26 -0
  66. package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
  67. package/dist/rest/MediaStreamHandler.js +192 -0
  68. package/dist/rest/MediaStreamHandler.js.map +1 -0
  69. package/dist/rest/mediaRange.d.ts +41 -0
  70. package/dist/rest/mediaRange.d.ts.map +1 -0
  71. package/dist/rest/mediaRange.js +60 -0
  72. package/dist/rest/mediaRange.js.map +1 -0
  73. package/package.json +83 -82
  74. package/src/__tests__/FileResolverPeaks.test.ts +64 -0
  75. package/src/__tests__/MediaAccessKeys.test.ts +68 -0
  76. package/src/__tests__/MediaStreamHandler.test.ts +91 -0
  77. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
  78. package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
  79. package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
  80. package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
  81. package/src/__tests__/SessionManager.test.ts +2 -0
  82. package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
  83. package/src/agentSessions/SessionJanitor.ts +15 -1
  84. package/src/agentSessions/SessionManager.ts +62 -10
  85. package/src/agentSessions/index.ts +3 -1
  86. package/src/generated/generated.ts +17659 -13322
  87. package/src/index.ts +15 -1
  88. package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
  89. package/src/resolvers/FileResolver.ts +199 -1
  90. package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
  91. package/src/resolvers/QueryResolver.ts +33 -19
  92. package/src/resolvers/RealtimeBridgeResolver.ts +82 -7
  93. package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
  94. package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
  95. package/src/resolvers/RunAIAgentResolver.ts +44 -11
  96. package/src/resolvers/RunTemplateResolver.ts +8 -0
  97. package/src/resolvers/meetingRecordingRegistration.ts +432 -0
  98. package/src/resolvers/peaksSidecar.ts +52 -0
  99. package/src/rest/MediaAccessKeys.ts +121 -0
  100. package/src/rest/MediaStreamHandler.ts +220 -0
  101. package/src/rest/mediaRange.ts +76 -0
@@ -0,0 +1,248 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
3
+
4
+ // ── Mocks ──────────────────────────────────────────────────────────────────
5
+ // The helper uses `RunView.FromMetadataProvider(provider).RunView(...)` for conversation lookups and
6
+ // `FileStorageEngine.Instance` for storage-account/driver/upload. We stub both so the test is pure
7
+ // (no DB, no network, no LiveKit/storage SDK).
8
+
9
+ const runViewMock = vi.fn();
10
+
11
+ vi.mock('@memberjunction/core', async (importOriginal) => {
12
+ const actual = await importOriginal<Record<string, unknown>>();
13
+ class RunView {
14
+ static FromMetadataProvider() {
15
+ return { RunView: runViewMock };
16
+ }
17
+ RunView = runViewMock;
18
+ }
19
+ return { ...actual, RunView, LogError: vi.fn(), LogStatus: vi.fn() };
20
+ });
21
+
22
+ // FileStorageEngine — a configurable fake. Tests reset its behavior in beforeEach.
23
+ const storage = {
24
+ accountsByProvider: new Map<string, Array<{ ID: string }>>(),
25
+ getObject: vi.fn(async () => Buffer.from('mp4-bytes')),
26
+ uploadFile: vi.fn(async () => ({ FileID: 'copied-file', StoragePath: 'meeting-recordings/2026/x.mp4' })),
27
+ };
28
+
29
+ vi.mock('@memberjunction/storage', () => ({
30
+ FileStorageEngine: {
31
+ Instance: {
32
+ Config: vi.fn(async () => undefined),
33
+ GetAccountsByProviderID: (id: string) => storage.accountsByProvider.get(id) ?? [],
34
+ GetDriver: vi.fn(async () => ({ GetObject: storage.getObject })),
35
+ UploadFile: (...args: unknown[]) => storage.uploadFile(...args),
36
+ },
37
+ },
38
+ }));
39
+
40
+ vi.mock('@memberjunction/core-entities', async (importOriginal) => {
41
+ const actual = await importOriginal<Record<string, unknown>>();
42
+ return { ...actual };
43
+ });
44
+
45
+ import {
46
+ registerMeetingRecordingFile,
47
+ resolveMeetingConversation,
48
+ type MeetingRecordingEgressResult,
49
+ } from '../resolvers/meetingRecordingRegistration';
50
+
51
+ // ── Fakes ──────────────────────────────────────────────────────────────────
52
+
53
+ const user = { ID: 'U1', Name: 'Amith', Email: 'amith@x.com' } as unknown as UserInfo;
54
+
55
+ /** A fake BaseEntity for Conversations / Files — records the values set on it. */
56
+ class FakeEntity {
57
+ public fields: Record<string, unknown> = {};
58
+ public ID = 'new-id';
59
+ public saved = false;
60
+ public loadedID: string | null = null;
61
+ constructor(public kind: 'conversation' | 'file', private saveResult = true) {}
62
+ NewRecord() {}
63
+ async Load(id: string) { this.loadedID = id; return true; }
64
+ async Save() { this.saved = this.saveResult; return this.saveResult; }
65
+ get LatestResult() { return { CompleteMessage: 'save failed' }; }
66
+ // Conversation fields
67
+ set UserID(v: string) { this.fields.UserID = v; }
68
+ set Name(v: string) { this.fields.Name = v; }
69
+ set Type(v: string) { this.fields.Type = v; }
70
+ set ExternalID(v: string) { this.fields.ExternalID = v; }
71
+ set ApplicationScope(v: string) { this.fields.ApplicationScope = v; }
72
+ set RecordingFileID(v: string) { this.fields.RecordingFileID = v; }
73
+ get RecordingFileID() { return this.fields.RecordingFileID as string; }
74
+ set EgressID(v: string) { this.fields.EgressID = v; }
75
+ get EgressID() { return this.fields.EgressID as string; }
76
+ // File fields
77
+ set ContentType(v: string) { this.fields.ContentType = v; }
78
+ set ProviderID(v: string) { this.fields.ProviderID = v; }
79
+ set ProviderKey(v: string) { this.fields.ProviderKey = v; }
80
+ set Status(v: string) { this.fields.Status = v; }
81
+ }
82
+
83
+ /** Builds a provider whose GetEntityObject hands back fresh fakes and records the created Files row. */
84
+ function makeProvider(opts: { fileSaveOk?: boolean } = {}) {
85
+ const created: { conversation: FakeEntity[]; file: FakeEntity[] } = { conversation: [], file: [] };
86
+ const provider = {
87
+ GetEntityObject: vi.fn(async (entityName: string) => {
88
+ if (entityName === 'MJ: Files') {
89
+ const f = new FakeEntity('file', opts.fileSaveOk ?? true);
90
+ f.ID = 'file-123';
91
+ created.file.push(f);
92
+ return f;
93
+ }
94
+ const c = new FakeEntity('conversation');
95
+ c.ID = 'conv-new';
96
+ created.conversation.push(c);
97
+ return c;
98
+ }),
99
+ } as unknown as IMetadataProvider;
100
+ return { provider, created };
101
+ }
102
+
103
+ const egress: MeetingRecordingEgressResult = {
104
+ EgressID: 'eg-1',
105
+ RoomName: 'room-1',
106
+ OutputLocation: 'room-1/2026.mp4',
107
+ OutputSizeBytes: 1234,
108
+ };
109
+
110
+ beforeEach(() => {
111
+ vi.clearAllMocks();
112
+ vi.unstubAllEnvs();
113
+ runViewMock.mockResolvedValue({ Success: true, Results: [] });
114
+ storage.accountsByProvider = new Map([['prov-sink', [{ ID: 'acct-sink' }]]]);
115
+ process.env.MJ_MEETING_RECORDING_STORAGE_PROVIDER = 'prov-sink';
116
+ delete process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER;
117
+ });
118
+
119
+ describe('registerMeetingRecordingFile', () => {
120
+ it('resolves the conversation, creates the Files row pointing at the sink, and stamps the Conversation', async () => {
121
+ // First RunView (by EgressID) → no match; second (by RoomName) → an existing conversation.
122
+ runViewMock
123
+ .mockResolvedValueOnce({ Success: true, Results: [] })
124
+ .mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-existing' }] });
125
+
126
+ const { provider, created } = makeProvider();
127
+ const result = await registerMeetingRecordingFile(egress, user, provider);
128
+
129
+ expect(result.Success).toBe(true);
130
+ expect(result.RecordingFileID).toBe('file-123');
131
+ expect(result.ConversationID).toBe('conv-existing');
132
+
133
+ // Files row: correct provider/key/content-type/status, pointing directly at the egress output.
134
+ const file = created.file[0];
135
+ expect(file.fields.ProviderID).toBe('prov-sink');
136
+ expect(file.fields.ProviderKey).toBe('room-1/2026.mp4');
137
+ expect(file.fields.ContentType).toBe('video/mp4');
138
+ expect(file.fields.Status).toBe('Uploaded');
139
+ expect(file.saved).toBe(true);
140
+
141
+ // Conversation stamped with the file id (loaded by id, then saved).
142
+ const conv = created.conversation[0];
143
+ expect(conv.loadedID).toBe('conv-existing');
144
+ expect(conv.fields.RecordingFileID).toBe('file-123');
145
+ expect(conv.fields.EgressID).toBe('eg-1');
146
+ expect(conv.saved).toBe(true);
147
+
148
+ // No byte copy in the default v1 path.
149
+ expect(storage.getObject).not.toHaveBeenCalled();
150
+ expect(storage.uploadFile).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('prefers matching the conversation by EgressID when present', async () => {
154
+ runViewMock.mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-by-egress' }] });
155
+ const { provider } = makeProvider();
156
+ const result = await registerMeetingRecordingFile(egress, user, provider);
157
+ expect(result.ConversationID).toBe('conv-by-egress');
158
+ // Only the EgressID lookup ran (no fall-through to room-name lookup).
159
+ expect(runViewMock).toHaveBeenCalledTimes(1);
160
+ });
161
+
162
+ it('creates a Meeting-Room Conversation when none exists', async () => {
163
+ runViewMock.mockResolvedValue({ Success: true, Results: [] }); // neither lookup matches
164
+ const { provider, created } = makeProvider();
165
+ const result = await registerMeetingRecordingFile(egress, user, provider);
166
+ expect(result.Success).toBe(true);
167
+ const conv = created.conversation[0];
168
+ expect(conv.fields.Type).toBe('Meeting Room');
169
+ expect(conv.fields.ExternalID).toBe('room-1');
170
+ expect(conv.fields.ApplicationScope).toBe('Application');
171
+ });
172
+
173
+ it('returns a graceful failure when the storage provider is not configured', async () => {
174
+ delete process.env.MJ_MEETING_RECORDING_STORAGE_PROVIDER;
175
+ const { provider } = makeProvider();
176
+ const result = await registerMeetingRecordingFile(egress, user, provider);
177
+ expect(result.Success).toBe(false);
178
+ expect(result.ErrorMessage).toMatch(/MJ_MEETING_RECORDING_STORAGE_PROVIDER/);
179
+ expect(result.RecordingFileID).toBeUndefined();
180
+ });
181
+
182
+ it('returns a graceful failure when no account is linked to the configured provider', async () => {
183
+ storage.accountsByProvider = new Map(); // no accounts for prov-sink
184
+ const { provider } = makeProvider();
185
+ const result = await registerMeetingRecordingFile(egress, user, provider);
186
+ expect(result.Success).toBe(false);
187
+ expect(result.ErrorMessage).toMatch(/No active MJStorage account/i);
188
+ });
189
+
190
+ it('returns a graceful failure when the egress has no output yet', async () => {
191
+ const { provider } = makeProvider();
192
+ const result = await registerMeetingRecordingFile({ ...egress, OutputLocation: undefined }, user, provider);
193
+ expect(result.Success).toBe(false);
194
+ expect(result.ErrorMessage).toMatch(/no output/i);
195
+ });
196
+
197
+ describe('copy-to-canonical (the "copy into Box" option)', () => {
198
+ it('copies the MP4 into a differing canonical provider and points the Files row there', async () => {
199
+ storage.accountsByProvider = new Map([
200
+ ['prov-sink', [{ ID: 'acct-sink' }]],
201
+ ['prov-canonical', [{ ID: 'acct-canonical' }]],
202
+ ]);
203
+ process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER = 'prov-canonical';
204
+ runViewMock.mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-1' }] });
205
+
206
+ const { provider, created } = makeProvider();
207
+ const result = await registerMeetingRecordingFile(egress, user, provider);
208
+
209
+ expect(result.Success).toBe(true);
210
+ // Bytes were read from the sink and uploaded into the canonical provider.
211
+ expect(storage.getObject).toHaveBeenCalledWith({ fullPath: 'room-1/2026.mp4' });
212
+ expect(storage.uploadFile).toHaveBeenCalledOnce();
213
+
214
+ // The Files row points at the canonical provider + its uploaded key.
215
+ const file = created.file[0];
216
+ expect(file.fields.ProviderID).toBe('prov-canonical');
217
+ expect(file.fields.ProviderKey).toBe('meeting-recordings/2026/x.mp4');
218
+ });
219
+
220
+ it('does NOT copy when the canonical provider equals the sink provider', async () => {
221
+ process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER = 'prov-sink'; // same as sink
222
+ runViewMock.mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-1' }] });
223
+ const { provider, created } = makeProvider();
224
+ const result = await registerMeetingRecordingFile(egress, user, provider);
225
+ expect(result.Success).toBe(true);
226
+ expect(storage.getObject).not.toHaveBeenCalled();
227
+ expect(created.file[0].fields.ProviderID).toBe('prov-sink');
228
+ });
229
+ });
230
+
231
+ it('fails gracefully (no throw) when the Files row save fails', async () => {
232
+ runViewMock.mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-1' }] });
233
+ const { provider } = makeProvider({ fileSaveOk: false });
234
+ const result = await registerMeetingRecordingFile(egress, user, provider);
235
+ expect(result.Success).toBe(false);
236
+ expect(result.ErrorMessage).toMatch(/Files row/i);
237
+ });
238
+ });
239
+
240
+ describe('resolveMeetingConversation', () => {
241
+ it('falls back to creating a conversation when neither lookup matches', async () => {
242
+ runViewMock.mockResolvedValue({ Success: true, Results: [] });
243
+ const { provider, created } = makeProvider();
244
+ const id = await resolveMeetingConversation(egress, user, provider);
245
+ expect(id).toBe('conv-new');
246
+ expect(created.conversation).toHaveLength(1);
247
+ });
248
+ });
@@ -228,8 +228,22 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
228
228
  if (page.length === 0) {
229
229
  break;
230
230
  }
231
+ // Batch-load every channel for this whole page of sessions in ONE query, then hand each
232
+ // session its own slice to CloseSession — avoids the N+1 channel read (one RunView per
233
+ // closing session) that tripped the sequential / multiple-same-entity telemetry.
234
+ const channelsBySession = await this.sessionManager.LoadActiveChannelsBySession(
235
+ page.map(s => s.ID),
236
+ systemUser,
237
+ provider,
238
+ );
231
239
  for (const session of page) {
232
- const closed = await this.sessionManager.CloseSession(session.ID, systemUser, provider, closeReason);
240
+ const closed = await this.sessionManager.CloseSession(
241
+ session.ID,
242
+ systemUser,
243
+ provider,
244
+ closeReason,
245
+ channelsBySession.get(session.ID.toLowerCase()) ?? [],
246
+ );
233
247
  if (closed) {
234
248
  closedCount++;
235
249
  }
@@ -137,6 +137,7 @@ export class SessionManager {
137
137
  contextUser: UserInfo,
138
138
  provider: IMetadataProvider,
139
139
  closeReason: SessionCloseReason = 'Explicit',
140
+ preloadedChannels?: MJAIAgentSessionChannelEntity[],
140
141
  ): Promise<boolean> {
141
142
  const session = await this.loadSession(agentSessionID, contextUser, provider);
142
143
  if (!session) {
@@ -156,7 +157,7 @@ export class SessionManager {
156
157
  if (!closed) {
157
158
  return false;
158
159
  }
159
- await this.disconnectChannels(agentSessionID, contextUser, provider);
160
+ await this.disconnectChannels(agentSessionID, contextUser, provider, preloadedChannels);
160
161
  await this.finalizeObservabilityRuns(session, contextUser, provider);
161
162
  await this.notifyChannelPluginsSessionClosed(agentSessionID, closeReason);
162
163
  return true;
@@ -180,6 +181,9 @@ export class SessionManager {
180
181
  AgentID: session.AgentID,
181
182
  UserID: session.UserID,
182
183
  ConversationID: session.ConversationID ?? null,
184
+ // Hand the verbatim per-session config blob to data-aware channels (e.g. the Media
185
+ // channel reads a per-session mediaCollectionID override from it). Opaque here.
186
+ AgentSessionConfig: session.Config_ ?? null,
183
187
  },
184
188
  contextUser,
185
189
  provider,
@@ -376,24 +380,72 @@ export class SessionManager {
376
380
  agentSessionID: string,
377
381
  contextUser: UserInfo,
378
382
  provider: IMetadataProvider,
383
+ preloadedChannels?: MJAIAgentSessionChannelEntity[],
379
384
  ): Promise<void> {
385
+ // Sweep callers (the janitor) close a whole page of sessions at once and pre-load every
386
+ // session's channels in ONE batched query (see LoadActiveChannelsBySession), passing this
387
+ // session's slice here to avoid an N+1 channel read (one RunView per closing session).
388
+ // Every other close path passes nothing and reads just this session's channels.
389
+ const channels = preloadedChannels
390
+ ?? await this.loadActiveChannels([agentSessionID], contextUser, provider);
391
+ const now = new Date();
392
+ for (const channel of channels) {
393
+ channel.Status = 'Disconnected';
394
+ channel.DisconnectedAt = now;
395
+ await this.saveOrLog(channel, 'disconnectChannels');
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Loads the non-Disconnected channels for one or more sessions in a SINGLE RunView
401
+ * (`AgentSessionID IN (...)`). Session IDs are server-generated GUIDs (never user input), so
402
+ * inline interpolation carries no injection risk. Returns [] on empty input or load failure.
403
+ */
404
+ private async loadActiveChannels(
405
+ sessionIDs: string[],
406
+ contextUser: UserInfo,
407
+ provider: IMetadataProvider,
408
+ ): Promise<MJAIAgentSessionChannelEntity[]> {
409
+ if (sessionIDs.length === 0) {
410
+ return [];
411
+ }
412
+ const inList = sessionIDs.map(id => `'${id}'`).join(',');
380
413
  const rv = RunView.FromMetadataProvider(provider);
381
414
  const result = await rv.RunView<MJAIAgentSessionChannelEntity>({
382
415
  EntityName: SESSION_CHANNEL_ENTITY,
383
- ExtraFilter: `AgentSessionID='${agentSessionID}' AND Status <> 'Disconnected'`,
416
+ ExtraFilter: `AgentSessionID IN (${inList}) AND Status <> 'Disconnected'`,
384
417
  ResultType: 'entity_object',
385
418
  }, contextUser);
386
-
387
419
  if (!result.Success) {
388
- LogError(`SessionManager.disconnectChannels failed to load channels: ${result.ErrorMessage}`);
389
- return;
420
+ LogError(`SessionManager.loadActiveChannels failed to load channels: ${result.ErrorMessage}`);
421
+ return [];
390
422
  }
391
- const now = new Date();
392
- for (const channel of result.Results) {
393
- channel.Status = 'Disconnected';
394
- channel.DisconnectedAt = now;
395
- await this.saveOrLog(channel, 'disconnectChannels');
423
+ return result.Results;
424
+ }
425
+
426
+ /**
427
+ * Batch-loads active channels for many sessions at once and groups them by AgentSessionID
428
+ * (lowercased), so a sweep can disconnect a whole page of sessions with ONE query instead of
429
+ * one-per-session. Sessions with no active channels are absent from the map (callers default
430
+ * to []). Pass each session's slice to {@link CloseSession}'s `preloadedChannels`.
431
+ */
432
+ public async LoadActiveChannelsBySession(
433
+ sessionIDs: string[],
434
+ contextUser: UserInfo,
435
+ provider: IMetadataProvider,
436
+ ): Promise<Map<string, MJAIAgentSessionChannelEntity[]>> {
437
+ const channels = await this.loadActiveChannels(sessionIDs, contextUser, provider);
438
+ const bySession = new Map<string, MJAIAgentSessionChannelEntity[]>();
439
+ for (const channel of channels) {
440
+ const key = String(channel.AgentSessionID).toLowerCase();
441
+ const list = bySession.get(key);
442
+ if (list) {
443
+ list.push(channel);
444
+ } else {
445
+ bySession.set(key, [channel]);
446
+ }
396
447
  }
448
+ return bySession;
397
449
  }
398
450
 
399
451
  /** True once the per-session coalescing window has elapsed since the last persisted write. */
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module @memberjunction/server
9
9
  */
10
- import { LoadWhiteboardChannelServer, LoadMeetingControlsChannelServer } from '@memberjunction/ai-agents';
10
+ import { LoadWhiteboardChannelServer, LoadMeetingControlsChannelServer, LoadMediaChannelServer, LoadClientContextChannelServer } from '@memberjunction/ai-agents';
11
11
  import { LoadRemoteBrowserChannel } from '@memberjunction/remote-browser-server';
12
12
  import { LoadSelfHostRemoteBrowser } from '@memberjunction/remote-browser-selfhost';
13
13
  import { BindRemoteBrowserGoalEngine } from './remoteBrowserGoalEngine.js';
@@ -17,6 +17,8 @@ import { BindRemoteBrowserGoalEngine } from './remoteBrowserGoalEngine.js';
17
17
  // the session lifecycle — `SessionManager.CreateSession` resolves them via the ClassFactory.
18
18
  LoadWhiteboardChannelServer();
19
19
  LoadMeetingControlsChannelServer();
20
+ LoadMediaChannelServer();
21
+ LoadClientContextChannelServer();
20
22
  // Remote Browser native channel (client-direct): the lifecycle-only server channel plugin + the
21
23
  // Self-Hosted Chrome backend driver (whose default runner launches a local headless Chromium via
22
24
  // Playwright — pulled in transitively through the SelfHost package, documented and acceptable).