@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.
Files changed (108) hide show
  1. package/README.md +67 -0
  2. package/dist/apolloServer/index.d.ts +0 -8
  3. package/dist/apolloServer/index.d.ts.map +1 -1
  4. package/dist/apolloServer/index.js +61 -0
  5. package/dist/apolloServer/index.js.map +1 -1
  6. package/dist/config.d.ts +68 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +24 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/context.js +2 -21
  12. package/dist/context.js.map +1 -1
  13. package/dist/generated/generated.d.ts +75 -0
  14. package/dist/generated/generated.d.ts.map +1 -1
  15. package/dist/generated/generated.js +314 -1
  16. package/dist/generated/generated.js.map +1 -1
  17. package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
  18. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
  19. package/dist/generic/FireAndForgetHeartbeat.js +44 -0
  20. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +120 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/logging/NoLog.d.ts +50 -0
  26. package/dist/logging/NoLog.d.ts.map +1 -0
  27. package/dist/logging/NoLog.js +80 -0
  28. package/dist/logging/NoLog.js.map +1 -0
  29. package/dist/logging/bootAudit.d.ts +43 -0
  30. package/dist/logging/bootAudit.d.ts.map +1 -0
  31. package/dist/logging/bootAudit.js +83 -0
  32. package/dist/logging/bootAudit.js.map +1 -0
  33. package/dist/logging/boundaryLogPayload.d.ts +18 -0
  34. package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
  35. package/dist/logging/boundaryLogPayload.js +18 -0
  36. package/dist/logging/boundaryLogPayload.js.map +1 -0
  37. package/dist/logging/secretRedactor.d.ts +23 -0
  38. package/dist/logging/secretRedactor.d.ts.map +1 -0
  39. package/dist/logging/secretRedactor.js +53 -0
  40. package/dist/logging/secretRedactor.js.map +1 -0
  41. package/dist/logging/shortenForLog.d.ts +8 -0
  42. package/dist/logging/shortenForLog.d.ts.map +1 -0
  43. package/dist/logging/shortenForLog.js +21 -0
  44. package/dist/logging/shortenForLog.js.map +1 -0
  45. package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
  46. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
  47. package/dist/logging/variablesLoggingMiddleware.js +127 -0
  48. package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
  49. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  50. package/dist/resolvers/GetDataResolver.js +8 -4
  51. package/dist/resolvers/GetDataResolver.js.map +1 -1
  52. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
  53. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  54. package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
  55. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  56. package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
  57. package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
  58. package/dist/resolvers/IntegrationProgressResolver.js +196 -0
  59. package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
  60. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  61. package/dist/resolvers/MCPResolver.js +19 -14
  62. package/dist/resolvers/MCPResolver.js.map +1 -1
  63. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  64. package/dist/resolvers/RunAIAgentResolver.js +26 -5
  65. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  66. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  67. package/dist/resolvers/RunTestResolver.js +12 -2
  68. package/dist/resolvers/RunTestResolver.js.map +1 -1
  69. package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
  70. package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
  71. package/dist/resolvers/SearchEntitiesResolver.js +216 -0
  72. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
  73. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  74. package/dist/resolvers/VectorizeEntityResolver.js +14 -1
  75. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  76. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  77. package/dist/services/ScheduledJobsService.js +14 -2
  78. package/dist/services/ScheduledJobsService.js.map +1 -1
  79. package/package.json +75 -74
  80. package/src/__tests__/NoLog.test.ts +76 -0
  81. package/src/__tests__/bootAudit.test.ts +188 -0
  82. package/src/__tests__/boundaryLogPayload.test.ts +31 -0
  83. package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
  84. package/src/__tests__/secretRedactor.test.ts +163 -0
  85. package/src/__tests__/subscriptionRedaction.test.ts +217 -0
  86. package/src/apolloServer/index.ts +58 -0
  87. package/src/config.ts +27 -0
  88. package/src/context.ts +2 -19
  89. package/src/generated/generated.ts +239 -1
  90. package/src/generic/FireAndForgetHeartbeat.ts +85 -0
  91. package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
  92. package/src/index.ts +137 -2
  93. package/src/logging/NoLog.ts +88 -0
  94. package/src/logging/bootAudit.ts +117 -0
  95. package/src/logging/boundaryLogPayload.ts +19 -0
  96. package/src/logging/secretRedactor.ts +82 -0
  97. package/src/logging/shortenForLog.ts +17 -0
  98. package/src/logging/variablesLoggingMiddleware.ts +191 -0
  99. package/src/resolvers/GetDataResolver.ts +9 -5
  100. package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
  101. package/src/resolvers/IntegrationProgressResolver.ts +220 -0
  102. package/src/resolvers/MCPResolver.ts +18 -14
  103. package/src/resolvers/RunAIAgentResolver.ts +28 -5
  104. package/src/resolvers/RunTestResolver.ts +14 -2
  105. package/src/resolvers/SearchEntitiesResolver.ts +173 -0
  106. package/src/resolvers/VectorizeEntityResolver.ts +14 -1
  107. package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
  108. 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
- LogStatus(`MCPResolver: [${ToolName}] Step 1 - Checking API key authorization...`);
708
+ LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 1 - Checking API key authorization...`, verboseOnly: true });
709
709
  await this.CheckAPIKeyScopeAuthorization('mcp:execute', ConnectionID, ctx.userPayload);
710
- LogStatus(`MCPResolver: [${ToolName}] Step 1 complete - Authorization passed (${Date.now() - startTime}ms)`);
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
- LogStatus(`MCPResolver: [${ToolName}] Step 2 - Initializing MCP client manager...`);
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
- LogStatus(`MCPResolver: [${ToolName}] Step 2 complete - Manager initialized (${Date.now() - startTime}ms)`);
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
- LogStatus(`MCPResolver: [${ToolName}] Step 3 - Connection status: ${isConnected ? 'already connected' : 'needs connection'}`);
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
- LogStatus(`MCPResolver: [${ToolName}] Step 3 complete - Connected (${Date.now() - startTime}ms)`);
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
- LogStatus(`MCPResolver: [${ToolName}] Step 4 - Parsing input arguments...`);
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
- LogStatus(`MCPResolver: [${ToolName}] Parsed args: ${JSON.stringify(parsedArgs).substring(0, 200)}...`);
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
- LogStatus(`MCPResolver: [${ToolName}] Step 4 complete - Args parsed (${Date.now() - startTime}ms)`);
784
+ LogStatusEx({ message: `MCPResolver: [${ToolName}] Step 4 complete - Args parsed (${Date.now() - startTime}ms)`, verboseOnly: true });
781
785
 
782
786
  // Call the tool
783
- LogStatus(`MCPResolver: [${ToolName}] Step 5 - Calling tool on connection ${ConnectionID}...`);
784
- LogStatus(`MCPResolver: [${ToolName}] Tool ID: ${ToolID}`);
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
- LogStatus(`MCPResolver: [${ToolName}] Step 5 complete - Tool call returned (${Date.now() - startTime}ms)`);
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
- LogStatus(`MCPResolver: [${ToolName}] Step 6 complete - Result formatted (${Date.now() - startTime}ms)`);
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
- const agentRunRef = { current: null as any };
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
- LogStatus(`VectorizeEntity pipeline ${pipelineRunID} complete: success=${result.success}`);
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
  };