@memberjunction/server 6.2.0-edge.1 → 6.2.0-edge.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +2189 -71
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +123 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +11 -23
  9. package/dist/context.js.map +1 -1
  10. package/dist/generated/graphql-schemas/__mj.d.ts +2143 -205
  11. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  12. package/dist/generated/graphql-schemas/__mj.js +24441 -14433
  13. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  14. package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
  15. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  16. package/dist/generic/FireAndForgetHeartbeat.js +16 -2
  17. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  18. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
  19. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
  20. package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
  21. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
  22. package/dist/generic/PushStatusResolver.d.ts +38 -0
  23. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  24. package/dist/generic/PushStatusResolver.js +84 -1
  25. package/dist/generic/PushStatusResolver.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +56 -4
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +191 -74
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/generic/RunViewResolver.d.ts +4 -0
  31. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  32. package/dist/generic/RunViewResolver.js +33 -1
  33. package/dist/generic/RunViewResolver.js.map +1 -1
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +204 -50
  37. package/dist/index.js.map +1 -1
  38. package/dist/postgresqlCompat.d.ts +8 -0
  39. package/dist/postgresqlCompat.d.ts.map +1 -0
  40. package/dist/postgresqlCompat.js +19 -0
  41. package/dist/postgresqlCompat.js.map +1 -0
  42. package/dist/postgresqlPoolSettings.d.ts +108 -0
  43. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  44. package/dist/postgresqlPoolSettings.js +134 -0
  45. package/dist/postgresqlPoolSettings.js.map +1 -0
  46. package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
  47. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  48. package/dist/resolvers/AdhocQueryResolver.js +77 -167
  49. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  50. package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
  51. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
  52. package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
  53. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
  54. package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
  55. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
  56. package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
  57. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
  58. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
  59. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
  60. package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
  61. package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
  62. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  63. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  64. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  65. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  66. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  67. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
  68. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  69. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  70. package/dist/resolvers/GetDataResolver.js +9 -0
  71. package/dist/resolvers/GetDataResolver.js.map +1 -1
  72. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  73. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  74. package/dist/resolvers/HumanHandoffResolver.js +324 -0
  75. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  76. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  77. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  78. package/dist/resolvers/MeetingResolver.js +381 -0
  79. package/dist/resolvers/MeetingResolver.js.map +1 -0
  80. package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
  81. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  82. package/dist/resolvers/MergeRecordsResolver.js +16 -2
  83. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  84. package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
  85. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  86. package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
  87. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +45 -89
  89. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  90. package/dist/resolvers/RealtimeClientSessionResolver.js +94 -240
  91. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  92. package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
  93. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  94. package/dist/resolvers/RunAIAgentResolver.js +23 -153
  95. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  96. package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
  97. package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
  98. package/dist/resolvers/RunDecisionResolver.js +396 -0
  99. package/dist/resolvers/RunDecisionResolver.js.map +1 -0
  100. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  101. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  102. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  103. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  104. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  105. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  106. package/dist/resolvers/adhocTimeout.js +12 -0
  107. package/dist/resolvers/adhocTimeout.js.map +1 -0
  108. package/dist/resolvers/meetings/index.d.ts +5 -0
  109. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  110. package/dist/resolvers/meetings/index.js +5 -0
  111. package/dist/resolvers/meetings/index.js.map +1 -0
  112. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  113. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  114. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  115. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  116. package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
  117. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  118. package/dist/resolvers/meetings/meetingMutations.js +418 -0
  119. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  120. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  121. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  122. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  123. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  124. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  125. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  126. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  127. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  128. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  129. package/dist/rest/ViewOperationsHandler.js +8 -5
  130. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  131. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  132. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  133. package/dist/services/TaskGraphActionRunner.js +27 -1
  134. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  135. package/dist/services/WorkQueueHostService.d.ts +36 -0
  136. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  137. package/dist/services/WorkQueueHostService.js +97 -0
  138. package/dist/services/WorkQueueHostService.js.map +1 -0
  139. package/dist/services/workQueueConfig.d.ts +103 -0
  140. package/dist/services/workQueueConfig.d.ts.map +1 -0
  141. package/dist/services/workQueueConfig.js +48 -0
  142. package/dist/services/workQueueConfig.js.map +1 -0
  143. package/dist/types.d.ts +5 -0
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/types.js.map +1 -1
  146. package/package.json +95 -92
  147. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  148. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  149. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  150. package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
  151. package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
  152. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  153. package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
  154. package/src/__tests__/MeetingResolver.test.ts +374 -0
  155. package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
  156. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  157. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
  158. package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
  159. package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
  160. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  161. package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
  162. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  163. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  164. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
  165. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
  166. package/src/__tests__/RunDecisionResolver.test.ts +699 -0
  167. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +43 -0
  168. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  169. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  170. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  171. package/src/__tests__/conversationRunEvents.test.ts +399 -0
  172. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
  173. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  174. package/src/__tests__/pushStatusFanOut.test.ts +206 -0
  175. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  176. package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
  177. package/src/config.ts +130 -3
  178. package/src/context.ts +11 -24
  179. package/src/generated/graphql-schemas/__mj.ts +24183 -17182
  180. package/src/generic/FireAndForgetHeartbeat.ts +16 -2
  181. package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
  182. package/src/generic/PushStatusResolver.ts +97 -1
  183. package/src/generic/ResolverBase.ts +220 -76
  184. package/src/generic/RunViewResolver.ts +33 -2
  185. package/src/index.ts +228 -51
  186. package/src/postgresqlCompat.ts +21 -0
  187. package/src/postgresqlPoolSettings.ts +213 -0
  188. package/src/resolvers/AdhocQueryResolver.ts +88 -188
  189. package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
  190. package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
  191. package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
  192. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  193. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
  194. package/src/resolvers/GetDataResolver.ts +10 -0
  195. package/src/resolvers/HumanHandoffResolver.ts +304 -0
  196. package/src/resolvers/MeetingResolver.ts +438 -0
  197. package/src/resolvers/MergeRecordsResolver.ts +14 -2
  198. package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
  199. package/src/resolvers/RealtimeClientSessionResolver.ts +99 -287
  200. package/src/resolvers/RunAIAgentResolver.ts +28 -174
  201. package/src/resolvers/RunDecisionResolver.ts +435 -0
  202. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  203. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
  204. package/src/resolvers/adhocTimeout.ts +12 -0
  205. package/src/resolvers/meetings/index.ts +4 -0
  206. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  207. package/src/resolvers/meetings/meetingMutations.ts +535 -0
  208. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  209. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  210. package/src/rest/ViewOperationsHandler.ts +8 -5
  211. package/src/services/TaskGraphActionRunner.ts +29 -1
  212. package/src/services/WorkQueueHostService.ts +129 -0
  213. package/src/services/workQueueConfig.ts +52 -0
  214. package/src/types.ts +5 -0
  215. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  216. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  217. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  218. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  219. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  220. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -2,8 +2,22 @@ import { PubSubEngine } from 'type-graphql';
