@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.
Files changed (93) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/generated/graphql-schemas/__mj.d.ts +1010 -0
  3. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  4. package/dist/generated/graphql-schemas/__mj.js +5178 -3
  5. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  6. package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
  7. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  8. package/dist/generic/FireAndForgetHeartbeat.js +16 -2
  9. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  10. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
  11. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
  12. package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
  13. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
  14. package/dist/generic/PushStatusResolver.d.ts +38 -0
  15. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  16. package/dist/generic/PushStatusResolver.js +84 -1
  17. package/dist/generic/PushStatusResolver.js.map +1 -1
  18. package/dist/generic/ResolverBase.d.ts +13 -1
  19. package/dist/generic/ResolverBase.d.ts.map +1 -1
  20. package/dist/generic/ResolverBase.js +49 -47
  21. package/dist/generic/ResolverBase.js.map +1 -1
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +90 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
  27. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
  28. package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
  29. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
  30. package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
  31. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
  32. package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
  33. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
  34. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
  35. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
  36. package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
  37. package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
  38. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  39. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
  40. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  41. package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
  42. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  43. package/dist/resolvers/MergeRecordsResolver.js +16 -2
  44. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  45. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
  46. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
  48. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  49. package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
  50. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RunAIAgentResolver.js +23 -153
  52. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  53. package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
  54. package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
  55. package/dist/resolvers/RunDecisionResolver.js +396 -0
  56. package/dist/resolvers/RunDecisionResolver.js.map +1 -0
  57. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  58. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  59. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  60. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  61. package/package.json +94 -92
  62. package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
  63. package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
  64. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  65. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
  66. package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
  67. package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
  68. package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
  69. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
  70. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
  71. package/src/__tests__/RunDecisionResolver.test.ts +699 -0
  72. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
  73. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  74. package/src/__tests__/conversationRunEvents.test.ts +399 -0
  75. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
  76. package/src/__tests__/pushStatusFanOut.test.ts +206 -0
  77. package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
  78. package/src/generated/graphql-schemas/__mj.ts +3637 -4
  79. package/src/generic/FireAndForgetHeartbeat.ts +16 -2
  80. package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
  81. package/src/generic/PushStatusResolver.ts +97 -1
  82. package/src/generic/ResolverBase.ts +49 -49
  83. package/src/index.ts +102 -2
  84. package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
  85. package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
  86. package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
  87. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
  88. package/src/resolvers/MergeRecordsResolver.ts +14 -2
  89. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  90. package/src/resolvers/RunAIAgentResolver.ts +28 -174
  91. package/src/resolvers/RunDecisionResolver.ts +435 -0
  92. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  93. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
