@memberjunction/server 5.40.1 → 5.41.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/HostInstance.d.ts +19 -0
  2. package/dist/agentSessions/HostInstance.d.ts.map +1 -0
  3. package/dist/agentSessions/HostInstance.js +48 -0
  4. package/dist/agentSessions/HostInstance.js.map +1 -0
  5. package/dist/agentSessions/SessionJanitor.d.ts +97 -0
  6. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -0
  7. package/dist/agentSessions/SessionJanitor.js +222 -0
  8. package/dist/agentSessions/SessionJanitor.js.map +1 -0
  9. package/dist/agentSessions/SessionManager.d.ts +142 -0
  10. package/dist/agentSessions/SessionManager.d.ts.map +1 -0
  11. package/dist/agentSessions/SessionManager.js +308 -0
  12. package/dist/agentSessions/SessionManager.js.map +1 -0
  13. package/dist/agentSessions/index.d.ts +4 -0
  14. package/dist/agentSessions/index.d.ts.map +1 -0
  15. package/dist/agentSessions/index.js +26 -0
  16. package/dist/agentSessions/index.js.map +1 -0
  17. package/dist/auth/initializeProviders.d.ts.map +1 -1
  18. package/dist/auth/initializeProviders.js +6 -1
  19. package/dist/auth/initializeProviders.js.map +1 -1
  20. package/dist/config.d.ts +245 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +34 -0
  23. package/dist/config.js.map +1 -1
  24. package/dist/context.d.ts.map +1 -1
  25. package/dist/context.js +41 -7
  26. package/dist/context.js.map +1 -1
  27. package/dist/generated/generated.d.ts +659 -3
  28. package/dist/generated/generated.d.ts.map +1 -1
  29. package/dist/generated/generated.js +5459 -1759
  30. package/dist/generated/generated.js.map +1 -1
  31. package/dist/generic/ResolverBase.js +1 -1
  32. package/dist/generic/ResolverBase.js.map +1 -1
  33. package/dist/generic/RunViewResolver.js +9 -10
  34. package/dist/generic/RunViewResolver.js.map +1 -1
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +112 -42
  38. package/dist/index.js.map +1 -1
  39. package/dist/logging/StartupLogger.d.ts +121 -0
  40. package/dist/logging/StartupLogger.d.ts.map +1 -0
  41. package/dist/logging/StartupLogger.js +245 -0
  42. package/dist/logging/StartupLogger.js.map +1 -0
  43. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
  44. package/dist/logging/variablesLoggingMiddleware.js +21 -2
  45. package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
  46. package/dist/middleware/RateLimitMiddleware.d.ts +16 -0
  47. package/dist/middleware/RateLimitMiddleware.d.ts.map +1 -0
  48. package/dist/middleware/RateLimitMiddleware.js +77 -0
  49. package/dist/middleware/RateLimitMiddleware.js.map +1 -0
  50. package/dist/middleware/index.d.ts +1 -0
  51. package/dist/middleware/index.d.ts.map +1 -1
  52. package/dist/middleware/index.js +1 -0
  53. package/dist/middleware/index.js.map +1 -1
  54. package/dist/resolvers/AgentSessionResolver.d.ts +42 -0
  55. package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -0
  56. package/dist/resolvers/AgentSessionResolver.js +152 -0
  57. package/dist/resolvers/AgentSessionResolver.js.map +1 -0
  58. package/dist/resolvers/EntityPermissionResolver.d.ts +16 -0
  59. package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -0
  60. package/dist/resolvers/EntityPermissionResolver.js +95 -0
  61. package/dist/resolvers/EntityPermissionResolver.js.map +1 -0
  62. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +688 -0
  63. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -0
  64. package/dist/resolvers/RealtimeClientSessionResolver.js +1774 -0
  65. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -0
  66. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +359 -0
  67. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -0
  68. package/dist/resolvers/RemoteBrowserActionResolver.js +896 -0
  69. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -0
  70. package/dist/rest/SignatureWebhookHandler.js +3 -2
  71. package/dist/rest/SignatureWebhookHandler.js.map +1 -1
  72. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  73. package/dist/services/ScheduledJobsService.js +6 -5
  74. package/dist/services/ScheduledJobsService.js.map +1 -1
  75. package/package.json +78 -74
  76. package/src/__tests__/RealtimeClientSessionResolver.test.ts +2605 -0
  77. package/src/__tests__/RemoteBrowserAudioStream.test.ts +174 -0
  78. package/src/__tests__/SessionJanitor.test.ts +234 -0
  79. package/src/__tests__/SessionManager.test.ts +465 -0
  80. package/src/__tests__/subscriptionRedaction.test.ts +5 -0
  81. package/src/agentSessions/HostInstance.ts +53 -0
  82. package/src/agentSessions/SessionJanitor.ts +267 -0
  83. package/src/agentSessions/SessionManager.ts +446 -0
  84. package/src/agentSessions/index.ts +27 -0
  85. package/src/auth/initializeProviders.ts +6 -1
  86. package/src/config.ts +38 -0
  87. package/src/context.ts +42 -7
  88. package/src/generated/generated.ts +3111 -567
  89. package/src/generic/ResolverBase.ts +1 -1
  90. package/src/generic/RunViewResolver.ts +9 -9
  91. package/src/index.ts +112 -42
  92. package/src/logging/StartupLogger.ts +317 -0
  93. package/src/logging/variablesLoggingMiddleware.ts +25 -5
  94. package/src/middleware/RateLimitMiddleware.ts +87 -0
  95. package/src/middleware/index.ts +1 -0
  96. package/src/resolvers/AgentSessionResolver.ts +138 -0
  97. package/src/resolvers/EntityPermissionResolver.ts +73 -0
  98. package/src/resolvers/RealtimeClientSessionResolver.ts +2162 -0
  99. package/src/resolvers/RemoteBrowserActionResolver.ts +907 -0
  100. package/src/rest/SignatureWebhookHandler.ts +3 -2
  101. package/src/services/ScheduledJobsService.ts +6 -5