2
2
  import { LogError } from '@memberjunction/core';
3
3
  import { PublishStatusUpdate } from './PushStatusResolver.js';
4
4
 
5
- /** Default cadence for fire-and-forget liveness pulses (5 minutes). */
6
- export const DEFAULT_PULSE_INTERVAL_MS = 5 * 60 * 1000;
5
+ /**
6
+ * Default cadence for fire-and-forget liveness pulses (60 seconds).
7
+ *
8
+ * MATCHED PAIR — do not change alone. The client's inactivity window
9
+ * (`DEFAULT_IDLE_TIMEOUT_MS` in GraphQLDataProvider's fireAndForgetHelper) is sized at roughly
10
+ * 3x this value, so a healthy run always refreshes the timer well before it expires. Lengthening
11
+ * this without lengthening that window makes every quiet stretch of a long agent run look like a
12
+ * stall and trips reconciliation; shortening the window without shortening this does the same.
13
+ *
14
+ * WAS 5 MINUTES (MJ #4222). Paired with the old 12-minute window, a client whose transport had
15
+ * silently died waited 12 minutes to notice — on runs that typically finish in 20 seconds. The
16
+ * pulse is a few dozen bytes and exists only while an operation is actually running, so the
17
+ * added traffic is negligible: a 40-minute run now sends ~40 pulses instead of ~8, and the
18
+ * 20-second run that motivated the change still sends none.
19
+ */
20
+ export const DEFAULT_PULSE_INTERVAL_MS = 60 * 1000;
7
21
 