@@ -0,0 +1,232 @@
1
+ import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Float, Int } from 'type-graphql';
2
+ import { AppContext, UserPayload } from '../types.js';
3
+ import { EntityFieldInfo, EntityInfo, IMetadataProvider, LogError, Metadata } from '@memberjunction/core';
4
+ import { DuplicateRecordDetector } from '@memberjunction/ai-vector-dupe';
5
+ import type { DuplicateEntryCheckResult, DuplicateEntryCheckStatus } from '@memberjunction/ai-vector-dupe';
6
+ import { ResolverBase } from '../generic/ResolverBase.js';
7
+ import { GetReadOnlyProvider } from '../util.js';
8
+
9
+ /**
10
+ * The most characters `valuesJSON` may hold. A larger request is refused before it is parsed. The
11
+ * form sends each field's text cut to 500 characters, so a real entry stays well under it; the
12
+ * detector then cuts what reaches the template and the decision model on its own.
13
+ */
14
+ export const DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH = 16 * 1024;
15
+
16
+ /**
17
+ * How a `CheckDuplicateEntry` call ended: a detector status, or `NotAuthorized` when the caller may
18
+ * not run the check (an API key without the scopes, or a user who cannot read the entity). Nothing
19
+ * ran, and a form stops asking about the entity for the session, as it does on `NotConfigured`.
20
+ */
21
+ export type DuplicateEntryCheckOutputStatus = DuplicateEntryCheckStatus | 'NotAuthorized';
22
+
23
+ /** One entered value, as `valuesJSON` may carry it. */
24
+ type EntryValue = string | number | boolean | null;
25
+
26
+ /** One existing record the entry-time check flags as a possible duplicate. */
27
+ @ObjectType()
28
+ export class DuplicateEntryCandidateOutput {
29
+ /** The candidate's primary key as a compact URL segment; `CompositeKey.FromURLSegment` reads it back. */
30
+ @Field()
31
+ RecordID: string;
32
+
33
+ /** The candidate's name, or its key when the caller may not read a name. */
34
+ @Field()
35
+ DisplayName: string;
36
+
37
+ /** The vector similarity score that surfaced the candidate. */
38
+ @Field(() => Float)
39
+ VectorScore: number;
40
+
41
+ /** The decision's probability that the candidate is the same entity; null when it gave no answer. */
42
+ @Field(() => Float, { nullable: true })
43
+ Probability: number | null;
44
+ }
45
+
46
+ /**
47
+ * The result of the `CheckDuplicateEntry` mutation. The mutation never throws to the client: an
48
+ * authorization denial is `Status: 'NotAuthorized'` and any other failure is `Status: 'Failed'`,
49
+ * each with an `ErrorMessage`.
50
+ */
51
+ @ObjectType()
52
+ export class DuplicateEntryCheckOutput {
53
+ /** `Checked`, `NotConfigured`, `NotAuthorized` or `Failed`. */
54
+ @Field(() => String)
55
+ Status: DuplicateEntryCheckOutputStatus;
56
+
57
+ @Field({ nullable: true })
58
+ ErrorMessage?: string;
59
+
60
+ /** The flagged candidates, most probable first. Empty unless `Status` is `Checked`. */
61
+ @Field(() => [DuplicateEntryCandidateOutput])
62
+ Candidates: DuplicateEntryCandidateOutput[];
63
+
64
+ /** How long the check took on the server, in milliseconds. */
65
+ @Field(() => Int)
66
+ ElapsedMs: number;
67
+ }
68
+
69
+ /** What the resolver needs from the detector. `DuplicateRecordDetector` provides it. */
70
+ export type DuplicateEntryChecker = Pick<DuplicateRecordDetector, 'CheckRecordValues'>;
71
+
72
+ /**
73
+ * Checks the values a person is entering for a new record against the entity's existing records,
74
+ * through `DuplicateRecordDetector.CheckRecordValues`, and returns the plausible duplicates to flag.
75
+ * It only flags: it saves, merges and blocks nothing.
76
+ */
77
+ @Resolver()
78
+ export class DuplicateEntryCheckResolver extends ResolverBase {
79
+ /**
80
+ * Flags existing records that may duplicate a new record being entered.
81
+ *
82
+ * Authorization comes before any other work. An API key needs two scopes: `view:run` on the
83
+ * entity, the scope `RunDynamicView` checks, since the check reads the entity's records; and
84
+ * `prompt:execute`, the scope `ExecuteSimplePrompt` checks, since each check pays for an embedding
85
+ * and a decision-model call. A session without an API key skips both. Then the user needs read
86
+ * permission on the entity. A denial is `NotAuthorized`, and is not logged. The detector then
87
+ * narrows the candidates to the rows the user can read.
88
+ *
89
+ * The detector bounds the check's time on the server (`DUPLICATE_ENTRY_CHECK_SERVER_BUDGET_MS` in
90
+ * `@memberjunction/ai-vector-dupe`), a little above the form's own budget.
91
+ *
92
+ * @param entityName The entity the new record belongs to.
93
+ * @param valuesJSON The entered values as the JSON of an object, by field name, at most
94
+ * {@link DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH} characters. Each value is a string,
95
+ * number, boolean or null. Fields the entity does not have, primary keys, read-only fields and
96
+ * MJ system fields are ignored.
97
+ */
98
+ @Mutation(() => DuplicateEntryCheckOutput)
99
+ async CheckDuplicateEntry(
100
+ @Arg('entityName') entityName: string,
101
+ @Arg('valuesJSON') valuesJSON: string,
102
+ @Ctx() { userPayload, providers }: AppContext
103
+ ): Promise<DuplicateEntryCheckOutput> {
104
+ const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
105
+ return this.RunEntryCheck(entityName, valuesJSON, userPayload, provider);
106
+ }
107
+
108
+ /**
109
+ * The check without the GraphQL plumbing: authorizes, reads the values, and runs the detector.
110
+ * Never throws.
111
+ *
112
+ * @param provider the request's metadata provider; the global one when null
113
+ */
114
+ public async RunEntryCheck(
115
+ entityName: string,
116
+ valuesJSON: string,
117
+ userPayload: UserPayload,
118
+ provider: IMetadataProvider | null
119
+ ): Promise<DuplicateEntryCheckOutput> {
120
+ const startTime = Date.now();
121
+ const entity = (provider ?? new Metadata()).EntityByName(entityName);
122
+ if (!entity) {
123
+ return this.failure('Failed', 'Entity not found in metadata', startTime);
124
+ }
125
+ const denial = await this.authorizeEntryCheck(entity, userPayload, provider);
126
+ if (denial) {
127
+ return this.failure('NotAuthorized', denial, startTime);
128
+ }
129
+ try {
130
+ const user = this.GetUserFromPayload(userPayload);
131
+ if (!user) {
132
+ return this.failure('Failed', 'Unable to determine current user', startTime);
133
+ }
134
+ const values = this.parseValues(valuesJSON, entity);
135
+ if ('Error' in values) {
136
+ return this.failure('Failed', values.Error, startTime);
137
+ }
138
+ const result = await this.CreateEntryChecker(provider).CheckRecordValues(entity.Name, values.Values, user);
139
+ return this.mapResult(result);
140
+ } catch (error) {
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ LogError(`CheckDuplicateEntry failed for ${entityName}: ${message}`);
143
+ return this.failure('Failed', message || 'Unknown error occurred', startTime);
144
+ }
145
+ }
146
+
147
+ /** The detector a check runs on, bound to the request's provider. */
148
+ protected CreateEntryChecker(provider: IMetadataProvider | null): DuplicateEntryChecker {
149
+ return new DuplicateRecordDetector(provider);
150
+ }
151
+
152
+ /**
153
+ * Why the caller may not run the check, or null when they may: the API-key scopes, then the
154
+ * user's read permission on the entity. A denial is an answer, not a fault, so nothing is logged.
155
+ */
156
+ private async authorizeEntryCheck(
157
+ entity: EntityInfo,
158
+ userPayload: UserPayload,
159
+ provider: IMetadataProvider | null
160
+ ): Promise<string | null> {
161
+ try {
162
+ await this.CheckAPIKeyScopeAuthorization('view:run', entity.Name, userPayload);
163
+ await this.CheckAPIKeyScopeAuthorization('prompt:execute', '*', userPayload);
164
+ this.CheckUserReadPermissions(entity.Name, userPayload, provider ?? undefined);
165
+ return null;
166
+ } catch (error) {
167
+ return error instanceof Error ? error.message : String(error);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Reads the entered values: the JSON of an object of scalar values, at most
173
+ * {@link DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH} characters, kept to the fields a person
174
+ * can enter (matched by name, case-insensitively, and keyed by the field's real name).
175
+ */
176
+ private parseValues(valuesJSON: string, entity: EntityInfo): { Values: Record<string, EntryValue> } | { Error: string } {
177
+ if (valuesJSON.length > DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH) {
178
+ return { Error: `valuesJSON is ${valuesJSON.length} characters; the limit is ${DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH}` };
179
+ }
180
+ let parsed: unknown;
181
+ try {
182
+ parsed = JSON.parse(valuesJSON);
183
+ } catch (e) {
184
+ return { Error: `valuesJSON is not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
185
+ }
186
+ if (!this.isPlainObject(parsed)) {
187
+ return { Error: 'valuesJSON must be the JSON of an object of field values' };
188
+ }
189
+ const values: Record<string, EntryValue> = {};
190
+ for (const [name, value] of Object.entries(parsed)) {
191
+ if (!this.isEntryValue(value)) {
192
+ return { Error: `valuesJSON field '${name}' must be a string, number, boolean or null` };
193
+ }
194
+ const field = entity.FieldByName(name);
195
+ if (field && this.isEnterable(field)) {
196
+ values[field.Name] = value;
197
+ }
198
+ }
199
+ return { Values: values };
200
+ }
201
+
202
+ /** Whether a person can enter the field: not a primary key, not read-only, not an MJ system field. */
203
+ private isEnterable(field: EntityFieldInfo): boolean {
204
+ return !field.IsPrimaryKey && !field.ReadOnly && !field.Name.startsWith('__mj_');
205
+ }
206
+
207
+ private isEntryValue(value: unknown): value is EntryValue {
208
+ return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
209
+ }
210
+
211
+ private isPlainObject(value: unknown): value is Record<string, unknown> {
212
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
213
+ }
214
+
215
+ private mapResult(result: DuplicateEntryCheckResult): DuplicateEntryCheckOutput {
216
+ return {
217
+ Status: result.Status,
218
+ ErrorMessage: result.ErrorMessage,
219
+ Candidates: result.Candidates.map(c => ({
220
+ RecordID: c.RecordID,
221
+ DisplayName: c.DisplayName,
222
+ VectorScore: c.VectorScore,
223
+ Probability: c.Probability,
224
+ })),
225
+ ElapsedMs: result.ElapsedMs,
226
+ };
227
+ }
228
+
229
+ private failure(status: DuplicateEntryCheckOutputStatus, errorMessage: string, startTime: number): DuplicateEntryCheckOutput {
230
+ return { Status: status, ErrorMessage: errorMessage, Candidates: [], ElapsedMs: Date.now() - startTime };
231
+ }
232
+ }
@@ -5,6 +5,7 @@ import { MJEntityDocumentEntity, MJVectorIndexEntity, MJVectorDatabaseEntity } f
5
5
  import { ResolverBase } from '../generic/ResolverBase.js';
6
6
  import { GetAIAPIKey } from '@memberjunction/ai';
7
7
  import { VectorDBBase } from '@memberjunction/ai-vectordb';
8
+ import { AIEngine } from '@memberjunction/aiengine';
8
9
  import { EscapeSQLString, MJGlobal, UUIDsEqual } from '@memberjunction/global';
9
10
 
10
11
  /* ───── GraphQL types ───── */
@@ -99,7 +100,7 @@ export class FetchEntityVectorsResolver extends ResolverBase {
99
100
  const metadataFilter: Record<string, unknown> = { Entity: { $eq: entityName } };
100
101
 
101
102
  const queryResponse = await vectorDBInstance.QueryIndex({
102
- id: vectorIndex.Name, // index name (stripped before Pinecone query)
103
+ id: AIEngine.Instance.GetProviderIndexName(vectorIndex),
103
104
  vector: uniformVector,
104
105
  topK: limit,
105
106
  includeMetadata: true,
@@ -50,7 +50,17 @@ export class RecordDependencyResult {
50
50
  FieldName: string; // required
51
51
 
52
52
  @Field(() => CompositeKeyOutputType)
53
+ PrimaryKey: CompositeKey;
54
+
55
+ /** The same key under its pre-6.2 name, so older clients keep working. */
56
+ @Field(() => CompositeKeyOutputType, { deprecationReason: 'Use PrimaryKey. CompositeKey will be removed in a future release.' })
53
57
  CompositeKey: CompositeKey;
58
+
59
+ @Field(() => Boolean, { nullable: true })
60
+ IsSoftLink?: boolean;
61
+
62
+ @Field(() => String, { nullable: true })
63
+ EntityIDFieldName?: string;
54
64
  }
55
65
 
56
66
  @Resolver(RecordDependencyResult)
@@ -67,12 +77,14 @@ export class RecordDependencyResolver {
67
77
  const ck = new CompositeKey(ckInput.KeyValuePairs);
68
78
  const result = await md.GetRecordDependencies(entityName, ck);
69
79
 
70
- // Map PrimaryKey to CompositeKey for GraphQL response
71
80
  return result.map(dep => ({
72
81
  EntityName: dep.EntityName,
73
82
  RelatedEntityName: dep.RelatedEntityName,
74
83
  FieldName: dep.FieldName,
75
- CompositeKey: dep.PrimaryKey // Map PrimaryKey to CompositeKey
84
+ PrimaryKey: dep.PrimaryKey,
85
+ CompositeKey: dep.PrimaryKey,
86
+ IsSoftLink: dep.IsSoftLink,
87
+ EntityIDFieldName: dep.EntityIDFieldName,
76
88
  }));
77
89
  } catch (e) {
78
90
  LogError(e);
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { Resolver, Mutation, Arg, Ctx, Int, Float, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
28
28
  import { AppContext, UserPayload } from '../types.js';
29
- import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView } from '@memberjunction/core';
29
+ import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView, BaseEntity } from '@memberjunction/core';
30
30
  import { UUIDsEqual, IsValidUUID } from '@memberjunction/global';
31
31
  import {
32
32
  MJAIAgentEntity,
@@ -381,6 +381,31 @@ export class RealtimeClientSessionResolver extends ResolverBase {
381
381
  private readonly clientSessionService = new RealtimeClientSessionService();
382
382
  private readonly sessionManager = new SessionManager(this.clientSessionService);
383
383
 
384
+ /**
385
+ * The reason a `Save()`/`Delete()` returned false, for a log line. `CompleteMessage` renders
386
+ * `Message`, `Error` and every `Errors[]` entry (validation errors included, since `Save()`
387
+ * runs `Validate()` and records its errors there), so there is no separate `Validate()` call.
388
+ * When a subclass refused the write without recording anything, say so explicitly instead of
389
+ * "unknown error", which is what hid MJ#4791.
390
+ *
391
+ * Only a NULL `LatestResult` proves the refusal happened before the provider (nothing was
392
+ * registered at all). A SUCCESS `LatestResult` is an earlier save on the same instance — the
393
+ * refusal registered nothing — so its text must never be reported as this failure's reason.
394
+ */
395
+ private describeSaveFailure(entity: BaseEntity): string {
396
+ const latest = entity.LatestResult;
397
+ if (!latest) {
398
+ return `no failure detail recorded (LatestResult null; ResultHistory length ${entity.ResultHistory.length}) ` +
399
+ '— the write was refused before reaching the provider';
400
+ }
401
+ if (latest.Success) {
402
+ return 'no failure detail recorded (latest result entry is a prior success; ' +
403
+ `ResultHistory length ${entity.ResultHistory.length})`;
404
+ }
405
+ const detail = latest.CompleteMessage?.trim();
406
+ return detail || `no failure detail recorded (LatestResult empty; ResultHistory length ${entity.ResultHistory.length})`;
407
+ }
408
+
384
409
  /**
385
410
  * Start a client-direct realtime voice session targeting `targetAgentId`.
386
411
  *
@@ -593,7 +618,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
593
618
  // Junction-link any artifacts the delegated run produced into the session's conversation
594
619
  // history (best-effort) — so chat, session review, and resume carryover can all see them.
595
620
  // Runs as `runUser`: the junction entity is not among an anonymous caller's relay grants.
596
- await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, provider);
621
+ // The hidden anchor detail it may need to create, though, is written by the CALLER (#4791)
622
+ // — same reason `persistDirectActionTurn` is: the System user is not the conversation owner.
623
+ await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, contextUser, provider);
597
624
 
598
625
  await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
599
626
  return ResultJson;
@@ -657,7 +684,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
657
684
  session.Config_ = JSON.stringify(next);
658
685
  if (!(await session.Save())) {
659
686
  LogError(
660
- `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
687
+ `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
661
688
  );
662
689
  }
663
690
  }
@@ -1812,7 +1839,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1812
1839
  const saved = await session.Save();
1813
1840
  if (!saved) {
1814
1841
  LogError(
1815
- `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
1842
+ `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
1816
1843
  );
1817
1844
  }
1818
1845
  }
@@ -1930,7 +1957,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1930
1957
  if (!saved) {
1931
1958
  LogError(
1932
1959
  `SaveSessionChannelState: save failed for session ${agentSessionID} / channel ${channelID}: ` +
1933
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
1960
+ `${this.describeSaveFailure(row)}`,
1934
1961
  );
1935
1962
  }
1936
1963
  return saved;
@@ -2313,7 +2340,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2313
2340
  artifact.UserID = contextUser.ID;
2314
2341
  artifact.Visibility = 'Always';
2315
2342
  if (!(await artifact.Save())) {
2316
- const message = `SaveSessionChannelArtifact: artifact save failed: ${artifact.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2343
+ const message = `SaveSessionChannelArtifact: artifact save failed for session ${session.ID}: ${this.describeSaveFailure(artifact)}`;
2317
2344
  LogError(message);
2318
2345
  return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
2319
2346
  }
@@ -2325,7 +2352,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2325
2352
  version.Content = contentJson;
2326
2353
  version.UserID = contextUser.ID;
2327
2354
  if (!(await version.Save())) {
2328
- const message = `SaveSessionChannelArtifact: artifact version save failed: ${version.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2355
+ const message = `SaveSessionChannelArtifact: artifact version save failed for session ${session.ID} (artifact ${artifact.ID}): ${this.describeSaveFailure(version)}`;
2329
2356
  LogError(message);
2330
2357
  return { Success: false, ErrorMessage: message, ArtifactID: artifact.ID, ConversationDetailLinked: false };
2331
2358
  }
@@ -2383,13 +2410,22 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2383
2410
  * queries (which scan junctions across ALL details) still surface the artifact. This is the
2384
2411
  * least-invasive correct anchor — no fake visible message, no orphaned artifact.
2385
2412
  *
2413
+ * TWO PRINCIPALS, deliberately: the lookup (`findLatestSessionDetailID`) and the junction row
2414
+ * itself (`saveDetailArtifactJunction`, `MJ: Conversation Detail Artifacts`) run as `runUser`
2415
+ * (elevated for a scoped-anonymous caller) — that junction entity is outside an anonymous
2416
+ * caller's relay grants. The hidden ANCHOR `Conversation Detail` this may need to create runs as
2417
+ * `callerUser` instead: like every other `MJ: Conversation Details` write, the System user is
2418
+ * refused because it is not the conversation's owner (see {@link persistDirectActionTurn},
2419
+ * issue #4791) — only the actual caller can create it.
2420
+ *
2386
2421
  * Strictly best-effort: every failure path logs and returns; a relayed tool call NEVER fails
2387
2422
  * because history linking did.
2388
2423
  */
2389
2424
  private async linkDelegatedArtifactsToConversation(
2390
2425
  session: MJAIAgentSessionEntity,
2391
2426
  artifacts: DelegatedRunArtifact[] | undefined,
2392
- contextUser: UserInfo,
2427
+ runUser: UserInfo,
2428
+ callerUser: UserInfo,
2393
2429
  provider: IMetadataProvider,
2394
2430
  ): Promise<void> {
2395
2431
  if (!artifacts || artifacts.length === 0 || !session.ConversationID) {
@@ -2397,13 +2433,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2397
2433
  }
2398
2434
  try {
2399
2435
  const detailID =
2400
- (await this.findLatestSessionDetailID(session, contextUser, provider)) ??
2401
- (await this.createHiddenSessionAnchorDetail(session, contextUser, provider));
2436
+ (await this.findLatestSessionDetailID(session, runUser, provider)) ??
2437
+ (await this.createHiddenSessionAnchorDetail(session, callerUser, provider));
2402
2438
  if (!detailID) {
2403
2439
  return; // anchor unavailable — logged in the helpers
2404
2440
  }
2405
2441
  for (const artifact of artifacts) {
2406
- await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, contextUser, provider);
2442
+ await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, runUser, provider);
2407
2443
  }
2408
2444
  } catch (error) {
2409
2445
  LogError(`ExecuteRealtimeSessionTool: delegated-artifact history link failed: ${(error as Error).message}`);
@@ -2444,25 +2480,25 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2444
2480
  */
2445
2481
  private async createHiddenSessionAnchorDetail(
2446
2482
  session: MJAIAgentSessionEntity,
2447
- contextUser: UserInfo,
2483
+ callerUser: UserInfo,
2448
2484
  provider: IMetadataProvider,
2449
2485
  ): Promise<string | null> {
2450
- const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, contextUser);
2486
+ const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, callerUser);
2451
2487
  detail.NewRecord();
2452
2488
  detail.ConversationID = session.ConversationID;
2453
2489
  detail.Role = 'AI';
2454
2490
  detail.HiddenToUser = true;
2455
2491
  detail.Message = 'Artifacts produced during a realtime session (system anchor).';
2456
2492
  detail.AgentSessionID = session.ID;
2457
- // Attribute the anchor to the SESSION owner, not the (possibly elevated) writer — identical
2458
- // for every non-elevated caller, whose ownership of the session is already proven.
2493
+ // Attribute to the SESSION owner — the caller writing this row, whose ownership
2494
+ // `loadOwnedActiveSession` already proved.
2459
2495
  detail.UserID = session.UserID;
2460
2496
  if (await detail.Save()) {
2461
2497
  return detail.ID;
2462
2498
  }
2463
2499
  LogError(
2464
2500
  `ExecuteRealtimeSessionTool: hidden anchor detail save failed for session ${session.ID}: ` +
2465
- `${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2501
+ `${this.describeSaveFailure(detail)}`,
2466
2502
  );
2467
2503
  return null;
2468
2504
  }
@@ -2489,7 +2525,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2489
2525
  if (!saved) {
2490
2526
  LogError(
2491
2527
  `RealtimeClientSessionResolver: artifact junction save failed for detail ${conversationDetailID} / version ${artifactVersionID}: ` +
2492
- `${junction.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2528
+ `${this.describeSaveFailure(junction)}`,
2493
2529
  );
2494
2530
  }
2495
2531
  return saved;
@@ -2529,7 +2565,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2529
2565
  if (!(await row.Save())) {
2530
2566
  LogError(
2531
2567
  `SaveSessionChannelArtifact: LastActiveAt stamp failed for session ${agentSessionID} / channel ${channelID}: ` +
2532
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2568
+ `${this.describeSaveFailure(row)}`,
2533
2569
  );
2534
2570
  }
2535
2571
  } catch (error) {
@@ -2673,12 +2709,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2673
2709
  return minutes && minutes > 0 ? minutes * 60 : undefined;
2674
2710
  }
2675
2711
 
2676
- /**
2677
- * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2678
- * conversation, the mapped role, the turn text, the session id, and the owning user.
2679
- *
2680
- * @returns The boolean save result (logs `CompleteMessage` on failure).
2681
- */
2682
2712
  /**
2683
2713
  * Stamps `RecordingStartedAt` (the recording `t0` alignment origin) + `RecordingMedia` on a
2684
2714
  * just-started session when the browser captured WITH consent. Best-effort: a parse/save failure
@@ -2705,11 +2735,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2705
2735
  session.RecordingMedia = 'Audio';
2706
2736
  if (!(await session.Save())) {
2707
2737
  LogError(
2708
- `RealtimeClientSessionResolver.stampRecordingStart save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2738
+ `RealtimeClientSessionResolver.stampRecordingStart save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
2709
2739
  );
2710
2740
  }
2711
2741
  }
2712
2742
 
2743
+ /**
2744
+ * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2745
+ * conversation, the mapped role, the turn text, the session id, and the owning user.
2746
+ *
2747
+ * @returns The boolean save result. On failure it logs the session, the write user and the
2748
+ * reason from {@link describeSaveFailure}.
2749
+ */
2713
2750
  private async persistTranscriptTurn(
2714
2751
  session: MJAIAgentSessionEntity,
2715
2752
  role: string,
@@ -2739,7 +2776,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2739
2776
  const saved = await detail.Save();
2740
2777
  if (!saved) {
2741
2778
  LogError(
2742
- `RealtimeClientSessionResolver.persistTranscriptTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2779
+ `RealtimeClientSessionResolver.persistTranscriptTurn save failed for session ${session.ID} (write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2743
2780
  );
2744
2781
  }
2745
2782
  return saved;
@@ -2780,8 +2817,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2780
2817
  // ⚠️ ELEVATING INSTEAD WAS TRIED AND IS A TRAP. `ResolveScopedAnonymousRunUser` returns
2781
2818
  // `UserCache.GetSystemUser()`, which on this deployment resolves to the unconfigured
2782
2819
  // placeholder `not.set@nowhere.com` ("Configured provisioning user not found; falling back
2783
- // to an Owner"). Saving as a user that does not exist returns false with a NULL
2784
- // `LatestResult` — indistinguishable from a permission denial, and just as silent.
2820
+ // to an Owner"). That user EXISTS — it is not a missing/invalid principal — and even carries
2821
+ // full CRUD on `MJ: Conversation Details`. It is refused anyway:
2822
+ // `MJConversationDetailEntityExtended`'s owner gate accepts only the conversation's OWNER or
2823
+ // an Edit/Owner Resource Permission grantee, and the System user is neither. Measured on
2824
+ // MJ#4791 (before the core-entities fix that now records the denial): `Save()` → `false`,
2825
+ // `LatestResult` null — indistinguishable from a permission denial, and just as silent. The
2826
+ // denial itself still happens; that fix only made it stop being silent.
2785
2827
  const writeUser = contextUser;
2786
2828
  const rv = RunView.FromMetadataProvider(provider);
2787
2829
  const result = await rv.RunView<MJConversationDetailEntity>(
@@ -2802,8 +2844,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2802
2844
  //
2803
2845
  // `ResultType: 'entity_object'` hands back hydrated entities, but they do not carry the
2804
2846
  // context user the way `GetEntityObject(entity, user)` does — so `Save()` ran with no
2805
- // principal and failed with an EMPTY message ("unknown error"), which is what the elevation
2806
- // fix above looked like when it was still broken. The insert path beside this one has
2847
+ // principal and failed with an EMPTY message ("unknown error"), which is what an empty-message
2848
+ // failure here looked like before the re-load. The insert path beside this one has
2807
2849
  // always used `GetEntityObject`; this now matches it, which is also why they now succeed
2808
2850
  // and fail for the same reasons.
2809
2851
  const previous = await provider.GetEntityObject<MJConversationDetailEntity>(
@@ -2835,7 +2877,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2835
2877
  `RealtimeClientSessionResolver.replacePreviousTranscriptTurn save failed for session `
2836
2878
  + `${session.ID} — the ${mappedRole} turn keeps its PREVIOUS, shorter text and the `
2837
2879
  + `transcript now understates what was said: `
2838
- + `${previous.LatestResult?.CompleteMessage || JSON.stringify(previous.LatestResult ?? null)}`,
2880
+ + `${this.describeSaveFailure(previous)}`,
2839
2881
  );
2840
2882
  }
2841
2883
  return saved;
@@ -2855,6 +2897,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2855
2897
  * Stored as stable facts (Role: 'AI', HiddenToUser: true, ExternalID: callId, CompletionTime: durationMs,
2856
2898
  * Message: JSON-serialized tool facts) so that session review can reconstruct the action card dynamically
2857
2899
  * on load (Option 1) without polluting user-facing speech chat bubbles.
2900
+ *
2901
+ * Writes as the CALLER, never the elevated run user (issue #4791). Owning the session
2902
+ * (`loadOwnedActiveSession`'s gate has already run on every path here) does NOT imply owning
2903
+ * the conversation: the conversation id was client-supplied at session start. What authorizes
2904
+ * this write is the save itself, evaluated for the caller — the conversation owner gate in
2905
+ * `MJConversationDetailEntityExtended`, plus the caller's own entity permission and Create RLS
2906
+ * on `MJ: Conversation Details` (for the magic-link role that RLS scopes by the invite's
2907
+ * `ScopeResourceID`, and the role belongs to a downstream app, not MJ core). That is why
2908
+ * writing as the caller is safe even though the conversation id originated client-side.
2909
+ * Elevating to the System user would bypass RLS entirely, and the owner gate refuses it anyway
2910
+ * because the System user is not the conversation owner. Measured on MJ#4791 (before the
2911
+ * core-entities fix that now records the denial): `Save()` returned `false` with a NULL `LatestResult` and an empty
2912
+ * `ResultHistory` — indistinguishable from any other silent failure. Elevating this write is
2913
+ * what silently dropped both hidden tool-execution turns of a real magic-link session; the
2914
+ * denial itself still happens today, only its silence was fixed separately.
2915
+ * `ResolveScopedAnonymousRunUser` elevates AI-**run**-entity writes only (prompt runs, agent
2916
+ * runs, the relayed dispatch itself) — never this one.
2858
2917
  */
2859
2918
  private async persistDirectActionTurn(
2860
2919
  session: MJAIAgentSessionEntity,
@@ -2870,10 +2929,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2870
2929
  return false;
2871
2930
  }
2872
2931
  try {
2873
- const writeUser = ResolveScopedAnonymousRunUser(contextUser);
2874
2932
  const detail = await provider.GetEntityObject<MJConversationDetailEntity>(
2875
2933
  CONVERSATION_DETAIL_ENTITY,
2876
- writeUser,
2934
+ contextUser,
2877
2935
  );
2878
2936
  detail.NewRecord();
2879
2937
  detail.ConversationID = session.ConversationID;
@@ -2915,13 +2973,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2915
2973
  const saved = await detail.Save();
2916
2974
  if (!saved) {
2917
2975
  LogError(
2918
- `RealtimeClientSessionResolver.persistDirectActionTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2976
+ `RealtimeClientSessionResolver.persistDirectActionTurn save failed for session ${session.ID} (tool '${toolName}', call ${callId ?? 'n/a'}, write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2919
2977
  );
2920
2978
  }
2921
2979
  return saved;
2922
2980
  } catch (error) {
2923
2981
  const message = error instanceof Error ? error.message : String(error);
2924
- LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error: ${message}`);
2982
+ LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error for session ${session.ID}: ${message}`);
2925
2983
  return false;
2926
2984
  }
2927
2985
  }