@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.
- package/dist/agentSessions/HostInstance.d.ts +19 -0
- package/dist/agentSessions/HostInstance.d.ts.map +1 -0
- package/dist/agentSessions/HostInstance.js +48 -0
- package/dist/agentSessions/HostInstance.js.map +1 -0
- package/dist/agentSessions/SessionJanitor.d.ts +97 -0
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -0
- package/dist/agentSessions/SessionJanitor.js +222 -0
- package/dist/agentSessions/SessionJanitor.js.map +1 -0
- package/dist/agentSessions/SessionManager.d.ts +142 -0
- package/dist/agentSessions/SessionManager.d.ts.map +1 -0
- package/dist/agentSessions/SessionManager.js +308 -0
- package/dist/agentSessions/SessionManager.js.map +1 -0
- package/dist/agentSessions/index.d.ts +4 -0
- package/dist/agentSessions/index.d.ts.map +1 -0
- package/dist/agentSessions/index.js +26 -0
- package/dist/agentSessions/index.js.map +1 -0
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +6 -1
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +245 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +34 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +41 -7
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +659 -3
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +5459 -1759
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.js +1 -1
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.js +9 -10
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -42
- package/dist/index.js.map +1 -1
- package/dist/logging/StartupLogger.d.ts +121 -0
- package/dist/logging/StartupLogger.d.ts.map +1 -0
- package/dist/logging/StartupLogger.js +245 -0
- package/dist/logging/StartupLogger.js.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
- package/dist/logging/variablesLoggingMiddleware.js +21 -2
- package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
- package/dist/middleware/RateLimitMiddleware.d.ts +16 -0
- package/dist/middleware/RateLimitMiddleware.d.ts.map +1 -0
- package/dist/middleware/RateLimitMiddleware.js +77 -0
- package/dist/middleware/RateLimitMiddleware.js.map +1 -0
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/resolvers/AgentSessionResolver.d.ts +42 -0
- package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -0
- package/dist/resolvers/AgentSessionResolver.js +152 -0
- package/dist/resolvers/AgentSessionResolver.js.map +1 -0
- package/dist/resolvers/EntityPermissionResolver.d.ts +16 -0
- package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -0
- package/dist/resolvers/EntityPermissionResolver.js +95 -0
- package/dist/resolvers/EntityPermissionResolver.js.map +1 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +688 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -0
- package/dist/resolvers/RealtimeClientSessionResolver.js +1774 -0
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +359 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.js +896 -0
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -0
- package/dist/rest/SignatureWebhookHandler.js +3 -2
- package/dist/rest/SignatureWebhookHandler.js.map +1 -1
- package/dist/services/ScheduledJobsService.d.ts.map +1 -1
- package/dist/services/ScheduledJobsService.js +6 -5
- package/dist/services/ScheduledJobsService.js.map +1 -1
- package/package.json +78 -74
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +2605 -0
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +174 -0
- package/src/__tests__/SessionJanitor.test.ts +234 -0
- package/src/__tests__/SessionManager.test.ts +465 -0
- package/src/__tests__/subscriptionRedaction.test.ts +5 -0
- package/src/agentSessions/HostInstance.ts +53 -0
- package/src/agentSessions/SessionJanitor.ts +267 -0
- package/src/agentSessions/SessionManager.ts +446 -0
- package/src/agentSessions/index.ts +27 -0
- package/src/auth/initializeProviders.ts +6 -1
- package/src/config.ts +38 -0
- package/src/context.ts +42 -7
- package/src/generated/generated.ts +3111 -567
- package/src/generic/ResolverBase.ts +1 -1
- package/src/generic/RunViewResolver.ts +9 -9
- package/src/index.ts +112 -42
- package/src/logging/StartupLogger.ts +317 -0
- package/src/logging/variablesLoggingMiddleware.ts +25 -5
- package/src/middleware/RateLimitMiddleware.ts +87 -0
- package/src/middleware/index.ts +1 -0
- package/src/resolvers/AgentSessionResolver.ts +138 -0
- package/src/resolvers/EntityPermissionResolver.ts +73 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +2162 -0
- package/src/resolvers/RemoteBrowserActionResolver.ts +907 -0
- package/src/rest/SignatureWebhookHandler.ts +3 -2
- package/src/services/ScheduledJobsService.ts +6 -5
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { BaseSingleton, ShutdownRegistry, IShutdownable } from '@memberjunction/global';
|
|
2
|
+
import {
|
|
3
|
+
IMetadataProvider,
|
|
4
|
+
UserInfo,
|
|
5
|
+
CompositeKey,
|
|
6
|
+
RunView,
|
|
7
|
+
RunViewParams,
|
|
8
|
+
LogError,
|
|
9
|
+
LogStatus,
|
|
10
|
+
} from '@memberjunction/core';
|
|
11
|
+
import { MJAIAgentSessionEntity } from '@memberjunction/core-entities';
|
|
12
|
+
import { GetHostInstanceID, GetHostNamePrefix } from './HostInstance.js';
|
|
13
|
+
import { SessionManager, SessionCloseReason } from './SessionManager.js';
|
|
14
|
+
|
|
15
|
+
/** Entity name for session rows (kept in sync with {@link SessionManager}). */
|
|
16
|
+
const SESSION_ENTITY = 'MJ: AI Agent Sessions';
|
|
17
|
+
|
|
18
|
+
/** Rows fetched per keyset page during a sweep. Small enough to bound memory on a large backlog. */
|
|
19
|
+
const SWEEP_PAGE_SIZE = 200;
|
|
20
|
+
|
|
21
|
+
/** Tuning knobs for the janitor. All durations in their named units. */
|
|
22
|
+
export interface SessionJanitorConfig {
|
|
23
|
+
/** A non-`Closed` session whose `LastActiveAt` is older than this is force-closed by the global sweep. */
|
|
24
|
+
closeThresholdMinutes: number;
|
|
25
|
+
/** How often the periodic staleness sweep runs once {@link SessionJanitor.Start} is called. */
|
|
26
|
+
sweepIntervalMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_CONFIG: SessionJanitorConfig = {
|
|
30
|
+
closeThresholdMinutes: 15,
|
|
31
|
+
sweepIntervalMs: 60_000,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `BaseSingleton` background reconciler that keeps the durable `AIAgentSession` state from drifting
|
|
36
|
+
* away from volatile process reality. A crash/redeploy vaporizes a host's in-memory sockets but
|
|
37
|
+
* leaves its session rows reading `Active`/`Idle` forever; the janitor force-closes those orphans.
|
|
38
|
+
*
|
|
39
|
+
* Two sweeps, both writing through {@link SessionManager.CloseSession} so Record Changes captures
|
|
40
|
+
* each transition and channel rows are disconnected consistently:
|
|
41
|
+
*
|
|
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).
|
|
48
|
+
*
|
|
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'`.
|
|
52
|
+
*
|
|
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
|
|
55
|
+
* (`AfterKey`) pagination per the deep-pagination guide so a large backlog can't blow up memory.
|
|
56
|
+
*/
|
|
57
|
+
export class SessionJanitor extends BaseSingleton<SessionJanitor> implements IShutdownable {
|
|
58
|
+
private _config: SessionJanitorConfig = DEFAULT_CONFIG;
|
|
59
|
+
private _sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
60
|
+
private _sweepRunning = false;
|
|
61
|
+
private _registered = false;
|
|
62
|
+
private _provider: IMetadataProvider | null = null;
|
|
63
|
+
private _systemUser: UserInfo | null = null;
|
|
64
|
+
private readonly sessionManager = new SessionManager();
|
|
65
|
+
|
|
66
|
+
protected constructor() {
|
|
67
|
+
super();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Process-wide singleton accessor (Global Object Store backed via {@link BaseSingleton}). */
|
|
71
|
+
public static get Instance(): SessionJanitor {
|
|
72
|
+
return super.getInstance<SessionJanitor>();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Identifier surfaced in graceful-shutdown logs. */
|
|
76
|
+
public readonly ShutdownName = 'SessionJanitor';
|
|
77
|
+
|
|
78
|
+
/** Overrides default tuning. Safe to call before or after {@link Start}. */
|
|
79
|
+
public Configure(config: Partial<SessionJanitorConfig>): void {
|
|
80
|
+
this._config = { ...this._config, ...config };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
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.
|
|
87
|
+
*/
|
|
88
|
+
public async Start(provider: IMetadataProvider, systemUser: UserInfo, intervalMs?: number): Promise<void> {
|
|
89
|
+
this._provider = provider;
|
|
90
|
+
this._systemUser = systemUser;
|
|
91
|
+
if (intervalMs != null) {
|
|
92
|
+
this._config = { ...this._config, sweepIntervalMs: intervalMs };
|
|
93
|
+
}
|
|
94
|
+
this.ensureRegistered();
|
|
95
|
+
await this.RunStartupRecovery(provider, systemUser);
|
|
96
|
+
this.scheduleSweep();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Stops the periodic sweep timer. Idempotent. Part of {@link IShutdownable}. */
|
|
100
|
+
public Stop(): void {
|
|
101
|
+
if (this._sweepTimer) {
|
|
102
|
+
clearInterval(this._sweepTimer);
|
|
103
|
+
this._sweepTimer = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
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.
|
|
110
|
+
* Drained sessions are stamped `CloseReason = 'Shutdown'` (vs. `'Janitor'` for crash orphans),
|
|
111
|
+
* so the dashboards can tell a clean redeploy from a reconciled crash. Never throws; the drain
|
|
112
|
+
* is skipped when {@link Start} was never called (no captured provider/user).
|
|
113
|
+
*/
|
|
114
|
+
public async Shutdown(): Promise<void> {
|
|
115
|
+
this.Stop();
|
|
116
|
+
if (this._provider && this._systemUser) {
|
|
117
|
+
try {
|
|
118
|
+
await this.RunShutdownDrain(this._provider, this._systemUser);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
LogError(`SessionJanitor shutdown drain failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Close every `Active`/`Idle` session owned by **this exact host instance** (current boot),
|
|
127
|
+
* stamping `CloseReason = 'Shutdown'`. Invoked from {@link Shutdown} during the graceful
|
|
128
|
+
* shutdown drain; exposed publicly for tests and for hosts that want to drain explicitly.
|
|
129
|
+
* Returns the number of sessions closed.
|
|
130
|
+
*/
|
|
131
|
+
public async RunShutdownDrain(provider: IMetadataProvider, systemUser: UserInfo): Promise<number> {
|
|
132
|
+
const current = GetHostInstanceID().replace(/'/g, "''");
|
|
133
|
+
const filter = `Status IN ('Active','Idle') AND HostInstanceID = '${current}'`;
|
|
134
|
+
const closed = await this.sweepAndClose(filter, provider, systemUser, 'Shutdown');
|
|
135
|
+
if (closed > 0) {
|
|
136
|
+
LogStatus(`[SessionJanitor] Shutdown drain closed ${closed} live session(s) owned by this host instance`);
|
|
137
|
+
}
|
|
138
|
+
return closed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
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.
|
|
144
|
+
*/
|
|
145
|
+
public async RunStartupRecovery(provider: IMetadataProvider, systemUser: UserInfo): Promise<number> {
|
|
146
|
+
const prefix = GetHostNamePrefix().replace(/'/g, "''");
|
|
147
|
+
const current = GetHostInstanceID().replace(/'/g, "''");
|
|
148
|
+
const filter =
|
|
149
|
+
`Status IN ('Active','Idle') ` +
|
|
150
|
+
`AND HostInstanceID LIKE '${prefix}%' ` +
|
|
151
|
+
`AND HostInstanceID <> '${current}'`;
|
|
152
|
+
const closed = await this.sweepAndClose(filter, provider, systemUser, 'Janitor');
|
|
153
|
+
if (closed > 0) {
|
|
154
|
+
LogStatus(`[SessionJanitor] Startup recovery closed ${closed} orphaned session(s) from a prior boot of this host`);
|
|
155
|
+
}
|
|
156
|
+
return closed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Force-close any `Active`/`Idle` session whose `LastActiveAt` is older than the configured
|
|
161
|
+
* close threshold, regardless of host. Idempotent and concurrency-safe. Returns the count closed.
|
|
162
|
+
*/
|
|
163
|
+
public async RunStalenessSweep(provider: IMetadataProvider, systemUser: UserInfo): Promise<number> {
|
|
164
|
+
const cutoffIso = new Date(Date.now() - this._config.closeThresholdMinutes * 60_000).toISOString();
|
|
165
|
+
const filter = `Status IN ('Active','Idle') AND LastActiveAt < '${cutoffIso}'`;
|
|
166
|
+
const closed = await this.sweepAndClose(filter, provider, systemUser, 'Janitor');
|
|
167
|
+
if (closed > 0) {
|
|
168
|
+
LogStatus(`[SessionJanitor] Staleness sweep closed ${closed} stale session(s) (>${this._config.closeThresholdMinutes}m idle)`);
|
|
169
|
+
}
|
|
170
|
+
return closed;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ----- internals -------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/** Register for graceful shutdown exactly once. */
|
|
176
|
+
private ensureRegistered(): void {
|
|
177
|
+
if (!this._registered) {
|
|
178
|
+
ShutdownRegistry.Instance.Register(this);
|
|
179
|
+
this._registered = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Schedule the periodic staleness sweep, exactly once. Timer is unref'd so it never blocks exit. */
|
|
184
|
+
private scheduleSweep(): void {
|
|
185
|
+
if (this._sweepTimer) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this._sweepTimer = setInterval(() => void this.periodicSweep(), this._config.sweepIntervalMs);
|
|
189
|
+
this._sweepTimer.unref?.();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Timer tick: run the staleness sweep under the captured provider/user, guarding overlap. */
|
|
193
|
+
private async periodicSweep(): Promise<void> {
|
|
194
|
+
if (this._sweepRunning || !this._provider || !this._systemUser) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this._sweepRunning = true;
|
|
198
|
+
try {
|
|
199
|
+
await this.RunStalenessSweep(this._provider, this._systemUser);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
LogError(`SessionJanitor periodic sweep failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
202
|
+
} finally {
|
|
203
|
+
this._sweepRunning = false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Page through every session matching `filter` with keyset (`AfterKey`) pagination and close each
|
|
209
|
+
* via {@link SessionManager.CloseSession}, stamping `closeReason` on every row it transitions.
|
|
210
|
+
* Returns the number successfully closed. A `Closed`-by-now row (raced by another instance) is a
|
|
211
|
+
* harmless no-op that keeps its original reason.
|
|
212
|
+
*/
|
|
213
|
+
private async sweepAndClose(
|
|
214
|
+
filter: string,
|
|
215
|
+
provider: IMetadataProvider,
|
|
216
|
+
systemUser: UserInfo,
|
|
217
|
+
closeReason: SessionCloseReason,
|
|
218
|
+
): Promise<number> {
|
|
219
|
+
let closedCount = 0;
|
|
220
|
+
let afterKey: CompositeKey | undefined;
|
|
221
|
+
|
|
222
|
+
// eslint-disable-next-line no-constant-condition
|
|
223
|
+
while (true) {
|
|
224
|
+
const page = await this.fetchPage(filter, afterKey, provider, systemUser);
|
|
225
|
+
if (page == null) {
|
|
226
|
+
break; // load failure already logged
|
|
227
|
+
}
|
|
228
|
+
if (page.length === 0) {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
for (const session of page) {
|
|
232
|
+
const closed = await this.sessionManager.CloseSession(session.ID, systemUser, provider, closeReason);
|
|
233
|
+
if (closed) {
|
|
234
|
+
closedCount++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (page.length < SWEEP_PAGE_SIZE) {
|
|
238
|
+
break; // partial page => end of data
|
|
239
|
+
}
|
|
240
|
+
afterKey = CompositeKey.FromID(page[page.length - 1].ID);
|
|
241
|
+
}
|
|
242
|
+
return closedCount;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Fetch one keyset page of matching sessions, or null on a load failure (logged). */
|
|
246
|
+
private async fetchPage(
|
|
247
|
+
filter: string,
|
|
248
|
+
afterKey: CompositeKey | undefined,
|
|
249
|
+
provider: IMetadataProvider,
|
|
250
|
+
systemUser: UserInfo,
|
|
251
|
+
): Promise<MJAIAgentSessionEntity[] | null> {
|
|
252
|
+
const params: RunViewParams = {
|
|
253
|
+
EntityName: SESSION_ENTITY,
|
|
254
|
+
ExtraFilter: filter,
|
|
255
|
+
AfterKey: afterKey,
|
|
256
|
+
MaxRows: SWEEP_PAGE_SIZE,
|
|
257
|
+
ResultType: 'entity_object',
|
|
258
|
+
};
|
|
259
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
260
|
+
const result = await rv.RunView<MJAIAgentSessionEntity>(params, systemUser);
|
|
261
|
+
if (!result.Success) {
|
|
262
|
+
LogError(`SessionJanitor sweep load failed: ${result.ErrorMessage}`);
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return result.Results;
|
|
266
|
+
}
|
|
267
|
+
}
|