@@ -0,0 +1,465 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // --- Mock CanRun authorization (AIAgentPermissionHelper.HasPermission) ---
4
+ const hasPermissionMock = vi.fn<[], Promise<boolean>>();
5
+ vi.mock('@memberjunction/ai-engine-base', () => ({
6
+ AIAgentPermissionHelper: {
7
+ HasPermission: (...args: unknown[]) => hasPermissionMock(...(args as [])),
8
+ },
9
+ }));
10
+
11
+ // --- Mock the client-direct service so CloseSession's run finalization is observable (no DB),
12
+ // plus the channel-plugin host so the start/close lifecycle notifications are observable. ---
13
+ const finalizeCoAgentRunMock = vi.fn(async () => undefined);
14
+ const hostSessionStartedMock = vi.fn(async () => undefined);
15
+ const hostSessionClosedMock = vi.fn(async () => undefined);
16
+ vi.mock('@memberjunction/ai-agents', () => ({
17
+ RealtimeClientSessionService: class {
18
+ FinalizeCoAgentRun = finalizeCoAgentRunMock;
19
+ },
20
+ RealtimeChannelServerHost: {
21
+ get Instance() {
22
+ return {
23
+ OnSessionStarted: hostSessionStartedMock,
24
+ OnSessionClosed: hostSessionClosedMock,
25
+ };
26
+ },
27
+ },
28
+ }));
29
+
30
+ // --- Mock RunView.FromMetadataProvider so channel sweeps are controllable, while leaving the
31
+ // rest of @memberjunction/core intact. ---
32
+ const runViewMock = vi.fn();
33
+ vi.mock('@memberjunction/core', async (importOriginal) => {
34
+ const actual = await importOriginal<typeof import('@memberjunction/core')>();
35
+ return {
36
+ ...actual,
37
+ RunView: {
38
+ FromMetadataProvider: () => ({ RunView: runViewMock }),
39
+ },
40
+ };
41
+ });
42
+
43
+ import { SessionManager, SessionAuthorizationError } from '../agentSessions/SessionManager.js';
44
+ import type { UserInfo, IMetadataProvider } from '@memberjunction/core';
45
+
46
+ /** Minimal fake BaseEntity-shaped record with the fields/methods SessionManager touches. */
47
+ interface FakeEntity {
48
+ [key: string]: unknown;
49
+ NewRecord: () => void;
50
+ Save: () => Promise<boolean>;
51
+ Load: (id: string) => Promise<boolean>;
52
+ LatestResult?: { CompleteMessage?: string };
53
+ }
54
+
55
+ function makeUser(): UserInfo {
56
+ return { ID: 'user-1', Email: 'tester@example.com' } as unknown as UserInfo;
57
+ }
58
+
59
+ /** Records the entity instances handed out, keyed by entity name, so tests can assert on them. */
60
+ function makeProvider(factory: (entityName: string) => FakeEntity): {
61
+ provider: IMetadataProvider;
62
+ handedOut: FakeEntity[];
63
+ } {
64
+ const handedOut: FakeEntity[] = [];
65
+ const provider = {
66
+ GetEntityObject: vi.fn(async (entityName: string) => {
67
+ const e = factory(entityName);
68
+ handedOut.push(e);
69
+ return e;
70
+ }),
71
+ } as unknown as IMetadataProvider;
72
+ return { provider, handedOut };
73
+ }
74
+
75
+ function makeSessionEntity(overrides: Partial<FakeEntity> = {}): FakeEntity {
76
+ return {
77
+ ID: 'session-1',
78
+ Status: 'Active',
79
+ ConversationID: null,
80
+ NewRecord: vi.fn(),
81
+ Save: vi.fn(async () => true),
82
+ Load: vi.fn(async () => true),
83
+ LatestResult: { CompleteMessage: '' },
84
+ ...overrides,
85
+ };
86
+ }
87
+
88
+ beforeEach(() => {
89
+ hasPermissionMock.mockReset();
90
+ finalizeCoAgentRunMock.mockClear();
91
+ hostSessionStartedMock.mockReset();
92
+ hostSessionStartedMock.mockResolvedValue(undefined);
93
+ hostSessionClosedMock.mockReset();
94
+ hostSessionClosedMock.mockResolvedValue(undefined);
95
+ runViewMock.mockReset();
96
+ runViewMock.mockResolvedValue({ Success: true, Results: [] });
97
+ });
98
+
99
+ describe('SessionManager.CreateSession', () => {
100
+ it('denies and creates no session when CanRun is false', async () => {
101
+ hasPermissionMock.mockResolvedValue(false);
102
+ const { provider, handedOut } = makeProvider(() => makeSessionEntity());
103
+ const mgr = new SessionManager();
104
+
105
+ await expect(
106
+ mgr.CreateSession({ agentID: 'agent-1', userID: 'user-1' }, makeUser(), provider),
107
+ ).rejects.toBeInstanceOf(SessionAuthorizationError);
108
+
109
+ // No entity should have been requested/created on denial.
110
+ expect(handedOut.length).toBe(0);
111
+ });
112
+
113
+ it('creates a Conversation when none is passed, then the session', async () => {
114
+ hasPermissionMock.mockResolvedValue(true);
115
+ const conversation = makeSessionEntity({ ID: 'conv-new' });
116
+ const session = makeSessionEntity({ ID: 'session-new' });
117
+ const { provider } = makeProvider((name) =>
118
+ name === 'MJ: Conversations' ? conversation : session,
119
+ );
120
+ const mgr = new SessionManager();
121
+
122
+ const result = await mgr.CreateSession(
123
+ { agentID: 'agent-1', userID: 'user-1' },
124
+ makeUser(),
125
+ provider,
126
+ );
127
+
128
+ expect(conversation.NewRecord).toHaveBeenCalled();
129
+ expect(conversation.Save).toHaveBeenCalled();
130
+ expect(result.ConversationID).toBe('conv-new');
131
+ expect(result.Status).toBe('Active');
132
+ expect(result.HostInstanceID).toBeTypeOf('string');
133
+ });
134
+
135
+ it('reuses a supplied conversation without creating a new one', async () => {
136
+ hasPermissionMock.mockResolvedValue(true);
137
+ const session = makeSessionEntity({ ID: 'session-x' });
138
+ const { provider, handedOut } = makeProvider(() => session);
139
+ const mgr = new SessionManager();
140
+
141
+ await mgr.CreateSession(
142
+ { agentID: 'agent-1', userID: 'user-1', conversationID: 'conv-existing' },
143
+ makeUser(),
144
+ provider,
145
+ );
146
+
147
+ // Only one entity (the session) should have been requested — no Conversation.
148
+ expect(handedOut.length).toBe(1);
149
+ expect(session.ConversationID).toBe('conv-existing');
150
+ });
151
+
152
+ it('throws (with the entity error detail) when the Conversation save fails — no session row attempted', async () => {
153
+ hasPermissionMock.mockResolvedValue(true);
154
+ const conversation = makeSessionEntity({
155
+ ID: 'conv-fail',
156
+ Save: vi.fn(async () => false),
157
+ LatestResult: { CompleteMessage: 'conversation insert denied' },
158
+ });
159
+ const { provider, handedOut } = makeProvider(() => conversation);
160
+ const mgr = new SessionManager();
161
+
162
+ await expect(
163
+ mgr.CreateSession({ agentID: 'agent-1', userID: 'user-1' }, makeUser(), provider),
164
+ ).rejects.toThrow(/conversation insert denied/);
165
+
166
+ expect(handedOut.length).toBe(1); // only the conversation was requested
167
+ });
168
+
169
+ it('throws (with the entity error detail) when the session save fails', async () => {
170
+ hasPermissionMock.mockResolvedValue(true);
171
+ const session = makeSessionEntity({
172
+ ID: 'session-fail',
173
+ Save: vi.fn(async () => false),
174
+ LatestResult: { CompleteMessage: 'session insert denied' },
175
+ });
176
+ const { provider } = makeProvider(() => session);
177
+ const mgr = new SessionManager();
178
+
179
+ await expect(
180
+ mgr.CreateSession(
181
+ { agentID: 'agent-1', userID: 'user-1', conversationID: 'conv-existing' },
182
+ makeUser(),
183
+ provider,
184
+ ),
185
+ ).rejects.toThrow(/session insert denied/);
186
+ });
187
+ });
188
+
189
+ describe('SessionManager.CloseSession', () => {
190
+ it('sets Closed + ClosedAt, disconnects channels', async () => {
191
+ const channel = makeSessionEntity({ ID: 'chan-1', Status: 'Connected' });
192
+ runViewMock.mockResolvedValue({ Success: true, Results: [channel] });
193
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Active' });
194
+ const { provider } = makeProvider(() => session);
195
+ const mgr = new SessionManager();
196
+
197
+ const ok = await mgr.CloseSession('session-1', makeUser(), provider);
198
+
199
+ expect(ok).toBe(true);
200
+ expect(session.Status).toBe('Closed');
201
+ expect(session.ClosedAt).toBeInstanceOf(Date);
202
+ // No reason supplied → the default stamps the common user-initiated case.
203
+ expect(session.CloseReason).toBe('Explicit');
204
+ expect(channel.Status).toBe('Disconnected');
205
+ expect(channel.DisconnectedAt).toBeInstanceOf(Date);
206
+ // No observability run ids in this session's config — finalize is not invoked.
207
+ expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it('finalizes the co-agent observability runs when the session config carries their ids', async () => {
211
+ const session = makeSessionEntity({
212
+ ID: 'session-voice',
213
+ Status: 'Active',
214
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-5', promptRunID: 'prompt-run-5' }),
215
+ });
216
+ const { provider } = makeProvider(() => session);
217
+ const mgr = new SessionManager();
218
+ const user = makeUser();
219
+
220
+ const ok = await mgr.CloseSession('session-voice', user, provider);
221
+
222
+ expect(ok).toBe(true);
223
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledTimes(1);
224
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-5', 'prompt-run-5', user, provider, true, null);
225
+ });
226
+
227
+ it('threads the co-agent run-step id into finalize when the session config carries it', async () => {
228
+ const session = makeSessionEntity({
229
+ ID: 'session-voice-step',
230
+ Status: 'Active',
231
+ Config_: JSON.stringify({
232
+ targetAgentID: 't1',
233
+ coAgentRunID: 'co-run-6',
234
+ promptRunID: 'prompt-run-6',
235
+ coAgentRunStepID: 'run-step-6',
236
+ }),
237
+ });
238
+ const { provider } = makeProvider(() => session);
239
+ const mgr = new SessionManager();
240
+ const user = makeUser();
241
+
242
+ const ok = await mgr.CloseSession('session-voice-step', user, provider);
243
+
244
+ expect(ok).toBe(true);
245
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-6', 'prompt-run-6', user, provider, true, 'run-step-6');
246
+ });
247
+
248
+ it('does not finalize when the session config has no run ids (target only)', async () => {
249
+ const session = makeSessionEntity({
250
+ ID: 'session-no-runs',
251
+ Status: 'Active',
252
+ Config_: JSON.stringify({ targetAgentID: 't1' }),
253
+ });
254
+ const { provider } = makeProvider(() => session);
255
+ const mgr = new SessionManager();
256
+
257
+ await mgr.CloseSession('session-no-runs', makeUser(), provider);
258
+
259
+ expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
260
+ });
261
+
262
+ it('stamps CloseReason = Explicit by default (untouched call sites get the right reason)', async () => {
263
+ const session = makeSessionEntity({ ID: 'session-default', Status: 'Active' });
264
+ const { provider } = makeProvider(() => session);
265
+ const mgr = new SessionManager();
266
+
267
+ const ok = await mgr.CloseSession('session-default', makeUser(), provider);
268
+
269
+ expect(ok).toBe(true);
270
+ expect(session.CloseReason).toBe('Explicit');
271
+ });
272
+
273
+ it.each(['Janitor', 'Shutdown', 'Error', 'Explicit'] as const)(
274
+ 'stamps the supplied closeReason: %s',
275
+ async (reason) => {
276
+ const session = makeSessionEntity({ ID: `session-${reason}`, Status: 'Active' });
277
+ const { provider } = makeProvider(() => session);
278
+ const mgr = new SessionManager();
279
+
280
+ const ok = await mgr.CloseSession(`session-${reason}`, makeUser(), provider, reason);
281
+
282
+ expect(ok).toBe(true);
283
+ expect(session.Status).toBe('Closed');
284
+ expect(session.CloseReason).toBe(reason);
285
+ },
286
+ );
287
+
288
+ it('does not overwrite the original CloseReason on an idempotent re-close', async () => {
289
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Closed', CloseReason: 'Janitor' });
290
+ const { provider } = makeProvider(() => session);
291
+ const mgr = new SessionManager();
292
+
293
+ const ok = await mgr.CloseSession('session-1', makeUser(), provider, 'Explicit');
294
+
295
+ expect(ok).toBe(true);
296
+ expect(session.CloseReason).toBe('Janitor'); // untouched — no save happened
297
+ expect(session.Save).not.toHaveBeenCalled();
298
+ });
299
+
300
+ it('is idempotent — closing an already-Closed session is a no-op returning true', async () => {
301
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Closed' });
302
+ const { provider } = makeProvider(() => session);
303
+ const mgr = new SessionManager();
304
+
305
+ const ok = await mgr.CloseSession('session-1', makeUser(), provider);
306
+
307
+ expect(ok).toBe(true);
308
+ expect(session.Save).not.toHaveBeenCalled();
309
+ expect(runViewMock).not.toHaveBeenCalled();
310
+ });
311
+
312
+ it('returns false when the session cannot be loaded', async () => {
313
+ const session = makeSessionEntity({ ID: 'missing', Load: vi.fn(async () => false) });
314
+ const { provider } = makeProvider(() => session);
315
+ const mgr = new SessionManager();
316
+
317
+ const ok = await mgr.CloseSession('missing', makeUser(), provider);
318
+ expect(ok).toBe(false);
319
+ });
320
+ });
321
+
322
+ describe('SessionManager.Heartbeat', () => {
323
+ it('coalesces writes within the min interval (one write, not two)', async () => {
324
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Active' });
325
+ const { provider } = makeProvider(() => session);
326
+ const mgr = new SessionManager();
327
+ const user = makeUser();
328
+
329
+ const first = await mgr.Heartbeat('session-1', user, provider);
330
+ const second = await mgr.Heartbeat('session-1', user, provider);
331
+
332
+ expect(first).toBe(true);
333
+ expect(second).toBe(true);
334
+ // First call forces a write (no cached timestamp); second is coalesced.
335
+ expect(session.Save).toHaveBeenCalledTimes(1);
336
+ });
337
+
338
+ it('reactivates an Idle session to Active on the first heartbeat', async () => {
339
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Idle' });
340
+ const { provider } = makeProvider(() => session);
341
+ const mgr = new SessionManager();
342
+
343
+ await mgr.Heartbeat('session-1', makeUser(), provider);
344
+ expect(session.Status).toBe('Active');
345
+ });
346
+
347
+ it('does not reactivate a Closed session (returns false)', async () => {
348
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Closed' });
349
+ const { provider } = makeProvider(() => session);
350
+ const mgr = new SessionManager();
351
+
352
+ const ok = await mgr.Heartbeat('session-1', makeUser(), provider);
353
+ expect(ok).toBe(false);
354
+ expect(session.Save).not.toHaveBeenCalled();
355
+ });
356
+ });
357
+
358
+ describe('SessionManager.MarkIdle', () => {
359
+ it('transitions Active → Idle', async () => {
360
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Active' });
361
+ const { provider } = makeProvider(() => session);
362
+ const mgr = new SessionManager();
363
+
364
+ const ok = await mgr.MarkIdle('session-1', makeUser(), provider);
365
+ expect(ok).toBe(true);
366
+ expect(session.Status).toBe('Idle');
367
+ });
368
+
369
+ it('is a no-op for an already-Idle session', async () => {
370
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Idle' });
371
+ const { provider } = makeProvider(() => session);
372
+ const mgr = new SessionManager();
373
+
374
+ const ok = await mgr.MarkIdle('session-1', makeUser(), provider);
375
+ expect(ok).toBe(true);
376
+ expect(session.Save).not.toHaveBeenCalled();
377
+ });
378
+ });
379
+
380
+ describe('SessionManager — server-side channel plugin lifecycle notifications', () => {
381
+ it('CreateSession notifies the channel-plugin host with the session context after the row persists', async () => {
382
+ hasPermissionMock.mockResolvedValue(true);
383
+ const session = makeSessionEntity({ ID: 'session-new', AgentID: 'agent-1', UserID: 'user-1' });
384
+ const { provider } = makeProvider(() => session);
385
+ const mgr = new SessionManager();
386
+
387
+ await mgr.CreateSession(
388
+ { agentID: 'agent-1', userID: 'user-1', conversationID: 'conv-existing' },
389
+ makeUser(),
390
+ provider,
391
+ );
392
+
393
+ expect(hostSessionStartedMock).toHaveBeenCalledTimes(1);
394
+ const [ctx, user, prov] = hostSessionStartedMock.mock.calls[0] as unknown[];
395
+ expect(ctx).toEqual({
396
+ AgentSessionID: 'session-new',
397
+ AgentID: 'agent-1',
398
+ UserID: 'user-1',
399
+ ConversationID: 'conv-existing',
400
+ });
401
+ expect((user as { ID: string }).ID).toBe('user-1');
402
+ expect(prov).toBe(provider);
403
+ });
404
+
405
+ it('CreateSession does NOT notify the host when the session save fails (no half-open plugins)', async () => {
406
+ hasPermissionMock.mockResolvedValue(true);
407
+ const session = makeSessionEntity({ Save: vi.fn(async () => false) });
408
+ const { provider } = makeProvider(() => session);
409
+ const mgr = new SessionManager();
410
+
411
+ await expect(
412
+ mgr.CreateSession({ agentID: 'agent-1', userID: 'user-1', conversationID: 'conv-1' }, makeUser(), provider),
413
+ ).rejects.toThrow();
414
+ expect(hostSessionStartedMock).not.toHaveBeenCalled();
415
+ });
416
+
417
+ it('a throwing host start notification never breaks session creation (best-effort)', async () => {
418
+ hasPermissionMock.mockResolvedValue(true);
419
+ hostSessionStartedMock.mockRejectedValue(new Error('plugin layer boom'));
420
+ const session = makeSessionEntity({ ID: 'session-new' });
421
+ const { provider } = makeProvider(() => session);
422
+ const mgr = new SessionManager();
423
+
424
+ const result = await mgr.CreateSession(
425
+ { agentID: 'agent-1', userID: 'user-1', conversationID: 'conv-1' },
426
+ makeUser(),
427
+ provider,
428
+ );
429
+ expect(result.ID).toBe('session-new');
430
+ });
431
+
432
+ it('CloseSession notifies the host with the stamped close reason (janitor provenance included)', async () => {
433
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Active' });
434
+ const { provider } = makeProvider(() => session);
435
+ const mgr = new SessionManager();
436
+
437
+ await mgr.CloseSession('session-1', makeUser(), provider, 'Janitor');
438
+
439
+ expect(hostSessionClosedMock).toHaveBeenCalledTimes(1);
440
+ expect(hostSessionClosedMock).toHaveBeenCalledWith('session-1', 'Janitor');
441
+ });
442
+
443
+ it('an idempotent re-close still notifies the host with the ORIGINAL close reason (race cleanup)', async () => {
444
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Closed', CloseReason: 'Explicit' });
445
+ const { provider } = makeProvider(() => session);
446
+ const mgr = new SessionManager();
447
+
448
+ const ok = await mgr.CloseSession('session-1', makeUser(), provider, 'Janitor');
449
+
450
+ expect(ok).toBe(true);
451
+ expect(session.Save).not.toHaveBeenCalled(); // still a DB no-op
452
+ expect(hostSessionClosedMock).toHaveBeenCalledWith('session-1', 'Explicit');
453
+ });
454
+
455
+ it('a throwing host close notification never breaks session close (best-effort)', async () => {
456
+ hostSessionClosedMock.mockRejectedValue(new Error('plugin layer boom'));
457
+ const session = makeSessionEntity({ ID: 'session-1', Status: 'Active' });
458
+ const { provider } = makeProvider(() => session);
459
+ const mgr = new SessionManager();
460
+
461
+ const ok = await mgr.CloseSession('session-1', makeUser(), provider);
462
+ expect(ok).toBe(true);
463
+ expect(session.Status).toBe('Closed');
464
+ });
465
+ });
@@ -21,8 +21,12 @@
21
21
  import 'reflect-metadata';
22
22
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
23
23
 
24
+ // The variables echo is gated behind BOTH logVariables=true AND telemetry.level='debug'
25
+ // (the single server log-level knob). Set both so the redaction logic under test actually
26
+ // emits its console.dir line.
24
27
  vi.mock('../config', () => ({
25
28
  configInfo: {
29
+ telemetry: { level: 'debug' },
26
30
  loggingSettings: {
27
31
  graphql: {
28
32
  logVariables: true,
@@ -32,6 +36,7 @@ vi.mock('../config', () => ({
32
36
  }));
33
37
  vi.mock('../config.js', () => ({
34
38
  configInfo: {
39
+ telemetry: { level: 'debug' },
35
40
  loggingSettings: {
36
41
  graphql: {
37
42
  logVariables: true,
@@ -0,0 +1,53 @@
1
+ import { hostname } from 'os';
2
+ import { randomUUID } from 'crypto';
3
+
4
+ /**
5
+ * Stable, per-process identity for this server node, used to anchor
6
+ * {@link import('@memberjunction/core-entities').MJAIAgentSessionEntity} rows to the
7
+ * instance that currently owns their in-memory sockets.
8
+ *
9
+ * The shape is `${hostname}:${pid}:${bootId}` where:
10
+ * - `hostname` and `pid` locate the OS process,
11
+ * - `bootId` is a UUID generated **once** at module load, so two different boots of the
12
+ * *same* host (a crash/redeploy that reuses pid, or a long-lived host that restarts the
13
+ * process) produce **distinct** identities.
14
+ *
15
+ * This last property is what lets {@link import('./SessionJanitor').SessionJanitor} tell a
16
+ * live session owned by *this* boot apart from an orphan left by a *previous* boot of the
17
+ * same host: only the latter has a `HostInstanceID` that starts with `${hostname}:` but
18
+ * carries a different `bootId`.
19
+ */
20
+ const BOOT_ID = randomUUID();
21
+
22
+ /** Resolved host name captured once at load (cheap; avoids repeated syscalls). */
23
+ const HOST_NAME = hostname();
24
+
25
+ /** The full, immutable host-instance identity string for this process. */
26
+ const HOST_INSTANCE_ID = `${HOST_NAME}:${process.pid}:${BOOT_ID}`;
27
+
28
+ /**
29
+ * Returns this process's stable host-instance identity (`hostname:pid:bootId`).
30
+ * Stamped into `AIAgentSession.HostInstanceID` at session create and read back by the
31
+ * janitor for own-host orphan recovery.
32
+ */
33
+ export function GetHostInstanceID(): string {
34
+ return HOST_INSTANCE_ID;
35
+ }
36
+
37
+ /**
38
+ * Returns the host-name prefix (`hostname:`) used to match *any* session previously hosted
39
+ * by this OS host, regardless of which boot created it. The janitor uses this to find rows
40
+ * that belong to this host but to a *different* (older) boot — i.e. crash/redeploy orphans.
41
+ */
42
+ export function GetHostNamePrefix(): string {
43
+ return `${HOST_NAME}:`;
44
+ }
45
+
46
+ /**
47
+ * Returns the UUID generated once at process start. Exposed primarily for diagnostics and
48
+ * tests; the janitor compares against the full {@link GetHostInstanceID} rather than this
49
+ * value directly.
50
+ */
51
+ export function GetBootID(): string {
52
+ return BOOT_ID;
53
+ }