@memberjunction/server 5.38.0 → 5.40.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 (180) 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/auth/index.js +1 -1
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkKeys.d.ts +58 -0
  9. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -0
  10. package/dist/auth/magicLink/MagicLinkKeys.js +99 -0
  11. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -0
  12. package/dist/auth/magicLink/MagicLinkRouter.d.ts +33 -0
  13. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -0
  14. package/dist/auth/magicLink/MagicLinkRouter.js +184 -0
  15. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -0
  16. package/dist/auth/magicLink/MagicLinkService.d.ts +123 -0
  17. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -0
  18. package/dist/auth/magicLink/MagicLinkService.js +605 -0
  19. package/dist/auth/magicLink/MagicLinkService.js.map +1 -0
  20. package/dist/auth/magicLink/index.d.ts +6 -0
  21. package/dist/auth/magicLink/index.d.ts.map +1 -0
  22. package/dist/auth/magicLink/index.js +6 -0
  23. package/dist/auth/magicLink/index.js.map +1 -0
  24. package/dist/auth/magicLink/magicLinkCore.d.ts +82 -0
  25. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -0
  26. package/dist/auth/magicLink/magicLinkCore.js +164 -0
  27. package/dist/auth/magicLink/magicLinkCore.js.map +1 -0
  28. package/dist/auth/magicLink/redeemLanding.d.ts +22 -0
  29. package/dist/auth/magicLink/redeemLanding.d.ts.map +1 -0
  30. package/dist/auth/magicLink/redeemLanding.js +61 -0
  31. package/dist/auth/magicLink/redeemLanding.js.map +1 -0
  32. package/dist/auth/magicLink/types.d.ts +131 -0
  33. package/dist/auth/magicLink/types.d.ts.map +1 -0
  34. package/dist/auth/magicLink/types.js +6 -0
  35. package/dist/auth/magicLink/types.js.map +1 -0
  36. package/dist/config.d.ts +479 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +84 -0
  39. package/dist/config.js.map +1 -1
  40. package/dist/context.d.ts.map +1 -1
  41. package/dist/context.js +223 -19
  42. package/dist/context.js.map +1 -1
  43. package/dist/generated/generated.d.ts +952 -4
  44. package/dist/generated/generated.d.ts.map +1 -1
  45. package/dist/generated/generated.js +25663 -20321
  46. package/dist/generated/generated.js.map +1 -1
  47. package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
  48. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
  49. package/dist/generic/FireAndForgetHeartbeat.js +44 -0
  50. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
  51. package/dist/generic/ResolverBase.d.ts.map +1 -1
  52. package/dist/generic/ResolverBase.js +35 -7
  53. package/dist/generic/ResolverBase.js.map +1 -1
  54. package/dist/index.d.ts +5 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +145 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/logging/NoLog.d.ts +50 -0
  59. package/dist/logging/NoLog.d.ts.map +1 -0
  60. package/dist/logging/NoLog.js +80 -0
  61. package/dist/logging/NoLog.js.map +1 -0
  62. package/dist/logging/bootAudit.d.ts +43 -0
  63. package/dist/logging/bootAudit.d.ts.map +1 -0
  64. package/dist/logging/bootAudit.js +83 -0
  65. package/dist/logging/bootAudit.js.map +1 -0
  66. package/dist/logging/boundaryLogPayload.d.ts +18 -0
  67. package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
  68. package/dist/logging/boundaryLogPayload.js +18 -0
  69. package/dist/logging/boundaryLogPayload.js.map +1 -0
  70. package/dist/logging/secretRedactor.d.ts +23 -0
  71. package/dist/logging/secretRedactor.d.ts.map +1 -0
  72. package/dist/logging/secretRedactor.js +53 -0
  73. package/dist/logging/secretRedactor.js.map +1 -0
  74. package/dist/logging/shortenForLog.d.ts +8 -0
  75. package/dist/logging/shortenForLog.d.ts.map +1 -0
  76. package/dist/logging/shortenForLog.js +21 -0
  77. package/dist/logging/shortenForLog.js.map +1 -0
  78. package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
  79. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
  80. package/dist/logging/variablesLoggingMiddleware.js +127 -0
  81. package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
  82. package/dist/resolvers/CurrentUserContextResolver.d.ts +9 -3
  83. package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -1
  84. package/dist/resolvers/CurrentUserContextResolver.js +19 -5
  85. package/dist/resolvers/CurrentUserContextResolver.js.map +1 -1
  86. package/dist/resolvers/EntityResolver.d.ts.map +1 -1
  87. package/dist/resolvers/EntityResolver.js +13 -2
  88. package/dist/resolvers/EntityResolver.js.map +1 -1
  89. package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts +28 -0
  90. package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts.map +1 -0
  91. package/dist/resolvers/GenerateSeedTaxonomyResolver.js +100 -0
  92. package/dist/resolvers/GenerateSeedTaxonomyResolver.js.map +1 -0
  93. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  94. package/dist/resolvers/GetDataResolver.js +8 -4
  95. package/dist/resolvers/GetDataResolver.js.map +1 -1
  96. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
  97. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  98. package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
  99. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  100. package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
  101. package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
  102. package/dist/resolvers/IntegrationProgressResolver.js +196 -0
  103. package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
  104. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  105. package/dist/resolvers/MCPResolver.js +19 -14
  106. package/dist/resolvers/MCPResolver.js.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  108. package/dist/resolvers/RunAIAgentResolver.js +26 -5
  109. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  110. package/dist/resolvers/RunClusterAnalysisResolver.d.ts +74 -0
  111. package/dist/resolvers/RunClusterAnalysisResolver.d.ts.map +1 -0
  112. package/dist/resolvers/RunClusterAnalysisResolver.js +243 -0
  113. package/dist/resolvers/RunClusterAnalysisResolver.js.map +1 -0
  114. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  115. package/dist/resolvers/RunTestResolver.js +12 -2
  116. package/dist/resolvers/RunTestResolver.js.map +1 -1
  117. package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
  118. package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
  119. package/dist/resolvers/SearchEntitiesResolver.js +216 -0
  120. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
  121. package/dist/resolvers/UserResolver.d.ts +16 -2
  122. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  123. package/dist/resolvers/UserResolver.js +45 -2
  124. package/dist/resolvers/UserResolver.js.map +1 -1
  125. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  126. package/dist/resolvers/VectorizeEntityResolver.js +14 -1
  127. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  128. package/dist/rest/SignatureWebhookHandler.d.ts +19 -0
  129. package/dist/rest/SignatureWebhookHandler.d.ts.map +1 -0
  130. package/dist/rest/SignatureWebhookHandler.js +86 -0
  131. package/dist/rest/SignatureWebhookHandler.js.map +1 -0
  132. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  133. package/dist/services/ScheduledJobsService.js +14 -2
  134. package/dist/services/ScheduledJobsService.js.map +1 -1
  135. package/package.json +79 -74
  136. package/src/__tests__/NoLog.test.ts +76 -0
  137. package/src/__tests__/bootAudit.test.ts +188 -0
  138. package/src/__tests__/boundaryLogPayload.test.ts +31 -0
  139. package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
  140. package/src/__tests__/magicLink.test.ts +387 -0
  141. package/src/__tests__/secretRedactor.test.ts +163 -0
  142. package/src/__tests__/subscriptionRedaction.test.ts +217 -0
  143. package/src/apolloServer/index.ts +58 -0
  144. package/src/auth/index.ts +2 -2
  145. package/src/auth/magicLink/MagicLinkKeys.ts +122 -0
  146. package/src/auth/magicLink/MagicLinkRouter.ts +209 -0
  147. package/src/auth/magicLink/MagicLinkService.ts +724 -0
  148. package/src/auth/magicLink/index.ts +17 -0
  149. package/src/auth/magicLink/magicLinkCore.ts +216 -0
  150. package/src/auth/magicLink/redeemLanding.ts +62 -0
  151. package/src/auth/magicLink/types.ts +137 -0
  152. package/src/config.ts +89 -0
  153. package/src/context.ts +249 -17
  154. package/src/generated/generated.ts +12528 -8866
  155. package/src/generic/FireAndForgetHeartbeat.ts +85 -0
  156. package/src/generic/ResolverBase.ts +35 -7
  157. package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
  158. package/src/index.ts +165 -2
  159. package/src/logging/NoLog.ts +88 -0
  160. package/src/logging/bootAudit.ts +117 -0
  161. package/src/logging/boundaryLogPayload.ts +19 -0
  162. package/src/logging/secretRedactor.ts +82 -0
  163. package/src/logging/shortenForLog.ts +17 -0
  164. package/src/logging/variablesLoggingMiddleware.ts +191 -0
  165. package/src/resolvers/CurrentUserContextResolver.ts +21 -5
  166. package/src/resolvers/EntityResolver.ts +17 -5
  167. package/src/resolvers/GenerateSeedTaxonomyResolver.ts +90 -0
  168. package/src/resolvers/GetDataResolver.ts +9 -5
  169. package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
  170. package/src/resolvers/IntegrationProgressResolver.ts +220 -0
  171. package/src/resolvers/MCPResolver.ts +18 -14
  172. package/src/resolvers/RunAIAgentResolver.ts +28 -5
  173. package/src/resolvers/RunClusterAnalysisResolver.ts +249 -0
  174. package/src/resolvers/RunTestResolver.ts +14 -2
  175. package/src/resolvers/SearchEntitiesResolver.ts +173 -0
  176. package/src/resolvers/UserResolver.ts +38 -2
  177. package/src/resolvers/VectorizeEntityResolver.ts +14 -1
  178. package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
  179. package/src/rest/SignatureWebhookHandler.ts +103 -0
  180. 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
  /**
@@ -0,0 +1,249 @@
1
+ import { Resolver, Mutation, Arg, Ctx, ObjectType, InputType, Field, Float, Int } from 'type-graphql';
2
+ import { AppContext } from '../types.js';
3
+ import { LogError, LogStatus, UserInfo, IMetadataProvider } from '@memberjunction/core';
4
+ import { ResolverBase } from '../generic/ResolverBase.js';
5
+ import { GetReadWriteProvider } from '../util.js';
6
+ import {
7
+ ClusteringEngine,
8
+ EntityDocumentVectorSource,
9
+ ClusterConfig,
10
+ ClusterAlgorithm,
11
+ ClusterDistanceMetric,
12
+ } from '@memberjunction/clustering-engine';
13
+
14
+ /* ───── GraphQL input ───── */
15
+
16
+ @InputType()
17
+ export class RunClusterAnalysisInput {
18
+ @Field({ nullable: true })
19
+ EntityName?: string;
20
+
21
+ @Field({ nullable: true })
22
+ EntityID?: string;
23
+
24
+ @Field({ nullable: true })
25
+ EntityDocumentID?: string;
26
+
27
+ /**
28
+ * Multi-entity clustering: one or more Entity Document IDs whose vectors are
29
+ * merged into a single analysis. Takes precedence over EntityDocumentID when
30
+ * non-empty. All selected docs must share the same embedding model (the engine
31
+ * hard-blocks mismatches).
32
+ */
33
+ @Field(() => [String], { nullable: true })
34
+ EntityDocumentIDs?: string[];
35
+
36
+ /** Legend mode: color points by 'cluster' (default) or 'entity'. */
37
+ @Field({ nullable: true })
38
+ ColorBy?: string;
39
+
40
+ /** Clustering algorithm: 'kmeans' or 'dbscan'. */
41
+ @Field({ nullable: true })
42
+ Algorithm?: string;
43
+
44
+ /** Number of clusters for K-Means. */
45
+ @Field(() => Int, { nullable: true })
46
+ K?: number;
47
+
48
+ /** Epsilon neighbourhood radius for DBSCAN. */
49
+ @Field(() => Float, { nullable: true })
50
+ Epsilon?: number;
51
+
52
+ /** Minimum points to form a dense region in DBSCAN. */
53
+ @Field(() => Int, { nullable: true })
54
+ MinPoints?: number;
55
+
56
+ /** Distance metric: 'cosine', 'euclidean' or 'dotproduct'. */
57
+ @Field({ nullable: true })
58
+ DistanceMetric?: string;
59
+
60
+ /** Maximum number of records to fetch for clustering. */
61
+ @Field(() => Int, { nullable: true })
62
+ MaxRecords?: number;
63
+
64
+ /** Optional SQL-style filter applied by the vector source. */
65
+ @Field({ nullable: true })
66
+ Filter?: string;
67
+
68
+ /** When true, runs the optional LLM cluster-naming step. */
69
+ @Field({ nullable: true })
70
+ NameClusters?: boolean;
71
+
72
+ /** Number of dimensions for the projected layout (2 or 3). Defaults to 2. */
73
+ @Field(() => Int, { nullable: true })
74
+ Dimensions?: number;
75
+
76
+ /**
77
+ * When supplied, the result is persisted as an `MJ: Cluster Analysis` row
78
+ * with this Name and the returned `AnalysisID` is populated.
79
+ */
80
+ @Field({ nullable: true })
81
+ PersistName?: string;
82
+ }
83
+
84
+ /* ───── GraphQL output ───── */
85
+
86
+ @ObjectType()
87
+ export class RunClusterAnalysisResult {
88
+ @Field()
89
+ Success: boolean;
90
+
91
+ @Field({ nullable: true })
92
+ ErrorMessage?: string;
93
+
94
+ /** Number of clusters discovered. */
95
+ @Field(() => Int)
96
+ ClusterCount: number;
97
+
98
+ /** Number of records that were clustered. */
99
+ @Field(() => Int)
100
+ RecordCount: number;
101
+
102
+ /** Number of outlier points (DBSCAN only). */
103
+ @Field(() => Int)
104
+ OutlierCount: number;
105
+
106
+ /** Silhouette score (-1 .. 1). */
107
+ @Field(() => Float)
108
+ SilhouetteScore: number;
109
+
110
+ /** Wall-clock computation time in milliseconds. */
111
+ @Field(() => Float)
112
+ ComputationTimeMs: number;
113
+
114
+ /** JSON-serialized {@link ClusterMetrics}. */
115
+ @Field()
116
+ MetricsJSON: string;
117
+
118
+ /** JSON-serialized array of {@link ClusterInfo}. */
119
+ @Field()
120
+ ClustersJSON: string;
121
+
122
+ /** JSON-serialized array of {@link ClusterPoint} (can be large). */
123
+ @Field()
124
+ PointsJSON: string;
125
+
126
+ /** The persisted `MJ: Cluster Analysis` ID when a PersistName was supplied. */
127
+ @Field({ nullable: true })
128
+ AnalysisID?: string;
129
+ }
130
+
131
+ /* ───── Resolver ───── */
132
+
133
+ @Resolver()
134
+ export class RunClusterAnalysisResolver extends ResolverBase {
135
+ @Mutation(() => RunClusterAnalysisResult)
136
+ async RunClusterAnalysis(
137
+ @Arg('input', () => RunClusterAnalysisInput) input: RunClusterAnalysisInput,
138
+ @Ctx() context: AppContext = {} as AppContext
139
+ ): Promise<RunClusterAnalysisResult> {
140
+ try {
141
+ const currentUser = this.GetUserFromPayload(context.userPayload);
142
+ if (!currentUser) {
143
+ return this.errorResult('Unable to determine current user');
144
+ }
145
+
146
+ // Per-request provider so reads/writes use this user's connection +
147
+ // security context (multi-user safe), not the process global.
148
+ const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
149
+ const config = this.buildConfig(input);
150
+ const engine = new ClusteringEngine();
151
+ const vectorSource = new EntityDocumentVectorSource(currentUser, provider);
152
+
153
+ LogStatus(
154
+ `RunClusterAnalysis: algorithm=${config.Algorithm} entity=${config.EntityName ?? config.EntityID ?? config.EntityDocumentID}`
155
+ );
156
+
157
+ const result = await engine.RunPipeline(config, vectorSource, currentUser);
158
+
159
+ let analysisID: string | undefined;
160
+ if (input.PersistName && input.PersistName.trim().length > 0) {
161
+ analysisID = await this.persistAnalysis(engine, result, config, input.PersistName.trim(), currentUser, provider);
162
+ }
163
+
164
+ return {
165
+ Success: true,
166
+ ClusterCount: result.Metrics.ClusterCount,
167
+ RecordCount: result.Metrics.RecordCount,
168
+ OutlierCount: result.Metrics.OutlierCount,
169
+ SilhouetteScore: result.Metrics.SilhouetteScore,
170
+ ComputationTimeMs: result.Metrics.ComputationTimeMs,
171
+ MetricsJSON: JSON.stringify(result.Metrics),
172
+ ClustersJSON: JSON.stringify(result.Clusters),
173
+ PointsJSON: JSON.stringify(result.Points),
174
+ AnalysisID: analysisID,
175
+ };
176
+ } catch (error) {
177
+ const msg = error instanceof Error ? error.message : String(error);
178
+ LogError(`RunClusterAnalysis mutation failed: ${msg}`);
179
+ return this.errorResult(msg);
180
+ }
181
+ }
182
+
183
+ /** Build a {@link ClusterConfig} from the GraphQL input, applying defaults. */
184
+ private buildConfig(input: RunClusterAnalysisInput): ClusterConfig {
185
+ return {
186
+ EntityName: input.EntityName ?? '',
187
+ EntityID: input.EntityID ?? '',
188
+ EntityDocumentID: input.EntityDocumentID ?? '',
189
+ EntityDocumentIDs: (input.EntityDocumentIDs ?? []).filter((id) => !!id && id.trim().length > 0),
190
+ ColorBy: input.ColorBy === 'entity' ? 'entity' : 'cluster',
191
+ Algorithm: this.normalizeAlgorithm(input.Algorithm),
192
+ K: input.K ?? 4,
193
+ Epsilon: input.Epsilon ?? 0.3,
194
+ MinPoints: input.MinPoints ?? 3,
195
+ DistanceMetric: this.normalizeMetric(input.DistanceMetric),
196
+ MaxRecords: input.MaxRecords ?? 500,
197
+ Filter: input.Filter ?? '',
198
+ NameClusters: input.NameClusters ?? false,
199
+ Dimensions: input.Dimensions === 3 ? 3 : 2,
200
+ };
201
+ }
202
+
203
+ /** Coerce a raw algorithm string to a supported {@link ClusterAlgorithm}. */
204
+ private normalizeAlgorithm(value: string | undefined): ClusterAlgorithm {
205
+ return value?.toLowerCase() === 'dbscan' ? 'dbscan' : 'kmeans';
206
+ }
207
+
208
+ /** Coerce a raw metric string to a supported {@link ClusterDistanceMetric}. */
209
+ private normalizeMetric(value: string | undefined): ClusterDistanceMetric {
210
+ const v = value?.toLowerCase();
211
+ if (v === 'euclidean' || v === 'dotproduct') {
212
+ return v;
213
+ }
214
+ return 'cosine';
215
+ }
216
+
217
+ /** Persist the result and return the new analysis ID (or undefined on failure). */
218
+ private async persistAnalysis(
219
+ engine: ClusteringEngine,
220
+ result: Awaited<ReturnType<ClusteringEngine['RunPipeline']>>,
221
+ config: ClusterConfig,
222
+ persistName: string,
223
+ currentUser: UserInfo,
224
+ provider?: IMetadataProvider
225
+ ): Promise<string | undefined> {
226
+ const owner = { Name: persistName, UserID: currentUser.ID };
227
+ const savedID = await engine.SaveAnalysis(result, config, owner, currentUser, provider);
228
+ if (!savedID) {
229
+ LogError(`RunClusterAnalysis: failed to persist analysis "${persistName}".`);
230
+ return undefined;
231
+ }
232
+ return savedID;
233
+ }
234
+
235
+ private errorResult(message: string): RunClusterAnalysisResult {
236
+ return {
237
+ Success: false,
238
+ ErrorMessage: message,
239
+ ClusterCount: 0,
240
+ RecordCount: 0,
241
+ OutlierCount: 0,
242
+ SilhouetteScore: 0,
243
+ ComputationTimeMs: 0,
244
+ MetricsJSON: '{}',
245
+ ClustersJSON: '[]',
246
+ PointsJSON: '[]',
247
+ };
248
+ }
249
+ }