@memberjunction/server 6.2.0-edge.1 → 6.2.0-edge.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +1010 -0
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +5178 -3
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.js +16 -2
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
- package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
- package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
- package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
- package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
- package/dist/generic/PushStatusResolver.d.ts +38 -0
- package/dist/generic/PushStatusResolver.d.ts.map +1 -1
- package/dist/generic/PushStatusResolver.js +84 -1
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +13 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +49 -47
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +90 -2
- package/dist/index.js.map +1 -1
- package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
- package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
- package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
- package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
- package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
- package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
- package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
- package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
- package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.js +16 -2
- package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +23 -153
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
- package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
- package/dist/resolvers/RunDecisionResolver.js +396 -0
- package/dist/resolvers/RunDecisionResolver.js.map +1 -0
- package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -10
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/package.json +94 -92
- package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
- package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
- package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
- package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
- package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
- package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
- package/src/__tests__/RunDecisionResolver.test.ts +699 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
- package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
- package/src/__tests__/conversationRunEvents.test.ts +399 -0
- package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
- package/src/__tests__/pushStatusFanOut.test.ts +206 -0
- package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
- package/src/generated/graphql-schemas/__mj.ts +3637 -4
- package/src/generic/FireAndForgetHeartbeat.ts +16 -2
- package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
- package/src/generic/PushStatusResolver.ts +97 -1
- package/src/generic/ResolverBase.ts +49 -49
- package/src/index.ts +102 -2
- package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
- package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
- package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
- package/src/resolvers/MergeRecordsResolver.ts +14 -2
- package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
- package/src/resolvers/RunAIAgentResolver.ts +28 -174
- package/src/resolvers/RunDecisionResolver.ts +435 -0
- package/src/resolvers/VectorizeEntityResolver.ts +16 -11
- package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
package/src/index.ts
CHANGED
|
@@ -61,6 +61,12 @@ import { GetAPIKeyEngine } from '@memberjunction/api-keys';
|
|
|
61
61
|
import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
|
|
62
62
|
import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
|
|
63
63
|
import { PubSubManager } from './generic/PubSubManager.js';
|
|
64
|
+
import { ReconcileOrphanedConversationDetails } from './generic/OrphanedConversationDetailReconciler.js';
|
|
65
|
+
import {
|
|
66
|
+
PUSH_STATUS_UPDATES_TOPIC,
|
|
67
|
+
SetPushStatusPublishHook,
|
|
68
|
+
ParseReplicatedStatusUpdate,
|
|
69
|
+
} from './generic/PushStatusResolver.js';
|
|
64
70
|
import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
|
|
65
71
|
import { PublishIntegrationProgress } from './resolvers/IntegrationProgressResolver.js';
|
|
66
72
|
import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
|
|
@@ -167,7 +173,9 @@ export * from './generic/refusalExtensions.js';
|
|
|
167
173
|
export * from './generic/RunViewResolver.js';
|
|
168
174
|
export * from './resolvers/RunTemplateResolver.js';
|
|
169
175
|
export * from './resolvers/RunAIPromptResolver.js';
|
|
176
|
+
export * from './resolvers/RunDecisionResolver.js';
|
|
170
177
|
export * from './resolvers/RunAIAgentResolver.js';
|
|
178
|
+
export { AgentRunStatusPublisher } from './resolvers/AgentRunStatusPublisher.js';
|
|
171
179
|
export * from './resolvers/VectorizeEntityResolver.js';
|
|
172
180
|
export * from './resolvers/SearchKnowledgeResolver.js';
|
|
173
181
|
export * from './resolvers/SearchKnowledgeStreamResolver.js';
|
|
@@ -224,6 +232,7 @@ export * from './rest/MediaAccessKeys.js';
|
|
|
224
232
|
export * from './rest/MediaStreamHandler.js';
|
|
225
233
|
export * from './resolvers/InfoResolver.js';
|
|
226
234
|
export * from './resolvers/PotentialDuplicateRecordResolver.js';
|
|
235
|
+
export * from './resolvers/DuplicateEntryCheckResolver.js';
|
|
227
236
|
export * from './resolvers/RunTestResolver.js';
|
|
228
237
|
export * from './resolvers/SearchEntitiesResolver.js';
|
|
229
238
|
export * from './resolvers/UserFavoriteResolver.js';
|
|
@@ -292,6 +301,61 @@ function resolveServerVersion(): string | undefined {
|
|
|
292
301
|
}
|
|
293
302
|
}
|
|
294
303
|
|
|
304
|
+
/** How often to re-check for conversation details left behind by finished runs. */
|
|
305
|
+
const ORPHAN_DETAIL_SWEEP_INTERVAL_MS = 5 * 60_000;
|
|
306
|
+
|
|
307
|
+
/** Redis channel carrying replicated push-status updates between server instances. */
|
|
308
|
+
const PUSH_STATUS_FANOUT_CHANNEL = 'push-status-updates';
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Replicate push-status updates across server instances over Redis (MJ #4222).
|
|
312
|
+
*
|
|
313
|
+
* Outbound: every locally-published update is forwarded on a shared channel. Inbound: a message
|
|
314
|
+
* from another instance is republished onto THIS instance's local topic, where the normal
|
|
315
|
+
* subscription filter decides who receives it — so the identity gate (`ownerUserId` vs. the
|
|
316
|
+
* connection's authenticated user) still applies to a replicated message exactly as it does to a
|
|
317
|
+
* local one. The replica has no say in who sees what.
|
|
318
|
+
*
|
|
319
|
+
* Republishing goes straight to `PubSubManager`, never back through `publishStatusUpdate`, so an
|
|
320
|
+
* inbound message cannot be re-broadcast and loop. `SourceServerId` guards the remaining case: a
|
|
321
|
+
* publisher also receives its own message from Redis.
|
|
322
|
+
*/
|
|
323
|
+
async function wirePushStatusFanOut(redisProvider: RedisLocalStorageProvider, startupLog: StartupLogger): Promise<void> {
|
|
324
|
+
try {
|
|
325
|
+
await redisProvider.SubscribeToChannel(PUSH_STATUS_FANOUT_CHANNEL, (raw: string) => {
|
|
326
|
+
try {
|
|
327
|
+
const payload = ParseReplicatedStatusUpdate(raw, MJGlobal.Instance.ProcessUUID);
|
|
328
|
+
if (!payload) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Rebuilt as a plain record: the topic's publish signature takes an index-signature type,
|
|
332
|
+
// and listing the fields keeps the wire shape explicit at the one place it crosses hosts.
|
|
333
|
+
PubSubManager.Instance.Publish(PUSH_STATUS_UPDATES_TOPIC, {
|
|
334
|
+
sessionId: payload.sessionId,
|
|
335
|
+
ownerUserId: payload.ownerUserId,
|
|
336
|
+
message: payload.message,
|
|
337
|
+
SourceServerId: payload.SourceServerId,
|
|
338
|
+
});
|
|
339
|
+
} catch {
|
|
340
|
+
// A malformed message on a shared channel must not take down the subscriber.
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
SetPushStatusPublishHook((payload) => {
|
|
345
|
+
redisProvider.PublishMessage(PUSH_STATUS_FANOUT_CHANNEL, JSON.stringify(payload));
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Printed unconditionally, not verbose-gated. "Is fan-out actually on?" is the first question
|
|
349
|
+
// anyone debugging a hung conversation behind a load balancer asks, and a silent default left
|
|
350
|
+
// no way to answer it.
|
|
351
|
+
console.log('[MJAPI] Push-status updates: cross-instance fan-out enabled via Redis');
|
|
352
|
+
} catch (err) {
|
|
353
|
+
// Single-instance delivery still works, and the durable tail query covers the rest. Degraded,
|
|
354
|
+
// not broken — so this must not stop the server from starting.
|
|
355
|
+
console.warn(`Push-status fan-out unavailable: ${(err as Error).message}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
295
359
|
// Bind MJStorage as the conversation-attachment blob store. The attachment service itself no longer
|
|
296
360
|
// imports `@memberjunction/storage` — that dependency made it unusable from any browser or React
|
|
297
361
|
// Native client, which is why the same attachment rules had been reimplemented three times.
|
|
@@ -720,6 +784,15 @@ const setupComplete$ = new ReplaySubject(1);
|
|
|
720
784
|
}
|
|
721
785
|
});
|
|
722
786
|
|
|
787
|
+
// Fan push-status updates across server instances (MJ #4222).
|
|
788
|
+
//
|
|
789
|
+
// Behind a load balancer the browser's WebSocket lives on one replica while the mutation that
|
|
790
|
+
// drives the agent can be handled by another. The push topic is an in-process PubSub, so a
|
|
791
|
+
// completion published on replica B never reaches a subscriber on replica A — the browser waits
|
|
792
|
+
// forever for an event that was delivered to nobody. Replicating progress and completion over
|
|
793
|
+
// Redis closes that, and the durable tail query remains the backstop if Redis is down.
|
|
794
|
+
await wirePushStatusFanOut(redisProvider, startupLog);
|
|
795
|
+
|
|
723
796
|
startupLog.LogIf('verbose', `Redis cache provider connected: ${process.env.REDIS_URL}`);
|
|
724
797
|
}
|
|
725
798
|
|
|
@@ -1075,6 +1148,15 @@ const setupComplete$ = new ReplaySubject(1);
|
|
|
1075
1148
|
}
|
|
1076
1149
|
});
|
|
1077
1150
|
|
|
1151
|
+
// graphql-ws's ws integration takes its keepalive as a THIRD POSITIONAL argument to useServer,
|
|
1152
|
+
// defaulting to 12_000 when omitted. That default was doing real work here while being invisible
|
|
1153
|
+
// at the call site: the server pings every 12s and terminates the socket after an unanswered
|
|
1154
|
+
// pong, which is why MJAPI notices a half-open link in ~12-24s while the browser — whose
|
|
1155
|
+
// graphql-ws client does nothing on an unanswered pong — noticed nothing at all (MJ #4222).
|
|
1156
|
+
// Stated explicitly so the behaviour is visible and tunable, and so the next reader does not
|
|
1157
|
+
// conclude from the call site that no server-side heartbeat exists. Value unchanged.
|
|
1158
|
+
const WS_SERVER_KEEPALIVE_MS = 12_000;
|
|
1159
|
+
|
|
1078
1160
|
// Track per-connection expiry timers so we can clean them up on close
|
|
1079
1161
|
const expiryTimers = new WeakMap<object, ReturnType<typeof setTimeout>>();
|
|
1080
1162
|
|
|
@@ -1149,7 +1231,8 @@ const setupComplete$ = new ReplaySubject(1);
|
|
|
1149
1231
|
console.error('WebSocket error:', errors);
|
|
1150
1232
|
},
|
|
1151
1233
|
},
|
|
1152
|
-
webSocketServer
|
|
1234
|
+
webSocketServer,
|
|
1235
|
+
WS_SERVER_KEEPALIVE_MS
|
|
1153
1236
|
);
|
|
1154
1237
|
|
|
1155
1238
|
const apolloServer = buildApolloServer(
|
|
@@ -1580,8 +1663,25 @@ const setupComplete$ = new ReplaySubject(1);
|
|
|
1580
1663
|
// instance is still heart-beating. The watchdog also self-registers for graceful-shutdown
|
|
1581
1664
|
// cancellation (via ShutdownRegistry) once it begins tracking this process's first live run.
|
|
1582
1665
|
if (resumeUser && Metadata.Provider instanceof DatabaseProviderBase) { // global-provider-ok: server startup recovery — one-shot orphaned-run sweep at boot
|
|
1583
|
-
|
|
1666
|
+
const sweepUser = resumeUser;
|
|
1667
|
+
const sweepProvider = Metadata.Provider; // global-provider-ok: server startup recovery — one-shot orphaned-run sweep at boot
|
|
1668
|
+
AgentRunWatchdog.SweepOrphanedRuns(sweepProvider, sweepUser)
|
|
1584
1669
|
.catch(err => console.warn(`[AgentRunWatchdog] Startup sweep failed: ${err}`));
|
|
1670
|
+
|
|
1671
|
+
// The watchdog repairs the RUN; nothing repaired the conversation detail, which is the row the
|
|
1672
|
+
// chat actually renders from (MJ #4222). A process that dies mid-run leaves a terminal run
|
|
1673
|
+
// beside a message that still claims to be generating, and it spins forever for anyone who
|
|
1674
|
+
// opens it. Runs at boot (closes restart orphans) and on a timer (closes mid-life orphans),
|
|
1675
|
+
// mirroring the watchdog's own two-phase shape.
|
|
1676
|
+
const reconcileOrphans = () =>
|
|
1677
|
+
ReconcileOrphanedConversationDetails(sweepProvider, sweepUser)
|
|
1678
|
+
.catch(err => console.warn(`[OrphanDetailReconciler] Pass failed: ${err}`));
|
|
1679
|
+
void reconcileOrphans();
|
|
1680
|
+
const orphanDetailTimer = setInterval(() => void reconcileOrphans(), ORPHAN_DETAIL_SWEEP_INTERVAL_MS);
|
|
1681
|
+
ShutdownRegistry.Instance.Register({
|
|
1682
|
+
ShutdownName: 'OrphanDetailReconciler',
|
|
1683
|
+
Shutdown: () => { clearInterval(orphanDetailTimer); },
|
|
1684
|
+
});
|
|
1585
1685
|
}
|
|
1586
1686
|
|
|
1587
1687
|
// Launch the AI Agent Session janitor: run own-host orphan recovery once at boot, then keep a
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishes one agent run's live status on the caller's session.
|
|
3
|
+
*
|
|
4
|
+
* `RunAIAgentResolver` uses this for every status it sends. A host whose
|
|
5
|
+
* `AgentTurnHandler` answers with an In-Progress reply uses the same publisher,
|
|
6
|
+
* on that caller's session, so the chat follows the turn: significant-step
|
|
7
|
+
* progress, streamed text, the partial result, and the completion. When the
|
|
8
|
+
* turn throws instead of returning a result, call `PublishFailure` with the
|
|
9
|
+
* reply row's id and the error.
|
|
10
|
+
*/
|
|
11
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
12
|
+
import type {
|
|
13
|
+
AgentExecutionProgressCallback,
|
|
14
|
+
AgentExecutionStreamingCallback,
|
|
15
|
+
ExecuteAgentResult,
|
|
16
|
+
} from '@memberjunction/ai-core-plus';
|
|
17
|
+
import type { UserPayload } from '../types.js';
|
|
18
|
+
import { PublishStatusUpdate } from '../generic/PushStatusResolver.js';
|
|
19
|
+
|
|
20
|
+
const SIGNIFICANT_STEPS = ['prompt_execution', 'action_execution', 'subagent_execution', 'decision_processing'];
|
|
21
|
+
|
|
22
|
+
/** The run fields the published messages read. Entities and test fakes both satisfy this. */
|
|
23
|
+
interface StatusRun {
|
|
24
|
+
ID?: string;
|
|
25
|
+
ConversationDetailID?: string;
|
|
26
|
+
ErrorMessage?: string;
|
|
27
|
+
Steps?: Array<{ StepName?: string }>;
|
|
28
|
+
GetAll?: () => unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class AgentRunStatusPublisher {
|
|
32
|
+
private run: StatusRun | null = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param sessionId Session stamped on the message body. Defaults to the payload's session.
|
|
36
|
+
* The push itself is always addressed to `userPayload.sessionId`, matching the resolver.
|
|
37
|
+
*/
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly pubSub: PubSubEngine,
|
|
40
|
+
private readonly userPayload: UserPayload,
|
|
41
|
+
private readonly sessionId: string = userPayload.sessionId,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
/** Latest run observed from a progress event, including steps that are not published. */
|
|
45
|
+
public get AgentRun(): StatusRun | null {
|
|
46
|
+
return this.run;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** AgentRunner's progress callback. Noise steps are kept for {@link AgentRun} and not published. */
|
|
50
|
+
public get OnProgress(): AgentExecutionProgressCallback {
|
|
51
|
+
return (progress) => {
|
|
52
|
+
const fromEvent = progress.metadata?.agentRun as StatusRun | undefined;
|
|
53
|
+
if (fromEvent) {
|
|
54
|
+
this.run = fromEvent;
|
|
55
|
+
}
|
|
56
|
+
if (!SIGNIFICANT_STEPS.includes(progress.step)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const agentRun = fromEvent || this.run;
|
|
60
|
+
if (!agentRun?.ID) {
|
|
61
|
+
// A host that streams before the first progress event otherwise sees nothing and no reason.
|
|
62
|
+
console.error('❌ No agent run available for progress callback');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.publish('ExecutionProgress', {
|
|
66
|
+
sessionId: this.sessionId,
|
|
67
|
+
agentRunId: agentRun.ID,
|
|
68
|
+
type: 'progress',
|
|
69
|
+
agentRun: agentRun.GetAll ? agentRun.GetAll() : agentRun,
|
|
70
|
+
progress: {
|
|
71
|
+
currentStep: progress.step,
|
|
72
|
+
percentage: progress.percentage,
|
|
73
|
+
message: progress.message,
|
|
74
|
+
agentName: progress.metadata?.agentName,
|
|
75
|
+
agentType: progress.metadata?.agentType,
|
|
76
|
+
stepCount: progress.metadata?.stepCount,
|
|
77
|
+
hierarchicalStep: progress.metadata?.hierarchicalStep,
|
|
78
|
+
},
|
|
79
|
+
timestamp: new Date(),
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** AgentRunner's streaming callback. Publishes nothing until a progress event has carried the run. */
|
|
85
|
+
public get OnStreaming(): AgentExecutionStreamingCallback {
|
|
86
|
+
return (chunk) => {
|
|
87
|
+
const agentRun = this.run;
|
|
88
|
+
if (!agentRun?.ID) {
|
|
89
|
+
console.error('❌ No agent run available for streaming callback');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.publish('StreamingContent', {
|
|
93
|
+
sessionId: this.sessionId,
|
|
94
|
+
agentRunId: agentRun.ID,
|
|
95
|
+
type: 'streaming',
|
|
96
|
+
agentRun: agentRun.GetAll ? agentRun.GetAll() : agentRun,
|
|
97
|
+
streaming: {
|
|
98
|
+
content: chunk.content,
|
|
99
|
+
isPartial: !chunk.isComplete,
|
|
100
|
+
stepName: chunk.stepType,
|
|
101
|
+
agentName: chunk.modelName,
|
|
102
|
+
kind: chunk.kind,
|
|
103
|
+
},
|
|
104
|
+
timestamp: new Date(),
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The partial result, then the completion. `conversationDetailId` is the reply row the
|
|
111
|
+
* chat should update. When omitted, the completion uses the run's own detail id.
|
|
112
|
+
*/
|
|
113
|
+
public PublishFinal(result: ExecuteAgentResult, conversationDetailId?: string, resultJson?: string): void {
|
|
114
|
+
const agentRun = result.agentRun as StatusRun | undefined;
|
|
115
|
+
if (agentRun?.ID) {
|
|
116
|
+
let lastStep = 'Completed';
|
|
117
|
+
const steps = agentRun.Steps;
|
|
118
|
+
if (steps && steps.length > 0) {
|
|
119
|
+
lastStep = steps[steps.length - 1]?.StepName || 'Completed';
|
|
120
|
+
}
|
|
121
|
+
this.publish('StreamingContent', {
|
|
122
|
+
sessionId: this.sessionId,
|
|
123
|
+
agentRunId: agentRun.ID,
|
|
124
|
+
type: 'partial_result',
|
|
125
|
+
partialResult: {
|
|
126
|
+
currentStep: lastStep,
|
|
127
|
+
partialOutput: result.payload || undefined,
|
|
128
|
+
},
|
|
129
|
+
timestamp: new Date(),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
this.publish('StreamingContent', {
|
|
133
|
+
sessionId: this.sessionId,
|
|
134
|
+
agentRunId: agentRun?.ID || 'unknown',
|
|
135
|
+
type: 'complete',
|
|
136
|
+
timestamp: new Date(),
|
|
137
|
+
conversationDetailId: conversationDetailId || agentRun?.ConversationDetailID,
|
|
138
|
+
success: result.success,
|
|
139
|
+
errorMessage: agentRun?.ErrorMessage || undefined,
|
|
140
|
+
result: resultJson || undefined,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Completion for a failure that has no run. The fire-and-forget path uses this when
|
|
146
|
+
* `executeAIAgent` rejects. A host uses it the same way: when the turn throws
|
|
147
|
+
* instead of returning a result, call `PublishFailure` with the reply row's id
|
|
148
|
+
* and the error. That completion is what tells the chat the reply finished.
|
|
149
|
+
*/
|
|
150
|
+
public PublishFailure(conversationDetailId: string | undefined, errorMessage: string): void {
|
|
151
|
+
this.publish('StreamingContent', {
|
|
152
|
+
sessionId: this.sessionId,
|
|
153
|
+
agentRunId: 'unknown',
|
|
154
|
+
type: 'complete',
|
|
155
|
+
timestamp: new Date(),
|
|
156
|
+
conversationDetailId,
|
|
157
|
+
success: false,
|
|
158
|
+
errorMessage,
|
|
159
|
+
result: JSON.stringify({ success: false, errorMessage }),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private publish(type: 'ExecutionProgress' | 'StreamingContent', data: unknown): void {
|
|
164
|
+
PublishStatusUpdate(this.pubSub, {
|
|
165
|
+
sessionId: this.userPayload.sessionId,
|
|
166
|
+
ownerUserId: this.userPayload?.userRecord?.ID ?? '',
|
|
167
|
+
message: JSON.stringify({
|
|
168
|
+
resolver: 'RunAIAgentResolver',
|
|
169
|
+
type,
|
|
170
|
+
status: 'ok',
|
|
171
|
+
data,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { Arg, Ctx, Field, Int, ObjectType, Query, Resolver } from 'type-graphql';
|
|
2
|
+
import { RunView } from '@memberjunction/core';
|
|
3
|
+
import { EscapeSQLString } from '@memberjunction/global';
|
|
4
|
+
import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
5
|
+
import type { MJAIAgentRunEntity, MJAIAgentRunStepEntity, MJConversationDetailEntity } from '@memberjunction/core-entities';
|
|
6
|
+
import type { AppContext } from '../types.js';
|
|
7
|
+
import { GetReadOnlyProvider } from '../util.js';
|
|
8
|
+
|
|
9
|
+
/** The run columns the tail reads. */
|
|
10
|
+
type TailRunRow = Pick<MJAIAgentRunEntity, 'ID' | 'Status' | 'Result'>;
|
|
11
|
+
|
|
12
|
+
/** The step columns an event carries. The large data columns are never read. */
|
|
13
|
+
type TailStepRow = Pick<
|
|
14
|
+
MJAIAgentRunStepEntity,
|
|
15
|
+
'StepNumber' | 'StepType' | 'StepName' | 'Status' | 'Success' | 'ErrorMessage' | 'StartedAt' | 'CompletedAt'
|
|
16
|
+
>;
|
|
17
|
+
|
|
18
|
+
const RUN_FIELDS: (keyof TailRunRow)[] = ['ID', 'Status', 'Result'];
|
|
19
|
+
const STEP_FIELDS: (keyof TailStepRow)[] = [
|
|
20
|
+
'StepNumber', 'StepType', 'StepName', 'Status', 'Success', 'ErrorMessage', 'StartedAt', 'CompletedAt',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One step of an agent run, projected for the client's progress view.
|
|
25
|
+
*
|
|
26
|
+
* Carries metadata only. `InputData`, `OutputData` and the payload snapshots are
|
|
27
|
+
* deliberately excluded: they are the largest columns on the row and a run can hold
|
|
28
|
+
* hundreds of steps, so including them would let a single tail call return tens of
|
|
29
|
+
* megabytes. The final result is delivered once, via `FinalPayload`, when the run
|
|
30
|
+
* reaches a terminal state.
|
|
31
|
+
*/
|
|
32
|
+
@ObjectType()
|
|
33
|
+
export class ConversationRunEvent {
|
|
34
|
+
/** Monotonic position within the run. The cursor a client pages from. */
|
|
35
|
+
@Field(() => Int)
|
|
36
|
+
Seq: number;
|
|
37
|
+
|
|
38
|
+
@Field(() => String)
|
|
39
|
+
StepType: string;
|
|
40
|
+
|
|
41
|
+
@Field(() => String)
|
|
42
|
+
StepName: string;
|
|
43
|
+
|
|
44
|
+
@Field(() => String)
|
|
45
|
+
Status: string;
|
|
46
|
+
|
|
47
|
+
@Field(() => Boolean, { nullable: true })
|
|
48
|
+
Success?: boolean;
|
|
49
|
+
|
|
50
|
+
@Field(() => String, { nullable: true })
|
|
51
|
+
ErrorMessage?: string;
|
|
52
|
+
|
|
53
|
+
@Field(() => Date)
|
|
54
|
+
StartedAt: Date;
|
|
55
|
+
|
|
56
|
+
@Field(() => Date, { nullable: true })
|
|
57
|
+
CompletedAt?: Date;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Result of a tail call. */
|
|
61
|
+
@ObjectType()
|
|
62
|
+
export class ConversationRunEventsOutput {
|
|
63
|
+
@Field(() => Boolean)
|
|
64
|
+
Success: boolean;
|
|
65
|
+
|
|
66
|
+
@Field(() => String)
|
|
67
|
+
Message: string;
|
|
68
|
+
|
|
69
|
+
@Field(() => [ConversationRunEvent])
|
|
70
|
+
Events: ConversationRunEvent[];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Highest `Seq` the caller has now seen. Pass it back as `sinceSeq` on the next call.
|
|
74
|
+
* Echoes the request's `sinceSeq` when nothing new has landed, so a client that polls
|
|
75
|
+
* a quiet run never rewinds.
|
|
76
|
+
*/
|
|
77
|
+
@Field(() => Int)
|
|
78
|
+
LatestSeq: number;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* True while the run is still executing, including a run `Paused` on a workflow that is still
|
|
82
|
+
* running. False means terminal — stop tailing, and `FinalPayload` / `RunStatus` hold the
|
|
83
|
+
* outcome. A failed call (`Success` false) reports true, because it knows nothing about the run.
|
|
84
|
+
*/
|
|
85
|
+
@Field(() => Boolean)
|
|
86
|
+
IsInFlight: boolean;
|
|
87
|
+
|
|
88
|
+
@Field(() => String, { nullable: true })
|
|
89
|
+
RunID?: string;
|
|
90
|
+
|
|
91
|
+
@Field(() => String, { nullable: true })
|
|
92
|
+
RunStatus?: string;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The run's answer, returned only once the run is terminal.
|
|
96
|
+
*
|
|
97
|
+
* Sourced from `AIAgentRun.Result`, falling back to the conversation detail's message because
|
|
98
|
+
* `Result` is agent-dependent and null on many successful runs. Still nullable: a caller must
|
|
99
|
+
* decide a message is finished from {@link IsInFlight} / {@link DetailStatus}, never from the
|
|
100
|
+
* presence of this field, or it will wait forever on an agent that writes neither.
|
|
101
|
+
*/
|
|
102
|
+
@Field(() => String, { nullable: true })
|
|
103
|
+
FinalPayload?: string;
|
|
104
|
+
|
|
105
|
+
/** `ConversationDetail.Status` — the value the chat UI renders from. */
|
|
106
|
+
@Field(() => String, { nullable: true })
|
|
107
|
+
DetailStatus?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every read here sets `BypassCache`. This is the last-resort recovery path, and the rows it
|
|
112
|
+
* judges are written by callers that fire no cache invalidation — `spSweepStaleAIAgentRuns`
|
|
113
|
+
* force-fails a run with a direct set-based UPDATE. `TrustServerCacheCompletely` defaults on, so
|
|
114
|
+
* a cached read would keep reporting a swept run as `Running` and recovery would never fire.
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/** Ceiling on events returned per call. A client with a gap larger than this pages. */
|
|
118
|
+
const MAX_EVENTS_PER_TAIL = 200;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Run statuses that mean execution is still in progress. `Paused` is a run parked on a workflow
|
|
122
|
+
* that is still executing; the orphan reconciler and the process panel treat it the same way.
|
|
123
|
+
*/
|
|
124
|
+
const IN_FLIGHT_STATUSES: MJAIAgentRunEntity['Status'][] = ['Running', 'Paused'];
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Durable, resumable read of an agent run's progress (MJ #4222).
|
|
128
|
+
*
|
|
129
|
+
* The `statusUpdates` subscription is the only live path a conversation has, and it has no
|
|
130
|
+
* replay: the server topic is an in-memory PubSub and the client subject is explicitly
|
|
131
|
+
* unbuffered. Anything published while a socket is half-open is gone, and the client cannot
|
|
132
|
+
* tell the difference between "nothing happened" and "I missed everything".
|
|
133
|
+
*
|
|
134
|
+
* This query is the durable counterpart, mirroring `IntegrationTailRunEvents` — the same
|
|
135
|
+
* shape the integration subsystem already uses, where the subscription is a latency
|
|
136
|
+
* optimization over a log rather than the only delivery path. A client keeps the `LatestSeq`
|
|
137
|
+
* it has seen and re-reads from there after any interruption.
|
|
138
|
+
*
|
|
139
|
+
* It is a READ MODEL over tables that already exist. `AIAgentRunStep` rows are inserted when
|
|
140
|
+
* a step starts and updated when it finishes, so progress is visible mid-run, and
|
|
141
|
+
* `StepNumber` is already monotonic per run. No new table and no migration.
|
|
142
|
+
*
|
|
143
|
+
* Authorization is delegated to `RunView` executed as the calling user, so MJ's existing
|
|
144
|
+
* row-level security decides what is visible. A caller who cannot read the conversation
|
|
145
|
+
* detail gets the same answer as one asking about a detail that does not exist.
|
|
146
|
+
*/
|
|
147
|
+
@Resolver()
|
|
148
|
+
export class ConversationRunEventsResolver {
|
|
149
|
+
@Query(() => ConversationRunEventsOutput)
|
|
150
|
+
async TailConversationEvents(
|
|
151
|
+
@Arg('conversationDetailID', () => String) conversationDetailID: string,
|
|
152
|
+
@Ctx() ctx: AppContext,
|
|
153
|
+
@Arg('sinceSeq', () => Int, { defaultValue: 0 }) sinceSeq?: number
|
|
154
|
+
): Promise<ConversationRunEventsOutput> {
|
|
155
|
+
const from = Math.max(0, sinceSeq ?? 0);
|
|
156
|
+
const empty = (message: string): ConversationRunEventsOutput => ({
|
|
157
|
+
Success: false,
|
|
158
|
+
Message: message,
|
|
159
|
+
Events: [],
|
|
160
|
+
LatestSeq: from,
|
|
161
|
+
IsInFlight: true,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const user = ctx.userPayload?.userRecord;
|
|
166
|
+
if (!user) {
|
|
167
|
+
return empty('User is not authenticated');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true });
|
|
171
|
+
const detail = await this.loadDetail(provider, conversationDetailID, user);
|
|
172
|
+
if (!detail) {
|
|
173
|
+
// Not found and not authorized are deliberately indistinguishable — answering
|
|
174
|
+
// "exists but denied" would confirm the id to someone probing for one.
|
|
175
|
+
return empty(`Conversation detail '${conversationDetailID}' not found`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const run = await this.loadLatestRun(provider, conversationDetailID, user);
|
|
179
|
+
if (!run) {
|
|
180
|
+
// The mutation is acknowledged before the run row exists, so this is an
|
|
181
|
+
// ordinary early-poll result, not an error. Reported as success with nothing
|
|
182
|
+
// to show so a client does not treat a race as a failure.
|
|
183
|
+
return {
|
|
184
|
+
Success: true,
|
|
185
|
+
Message: 'No agent run yet for this conversation detail',
|
|
186
|
+
Events: [],
|
|
187
|
+
LatestSeq: from,
|
|
188
|
+
IsInFlight: false,
|
|
189
|
+
DetailStatus: detail.Status ?? undefined,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const isInFlight = IN_FLIGHT_STATUSES.includes(run.Status);
|
|
194
|
+
const steps = await this.loadSteps(provider, run.ID, from, user);
|
|
195
|
+
const events = steps.map((s) => this.toEvent(s));
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
Success: true,
|
|
199
|
+
Message: `${events.length} event(s)`,
|
|
200
|
+
Events: events,
|
|
201
|
+
// Never rewind: a quiet run returns the cursor the caller already held.
|
|
202
|
+
LatestSeq: events.length > 0 ? events[events.length - 1].Seq : from,
|
|
203
|
+
IsInFlight: isInFlight,
|
|
204
|
+
RunID: run.ID,
|
|
205
|
+
RunStatus: run.Status,
|
|
206
|
+
// Only meaningful once terminal; sending it mid-run would hand the client a
|
|
207
|
+
// partial payload it could mistake for the final answer.
|
|
208
|
+
//
|
|
209
|
+
// `AIAgentRun.Result` is agent-dependent — it is null on a large share of
|
|
210
|
+
// genuinely successful runs — so the conversation detail's own message is the
|
|
211
|
+
// fallback. That is the text the chat renders, so it is the answer a caller
|
|
212
|
+
// waiting on this field actually wants.
|
|
213
|
+
FinalPayload: isInFlight ? undefined : (run.Result ?? detail.Message ?? undefined),
|
|
214
|
+
DetailStatus: detail.Status ?? undefined,
|
|
215
|
+
};
|
|
216
|
+
} catch (e) {
|
|
217
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
218
|
+
return empty(`TailConversationEvents failed: ${message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Loads the conversation detail as the calling user; null when absent or not permitted. */
|
|
223
|
+
private async loadDetail(
|
|
224
|
+
provider: IMetadataProvider,
|
|
225
|
+
conversationDetailID: string,
|
|
226
|
+
user: UserInfo
|
|
227
|
+
): Promise<MJConversationDetailEntity | undefined> {
|
|
228
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
229
|
+
const result = await rv.RunView<MJConversationDetailEntity>(
|
|
230
|
+
{
|
|
231
|
+
EntityName: 'MJ: Conversation Details',
|
|
232
|
+
ExtraFilter: `ID='${this.escape(conversationDetailID)}'`,
|
|
233
|
+
MaxRows: 1,
|
|
234
|
+
ResultType: 'entity_object',
|
|
235
|
+
BypassCache: true,
|
|
236
|
+
},
|
|
237
|
+
user
|
|
238
|
+
);
|
|
239
|
+
return result.Success ? result.Results?.[0] : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Newest run for the detail. A conversation detail can be retried, and the latest run is
|
|
244
|
+
* the one the UI is showing.
|
|
245
|
+
*/
|
|
246
|
+
private async loadLatestRun(
|
|
247
|
+
provider: IMetadataProvider,
|
|
248
|
+
conversationDetailID: string,
|
|
249
|
+
user: UserInfo
|
|
250
|
+
): Promise<TailRunRow | undefined> {
|
|
251
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
252
|
+
const result = await rv.RunView<TailRunRow>(
|
|
253
|
+
{
|
|
254
|
+
EntityName: 'MJ: AI Agent Runs',
|
|
255
|
+
ExtraFilter: `ConversationDetailID='${this.escape(conversationDetailID)}'`,
|
|
256
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
257
|
+
MaxRows: 1,
|
|
258
|
+
Fields: RUN_FIELDS,
|
|
259
|
+
ResultType: 'simple',
|
|
260
|
+
BypassCache: true,
|
|
261
|
+
},
|
|
262
|
+
user
|
|
263
|
+
);
|
|
264
|
+
return result.Success ? result.Results?.[0] : undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Steps after `sinceSeq`, oldest first, bounded by {@link MAX_EVENTS_PER_TAIL}. */
|
|
268
|
+
private async loadSteps(
|
|
269
|
+
provider: IMetadataProvider,
|
|
270
|
+
agentRunID: string,
|
|
271
|
+
sinceSeq: number,
|
|
272
|
+
user: UserInfo
|
|
273
|
+
): Promise<TailStepRow[]> {
|
|
274
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
275
|
+
const result = await rv.RunView<TailStepRow>(
|
|
276
|
+
{
|
|
277
|
+
EntityName: 'MJ: AI Agent Run Steps',
|
|
278
|
+
ExtraFilter: `AgentRunID='${this.escape(agentRunID)}' AND StepNumber > ${Math.floor(sinceSeq)}`,
|
|
279
|
+
OrderBy: 'StepNumber ASC',
|
|
280
|
+
MaxRows: MAX_EVENTS_PER_TAIL,
|
|
281
|
+
Fields: STEP_FIELDS,
|
|
282
|
+
ResultType: 'simple',
|
|
283
|
+
BypassCache: true,
|
|
284
|
+
},
|
|
285
|
+
user
|
|
286
|
+
);
|
|
287
|
+
return result.Success ? (result.Results ?? []) : [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Projects a plain step row to an event. Plain rows may carry dates as strings. */
|
|
291
|
+
private toEvent(step: TailStepRow): ConversationRunEvent {
|
|
292
|
+
return {
|
|
293
|
+
Seq: step.StepNumber,
|
|
294
|
+
StepType: step.StepType,
|
|
295
|
+
StepName: step.StepName,
|
|
296
|
+
Status: step.Status,
|
|
297
|
+
Success: step.Success ?? undefined,
|
|
298
|
+
ErrorMessage: step.ErrorMessage ?? undefined,
|
|
299
|
+
StartedAt: new Date(step.StartedAt),
|
|
300
|
+
CompletedAt: step.CompletedAt != null ? new Date(step.CompletedAt) : undefined,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Escapes a value for the `ExtraFilter` string. Ids are UUIDs, but never trust the shape. */
|
|
305
|
+
private escape(value: string): string {
|
|
306
|
+
return EscapeSQLString(value);
|
|
307
|
+
}
|
|
308
|
+
}
|