@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.
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +5 -1
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +15 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +53 -11
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +3 -1
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/generated/generated.d.ts +1180 -51
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +35564 -29262
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
- package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +74 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +211 -2
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +6 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +17 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +78 -7
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +37 -15
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +8 -0
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.js +311 -0
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
- package/dist/resolvers/peaksSidecar.d.ts +30 -0
- package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
- package/dist/resolvers/peaksSidecar.js +51 -0
- package/dist/resolvers/peaksSidecar.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +68 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
- package/dist/rest/MediaAccessKeys.js +96 -0
- package/dist/rest/MediaAccessKeys.js.map +1 -0
- package/dist/rest/MediaStreamHandler.d.ts +26 -0
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
- package/dist/rest/MediaStreamHandler.js +192 -0
- package/dist/rest/MediaStreamHandler.js.map +1 -0
- package/dist/rest/mediaRange.d.ts +41 -0
- package/dist/rest/mediaRange.d.ts.map +1 -0
- package/dist/rest/mediaRange.js +60 -0
- package/dist/rest/mediaRange.js.map +1 -0
- package/package.json +83 -82
- package/src/__tests__/FileResolverPeaks.test.ts +64 -0
- package/src/__tests__/MediaAccessKeys.test.ts +68 -0
- package/src/__tests__/MediaStreamHandler.test.ts +91 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
- package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
- package/src/__tests__/SessionManager.test.ts +2 -0
- package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
- package/src/agentSessions/SessionJanitor.ts +15 -1
- package/src/agentSessions/SessionManager.ts +62 -10
- package/src/agentSessions/index.ts +3 -1
- package/src/generated/generated.ts +17659 -13322
- package/src/index.ts +15 -1
- package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
- package/src/resolvers/FileResolver.ts +199 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
- package/src/resolvers/QueryResolver.ts +33 -19
- package/src/resolvers/RealtimeBridgeResolver.ts +82 -7
- package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
- package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
- package/src/resolvers/RunAIAgentResolver.ts +44 -11
- package/src/resolvers/RunTemplateResolver.ts +8 -0
- package/src/resolvers/meetingRecordingRegistration.ts +432 -0
- package/src/resolvers/peaksSidecar.ts +52 -0
- package/src/rest/MediaAccessKeys.ts +121 -0
- package/src/rest/MediaStreamHandler.ts +220 -0
- 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(
|
|
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
|
|
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.
|
|
389
|
-
return;
|
|
420
|
+
LogError(`SessionManager.loadActiveChannels failed to load channels: ${result.ErrorMessage}`);
|
|
421
|
+
return [];
|
|
390
422
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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).
|