@memberjunction/server 5.38.0 → 5.39.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/README.md +67 -0
- package/dist/apolloServer/index.d.ts +0 -8
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +61 -0
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/config.d.ts +68 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +24 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +2 -21
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +75 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +314 -1
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
- package/dist/generic/FireAndForgetHeartbeat.js +44 -0
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +120 -2
- package/dist/index.js.map +1 -1
- package/dist/logging/NoLog.d.ts +50 -0
- package/dist/logging/NoLog.d.ts.map +1 -0
- package/dist/logging/NoLog.js +80 -0
- package/dist/logging/NoLog.js.map +1 -0
- package/dist/logging/bootAudit.d.ts +43 -0
- package/dist/logging/bootAudit.d.ts.map +1 -0
- package/dist/logging/bootAudit.js +83 -0
- package/dist/logging/bootAudit.js.map +1 -0
- package/dist/logging/boundaryLogPayload.d.ts +18 -0
- package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
- package/dist/logging/boundaryLogPayload.js +18 -0
- package/dist/logging/boundaryLogPayload.js.map +1 -0
- package/dist/logging/secretRedactor.d.ts +23 -0
- package/dist/logging/secretRedactor.d.ts.map +1 -0
- package/dist/logging/secretRedactor.js +53 -0
- package/dist/logging/secretRedactor.js.map +1 -0
- package/dist/logging/shortenForLog.d.ts +8 -0
- package/dist/logging/shortenForLog.d.ts.map +1 -0
- package/dist/logging/shortenForLog.js +21 -0
- package/dist/logging/shortenForLog.js.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.js +127 -0
- package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +8 -4
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
- package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationProgressResolver.js +196 -0
- package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
- package/dist/resolvers/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +19 -14
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +26 -5
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +12 -2
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
- package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
- package/dist/resolvers/SearchEntitiesResolver.js +216 -0
- package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -1
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/dist/services/ScheduledJobsService.d.ts.map +1 -1
- package/dist/services/ScheduledJobsService.js +14 -2
- package/dist/services/ScheduledJobsService.js.map +1 -1
- package/package.json +75 -74
- package/src/__tests__/NoLog.test.ts +76 -0
- package/src/__tests__/bootAudit.test.ts +188 -0
- package/src/__tests__/boundaryLogPayload.test.ts +31 -0
- package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
- package/src/__tests__/secretRedactor.test.ts +163 -0
- package/src/__tests__/subscriptionRedaction.test.ts +217 -0
- package/src/apolloServer/index.ts +58 -0
- package/src/config.ts +27 -0
- package/src/context.ts +2 -19
- package/src/generated/generated.ts +239 -1
- package/src/generic/FireAndForgetHeartbeat.ts +85 -0
- package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
- package/src/index.ts +137 -2
- package/src/logging/NoLog.ts +88 -0
- package/src/logging/bootAudit.ts +117 -0
- package/src/logging/boundaryLogPayload.ts +19 -0
- package/src/logging/secretRedactor.ts +82 -0
- package/src/logging/shortenForLog.ts +17 -0
- package/src/logging/variablesLoggingMiddleware.ts +191 -0
- package/src/resolvers/GetDataResolver.ts +9 -5
- package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
- package/src/resolvers/IntegrationProgressResolver.ts +220 -0
- package/src/resolvers/MCPResolver.ts +18 -14
- package/src/resolvers/RunAIAgentResolver.ts +28 -5
- package/src/resolvers/RunTestResolver.ts +14 -2
- package/src/resolvers/SearchEntitiesResolver.ts +173 -0
- package/src/resolvers/VectorizeEntityResolver.ts +14 -1
- package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
- package/src/services/ScheduledJobsService.ts +15 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IntegrationProgressResolver — GraphQL subscription surface for long-running
|
|
3
|
+
* integration events (plan.md §11).
|
|
4
|
+
*
|
|
5
|
+
* Exposes a SINGLE @Subscription topic carrying structured progress notifications
|
|
6
|
+
* for every kind of integration run — metadata refresh, connector creation, sync,
|
|
7
|
+
* and RSU — so a UI can stream live updates instead of polling
|
|
8
|
+
* IntegrationTailRunEvents. Filtering by `runID`, `companyIntegrationID`, and `kind`
|
|
9
|
+
* happens in the subscription filter, exactly like PipelineProgressResolver.
|
|
10
|
+
*
|
|
11
|
+
* Wiring through the MJ event system: server-side producers (the
|
|
12
|
+
* IntegrationProgressEmitter, the connector-creation pipeline, the sync engine,
|
|
13
|
+
* the RSU pipeline) call the exported {@link PublishIntegrationProgress} helper,
|
|
14
|
+
* which forwards onto the same `PubSubManager` PubSub infrastructure that backs
|
|
15
|
+
* PipelineProgress and cache-invalidation events — NOT a parallel mechanism.
|
|
16
|
+
*/
|
|
17
|
+
import { Resolver, Subscription, Root, ObjectType, Field, Int, Arg } from 'type-graphql';
|
|
18
|
+
import { LogError } from '@memberjunction/core';
|
|
19
|
+
import type {
|
|
20
|
+
IntegrationRunKind,
|
|
21
|
+
IntegrationProgressEventType,
|
|
22
|
+
IntegrationProgressLevel,
|
|
23
|
+
} from '@memberjunction/integration-progress-artifacts';
|
|
24
|
+
import { PubSubManager } from '../generic/PubSubManager.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The single PubSub topic all integration progress notifications ride. Mirrors
|
|
28
|
+
* PIPELINE_PROGRESS_TOPIC — one topic, filtered per-subscriber by run/connector/kind.
|
|
29
|
+
*/
|
|
30
|
+
export const INTEGRATION_PROGRESS_TOPIC = 'INTEGRATION_PROGRESS';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Logical channels (the "topic enum" of plan.md §11) a subscriber can scope to.
|
|
34
|
+
* These map 1:1 onto a subset of {@link IntegrationRunKind} so a UI can subscribe
|
|
35
|
+
* to, e.g., only sync progress without parsing every notification. Expressed as a
|
|
36
|
+
* string union (per the union-types-over-enums convention) and surfaced over
|
|
37
|
+
* GraphQL as a plain String argument/field.
|
|
38
|
+
*/
|
|
39
|
+
export type IntegrationProgressTopic =
|
|
40
|
+
| 'MetadataRefresh'
|
|
41
|
+
| 'ConnectorCreation'
|
|
42
|
+
| 'Sync'
|
|
43
|
+
| 'RSU';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Maps an {@link IntegrationRunKind} (the vocabulary the emitter writes) onto the
|
|
47
|
+
* coarser {@link IntegrationProgressTopic} channel a subscriber filters by. Kinds
|
|
48
|
+
* without a dedicated channel resolve to `undefined` and only match unscoped
|
|
49
|
+
* subscriptions.
|
|
50
|
+
*/
|
|
51
|
+
const RUN_KIND_TO_TOPIC: Readonly<Record<IntegrationRunKind, IntegrationProgressTopic | undefined>> = {
|
|
52
|
+
Discovery: 'MetadataRefresh',
|
|
53
|
+
Enrichment: 'MetadataRefresh',
|
|
54
|
+
ConnectorCreation: 'ConnectorCreation',
|
|
55
|
+
SyncRun: 'Sync',
|
|
56
|
+
RSU: 'RSU',
|
|
57
|
+
TableCreation: 'RSU',
|
|
58
|
+
Other: undefined,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Resolve the logical channel for a run kind (helper kept tiny + testable). */
|
|
62
|
+
export function IntegrationProgressTopicForKind(kind: IntegrationRunKind): IntegrationProgressTopic | undefined {
|
|
63
|
+
return RUN_KIND_TO_TOPIC[kind];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A single integration progress notification pushed to the subscription topic.
|
|
68
|
+
* Carries the structured shape plan.md §11 asks for — the subsystem-specific
|
|
69
|
+
* `data` payload is serialized into `DataJSON` so the GraphQL field stays a scalar.
|
|
70
|
+
*/
|
|
71
|
+
@ObjectType()
|
|
72
|
+
export class IntegrationProgressNotification {
|
|
73
|
+
/** The run this event belongs to (matches manifest.runID). */
|
|
74
|
+
@Field(() => String)
|
|
75
|
+
RunID: string;
|
|
76
|
+
|
|
77
|
+
/** The connector (CompanyIntegration) the run is for, when known. */
|
|
78
|
+
@Field(() => String, { nullable: true })
|
|
79
|
+
CompanyIntegrationID?: string;
|
|
80
|
+
|
|
81
|
+
/** What kind of run produced this event (IntegrationRunKind, as a string). */
|
|
82
|
+
@Field(() => String)
|
|
83
|
+
Kind: string;
|
|
84
|
+
|
|
85
|
+
/** The logical channel this notification belongs to (IntegrationProgressTopic). */
|
|
86
|
+
@Field(() => String, { nullable: true })
|
|
87
|
+
Topic?: string;
|
|
88
|
+
|
|
89
|
+
/** The progress event type (IntegrationProgressEventType, as a string). */
|
|
90
|
+
@Field(() => String)
|
|
91
|
+
EventType: string;
|
|
92
|
+
|
|
93
|
+
/** Monotonic sequence number within the run. */
|
|
94
|
+
@Field(() => Int)
|
|
95
|
+
Seq: number;
|
|
96
|
+
|
|
97
|
+
/** Free-form human message (optional). */
|
|
98
|
+
@Field(() => String, { nullable: true })
|
|
99
|
+
Message?: string;
|
|
100
|
+
|
|
101
|
+
/** Stage name within the run (optional). */
|
|
102
|
+
@Field(() => String, { nullable: true })
|
|
103
|
+
Stage?: string;
|
|
104
|
+
|
|
105
|
+
/** Severity level (info/warn/error/debug). */
|
|
106
|
+
@Field(() => String, { nullable: true })
|
|
107
|
+
Level?: string;
|
|
108
|
+
|
|
109
|
+
/** Subsystem-specific structured payload, JSON-serialized. */
|
|
110
|
+
@Field(() => String, { nullable: true })
|
|
111
|
+
DataJSON?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Arguments the emitter/pipeline pass to {@link PublishIntegrationProgress}. The
|
|
116
|
+
* helper derives `Topic` and serializes `Data`, so callers pass the raw event shape.
|
|
117
|
+
*/
|
|
118
|
+
export interface PublishIntegrationProgressArgs {
|
|
119
|
+
RunID: string;
|
|
120
|
+
Kind: IntegrationRunKind;
|
|
121
|
+
EventType: IntegrationProgressEventType;
|
|
122
|
+
Seq: number;
|
|
123
|
+
CompanyIntegrationID?: string;
|
|
124
|
+
Message?: string;
|
|
125
|
+
Stage?: string;
|
|
126
|
+
Level?: IntegrationProgressLevel;
|
|
127
|
+
Data?: Record<string, unknown>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Predicate used by the @Subscription filter — exported so it is unit-testable. */
|
|
131
|
+
export function MatchesIntegrationSubscription(
|
|
132
|
+
payload: IntegrationProgressNotification,
|
|
133
|
+
args: { runID?: string; companyIntegrationID?: string; topic?: string }
|
|
134
|
+
): boolean {
|
|
135
|
+
if (args.runID && payload.RunID !== args.runID) return false;
|
|
136
|
+
if (args.companyIntegrationID && payload.CompanyIntegrationID !== args.companyIntegrationID) return false;
|
|
137
|
+
if (args.topic && payload.Topic !== args.topic) return false;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build the notification payload from publish args. Pure + side-effect free so the
|
|
143
|
+
* shape (including JSON serialization + topic derivation) can be asserted in tests
|
|
144
|
+
* without touching PubSub.
|
|
145
|
+
*/
|
|
146
|
+
export function BuildIntegrationProgressNotification(
|
|
147
|
+
args: PublishIntegrationProgressArgs
|
|
148
|
+
): IntegrationProgressNotification {
|
|
149
|
+
return {
|
|
150
|
+
RunID: args.RunID,
|
|
151
|
+
CompanyIntegrationID: args.CompanyIntegrationID,
|
|
152
|
+
Kind: args.Kind,
|
|
153
|
+
Topic: IntegrationProgressTopicForKind(args.Kind),
|
|
154
|
+
EventType: args.EventType,
|
|
155
|
+
Seq: args.Seq,
|
|
156
|
+
Message: args.Message,
|
|
157
|
+
Stage: args.Stage,
|
|
158
|
+
Level: args.Level,
|
|
159
|
+
DataJSON: serializeData(args.Data),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** JSON-serialize the subsystem payload, swallowing cyclic/serialization failures. */
|
|
164
|
+
function serializeData(data?: Record<string, unknown>): string | undefined {
|
|
165
|
+
if (data == null) return undefined;
|
|
166
|
+
try {
|
|
167
|
+
return JSON.stringify(data);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
LogError(`IntegrationProgressResolver: failed to serialize data payload: ${e}`);
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Publish an integration progress event onto the subscription topic. This is the
|
|
176
|
+
* entry point server-side producers (IntegrationProgressEmitter, pipelines, sync
|
|
177
|
+
* engine, RSU) call to push live updates to subscribed clients. Uses the shared
|
|
178
|
+
* PubSubManager — the same infrastructure as PipelineProgress and cache events.
|
|
179
|
+
*
|
|
180
|
+
* Returns the published notification (handy for tests / logging); returns
|
|
181
|
+
* undefined and logs if publishing fails (never throws into the producer path).
|
|
182
|
+
*/
|
|
183
|
+
export function PublishIntegrationProgress(
|
|
184
|
+
args: PublishIntegrationProgressArgs
|
|
185
|
+
): IntegrationProgressNotification | undefined {
|
|
186
|
+
try {
|
|
187
|
+
const notification = BuildIntegrationProgressNotification(args);
|
|
188
|
+
PubSubManager.Instance.Publish(INTEGRATION_PROGRESS_TOPIC, { ...notification });
|
|
189
|
+
return notification;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
LogError(`PublishIntegrationProgress failed: ${e}`);
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@Resolver()
|
|
197
|
+
export class IntegrationProgressResolver {
|
|
198
|
+
/**
|
|
199
|
+
* Subscribe to integration progress notifications. With no arguments the
|
|
200
|
+
* subscriber receives every integration event on this server; pass any of
|
|
201
|
+
* `runID`, `companyIntegrationID`, or `topic` to scope the stream.
|
|
202
|
+
*/
|
|
203
|
+
@Subscription(() => IntegrationProgressNotification, {
|
|
204
|
+
topics: INTEGRATION_PROGRESS_TOPIC,
|
|
205
|
+
filter: ({ payload, args }: {
|
|
206
|
+
payload: IntegrationProgressNotification;
|
|
207
|
+
args: { runID?: string; companyIntegrationID?: string; topic?: string };
|
|
208
|
+
}) => MatchesIntegrationSubscription(payload, args),
|
|
209
|
+
})
|
|
210
|
+
IntegrationProgress(
|
|
211
|
+
@Root() notification: IntegrationProgressNotification,
|
|
212
|
+
@Arg('runID', () => String, { nullable: true }) _runID?: string,
|
|
213
|
+
@Arg('companyIntegrationID', () => String, { nullable: true }) _companyIntegrationID?: string,
|
|
214
|
+
@Arg('topic', () => String, { nullable: true }) _topic?: string
|
|
215
|
+
): IntegrationProgressNotification {
|
|
216
|
+
return notification;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export default IntegrationProgressResolver;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Resolver, Mutation, Query, Subscription, Arg, Ctx, Root, Field, Int, ObjectType, InputType, PubSub, registerEnumType } from 'type-graphql';
|
|
9
9
|
import { PubSubEngine } from 'type-graphql';
|
|
10
|
-
import { LogError, LogStatus, UserInfo, Metadata, RunView } from '@memberjunction/core';
|
|
10
|
+
import { LogError, LogStatus, LogStatusEx, UserInfo, Metadata, RunView } from '@memberjunction/core';
|
|
11
11
|
import {
|
|
12
12
|
MCPClientManager,
|
|
13
13
|
MCPSyncToolsResult,
|
|
@@ -705,25 +705,25 @@ export class MCPResolver extends ResolverBase {
|
|
|
705
705
|
|
|
706
706
|
try {
|
|
707
707
|
// Check API key scope authorization
|
|
708
|
-
|
|
708
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 1 - Checking API key authorization...`, verboseOnly: true });
|
|
709
709
|
await this.CheckAPIKeyScopeAuthorization('mcp:execute', ConnectionID, ctx.userPayload);
|
|
710
|
-
|
|
710
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 1 complete - Authorization passed (${Date.now() - startTime}ms)`, verboseOnly: true });
|
|
711
711
|
|
|
712
712
|
// Get the MCP client manager instance and ensure it's initialized
|
|
713
|
-
|
|
713
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 2 - Initializing MCP client manager...`, verboseOnly: true });
|
|
714
714
|
const manager = MCPClientManager.Instance;
|
|
715
715
|
const publicUrl = this.getPublicUrl();
|
|
716
716
|
await manager.initialize(user, { publicUrl });
|
|
717
|
-
|
|
717
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 2 complete - Manager initialized (${Date.now() - startTime}ms)`, verboseOnly: true });
|
|
718
718
|
|
|
719
719
|
// Connect if not already connected
|
|
720
720
|
const isConnected = manager.isConnected(ConnectionID);
|
|
721
|
-
|
|
721
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 3 - Connection status: ${isConnected ? 'already connected' : 'needs connection'}`, verboseOnly: true });
|
|
722
722
|
if (!isConnected) {
|
|
723
723
|
LogStatus(`MCPResolver: [${ToolName}] Connecting to MCP server for connection ${ConnectionID}...`);
|
|
724
724
|
try {
|
|
725
725
|
await manager.connect(ConnectionID, { contextUser: user });
|
|
726
|
-
|
|
726
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 3 complete - Connected (${Date.now() - startTime}ms)`, verboseOnly: true });
|
|
727
727
|
} catch (connectError) {
|
|
728
728
|
// Check for OAuth authorization required
|
|
729
729
|
if (connectError instanceof OAuthAuthorizationRequiredError) {
|
|
@@ -763,12 +763,16 @@ export class MCPResolver extends ResolverBase {
|
|
|
763
763
|
}
|
|
764
764
|
|
|
765
765
|
// Parse input arguments
|
|
766
|
-
|
|
766
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 4 - Parsing input arguments...`, verboseOnly: true });
|
|
767
767
|
let parsedArgs: Record<string, unknown> = {};
|
|
768
768
|
if (InputArgs) {
|
|
769
769
|
try {
|
|
770
770
|
parsedArgs = JSON.parse(InputArgs);
|
|
771
|
-
|
|
771
|
+
if (configInfo.loggingSettings.graphql.logVariables) {
|
|
772
|
+
LogStatus(`MCPResolver: [${ToolName}] Parsed args: ${JSON.stringify(parsedArgs)}`);
|
|
773
|
+
} else {
|
|
774
|
+
LogStatus(`MCPResolver: [${ToolName}] tool invoked`);
|
|
775
|
+
}
|
|
772
776
|
} catch (parseError) {
|
|
773
777
|
LogError(`MCPResolver: [${ToolName}] Failed to parse InputArgs: ${parseError}`);
|
|
774
778
|
return {
|
|
@@ -777,18 +781,18 @@ export class MCPResolver extends ResolverBase {
|
|
|
777
781
|
};
|
|
778
782
|
}
|
|
779
783
|
}
|
|
780
|
-
|
|
784
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 4 complete - Args parsed (${Date.now() - startTime}ms)`, verboseOnly: true });
|
|
781
785
|
|
|
782
786
|
// Call the tool
|
|
783
|
-
|
|
784
|
-
|
|
787
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 5 - Calling tool on connection ${ConnectionID}...`, verboseOnly: true });
|
|
788
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Tool ID: ${ToolID}`, verboseOnly: true });
|
|
785
789
|
const result: MCPToolCallResult = await manager.callTool(
|
|
786
790
|
ConnectionID,
|
|
787
791
|
ToolName,
|
|
788
792
|
{ arguments: parsedArgs },
|
|
789
793
|
{ contextUser: user }
|
|
790
794
|
);
|
|
791
|
-
|
|
795
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 5 complete - Tool call returned (${Date.now() - startTime}ms)`, verboseOnly: true });
|
|
792
796
|
|
|
793
797
|
// Format the result for the response - wrap in object for GraphQLJSONObject
|
|
794
798
|
let formattedResult: Record<string, unknown> | null = null;
|
|
@@ -827,7 +831,7 @@ export class MCPResolver extends ResolverBase {
|
|
|
827
831
|
formattedResult = result.structuredContent as Record<string, unknown>;
|
|
828
832
|
}
|
|
829
833
|
|
|
830
|
-
|
|
834
|
+
LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 6 complete - Result formatted (${Date.now() - startTime}ms)`, verboseOnly: true });
|
|
831
835
|
LogStatus(`MCPResolver: [${ToolName}] Tool execution complete - Success: ${result.success}, Duration: ${result.durationMs}ms, Total time: ${Date.now() - startTime}ms`);
|
|
832
836
|
|
|
833
837
|
return {
|
|
@@ -9,6 +9,7 @@ import { AIEngine } from '@memberjunction/aiengine';
|
|
|
9
9
|
import { ChatMessage, ChatMessageContent } from '@memberjunction/ai';
|
|
10
10
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
11
11
|
import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
|
|
12
|
+
import { startLivenessPulse } from '../generic/FireAndForgetHeartbeat.js';
|
|
12
13
|
import { RequireSystemUser } from '../directives/RequireSystemUser.js';
|
|
13
14
|
import { GetReadWriteProvider } from '../util.js';
|
|
14
15
|
import { SafeJSONParse, UUIDsEqual } from '@memberjunction/global';
|
|
@@ -242,6 +243,13 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
242
243
|
*/
|
|
243
244
|
private createProgressCallback(pubSub: PubSubEngine, sessionId: string, userPayload: UserPayload, agentRunRef: { current: any }) {
|
|
244
245
|
return (progress: any) => {
|
|
246
|
+
// Capture the agent run into the ref as soon as any progress event carries it (even
|
|
247
|
+
// "noise" steps), so the fire-and-forget liveness pulse can read its id/status mid-run
|
|
248
|
+
// rather than only after RunAgentInConversation returns.
|
|
249
|
+
if (progress.metadata?.agentRun) {
|
|
250
|
+
agentRunRef.current = progress.metadata.agentRun;
|
|
251
|
+
}
|
|
252
|
+
|
|
245
253
|
// Only publish progress for significant steps (not initialization noise)
|
|
246
254
|
const significantSteps = ['prompt_execution', 'action_execution', 'subagent_execution', 'decision_processing'];
|
|
247
255
|
if (!significantSteps.includes(progress.step)) {
|
|
@@ -373,7 +381,10 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
373
381
|
sourceArtifactId?: string,
|
|
374
382
|
sourceArtifactVersionId?: string,
|
|
375
383
|
/** LATENCY OPT #2: Pre-resolved conversationId avoids redundant DB load in AgentRunner */
|
|
376
|
-
conversationId?: string
|
|
384
|
+
conversationId?: string,
|
|
385
|
+
/** Optional external ref the caller can read to observe the agent run as it becomes available
|
|
386
|
+
* (used by the fire-and-forget liveness pulse to enrich heartbeats with the run id/status). */
|
|
387
|
+
runRef?: { current: MJAIAgentRunEntityExtended | null }
|
|
377
388
|
): Promise<AIAgentRunResult> {
|
|
378
389
|
const startTime = Date.now();
|
|
379
390
|
|
|
@@ -407,8 +418,9 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
407
418
|
// singleton's transaction state with concurrent requests (e.g. conversation deletes).
|
|
408
419
|
const agentRunner = new AgentRunner(p);
|
|
409
420
|
|
|
410
|
-
// Track agent run for streaming (use ref to update later)
|
|
411
|
-
|
|
421
|
+
// Track agent run for streaming (use ref to update later). Reuse the caller-supplied
|
|
422
|
+
// ref when provided so the fire-and-forget liveness pulse can observe the run.
|
|
423
|
+
const agentRunRef = runRef ?? { current: null as any };
|
|
412
424
|
|
|
413
425
|
console.log(`🚀 Starting agent execution with sessionId: ${sessionId}`);
|
|
414
426
|
|
|
@@ -1225,12 +1237,23 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
1225
1237
|
/** LATENCY OPT #2: Pre-resolved conversationId avoids redundant DB load in AgentRunner */
|
|
1226
1238
|
conversationId?: string
|
|
1227
1239
|
): void {
|
|
1240
|
+
// Ref the liveness pulse reads to enrich heartbeats once the run is created.
|
|
1241
|
+
const runRef: { current: MJAIAgentRunEntityExtended | null } = { current: null };
|
|
1242
|
+
const pulse = startLivenessPulse({
|
|
1243
|
+
pubSub,
|
|
1244
|
+
sessionId,
|
|
1245
|
+
resolver: 'RunAIAgentResolver',
|
|
1246
|
+
readStatus: () => runRef.current
|
|
1247
|
+
? { runId: runRef.current.ID, status: runRef.current.Status }
|
|
1248
|
+
: undefined,
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1228
1251
|
// Execute in background - errors are handled within, not propagated
|
|
1229
1252
|
this.executeAIAgent(
|
|
1230
1253
|
p, dataSource, agentId, userPayload, messagesJson, sessionId, pubSub,
|
|
1231
1254
|
data, payload, undefined, lastRunId, autoPopulateLastRunPayload,
|
|
1232
1255
|
configurationId, conversationDetailId, createArtifacts, createNotification,
|
|
1233
|
-
sourceArtifactId, sourceArtifactVersionId, conversationId
|
|
1256
|
+
sourceArtifactId, sourceArtifactVersionId, conversationId, runRef
|
|
1234
1257
|
).catch((error: unknown) => {
|
|
1235
1258
|
// Background execution failed unexpectedly (executeAIAgent has its own try-catch,
|
|
1236
1259
|
// so this would only fire for truly unexpected errors).
|
|
@@ -1249,7 +1272,7 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
1249
1272
|
result: JSON.stringify({ success: false, errorMessage })
|
|
1250
1273
|
};
|
|
1251
1274
|
this.PublishStreamingUpdate(pubSub, errorCompletionData, userPayload);
|
|
1252
|
-
});
|
|
1275
|
+
}).finally(() => pulse.stop());
|
|
1253
1276
|
}
|
|
1254
1277
|
|
|
1255
1278
|
/**
|
|
@@ -16,6 +16,7 @@ import { LogError, LogStatus, UserInfo } from '@memberjunction/core';
|
|
|
16
16
|
import { TestEngine } from '@memberjunction/testing-engine';
|
|
17
17
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
18
18
|
import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
|
|
19
|
+
import { startLivenessPulse } from '../generic/FireAndForgetHeartbeat.js';
|
|
19
20
|
import { TestRunVariables, TestLogMessage, TestRunResult as EngineTestRunResult } from '@memberjunction/testing-engine-base';
|
|
20
21
|
|
|
21
22
|
// ===== GraphQL Types =====
|
|
@@ -254,12 +255,17 @@ export class RunTestResolver extends ResolverBase {
|
|
|
254
255
|
userPayload: UserPayload,
|
|
255
256
|
user: UserInfo
|
|
256
257
|
): void {
|
|
258
|
+
// Keep-alive pulse so a long-running test never trips the client idle timeout.
|
|
259
|
+
// The client captures the testRunId from progress events for reconciliation.
|
|
260
|
+
const pulse = startLivenessPulse({ pubSub, sessionId: userPayload.sessionId, resolver: 'RunTestResolver' });
|
|
261
|
+
|
|
257
262
|
this.executeTest(testId, verbose, environment, tags, variables, pubSub, userPayload, user)
|
|
258
263
|
.catch((error: unknown) => {
|
|
259
264
|
const errorMessage = (error instanceof Error) ? error.message : 'Unknown background test execution error';
|
|
260
265
|
LogError(`🔥 Fire-and-forget test execution failed: ${errorMessage}`, undefined, error);
|
|
261
266
|
this.publishFireAndForgetError(pubSub, userPayload, testId, errorMessage);
|
|
262
|
-
})
|
|
267
|
+
})
|
|
268
|
+
.finally(() => pulse.stop());
|
|
263
269
|
}
|
|
264
270
|
|
|
265
271
|
/**
|
|
@@ -343,6 +349,12 @@ export class RunTestResolver extends ResolverBase {
|
|
|
343
349
|
userPayload: UserPayload,
|
|
344
350
|
user: UserInfo
|
|
345
351
|
): void {
|
|
352
|
+
// Keep-alive pulse so a long-running suite never trips the client idle timeout.
|
|
353
|
+
// (Suite reconciliation isn't wired: suite progress carries the per-test run id, not the
|
|
354
|
+
// Test Suite Run id, so the client has no handle to reconcile against — the pulse is the
|
|
355
|
+
// protection here.)
|
|
356
|
+
const pulse = startLivenessPulse({ pubSub, sessionId: userPayload.sessionId, resolver: 'RunTestResolver' });
|
|
357
|
+
|
|
346
358
|
this.executeSuite(
|
|
347
359
|
suiteId, verbose, environment, parallel, tags, variables,
|
|
348
360
|
selectedTestIds, sequenceStart, sequenceEnd, pubSub, userPayload, user
|
|
@@ -350,7 +362,7 @@ export class RunTestResolver extends ResolverBase {
|
|
|
350
362
|
const errorMessage = (error instanceof Error) ? error.message : 'Unknown background suite execution error';
|
|
351
363
|
LogError(`🔥 Fire-and-forget suite execution failed: ${errorMessage}`, undefined, error);
|
|
352
364
|
this.publishFireAndForgetSuiteError(pubSub, userPayload, suiteId, errorMessage);
|
|
353
|
-
});
|
|
365
|
+
}).finally(() => pulse.stop());
|
|
354
366
|
}
|
|
355
367
|
|
|
356
368
|
// ===== Result Building =====
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Arg, Ctx, Field, Float, InputType, Int, ObjectType, Query, Resolver } from 'type-graphql';
|
|
2
|
+
import { EntitySearchResult, SearchEntityParams } from '@memberjunction/core';
|
|
3
|
+
import { AppContext } from '../types.js';
|
|
4
|
+
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
5
|
+
import { GetReadOnlyProvider } from '../util.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GraphQL surface for {@link Metadata.Provider.SearchEntity} / `SearchEntities`.
|
|
9
|
+
*
|
|
10
|
+
* Both client forms ({@link GraphQLDataProvider.SearchEntity} and `SearchEntities`)
|
|
11
|
+
* proxy through this single batched resolver — one HTTP round-trip carries N
|
|
12
|
+
* per-entity searches in both directions. The actual ranking (lexical +
|
|
13
|
+
* semantic + RRF blend + permission filter) runs server-side via
|
|
14
|
+
* `GenericDatabaseProvider.SearchEntity`, fanned out concurrently across
|
|
15
|
+
* the input list.
|
|
16
|
+
*
|
|
17
|
+
* Result groups are aligned by input order so the client can map them back
|
|
18
|
+
* to the original `params[i]` slot without needing the entity name.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
@InputType()
|
|
22
|
+
export class SearchEntityInput {
|
|
23
|
+
@Field(() => String)
|
|
24
|
+
declare EntityName: string;
|
|
25
|
+
|
|
26
|
+
@Field(() => String)
|
|
27
|
+
declare SearchText: string;
|
|
28
|
+
|
|
29
|
+
@Field(() => String, { nullable: true })
|
|
30
|
+
Mode?: 'lexical' | 'semantic' | 'hybrid';
|
|
31
|
+
|
|
32
|
+
@Field(() => Int, { nullable: true })
|
|
33
|
+
RrfK?: number;
|
|
34
|
+
|
|
35
|
+
@Field(() => Float, { nullable: true })
|
|
36
|
+
LexicalWeight?: number;
|
|
37
|
+
|
|
38
|
+
@Field(() => Float, { nullable: true })
|
|
39
|
+
SemanticWeight?: number;
|
|
40
|
+
|
|
41
|
+
@Field(() => Int, { nullable: true })
|
|
42
|
+
TopK?: number;
|
|
43
|
+
|
|
44
|
+
@Field(() => Float, { nullable: true })
|
|
45
|
+
MinScore?: number;
|
|
46
|
+
|
|
47
|
+
@Field(() => String, { nullable: true })
|
|
48
|
+
EntityDocumentID?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@ObjectType()
|
|
52
|
+
export class EntitySearchResultType {
|
|
53
|
+
@Field(() => String, { nullable: true })
|
|
54
|
+
declare EntityRecordDocumentID: string | null;
|
|
55
|
+
|
|
56
|
+
@Field(() => String)
|
|
57
|
+
declare RecordID: string;
|
|
58
|
+
|
|
59
|
+
@Field(() => Float)
|
|
60
|
+
declare Score: number;
|
|
61
|
+
|
|
62
|
+
@Field(() => String)
|
|
63
|
+
declare MatchType: 'lexical' | 'semantic' | 'hybrid';
|
|
64
|
+
|
|
65
|
+
@Field(() => Float, { nullable: true })
|
|
66
|
+
LexicalScore?: number | null;
|
|
67
|
+
|
|
68
|
+
@Field(() => Float, { nullable: true })
|
|
69
|
+
SemanticScore?: number | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@ObjectType()
|
|
73
|
+
export class EntitySearchResultGroupType {
|
|
74
|
+
@Field(() => String)
|
|
75
|
+
declare EntityName: string;
|
|
76
|
+
|
|
77
|
+
@Field(() => [EntitySearchResultType])
|
|
78
|
+
declare Results: EntitySearchResultType[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@ObjectType()
|
|
82
|
+
export class SearchEntitiesResponseType {
|
|
83
|
+
@Field(() => Boolean)
|
|
84
|
+
declare Success: boolean;
|
|
85
|
+
|
|
86
|
+
@Field(() => String, { nullable: true })
|
|
87
|
+
ErrorMessage?: string;
|
|
88
|
+
|
|
89
|
+
@Field(() => [EntitySearchResultGroupType])
|
|
90
|
+
declare Groups: EntitySearchResultGroupType[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Per-request cap on how many entities a single `SearchEntities` call may
|
|
95
|
+
* search. Each entity in the batch triggers 2–4 RunViews server-side; an
|
|
96
|
+
* uncapped batch from a malicious or buggy client could fan out into
|
|
97
|
+
* thousands of concurrent DB queries. 20 covers every realistic use case
|
|
98
|
+
* (agent prompt seeding, multi-entity navbar search) with margin to spare.
|
|
99
|
+
*/
|
|
100
|
+
const MAX_SEARCH_ENTITIES_BATCH_SIZE = 20;
|
|
101
|
+
|
|
102
|
+
@Resolver(SearchEntitiesResponseType)
|
|
103
|
+
export class SearchEntitiesResolver {
|
|
104
|
+
@Query(() => SearchEntitiesResponseType)
|
|
105
|
+
async SearchEntities(
|
|
106
|
+
@Ctx() { providers, userPayload }: AppContext,
|
|
107
|
+
@Arg('params', () => [SearchEntityInput]) params: SearchEntityInput[]
|
|
108
|
+
): Promise<SearchEntitiesResponseType> {
|
|
109
|
+
try {
|
|
110
|
+
if (params.length > MAX_SEARCH_ENTITIES_BATCH_SIZE) {
|
|
111
|
+
return {
|
|
112
|
+
Success: false,
|
|
113
|
+
ErrorMessage: `Batch size ${params.length} exceeds the per-request cap of ${MAX_SEARCH_ENTITIES_BATCH_SIZE}. Split the request.`,
|
|
114
|
+
Groups: params.map(p => ({ EntityName: p.EntityName, Results: [] })),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const md = GetReadOnlyProvider(providers);
|
|
118
|
+
const user = UserCache.Instance.Users.find(
|
|
119
|
+
(u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase()
|
|
120
|
+
);
|
|
121
|
+
if (!user) {
|
|
122
|
+
return {
|
|
123
|
+
Success: false,
|
|
124
|
+
ErrorMessage: `User ${userPayload.email} not found`,
|
|
125
|
+
Groups: params.map(p => ({ EntityName: p.EntityName, Results: [] })),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const callParams: SearchEntityParams[] = params.map(p => ({
|
|
130
|
+
entityName: p.EntityName,
|
|
131
|
+
searchText: p.SearchText,
|
|
132
|
+
options: {
|
|
133
|
+
mode: p.Mode ?? 'hybrid',
|
|
134
|
+
rrfK: p.RrfK ?? undefined,
|
|
135
|
+
weights: {
|
|
136
|
+
lexical: p.LexicalWeight ?? undefined,
|
|
137
|
+
semantic: p.SemanticWeight ?? undefined,
|
|
138
|
+
},
|
|
139
|
+
topK: p.TopK ?? undefined,
|
|
140
|
+
minScore: p.MinScore ?? undefined,
|
|
141
|
+
entityDocumentId: p.EntityDocumentID ?? undefined,
|
|
142
|
+
contextUser: user,
|
|
143
|
+
},
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
// Server-side provider fans the batch out via Promise.all under the
|
|
147
|
+
// hood — see ProviderBase.SearchEntities. Results arrive aligned by
|
|
148
|
+
// input order; we just map each group's records to the GraphQL shape.
|
|
149
|
+
const groupedResults: EntitySearchResult[][] = await md.SearchEntities(callParams);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
Success: true,
|
|
153
|
+
Groups: groupedResults.map((results, i) => ({
|
|
154
|
+
EntityName: params[i].EntityName,
|
|
155
|
+
Results: results.map(r => ({
|
|
156
|
+
EntityRecordDocumentID: r.entityRecordDocumentId,
|
|
157
|
+
RecordID: r.recordId,
|
|
158
|
+
Score: r.score,
|
|
159
|
+
MatchType: r.matchType,
|
|
160
|
+
LexicalScore: r.components.lexical ?? null,
|
|
161
|
+
SemanticScore: r.components.semantic ?? null,
|
|
162
|
+
})),
|
|
163
|
+
})),
|
|
164
|
+
};
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return {
|
|
167
|
+
Success: false,
|
|
168
|
+
ErrorMessage: e instanceof Error ? e.message : String(e),
|
|
169
|
+
Groups: params.map(p => ({ EntityName: p.EntityName, Results: [] })),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -90,7 +90,13 @@ export class VectorizeEntityResolver extends ResolverBase {
|
|
|
90
90
|
};
|
|
91
91
|
|
|
92
92
|
const result = await syncer.VectorizeEntity(params, currentUser);
|
|
93
|
-
|
|
93
|
+
if (result.success) {
|
|
94
|
+
LogStatus(`VectorizeEntity pipeline ${pipelineRunID} complete: success=true`);
|
|
95
|
+
} else {
|
|
96
|
+
// The run finished but some/all records failed to render or upsert. Surface it
|
|
97
|
+
// loudly instead of logging a bare "success=false" that reads like a clean finish.
|
|
98
|
+
LogError(`VectorizeEntity pipeline ${pipelineRunID} completed with errors (status=${result.status}): ${result.errorMessage}`);
|
|
99
|
+
}
|
|
94
100
|
} catch (error) {
|
|
95
101
|
const msg = error instanceof Error ? error.message : String(error);
|
|
96
102
|
LogError(`VectorizeEntity pipeline ${pipelineRunID} failed: ${msg}`);
|
|
@@ -110,11 +116,18 @@ export class VectorizeEntityResolver extends ResolverBase {
|
|
|
110
116
|
* Publish a progress update to the PipelineProgress subscription topic.
|
|
111
117
|
*/
|
|
112
118
|
private publishProgress(pipelineRunID: string, update: VectorizeProgressUpdate): void {
|
|
119
|
+
// Carry a short error summary to the client via CurrentItem when the update reports failures,
|
|
120
|
+
// so the subscription reflects that the run didn't cleanly succeed.
|
|
121
|
+
const errorSummary = update.Errors && update.Errors.length > 0
|
|
122
|
+
? `${update.Errors.length} record(s) failed — e.g. ${update.Errors[0].Message}`
|
|
123
|
+
: undefined;
|
|
124
|
+
|
|
113
125
|
const notification: PipelineProgressNotification = {
|
|
114
126
|
PipelineRunID: pipelineRunID,
|
|
115
127
|
Stage: update.Stage,
|
|
116
128
|
TotalItems: update.TotalRecords,
|
|
117
129
|
ProcessedItems: update.ProcessedRecords,
|
|
130
|
+
CurrentItem: errorSummary,
|
|
118
131
|
ElapsedMs: update.ElapsedMs,
|
|
119
132
|
PercentComplete: update.PercentComplete,
|
|
120
133
|
};
|