@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.3
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/LICENSE +180 -4
- package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.js +69 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
- package/dist/auth/AuthProviderEngine.d.ts +96 -0
- package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
- package/dist/auth/AuthProviderEngine.js +245 -0
- package/dist/auth/AuthProviderEngine.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +56 -13
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/initializeProviders.d.ts +64 -1
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +117 -5
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +89 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +52 -35
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +501 -1597
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2711 -17021
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +7 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +31 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +99 -14
- package/dist/index.js.map +1 -1
- package/dist/integration/RSUProgressBridge.d.ts +83 -0
- package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
- package/dist/integration/RSUProgressBridge.js +230 -0
- package/dist/integration/RSUProgressBridge.js.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts +52 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.js +50 -0
- package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
- package/dist/providerConfigUnits.d.ts +20 -0
- package/dist/providerConfigUnits.d.ts.map +1 -0
- package/dist/providerConfigUnits.js +22 -0
- package/dist/providerConfigUnits.js.map +1 -0
- package/dist/resolvers/FileResolver.d.ts +34 -4
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +218 -28
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +76 -3
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +299 -81
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +5 -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/QuerySystemUserResolver.d.ts +12 -3
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +33 -14
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +20 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/UserResolver.d.ts +19 -17
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +66 -67
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/resolvers/currentUserRoles.d.ts +19 -0
- package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
- package/dist/resolvers/currentUserRoles.js +36 -0
- package/dist/resolvers/currentUserRoles.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +17 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
- package/dist/rest/MediaAccessKeys.js +42 -1
- package/dist/rest/MediaAccessKeys.js.map +1 -1
- package/dist/rest/MediaStreamHandler.d.ts +3 -2
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
- package/dist/rest/MediaStreamHandler.js +83 -2
- package/dist/rest/MediaStreamHandler.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +129 -43
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/rest/UploadTokenManager.d.ts +90 -0
- package/dist/rest/UploadTokenManager.d.ts.map +1 -0
- package/dist/rest/UploadTokenManager.js +156 -0
- package/dist/rest/UploadTokenManager.js.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.js +132 -0
- package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
- package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
- package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +21 -1
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +23 -0
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +91 -91
- package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
- package/src/__tests__/AuthProviderEngine.test.ts +278 -0
- package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
- package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
- package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
- package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
- package/src/__tests__/RSUProgressBridge.test.ts +250 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
- package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
- package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
- package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
- package/src/__tests__/config-units.test.ts +74 -0
- package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
- package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
- package/src/__tests__/newUsers.test.ts +726 -0
- package/src/__tests__/uploadTokenManager.test.ts +159 -0
- package/src/auth/AuthProviderCatalogRouter.ts +76 -0
- package/src/auth/AuthProviderEngine.ts +253 -0
- package/src/auth/index.ts +59 -15
- package/src/auth/initializeProviders.ts +128 -7
- package/src/config.ts +55 -36
- package/src/generated/generated.ts +1911 -10551
- package/src/generic/ResolverBase.ts +11 -4
- package/src/generic/RunViewResolver.ts +31 -0
- package/src/index.ts +108 -15
- package/src/integration/RSUProgressBridge.ts +248 -0
- package/src/integration/SchemaRefreshLaunch.ts +77 -0
- package/src/providerConfigUnits.ts +21 -0
- package/src/resolvers/FileResolver.ts +200 -40
- package/src/resolvers/IntegrationDiscoveryResolver.ts +316 -68
- package/src/resolvers/QueryResolver.ts +25 -11
- package/src/resolvers/QuerySystemUserResolver.ts +28 -13
- package/src/resolvers/RunAIAgentResolver.ts +21 -6
- package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
- package/src/resolvers/UserResolver.ts +99 -60
- package/src/resolvers/currentUserRoles.ts +47 -0
- package/src/rest/MediaAccessKeys.ts +47 -1
- package/src/rest/MediaStreamHandler.ts +98 -3
- package/src/rest/OAuthCallbackHandler.ts +149 -41
- package/src/rest/UploadTokenManager.ts +211 -0
- package/src/services/IntegrationSyncWorkerService.ts +141 -0
- package/src/services/StartTaskGraphDispatcher.ts +21 -1
- package/src/services/TaskGraphAgentRunner.ts +23 -0
- package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
- package/src/types.ts +6 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Ephemeral in-memory staged upload token manager.
|
|
3
|
+
*
|
|
4
|
+
* Provides a secure, single-use, time-to-live (TTL) bounded staging area for raw
|
|
5
|
+
* binary upload buffers received via REST streaming before being committed through
|
|
6
|
+
* the GraphQL `UploadStorageFile` pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Security properties:
|
|
9
|
+
* - Single-use nonce: Token and buffer are atomically destroyed on first consumption.
|
|
10
|
+
* - User binding: Tokens are bound to the authenticated User ID that uploaded them.
|
|
11
|
+
* - Auto-eviction: Abandoned tokens are automatically evicted from RAM after the TTL.
|
|
12
|
+
* - Memory caps: Enforces per-file max size (default 100MB) and total pool capacity (default 500MB).
|
|
13
|
+
*
|
|
14
|
+
* @module @memberjunction/server/rest/UploadTokenManager
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
|
|
18
|
+
import { LogError } from '@memberjunction/core';
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An ephemeral in-memory staged upload record.
|
|
23
|
+
*/
|
|
24
|
+
export interface StagedUploadEntry {
|
|
25
|
+
token: string;
|
|
26
|
+
buffer: Buffer;
|
|
27
|
+
fileName: string;
|
|
28
|
+
mimeType: string;
|
|
29
|
+
contentLength: number;
|
|
30
|
+
userId: string;
|
|
31
|
+
createdAt: number;
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
timer: NodeJS.Timeout;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ephemeral In-Memory Staged Upload Token Manager.
|
|
38
|
+
*
|
|
39
|
+
* Architecture & Deployment Topology Note:
|
|
40
|
+
* This implementation maintains staged buffers in-process RAM with a 5-minute sliding TTL.
|
|
41
|
+
* In a clustered/multi-replica deployment behind a load balancer, sticky sessions (or routing
|
|
42
|
+
* the subsequent GraphQL `UploadStorageFile` commit to the same node) are recommended.
|
|
43
|
+
* For shared-memory multi-node clustering, an external ephemeral store (such as Redis)
|
|
44
|
+
* can be plugged in behind this interface.
|
|
45
|
+
*/
|
|
46
|
+
export class UploadTokenManager extends BaseSingleton<UploadTokenManager> {
|
|
47
|
+
private readonly _staged = new Map<string, StagedUploadEntry>();
|
|
48
|
+
private _totalMemoryBytes = 0;
|
|
49
|
+
|
|
50
|
+
public defaultTtlSeconds = 300; // 5 minutes
|
|
51
|
+
public maxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
|
|
52
|
+
public maxUserMemoryBytes = 150 * 1024 * 1024; // 150 MB per user
|
|
53
|
+
public maxPoolMemoryBytes = 500 * 1024 * 1024; // 500 MB
|
|
54
|
+
|
|
55
|
+
// Public constructor required by BaseSingleton
|
|
56
|
+
public constructor() {
|
|
57
|
+
super();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public static get Instance(): UploadTokenManager {
|
|
61
|
+
return UploadTokenManager.getInstance<UploadTokenManager>();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Calculates total active staged bytes currently held for a given user.
|
|
66
|
+
*/
|
|
67
|
+
public GetUserMemoryBytes(userId: string): number {
|
|
68
|
+
let total = 0;
|
|
69
|
+
for (const entry of this._staged.values()) {
|
|
70
|
+
if (UUIDsEqual(entry.userId, userId)) {
|
|
71
|
+
total += entry.contentLength;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return total;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Stages a raw binary buffer in RAM, returning a single-use cryptographic token.
|
|
79
|
+
*
|
|
80
|
+
* @throws Error if buffer exceeds single file limit, per-user quota, or global pool memory capacity.
|
|
81
|
+
*/
|
|
82
|
+
public Stage(params: {
|
|
83
|
+
buffer: Buffer;
|
|
84
|
+
fileName: string;
|
|
85
|
+
mimeType: string;
|
|
86
|
+
userId: string;
|
|
87
|
+
ttlSeconds?: number;
|
|
88
|
+
}): string {
|
|
89
|
+
const size = params.buffer.length;
|
|
90
|
+
|
|
91
|
+
if (size > this.maxFileSizeBytes) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Staged upload exceeds maximum allowed file size of ${Math.round(this.maxFileSizeBytes / (1024 * 1024))}MB (received ${Math.round(size / (1024 * 1024))}MB).`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const userCurrentBytes = this.GetUserMemoryBytes(params.userId);
|
|
98
|
+
if (userCurrentBytes + size > this.maxUserMemoryBytes) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Per-user upload memory quota reached (${Math.round(userCurrentBytes / (1024 * 1024))}MB / ${Math.round(this.maxUserMemoryBytes / (1024 * 1024))}MB used). Please wait for active uploads to finalize.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (this._totalMemoryBytes + size > this.maxPoolMemoryBytes) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Staged upload memory capacity reached (${Math.round(this._totalMemoryBytes / (1024 * 1024))}MB / ${Math.round(this.maxPoolMemoryBytes / (1024 * 1024))}MB used). Please try again shortly.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const token = 'upt_' + randomBytes(32).toString('hex');
|
|
111
|
+
const ttl = params.ttlSeconds || this.defaultTtlSeconds;
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const expiresAt = now + ttl * 1000;
|
|
114
|
+
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
this.Evict(token);
|
|
117
|
+
}, ttl * 1000);
|
|
118
|
+
|
|
119
|
+
// Unref timer so it doesn't hold open process shutdown in tests or CLI
|
|
120
|
+
if (typeof timer.unref === 'function') {
|
|
121
|
+
timer.unref();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const entry: StagedUploadEntry = {
|
|
125
|
+
token,
|
|
126
|
+
buffer: params.buffer,
|
|
127
|
+
fileName: params.fileName,
|
|
128
|
+
mimeType: params.mimeType,
|
|
129
|
+
contentLength: size,
|
|
130
|
+
userId: params.userId,
|
|
131
|
+
createdAt: now,
|
|
132
|
+
expiresAt,
|
|
133
|
+
timer,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
this._staged.set(token, entry);
|
|
137
|
+
this._totalMemoryBytes += size;
|
|
138
|
+
|
|
139
|
+
return token;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Atomically consumes and evicts a staged upload token.
|
|
144
|
+
*
|
|
145
|
+
* @param token The staged upload token.
|
|
146
|
+
* @param claimingUserId The user ID claiming this token. If provided, must match the user who uploaded it.
|
|
147
|
+
* @returns The staged buffer and metadata, or null if the token is invalid, expired, or claimed by the wrong user.
|
|
148
|
+
*/
|
|
149
|
+
public Consume(
|
|
150
|
+
token: string,
|
|
151
|
+
claimingUserId?: string
|
|
152
|
+
): { buffer: Buffer; fileName: string; mimeType: string; contentLength: number } | null {
|
|
153
|
+
if (!token || !this._staged.has(token)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const entry = this._staged.get(token)!;
|
|
158
|
+
|
|
159
|
+
// Security check: verify user ownership using UUIDsEqual for robust ID normalization
|
|
160
|
+
if (claimingUserId && entry.userId && !UUIDsEqual(entry.userId, claimingUserId)) {
|
|
161
|
+
LogError(`[UploadTokenManager] Security violation: User '${claimingUserId}' attempted to claim upload token owned by User '${entry.userId}'.`);
|
|
162
|
+
// Evict immediately to prevent further probing
|
|
163
|
+
this.Evict(token);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Atomic consumption & memory reclamation
|
|
168
|
+
this.Evict(token);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
buffer: entry.buffer,
|
|
172
|
+
fileName: entry.fileName,
|
|
173
|
+
mimeType: entry.mimeType,
|
|
174
|
+
contentLength: entry.contentLength,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Evicts a token from RAM and reclaims memory.
|
|
180
|
+
*/
|
|
181
|
+
public Evict(token: string): boolean {
|
|
182
|
+
const entry = this._staged.get(token);
|
|
183
|
+
if (!entry) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
clearTimeout(entry.timer);
|
|
188
|
+
this._staged.delete(token);
|
|
189
|
+
this._totalMemoryBytes = Math.max(0, this._totalMemoryBytes - entry.contentLength);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Total RAM in bytes currently consumed by all active staged uploads. */
|
|
194
|
+
public get TotalMemoryBytes(): number {
|
|
195
|
+
return this._totalMemoryBytes;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Count of currently active staged uploads. */
|
|
199
|
+
public get ActiveCount(): number {
|
|
200
|
+
return this._staged.size;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Clears all active staged uploads. */
|
|
204
|
+
public Clear(): void {
|
|
205
|
+
for (const [, entry] of this._staged) {
|
|
206
|
+
clearTimeout(entry.timer);
|
|
207
|
+
}
|
|
208
|
+
this._staged.clear();
|
|
209
|
+
this._totalMemoryBytes = 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Worker-mode poll loop for durable integration sync runs (PR 1 item 8).
|
|
3
|
+
* @module MJServer/services
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { LogError, LogStatus, UserInfo } from '@memberjunction/core';
|
|
7
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
8
|
+
import { IntegrationEngine } from '@memberjunction/integration-engine';
|
|
9
|
+
import { IntegrationSyncWorkerConfig } from '../config.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Polls `CompanyIntegrationRun` for `Status='Queued'` rows, claims each one atomically,
|
|
13
|
+
* executes it, and releases — the enqueue/execute split that lets a sync outlive the
|
|
14
|
+
* request that asked for it.
|
|
15
|
+
*
|
|
16
|
+
* Multiple workers (in this process or others) may poll the same queue: the claim sproc
|
|
17
|
+
* admits exactly one, and every loser simply moves on to the next candidate. Nothing here
|
|
18
|
+
* assumes it is the only worker.
|
|
19
|
+
*/
|
|
20
|
+
export class IntegrationSyncWorkerService {
|
|
21
|
+
private config: IntegrationSyncWorkerConfig;
|
|
22
|
+
private systemUser: UserInfo | null = null;
|
|
23
|
+
private timer: NodeJS.Timeout | null = null;
|
|
24
|
+
private isRunning = false;
|
|
25
|
+
private polling = false;
|
|
26
|
+
/** Run IDs currently executing in THIS process — bounds concurrency and prevents self-collision. */
|
|
27
|
+
private inFlight = new Set<string>();
|
|
28
|
+
|
|
29
|
+
constructor(config: IntegrationSyncWorkerConfig) {
|
|
30
|
+
this.config = config;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public get IsEnabled(): boolean {
|
|
34
|
+
return this.config.enabled;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public get IsRunning(): boolean {
|
|
38
|
+
return this.isRunning;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Number of queued runs this worker is currently executing. */
|
|
42
|
+
public get InFlightCount(): number {
|
|
43
|
+
return this.inFlight.size;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve the system user the worker executes syncs as. */
|
|
47
|
+
public async Initialize(): Promise<void> {
|
|
48
|
+
if (!this.config.enabled) return;
|
|
49
|
+
|
|
50
|
+
const email = this.config.systemUserEmail;
|
|
51
|
+
this.systemUser = UserCache.Users.find(u => u.Email?.toLowerCase() === email.toLowerCase()) ?? null;
|
|
52
|
+
if (!this.systemUser) {
|
|
53
|
+
throw new Error(`[IntegrationSyncWorker] System user not found with email: ${email}`);
|
|
54
|
+
}
|
|
55
|
+
await IntegrationEngine.Instance.Config(false, this.systemUser);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Begin polling the queue. */
|
|
59
|
+
public Start(): void {
|
|
60
|
+
if (!this.config.enabled || this.isRunning) return;
|
|
61
|
+
if (!this.systemUser) {
|
|
62
|
+
throw new Error('[IntegrationSyncWorker] Not initialized — call Initialize() first');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.isRunning = true;
|
|
66
|
+
this.timer = setInterval(() => void this.pollOnce(), this.config.pollingIntervalMs);
|
|
67
|
+
// Standard level, NOT verboseOnly: this states a deployment-shaping fact — that this process
|
|
68
|
+
// executes queued syncs — and it's one line per process lifetime. Stop() already logs at
|
|
69
|
+
// standard level, so hiding Start() produced logs showing a worker stopping that never
|
|
70
|
+
// started, and no way to confirm from logs that a deployment is in worker mode at all.
|
|
71
|
+
LogStatus(
|
|
72
|
+
`🔄 Integration Sync Worker: polling every ${Math.round(this.config.pollingIntervalMs / 1000)}s, ` +
|
|
73
|
+
`up to ${this.config.maxConcurrentRuns} concurrent run(s)`
|
|
74
|
+
);
|
|
75
|
+
// Don't wait a full interval to drain a queue that already has work.
|
|
76
|
+
void this.pollOnce();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Stop polling. In-flight runs are left to finish on their own — their leases are
|
|
81
|
+
* renewed by the engine's heartbeat, so killing them here would only strand rows.
|
|
82
|
+
*/
|
|
83
|
+
public Stop(): void {
|
|
84
|
+
if (!this.isRunning) return;
|
|
85
|
+
if (this.timer) {
|
|
86
|
+
clearInterval(this.timer);
|
|
87
|
+
this.timer = null;
|
|
88
|
+
}
|
|
89
|
+
this.isRunning = false;
|
|
90
|
+
LogStatus(`[IntegrationSyncWorker] Stopped polling (${this.inFlight.size} run(s) still in flight)`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One poll pass: fetch claimable candidates up to the free concurrency slots and start
|
|
95
|
+
* each. Overlapping passes are suppressed so a slow query can't stack up timers.
|
|
96
|
+
*/
|
|
97
|
+
private async pollOnce(): Promise<void> {
|
|
98
|
+
if (this.polling || !this.isRunning || !this.systemUser) return;
|
|
99
|
+
|
|
100
|
+
const slots = this.config.maxConcurrentRuns - this.inFlight.size;
|
|
101
|
+
if (slots <= 0) return;
|
|
102
|
+
|
|
103
|
+
this.polling = true;
|
|
104
|
+
try {
|
|
105
|
+
const candidates = await IntegrationEngine.PollQueuedRuns(this.systemUser, slots);
|
|
106
|
+
for (const candidate of candidates) {
|
|
107
|
+
if (this.inFlight.has(candidate.ID)) continue;
|
|
108
|
+
this.startRun(candidate.ID);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
LogError(`[IntegrationSyncWorker] Poll failed`, undefined, err);
|
|
112
|
+
} finally {
|
|
113
|
+
this.polling = false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Execute one queued run in the background. Losing the claim race is normal — the run
|
|
119
|
+
* is simply someone else's — so it is logged as information, never as an error.
|
|
120
|
+
*/
|
|
121
|
+
private startRun(runID: string): void {
|
|
122
|
+
if (!this.systemUser) return;
|
|
123
|
+
|
|
124
|
+
this.inFlight.add(runID);
|
|
125
|
+
void IntegrationEngine.Instance
|
|
126
|
+
.ExecuteQueuedRun(runID, this.systemUser)
|
|
127
|
+
.then(result => {
|
|
128
|
+
if (result.Success) {
|
|
129
|
+
LogStatus(`[IntegrationSyncWorker] Run ${runID} completed: ${result.RecordsProcessed} record(s) processed`);
|
|
130
|
+
} else {
|
|
131
|
+
LogStatus(`[IntegrationSyncWorker] Run ${runID} did not complete: ${result.ErrorMessage ?? 'unknown reason'}`);
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
.catch(err => {
|
|
135
|
+
LogError(`[IntegrationSyncWorker] Run ${runID} threw`, undefined, err);
|
|
136
|
+
})
|
|
137
|
+
.finally(() => {
|
|
138
|
+
this.inFlight.delete(runID);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -24,6 +24,7 @@ import { DurableEntityActionRegistry } from '@memberjunction/actions-base';
|
|
|
24
24
|
import { CreateTaskGraphProviderFactory } from './TaskGraphProviderFactory.js';
|
|
25
25
|
import { TaskGraphActionRunner } from './TaskGraphActionRunner.js';
|
|
26
26
|
import { TaskGraphAgentRunner } from './TaskGraphAgentRunner.js';
|
|
27
|
+
import { randomBytes } from 'node:crypto';
|
|
27
28
|
import { DurableEntityActionTaskSubmitter } from './DurableEntityActionTaskSubmitter.js';
|
|
28
29
|
import { TaskGraphContinuationDeliverer } from './TaskGraphContinuationDeliverer.js';
|
|
29
30
|
import { PubSubManager } from '../generic/PubSubManager.js';
|
|
@@ -39,6 +40,19 @@ import { TaskGraphFrameBroadcaster } from '../resolvers/TaskGraphFrameResolver.j
|
|
|
39
40
|
*
|
|
40
41
|
* The dispatcher self-registers with `ShutdownRegistry`, so the caller does not wire teardown.
|
|
41
42
|
*/
|
|
43
|
+
/**
|
|
44
|
+
* A per-process identity for the claim protocol.
|
|
45
|
+
*
|
|
46
|
+
* Host and pid are kept because they are what a human reads in a log; the random suffix is what
|
|
47
|
+
* makes the value actually unique. Generated once per call, so a restart is a new instance — which
|
|
48
|
+
* is correct: the previous process's claims should expire rather than be inherited.
|
|
49
|
+
*/
|
|
50
|
+
function defaultInstanceID(): string {
|
|
51
|
+
const host = process.env.HOSTNAME ?? 'mjapi';
|
|
52
|
+
const entropy = randomBytes(4).toString('hex');
|
|
53
|
+
return `${host}-${process.pid}-${entropy}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
export async function StartTaskGraphDispatcher(
|
|
43
57
|
pool: sql.ConnectionPool,
|
|
44
58
|
contextUser: UserInfo,
|
|
@@ -52,7 +66,13 @@ export async function StartTaskGraphDispatcher(
|
|
|
52
66
|
providerFactory,
|
|
53
67
|
new TaskGraphAgentRunner(),
|
|
54
68
|
contextUser,
|
|
55
|
-
|
|
69
|
+
// A RANDOM SUFFIX, because host+pid is not unique in the deployments this runs in (C2).
|
|
70
|
+
// `HOSTNAME` is not exported to child processes under systemd or pm2, and a containerised
|
|
71
|
+
// process is routinely pid 1 — so two hosts default to the same `mjapi-1`. A shared instance
|
|
72
|
+
// id defeats every `ClaimedBy=@me` guard at once: A's heartbeat renews B's lease, and A's
|
|
73
|
+
// stale terminal write lands over B's live execution. An explicit `instanceID` still wins,
|
|
74
|
+
// for hosts that have a real identity to supply.
|
|
75
|
+
{ InstanceID: instanceID ?? defaultInstanceID() },
|
|
56
76
|
// Without this the dispatcher had nowhere to deliver: a finished graph logged its outcome,
|
|
57
77
|
// marked itself delivered, and said nothing to the conversation that asked for it.
|
|
58
78
|
new TaskGraphContinuationDeliverer(providerFactory, contextUser),
|
|
@@ -16,6 +16,7 @@ import { AgentRunner } from '@memberjunction/ai-agents';
|
|
|
16
16
|
import { ChatMessageRole } from '@memberjunction/ai';
|
|
17
17
|
import { LogError } from '@memberjunction/core';
|
|
18
18
|
import { MJAIAgentEntityExtended, MJAIAgentRunEntityExtended } from '@memberjunction/ai-core-plus';
|
|
19
|
+
import type { AgentExecutionProgressCallback } from '@memberjunction/ai-core-plus';
|
|
19
20
|
import type { TaskAgentRunner, TaskAgentRunParams, TaskAgentRunResult } from '@memberjunction/task-graph';
|
|
20
21
|
|
|
21
22
|
export class TaskGraphAgentRunner implements TaskAgentRunner {
|
|
@@ -34,6 +35,10 @@ export class TaskGraphAgentRunner implements TaskAgentRunner {
|
|
|
34
35
|
// rather than starting the chain over. Without it MAX_REINVOKE_DEPTH never fires.
|
|
35
36
|
continuationDepth: params.ContinuationDepth ?? 0,
|
|
36
37
|
parentRun: await this.loadSubmittingRun(params),
|
|
38
|
+
// The bridge from the agent's own progress stream up to graph-level observers: the
|
|
39
|
+
// dispatcher turns these into rate-limited `NodeProgress` frames, which is how a
|
|
40
|
+
// running Agent step shows "what it is doing right now" on the workflow canvas.
|
|
41
|
+
onProgress: this.bridgeProgress(params),
|
|
37
42
|
});
|
|
38
43
|
|
|
39
44
|
const success = result?.success === true;
|
|
@@ -50,6 +55,24 @@ export class TaskGraphAgentRunner implements TaskAgentRunner {
|
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Adapts the agent framework's progress callback onto the dispatcher's plain sink.
|
|
60
|
+
*
|
|
61
|
+
* Undefined when nobody is listening, so the agent run pays nothing for observability it is not
|
|
62
|
+
* providing. Never throws — a progress announcement must not be able to fail the step.
|
|
63
|
+
*/
|
|
64
|
+
private bridgeProgress(params: TaskAgentRunParams): AgentExecutionProgressCallback | undefined {
|
|
65
|
+
const sink = params.OnProgress;
|
|
66
|
+
if (!sink) return undefined;
|
|
67
|
+
return (progress) => {
|
|
68
|
+
try {
|
|
69
|
+
sink(progress.message, progress.percentage);
|
|
70
|
+
} catch {
|
|
71
|
+
// The dispatcher's sink already guards itself; this is belt over braces.
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
53
76
|
/**
|
|
54
77
|
* The run that submitted this graph, as the entity `RunAgent` wants for `parentRun`.
|
|
55
78
|
*
|
|
@@ -135,7 +135,17 @@ export class TaskGraphContinuationDeliverer implements TaskContinuationDeliverer
|
|
|
135
135
|
continuationDepth: params.ReinvokeDepth + 1,
|
|
136
136
|
});
|
|
137
137
|
} catch (e) {
|
|
138
|
-
|
|
138
|
+
// FALL BACK, don't just log (C3). Both load-failure paths above already post instead of
|
|
139
|
+
// reinvoking, on the principle that a completed workflow's outcome must reach the user
|
|
140
|
+
// somehow — but a throw out of `RunAgent` skipped that and lost the outcome entirely.
|
|
141
|
+
// The marker has been claimed by the time this runs, so nothing will look at the graph
|
|
142
|
+
// again: the work finished, and the only record of it is a server log line.
|
|
143
|
+
LogError(`[TaskGraphContinuationDeliverer] Reinvoking for "${params.WorkflowName}" threw — posting instead`, undefined, e);
|
|
144
|
+
try {
|
|
145
|
+
await this.PostMessage(params);
|
|
146
|
+
} catch (fallbackError) {
|
|
147
|
+
LogError(`[TaskGraphContinuationDeliverer] The fallback post for "${params.WorkflowName}" also failed`, undefined, fallbackError);
|
|
148
|
+
}
|
|
139
149
|
}
|
|
140
150
|
}
|
|
141
151
|
|
package/src/types.ts
CHANGED
|
@@ -107,6 +107,12 @@ export type RunViewGenericParams = {
|
|
|
107
107
|
* happens. Propagated to `RunViewParams.BypassCache`.
|
|
108
108
|
*/
|
|
109
109
|
bypassCache?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Optional source-of-truth selector for entities that have a base-view materialization.
|
|
112
|
+
* 'Materialized' routes the read to the entity's materialized wrapper view; defaults to 'Live'.
|
|
113
|
+
* Propagated to `RunViewParams.DataSource`.
|
|
114
|
+
*/
|
|
115
|
+
dataSource?: 'Live' | 'Materialized';
|
|
110
116
|
};
|
|
111
117
|
|
|
112
118
|
|