8
22
  /** The `type` discriminator carried by liveness pulse messages. */
9
23
  export const HEARTBEAT_MESSAGE_TYPE = 'Heartbeat';
@@ -0,0 +1,267 @@
1
+ import { LogError, LogStatus, RunView } from '@memberjunction/core';
2
+ import { UUIDsEqual } from '@memberjunction/global';
3
+ import { UserCache } from '@memberjunction/sqlserver-dataprovider';
4
+ import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
5
+ import type { MJAIAgentRunEntity, MJConversationDetailEntity } from '@memberjunction/core-entities';
6
+
7
+ /**
8
+ * Grace period after a run reaches a terminal state before its conversation detail is considered
9
+ * abandoned. The normal path closes the detail moments after the run finishes; this waits well past
10
+ * that so a reconcile can never race a completion that is already on its way.
11
+ */
12
+ export const ORPHAN_DETAIL_GRACE_MS = 2 * 60 * 1000;
13
+
14
+ /** Ceiling on details closed per pass, so a large backlog cannot stall startup. */
15
+ const MAX_DETAILS_PER_PASS = 200;
16
+
17
+ /**
18
+ * Agent-run statuses that mean the run succeeded. `AwaitingFeedback` is the normal end of a chat
19
+ * turn: the agent replied and the next move is the user's.
20
+ */
21
+ const SUCCESSFUL_RUN_STATUSES: MJAIAgentRunEntity['Status'][] = ['Completed', 'AwaitingFeedback'];
22
+
23
+ /**
24
+ * Agent-run statuses that mean execution is over, whatever the outcome. `Paused` is absent: the
25
+ * run is parked on a workflow that is still executing.
26
+ */
27
+ const TERMINAL_RUN_STATUSES: MJAIAgentRunEntity['Status'][] = [...SUCCESSFUL_RUN_STATUSES, 'Failed', 'Cancelled'];
28
+
29
+ /**
30
+ * Close conversation details left `In-Progress` by a run that is already over (MJ #4222).
31
+ *
32
+ * WHY THIS EXISTS. `AgentRunner` closes the detail as the final step of a run, so a process that
33
+ * dies mid-run never reaches it. The agent-run watchdog repairs the RUN — force-failing it once the
34
+ * heartbeat goes stale — but nothing repairs the detail, and the detail is the row the chat renders
35
+ * from. The result is durable state that disagrees with itself: a terminal run beside a message
36
+ * that still says it is being generated, spinning forever for anyone who opens it.
37
+ *
38
+ * WHY NOT IN THE SWEEP PROC. `spSweepStaleAIAgentRuns` is a single set-based UPDATE, and extending
39
+ * it to the detail would be the obvious fix. It would also be the wrong one: `MJ: Conversation
40
+ * Details` declares `TrustServerCacheCompletely`, so the server's RunView cache relies on
41
+ * `BaseEntity` events for invalidation and a stored procedure fires none. The row would change
42
+ * while every instance kept serving `In-Progress` from cache — a worse failure than the one being
43
+ * fixed, because reconciliation itself would read the stale value back. Going through
44
+ * `BaseEntity.Save()` costs a per-row write and buys correct invalidation everywhere.
45
+ *
46
+ * Idempotent and safe to run concurrently on several instances: the write is a no-op once the
47
+ * status is terminal, and each pass re-reads state before deciding.
48
+ *
49
+ * @returns How many details were closed.
50
+ */
51
+ export async function ReconcileOrphanedConversationDetails(
52
+ provider: IMetadataProvider,
53
+ contextUser: UserInfo,
54
+ graceMs: number = ORPHAN_DETAIL_GRACE_MS
55
+ ): Promise<number> {
56
+ try {
57
+ const rv = RunView.FromMetadataProvider(provider);
58
+
59
+ const detailResult = await rv.RunView<MJConversationDetailEntity>(
60
+ {
61
+ EntityName: 'MJ: Conversation Details',
62
+ ExtraFilter: `Status = 'In-Progress' AND Role = 'AI'${hasTerminalRunClause(provider)}`,
63
+ OrderBy: '__mj_CreatedAt ASC',
64
+ MaxRows: MAX_DETAILS_PER_PASS,
65
+ ResultType: 'entity_object',
66
+ },
67
+ contextUser
68
+ );
69
+ if (!detailResult.Success || !detailResult.Results?.length) {
70
+ return 0;
71
+ }
72
+
73
+ const runsByDetail = await loadTerminalRuns(rv, detailResult.Results.map(d => d.ID), contextUser);
74
+ if (runsByDetail.size === 0) {
75
+ return 0;
76
+ }
77
+
78
+ const ownersByConversation = await loadConversationOwners(
79
+ rv,
80
+ detailResult.Results.map(d => d.ConversationID).filter((id): id is string => !!id),
81
+ contextUser
82
+ );
83
+
84
+ const cutoff = Date.now() - graceMs;
85
+ let closed = 0;
86
+
87
+ for (const detail of detailResult.Results) {
88
+ const run = runsByDetail.get(detail.ID);
89
+ if (!run) {
90
+ // No run yet, or its run is still executing. Both are legitimately in progress.
91
+ continue;
92
+ }
93
+ const completedAt = run.CompletedAt ? new Date(run.CompletedAt).getTime() : NaN;
94
+ if (!Number.isFinite(completedAt) || completedAt > cutoff) {
95
+ // Inside the grace window — the owning process may still be closing this itself.
96
+ continue;
97
+ }
98
+
99
+ // Write as the conversation's OWNER, not as the system user.
100
+ //
101
+ // `MJConversationDetailEntityExtended.Save()` gates writes on conversation ownership: a
102
+ // non-owner without a resource grant is refused, returning false with no result
103
+ // registered anywhere — indistinguishable from a crash, and the reason this pass
104
+ // silently wrote nothing at all until it was traced. The system user owns no
105
+ // conversations, so it is exactly the caller that gate exists to stop. Saving as the
106
+ // owner satisfies the gate on its own terms rather than routing around it, and keeps
107
+ // the write attributable to the person whose message it is.
108
+ const owner = detail.ConversationID ? ownersByConversation.get(detail.ConversationID) : undefined;
109
+ if (!owner) {
110
+ LogError(`[OrphanDetailReconciler] No owner resolved for conversation ${detail.ConversationID}; leaving detail ${detail.ID}`);
111
+ continue;
112
+ }
113
+ const writable = await provider.GetEntityObject<MJConversationDetailEntity>('MJ: Conversation Details', owner);
114
+ if (!(await writable.Load(detail.ID))) {
115
+ LogError(`[OrphanDetailReconciler] Could not load detail ${detail.ID} for write`);
116
+ continue;
117
+ }
118
+ if (writable.Status !== 'In-Progress') {
119
+ // Closed by something else since the list query. Its outcome stands.
120
+ continue;
121
+ }
122
+ // The failure marker belongs only to the error branch. A completed run has nothing to
123
+ // report, so an empty message stays empty rather than contradicting its own status.
124
+ if (SUCCESSFUL_RUN_STATUSES.includes(run.Status)) {
125
+ writable.Status = 'Complete';
126
+ } else {
127
+ writable.Status = 'Error';
128
+ if (!writable.Message) {
129
+ writable.Message = run.ErrorMessage ?? '❌ Failed';
130
+ }
131
+ }
132
+
133
+ if (await writable.Save()) {
134
+ closed++;
135
+ } else {
136
+ const result = writable.LatestResult;
137
+ LogError(
138
+ `[OrphanDetailReconciler] Failed to close detail ${detail.ID}: ` +
139
+ `${result?.CompleteMessage || result?.Message || 'no message'}`
140
+ );
141
+ }
142
+ }
143
+
144
+ if (closed > 0) {
145
+ LogStatus(`[OrphanDetailReconciler] Closed ${closed} conversation detail(s) left behind by finished runs`);
146
+ }
147
+ return closed;
148
+ } catch (err) {
149
+ // Maintenance work must never take the server down with it.
150
+ LogError(`[OrphanDetailReconciler] Pass failed: ${err instanceof Error ? err.message : String(err)}`);
151
+ return 0;
152
+ }
153
+ }
154
+
155
+ /** The quoting a database provider offers. Present on every server-side provider. */
156
+ interface QuotingProvider {
157
+ QuoteIdentifier(name: string): string;
158
+ QuoteSchemaAndView(schemaName: string, objectName: string): string;
159
+ }
160
+
161
+ function isQuotingProvider(provider: IMetadataProvider): provider is IMetadataProvider & QuotingProvider {
162
+ const p = provider as IMetadataProvider & Partial<QuotingProvider>;
163
+ return typeof p.QuoteIdentifier === 'function' && typeof p.QuoteSchemaAndView === 'function';
164
+ }
165
+
166
+ /**
167
+ * Filter clause that keeps only details with at least one finished run.
168
+ *
169
+ * Details with no run, or whose run is still executing, are never closed here. Without this clause
170
+ * they fill the oldest-first window, and once 200 of them accumulate no closable orphan is reached.
171
+ * The newest-run check in {@link loadTerminalRuns} still decides each remaining detail.
172
+ *
173
+ * @returns An ` AND ...` clause, or an empty string when the provider cannot quote names.
174
+ */
175
+ function hasTerminalRunClause(provider: IMetadataProvider): string {
176
+ const runs = provider.EntityByName('MJ: AI Agent Runs');
177
+ if (!runs || !isQuotingProvider(provider)) {
178
+ return '';
179
+ }
180
+ const statuses = TERMINAL_RUN_STATUSES.map(s => `'${s}'`).join(',');
181
+ return (
182
+ ` AND ID IN (SELECT ${provider.QuoteIdentifier('ConversationDetailID')}` +
183
+ ` FROM ${provider.QuoteSchemaAndView(runs.SchemaName, runs.BaseView)}` +
184
+ ` WHERE ${provider.QuoteIdentifier('Status')} IN (${statuses}))`
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Newest terminal run per conversation detail. Details whose newest run is still executing are
190
+ * deliberately absent, so a retried message is judged on its latest attempt rather than an older
191
+ * finished one.
192
+ */
193
+ async function loadTerminalRuns(
194
+ rv: RunView,
195
+ detailIds: string[],
196
+ contextUser: UserInfo
197
+ ): Promise<Map<string, MJAIAgentRunEntity>> {
198
+ const quoted = detailIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',');
199
+ const result = await rv.RunView<MJAIAgentRunEntity>(
200
+ {
201
+ EntityName: 'MJ: AI Agent Runs',
202
+ ExtraFilter: `ConversationDetailID IN (${quoted})`,
203
+ OrderBy: '__mj_CreatedAt DESC',
204
+ ResultType: 'entity_object',
205
+ },
206
+ contextUser
207
+ );
208
+ if (!result.Success) {
209
+ return new Map();
210
+ }
211
+
212
+ const newest = new Map<string, MJAIAgentRunEntity>();
213
+ for (const run of result.Results ?? []) {
214
+ const detailId = run.ConversationDetailID;
215
+ if (!detailId || newest.has(detailId)) {
216
+ continue; // DESC order puts the newest run first
217
+ }
218
+ newest.set(detailId, run);
219
+ }
220
+
221
+ // Drop any detail whose newest attempt is still running.
222
+ for (const [detailId, run] of newest) {
223
+ if (!TERMINAL_RUN_STATUSES.includes(run.Status)) {
224
+ newest.delete(detailId);
225
+ }
226
+ }
227
+ return newest;
228
+ }
229
+
230
+ /**
231
+ * Owner `UserInfo` per conversation, resolved through {@link UserCache}. The reconciler writes as
232
+ * the owner so the conversation-detail permission gate is satisfied on its own terms.
233
+ */
234
+ async function loadConversationOwners(
235
+ rv: RunView,
236
+ conversationIds: string[],
237
+ contextUser: UserInfo
238
+ ): Promise<Map<string, UserInfo>> {
239
+ const owners = new Map<string, UserInfo>();
240
+ if (conversationIds.length === 0) {
241
+ return owners;
242
+ }
243
+ const quoted = [...new Set(conversationIds)].map(id => `'${id.replace(/'/g, "''")}'`).join(',');
244
+ const result = await rv.RunView<{ ID: string; UserID: string }>(
245
+ {
246
+ EntityName: 'MJ: Conversations',
247
+ ExtraFilter: `ID IN (${quoted})`,
248
+ Fields: ['ID', 'UserID'],
249
+ ResultType: 'simple',
250
+ },
251
+ contextUser
252
+ );
253
+ if (!result.Success) {
254
+ return owners;
255
+ }
256
+ const users = UserCache.Instance.Users;
257
+ for (const row of result.Results ?? []) {
258
+ if (!row.UserID) {
259
+ continue;
260
+ }
261
+ const user = users.find(u => UUIDsEqual(u.ID, row.UserID));
262
+ if (user) {
263
+ owners.set(row.ID, user);
264
+ }
265
+ }
266
+ return owners;
267
+ }
@@ -1,5 +1,5 @@
1
1
  import { Arg, Field, ID, ObjectType, PubSubEngine, Resolver, ResolverFilterData, Root, Subscription } from 'type-graphql';
2
- import { UUIDsEqual } from '@memberjunction/global';
2
+ import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
3
3
  import { UserPayload } from '../types.js';
4
4
 
5
5
  export const PUSH_STATUS_UPDATES_TOPIC = 'PUSH_STATUS_UPDATES';
@@ -32,6 +32,89 @@ export interface PushStatusNotificationPayload {
32
32
  sessionId: string;
33
33
  /** Authenticated user the update belongs to. Server-side filter key; never sent to the client. */
34
34
  ownerUserId: string;
35
+ /**
36
+ * `MJGlobal.ProcessUUID` of the instance that first published this update. Used only for
37
+ * cross-instance echo suppression: an instance that receives its own message back from the
38
+ * message bus must drop it, or every push is delivered twice.
39
+ */
40
+ SourceServerId?: string;
41
+ }
42
+
43
+ /** Receives every locally-published status update, for fan-out to other server instances. */
44
+ export type PushStatusPublishHook = (payload: PushStatusNotificationPayload) => void;
45
+
46
+ let _publishHook: PushStatusPublishHook | undefined;
47
+
48
+ /**
49
+ * Register (or clear, with no arg) the cross-instance fan-out hook.
50
+ *
51
+ * Inversion of control, deliberately: this module must not know that Redis exists. The server
52
+ * registers the hook at startup only when a message bus is configured, so a single-instance
53
+ * deployment behaves exactly as before.
54
+ */
55
+ export function SetPushStatusPublishHook(hook?: PushStatusPublishHook): void {
56
+ _publishHook = hook;
57
+ }
58
+
59
+ /**
60
+ * Validate an update received from the cross-instance message bus.
61
+ *
62
+ * The bus carries messages from other processes, so every field is type-checked before the update
63
+ * is republished onto the local topic, where {@link StatusUpdatesFilter} compares `ownerUserId`
64
+ * with the connection's user.
65
+ *
66
+ * @param raw The JSON text taken off the bus.
67
+ * @param localServerId This process's `MJGlobal.ProcessUUID`, used to drop our own echo.
68
+ * @returns The update to republish, or `null` to drop it.
69
+ */
70
+ export function ParseReplicatedStatusUpdate(raw: string, localServerId: string): PushStatusNotificationPayload | null {
71
+ let parsed: unknown;
72
+ try {
73
+ parsed = JSON.parse(raw);
74
+ } catch {
75
+ return null;
76
+ }
77
+ if (typeof parsed !== 'object' || parsed === null) {
78
+ return null;
79
+ }
80
+
81
+ const { sessionId, ownerUserId, message, SourceServerId } = parsed as Record<string, unknown>;
82
+ const source = typeof SourceServerId === 'string' ? SourceServerId : undefined;
83
+ const body = typeof message === 'string' ? message : undefined;
84
+ if ((SourceServerId != null && source === undefined) || (message != null && body === undefined)) {
85
+ return null; // a present field of the wrong type
86
+ }
87
+ if (source === localServerId) {
88
+ return null; // our own message, echoed back
89
+ }
90
+ if (typeof sessionId !== 'string' || !sessionId || typeof ownerUserId !== 'string' || !ownerUserId) {
91
+ return null; // fail closed: an update with no valid identity can never be routed safely
92
+ }
93
+
94
+ return { sessionId, ownerUserId, message: body, SourceServerId: source };
95
+ }
96
+
97
+ /**
98
+ * Whether an update is worth sending to other server instances.
99
+ *
100
+ * Streaming content is excluded. It arrives at hundreds of messages per second and each one carries
101
+ * a full serialized agent run, so replicating it would cost far more bandwidth than the delivery it
102
+ * buys — and a token delta is worthless to a client that has already missed the ones before it.
103
+ * Progress and completion are low-rate and each is individually meaningful, which is exactly the
104
+ * kind of message that must survive landing on the wrong replica.
105
+ */
106
+ export function ShouldReplicateStatusUpdate(message?: string): boolean {
107
+ if (!message) {
108
+ return false;
109
+ }
110
+ try {
111
+ const parsed: unknown = JSON.parse(message);
112
+ const type = (parsed as { type?: unknown })?.type;
113
+ return type !== 'StreamingContent';
114
+ } catch {
115
+ // A plain-string message (not the resolvers' JSON envelope) is rare and low-rate. Replicate it.
116
+ return true;
117
+ }
35
118
  }
36
119
 
37
120
  interface PushStatusNotificationArgs {
@@ -60,8 +143,21 @@ export function PublishStatusUpdate(pubSub: PubSubEngine, params: StatusUpdatePa
60
143
  sessionId: params.sessionId,
61
144
  ownerUserId: params.ownerUserId,
62
145
  message: params.message,
146
+ SourceServerId: MJGlobal.Instance.ProcessUUID,
63
147
  };
64
148
  pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, payload);
149
+
150
+ // Fan out to other instances. Only reached by updates originating HERE: a message arriving from
151
+ // the bus is republished straight onto the local topic and never comes back through this
152
+ // function, so there is no loop to break beyond the SourceServerId check on the receiving side.
153
+ if (_publishHook && ShouldReplicateStatusUpdate(params.message)) {
154
+ try {
155
+ _publishHook(payload);
156
+ } catch {
157
+ // Fan-out is an optimization over the durable tail query. A message bus that is down must
158
+ // never break local delivery, which is the path that works for the common case.
159
+ }
160
+ }
65
161
  }
66
162
 
67
163
  /** @deprecated Use {@link PublishStatusUpdate}. */