@memberjunction/core-entities 6.2.0-edge.0 → 6.2.0-edge.1
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/artifact-extraction/artifact-extractor.d.ts +1 -1
- package/dist/artifact-extraction/artifact-extractor.js +2 -2
- package/dist/custom/MJConversationDetailEntityExtended.d.ts +1 -1
- package/dist/custom/MJConversationDetailEntityExtended.js +5 -5
- package/dist/custom/MJTemplateEntityExtended.d.ts +2 -2
- package/dist/custom/MJTemplateEntityExtended.js +6 -6
- package/dist/custom/MJUserViewEntityExtended.d.ts +4 -4
- package/dist/custom/MJUserViewEntityExtended.js +22 -22
- package/dist/custom/MJUserViewEntityExtended.js.map +1 -1
- package/dist/custom/Permissions/BaseShareEntityExtended.d.ts +8 -0
- package/dist/custom/Permissions/BaseShareEntityExtended.d.ts.map +1 -1
- package/dist/custom/Permissions/BaseShareEntityExtended.js +20 -4
- package/dist/custom/Permissions/BaseShareEntityExtended.js.map +1 -1
- package/dist/custom/Permissions/MJAccessControlRuleEntityExtended.js +4 -4
- package/dist/custom/Permissions/MJArtifactPermissionEntityExtended.js +5 -5
- package/dist/custom/Permissions/MJCollectionPermissionEntityExtended.js +5 -5
- package/dist/custom/Permissions/MJDashboardPermissionEntityExtended.js +5 -5
- package/dist/custom/ResourcePermissions/MJResourcePermissionEntityExtended.js +2 -2
- package/dist/custom/ResourcePermissions/ResourcePermissionEngine.d.ts +2 -2
- package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js +5 -5
- package/dist/engines/ApplicationSettingEngine.d.ts +1 -1
- package/dist/engines/ApplicationSettingEngine.js +8 -8
- package/dist/engines/AuditLogTypeEngine.d.ts +1 -1
- package/dist/engines/AuditLogTypeEngine.js +4 -4
- package/dist/engines/InstanceConfigEngine.d.ts +1 -1
- package/dist/engines/InstanceConfigEngine.js +2 -2
- package/dist/engines/MCPEngine.d.ts +4 -4
- package/dist/engines/MCPEngine.js +24 -24
- package/dist/engines/PermissionEngine.d.ts +2 -0
- package/dist/engines/PermissionEngine.d.ts.map +1 -1
- package/dist/engines/PermissionEngine.js +5 -1
- package/dist/engines/PermissionEngine.js.map +1 -1
- package/dist/engines/ResourceTypeEngine.d.ts +1 -1
- package/dist/engines/ResourceTypeEngine.js +4 -4
- package/dist/engines/TypeTablesCache.d.ts +1 -1
- package/dist/engines/TypeTablesCache.js +3 -3
- package/dist/engines/UserInfoEngine.d.ts +8 -8
- package/dist/engines/UserInfoEngine.js +48 -48
- package/dist/engines/UserRoutineEngine.d.ts +3 -3
- package/dist/engines/UserRoutineEngine.js +15 -15
- package/dist/engines/conversations.d.ts +192 -12
- package/dist/engines/conversations.d.ts.map +1 -1
- package/dist/engines/conversations.js +547 -102
- package/dist/engines/conversations.js.map +1 -1
- package/dist/generated/entities/__mj.d.ts +623 -14
- package/dist/generated/entities/__mj.d.ts.map +1 -1
- package/dist/generated/entities/__mj.js +112 -8
- package/dist/generated/entities/__mj.js.map +1 -1
- package/package.json +5 -5
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { BaseEngine, RunQuery, RunView, TransformSimpleObjectToEntityObject } from "@memberjunction/core";
|
|
2
|
-
import { NormalizeUUID, ToEpochMs, UUIDsEqual } from "@memberjunction/global";
|
|
1
|
+
import { BaseEngine, EntityEventRowIsFree, ResolveEntityEventKey, ResolveEntityEventRow, RunQuery, RunView, TransformSimpleObjectToEntityObject } from "@memberjunction/core";
|
|
2
|
+
import { EscapeSQLString, NormalizeUUID, ToEpochMs, UUIDsEqual } from "@memberjunction/global";
|
|
3
3
|
import { BehaviorSubject } from "rxjs";
|
|
4
4
|
import { ArtifactMetadataEngine } from "./artifacts.js";
|
|
5
5
|
import { ResourcePermissionEngine } from "../custom/ResourcePermissions/ResourcePermissionEngine.js";
|
|
@@ -14,11 +14,11 @@ const CONVERSATIONS_RESOURCE_TYPE_ID = '81D4BC3D-9FEB-EF11-B01A-286B35C04427';
|
|
|
14
14
|
* {@link ConversationWindowSourceRow} — single-sourced so server callers cannot drift
|
|
15
15
|
* from the assembler's requirements.
|
|
16
16
|
*/
|
|
17
|
-
export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation'];
|
|
17
|
+
export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation', '__mj_CreatedAt'];
|
|
18
18
|
/**
|
|
19
19
|
* Helper: parse a raw ConversationDetailComplete row into typed arrays.
|
|
20
20
|
*/
|
|
21
|
-
export function
|
|
21
|
+
export function ParseConversationDetailComplete(queryResult) {
|
|
22
22
|
return {
|
|
23
23
|
...queryResult,
|
|
24
24
|
agentRuns: queryResult.AgentRunsJSON
|
|
@@ -32,6 +32,10 @@ export function parseConversationDetailComplete(queryResult) {
|
|
|
32
32
|
: []
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
/** @deprecated Use {@link ParseConversationDetailComplete}. */
|
|
36
|
+
export function parseConversationDetailComplete(queryResult) {
|
|
37
|
+
return ParseConversationDetailComplete(queryResult);
|
|
38
|
+
}
|
|
35
39
|
/**
|
|
36
40
|
* Timeline items the UI asks for per page. Mirrors `DEFAULT_TRANSCRIPT_PAGE_SIZE` in
|
|
37
41
|
* `@memberjunction/ng-conversations` — duplicated rather than imported because the
|
|
@@ -160,6 +164,61 @@ function mergeArtifactJSON(junction, version, artifact) {
|
|
|
160
164
|
Visibility: artifact.Visibility
|
|
161
165
|
};
|
|
162
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* The row filter that decides which folders a user may see: SHARED ones (no owner)
|
|
169
|
+
* plus their OWN.
|
|
170
|
+
*
|
|
171
|
+
* Exported and used by every list read of 'MJ: Projects', because the rule being in
|
|
172
|
+
* one place is the point. It first shipped inline in LoadProjects, and review caught
|
|
173
|
+
* ProjectSelectorComponent running its own RunView with no ownership clause at all —
|
|
174
|
+
* so personal folder names were still listed in the chat area's Assign Project modal,
|
|
175
|
+
* which is precisely the exposure OwnerUserID exists to close. Gating one reader never
|
|
176
|
+
* gates the others; a second copy of a predicate is a second place to forget it.
|
|
177
|
+
*
|
|
178
|
+
* NOT for the Explorer entity-admin surfaces (the Projects record view and its
|
|
179
|
+
* hierarchy panel). Those are the raw entity browser, where an admin sees every row of
|
|
180
|
+
* every entity, and narrowing them here would be inconsistent with how MJ treats
|
|
181
|
+
* entity administration generally.
|
|
182
|
+
*
|
|
183
|
+
* A missing user gets SHARED ONLY, never every personal folder in the environment:
|
|
184
|
+
* without an identity there is nobody to be the owner of, and widening on absent input
|
|
185
|
+
* is how a personal folder reaches a stranger's list.
|
|
186
|
+
*/
|
|
187
|
+
export function BuildProjectVisibilityFilter(userId) {
|
|
188
|
+
const id = String(userId ?? '').trim();
|
|
189
|
+
return id.length > 0
|
|
190
|
+
? `(OwnerUserID IS NULL OR OwnerUserID='${EscapeSQLString(id)}')`
|
|
191
|
+
: `OwnerUserID IS NULL`;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Turns a failed folder delete into something the person reading it can act on.
|
|
195
|
+
*
|
|
196
|
+
* `Project.ParentID` and `Conversation.ProjectID` are RESTRICT foreign keys, so the delete
|
|
197
|
+
* fails while anything still points at the row. `DeleteProject` clears everything it can
|
|
198
|
+
* reach first, which leaves exactly one interesting residue: a referencing row the caller
|
|
199
|
+
* is not permitted to read, and therefore could not reparent. The raw message for that is a
|
|
200
|
+
* constraint name, about a record whose existence is deliberately hidden from them.
|
|
201
|
+
*
|
|
202
|
+
* The constraint name is the signal, and it is kept in the text — a support engineer needs
|
|
203
|
+
* it — but it is no longer the whole message.
|
|
204
|
+
*/
|
|
205
|
+
export function ExplainProjectDeleteFailure(dbMessage) {
|
|
206
|
+
const raw = (dbMessage ?? '').trim();
|
|
207
|
+
if (!raw) {
|
|
208
|
+
return 'Failed to delete folder.';
|
|
209
|
+
}
|
|
210
|
+
const blockedByTree = /FK_Project_Parent/i.test(raw);
|
|
211
|
+
// Constraint names only. A bare /ProjectID/ also matched things like "Invalid column
|
|
212
|
+
// name 'ProjectID'", and answering an unrelated error with "conversations you do not
|
|
213
|
+
// have access to" is worse than passing the raw message through.
|
|
214
|
+
const blockedByConversation = /FK_Conversation_Project/i.test(raw);
|
|
215
|
+
if (blockedByTree || blockedByConversation) {
|
|
216
|
+
const what = blockedByTree ? 'subfolders' : 'conversations';
|
|
217
|
+
return `This folder still contains ${what} that you do not have access to, so it cannot be deleted. `
|
|
218
|
+
+ `Ask someone who can see them to move or remove them first. (${raw})`;
|
|
219
|
+
}
|
|
220
|
+
return raw;
|
|
221
|
+
}
|
|
163
222
|
/**
|
|
164
223
|
* ConversationEngine provides centralized, reactive caching for conversations,
|
|
165
224
|
* conversation details (messages), and peripheral data (agent runs, artifacts).
|
|
@@ -206,6 +265,14 @@ export class ConversationEngine extends BaseEngine {
|
|
|
206
265
|
this._lastEnvironmentId = null;
|
|
207
266
|
/** Track the environment ID used for the last projects (folders) load */
|
|
208
267
|
this._lastProjectsEnvironmentId = null;
|
|
268
|
+
/**
|
|
269
|
+
* The user the cached projects were loaded FOR. Part of the cache key because
|
|
270
|
+
* the project read is now user-dependent (personal folders): keyed on the
|
|
271
|
+
* environment alone, a second user in the same process — a server-side caller,
|
|
272
|
+
* an impersonated context — would be served the first user's personal folders
|
|
273
|
+
* from cache and never issue a read of their own.
|
|
274
|
+
*/
|
|
275
|
+
this._lastProjectsUserId = null;
|
|
209
276
|
/**
|
|
210
277
|
* Monotonic ticket for conversation loads, to keep the newest ANSWER rather than the
|
|
211
278
|
* newest REQUEST.
|
|
@@ -274,6 +341,29 @@ export class ConversationEngine extends BaseEngine {
|
|
|
274
341
|
GetSharedByInfo(conversationId) {
|
|
275
342
|
return this._sharedByByConversationId.get(NormalizeUUID(conversationId)) ?? null;
|
|
276
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* True when the user may grant others access to the conversation: they own it,
|
|
346
|
+
* or hold an Owner-level grant on it. Mirrors the server's share gate in
|
|
347
|
+
* `MJResourcePermissionEntityExtended`, so the UI never offers a share the save
|
|
348
|
+
* would refuse.
|
|
349
|
+
*/
|
|
350
|
+
CanShareConversation(conversation, userId) {
|
|
351
|
+
if (conversation.UserID && UUIDsEqual(conversation.UserID, userId)) {
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
return this.GetSharedByInfo(conversation.ID)?.Level === 'Owner';
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* True when the user may change the conversation's folder and pin: they own
|
|
358
|
+
* it, or hold an Edit or Owner grant on it. A View grant is read-only.
|
|
359
|
+
*/
|
|
360
|
+
CanEditConversation(conversation, userId) {
|
|
361
|
+
if (conversation.UserID && UUIDsEqual(conversation.UserID, userId)) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
const level = this.GetSharedByInfo(conversation.ID)?.Level;
|
|
365
|
+
return level === 'Edit' || level === 'Owner';
|
|
366
|
+
}
|
|
277
367
|
// ========================================================================
|
|
278
368
|
// ENGINE CONFIG (BaseEngine pattern)
|
|
279
369
|
// ========================================================================
|
|
@@ -317,28 +407,9 @@ export class ConversationEngine extends BaseEngine {
|
|
|
317
407
|
// Claim a ticket for this load. Anything published below is gated on still holding
|
|
318
408
|
// the newest one — see _conversationsLoadGeneration.
|
|
319
409
|
const generation = ++this._conversationsLoadGeneration;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// permission table; GetUserAvailableResources filters it to approved
|
|
323
|
-
// grants (direct + role-inherited) for this user + resource type.
|
|
324
|
-
await ResourcePermissionEngine.Instance.Config(false, contextUser);
|
|
325
|
-
const sharedPermissions = ResourcePermissionEngine.Instance
|
|
326
|
-
.GetUserAvailableResources(contextUser, CONVERSATIONS_RESOURCE_TYPE_ID);
|
|
327
|
-
const sharedConversationIds = sharedPermissions.map((p) => p.ResourceRecordID);
|
|
410
|
+
const sharedPermissions = await this.getSharedConversationPermissions(contextUser);
|
|
411
|
+
const filter = this.buildVisibleConversationsFilter(environmentId, contextUser.ID, sharedPermissions.map((p) => p.ResourceRecordID), options);
|
|
328
412
|
const rv = new RunView();
|
|
329
|
-
const ownershipClause = `UserID='${contextUser.ID}'`;
|
|
330
|
-
const sharedClause = sharedConversationIds.length > 0
|
|
331
|
-
? ` OR ID IN (${sharedConversationIds.map((id) => `'${id}'`).join(',')})`
|
|
332
|
-
: '';
|
|
333
|
-
// Default main-chat view shows Global + Both. App-scoped
|
|
334
|
-
// conversations live inside their owning Application's embedded
|
|
335
|
-
// surface and are filtered out here. Callers that want to surface
|
|
336
|
-
// them (e.g. an "Include app conversations" toggle) pass
|
|
337
|
-
// includeApplicationScoped=true to drop the scope predicate.
|
|
338
|
-
const scopeClause = options?.includeApplicationScoped
|
|
339
|
-
? ''
|
|
340
|
-
: ` AND ApplicationScope IN ('Global', 'Both')`;
|
|
341
|
-
const filter = `EnvironmentID='${environmentId}' AND (${ownershipClause}${sharedClause}) AND (IsArchived IS NULL OR IsArchived=0)${scopeClause}`;
|
|
342
413
|
const result = await rv.RunView({
|
|
343
414
|
EntityName: 'MJ: Conversations',
|
|
344
415
|
ExtraFilter: filter,
|
|
@@ -384,6 +455,42 @@ export class ConversationEngine extends BaseEngine {
|
|
|
384
455
|
// own per-environment guard to avoid redundant reloads.
|
|
385
456
|
await this.LoadProjects(environmentId, contextUser, forceRefresh);
|
|
386
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* Returns an `ExtraFilter` for `MJ: Conversations` that matches exactly the conversations
|
|
460
|
+
* {@link LoadConversations} shows the user: owned by the user or shared with them, not
|
|
461
|
+
* archived, and (unless `includeApplicationScoped`) Global or Both scope.
|
|
462
|
+
*
|
|
463
|
+
* Use it anywhere that reads conversations for display (search, pickers) so those reads
|
|
464
|
+
* cannot show more than the conversation list does. It is not capped, unlike the list load.
|
|
465
|
+
*
|
|
466
|
+
* @param environmentId - The environment to filter conversations by
|
|
467
|
+
* @param contextUser - The user whose conversations to match
|
|
468
|
+
* @param options - `includeApplicationScoped` also matches app-scoped conversations
|
|
469
|
+
*/
|
|
470
|
+
async GetVisibleConversationsFilter(environmentId, contextUser, options) {
|
|
471
|
+
const sharedPermissions = await this.getSharedConversationPermissions(contextUser);
|
|
472
|
+
return this.buildVisibleConversationsFilter(environmentId, contextUser.ID, sharedPermissions.map((p) => p.ResourceRecordID), options);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Approved conversation grants (direct and role-inherited) for the user, from
|
|
476
|
+
* `MJ: Resource Permissions`. ResourcePermissionEngine caches the full permission table.
|
|
477
|
+
*/
|
|
478
|
+
async getSharedConversationPermissions(contextUser) {
|
|
479
|
+
await ResourcePermissionEngine.Instance.Config(false, contextUser);
|
|
480
|
+
return ResourcePermissionEngine.Instance.GetUserAvailableResources(contextUser, CONVERSATIONS_RESOURCE_TYPE_ID);
|
|
481
|
+
}
|
|
482
|
+
buildVisibleConversationsFilter(environmentId, userId, sharedConversationIds, options) {
|
|
483
|
+
const ownershipClause = `UserID='${userId}'`;
|
|
484
|
+
const sharedClause = sharedConversationIds.length > 0
|
|
485
|
+
? ` OR ID IN (${sharedConversationIds.map((id) => `'${id}'`).join(',')})`
|
|
486
|
+
: '';
|
|
487
|
+
// The main chat view shows Global and Both. App-scoped conversations live inside
|
|
488
|
+
// their owning Application's embedded surface.
|
|
489
|
+
const scopeClause = options?.includeApplicationScoped
|
|
490
|
+
? ''
|
|
491
|
+
: ` AND ApplicationScope IN ('Global', 'Both')`;
|
|
492
|
+
return `EnvironmentID='${environmentId}' AND (${ownershipClause}${sharedClause}) AND (IsArchived IS NULL OR IsArchived=0)${scopeClause}`;
|
|
493
|
+
}
|
|
387
494
|
/**
|
|
388
495
|
* Populates {@link _sharedByByConversationId}. Resolves grantor emails via
|
|
389
496
|
* one `MJ: Users` query keyed on the unique set of `SharedByUserID`s.
|
|
@@ -424,22 +531,34 @@ export class ConversationEngine extends BaseEngine {
|
|
|
424
531
|
}
|
|
425
532
|
}
|
|
426
533
|
/**
|
|
427
|
-
* Loads the projects (conversation folders)
|
|
428
|
-
*
|
|
429
|
-
*
|
|
534
|
+
* Loads the projects (conversation folders) visible to a user in an environment and
|
|
535
|
+
* emits via Projects$.
|
|
536
|
+
*
|
|
537
|
+
* Visible means SHARED (OwnerUserID IS NULL — every folder that existed before
|
|
538
|
+
* ownership was expressible) plus the user's OWN personal folders. A personal folder
|
|
539
|
+
* never reaches anyone else's sidebar. The set is small, so it is cached whole;
|
|
540
|
+
* reloading is skipped only when both the environment AND the user match, because the
|
|
541
|
+
* read now depends on both.
|
|
430
542
|
*
|
|
431
543
|
* @param environmentId - The environment to filter projects by
|
|
432
|
-
* @param contextUser - The current user context
|
|
433
|
-
* @param forceRefresh - If true, reloads even if already cached for this environment
|
|
544
|
+
* @param contextUser - The current user context; also decides which personal folders load
|
|
545
|
+
* @param forceRefresh - If true, reloads even if already cached for this environment/user
|
|
434
546
|
*/
|
|
435
547
|
async LoadProjects(environmentId, contextUser, forceRefresh = false) {
|
|
436
|
-
|
|
548
|
+
const userId = contextUser?.ID ?? null;
|
|
549
|
+
if (!forceRefresh
|
|
550
|
+
&& this._lastProjectsEnvironmentId === environmentId
|
|
551
|
+
&& this._lastProjectsUserId === userId) {
|
|
437
552
|
return;
|
|
438
553
|
}
|
|
554
|
+
// Shared folders, plus this user's own. One definition, shared with every other
|
|
555
|
+
// list read of this entity — see BuildProjectVisibilityFilter.
|
|
556
|
+
const ownership = BuildProjectVisibilityFilter(userId);
|
|
439
557
|
const rv = new RunView();
|
|
440
558
|
const result = await rv.RunView({
|
|
441
559
|
EntityName: 'MJ: Projects',
|
|
442
|
-
ExtraFilter: `EnvironmentID='${environmentId}' AND (IsArchived IS NULL OR IsArchived=0)
|
|
560
|
+
ExtraFilter: `EnvironmentID='${environmentId}' AND (IsArchived IS NULL OR IsArchived=0)`
|
|
561
|
+
+ ` AND ${ownership}`,
|
|
443
562
|
OrderBy: 'Name ASC',
|
|
444
563
|
MaxRows: 1000,
|
|
445
564
|
ResultType: 'entity_object',
|
|
@@ -452,6 +571,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
452
571
|
}, contextUser);
|
|
453
572
|
if (result.Success) {
|
|
454
573
|
this._lastProjectsEnvironmentId = environmentId;
|
|
574
|
+
this._lastProjectsUserId = userId;
|
|
455
575
|
this._projects$.next(result.Results || []);
|
|
456
576
|
}
|
|
457
577
|
else {
|
|
@@ -525,15 +645,41 @@ export class ConversationEngine extends BaseEngine {
|
|
|
525
645
|
*/
|
|
526
646
|
async DeleteProject(id, contextUser) {
|
|
527
647
|
const md = this.ProviderToUse;
|
|
648
|
+
// 0. Resolve the folder itself FIRST. Its ParentID is where the children go, and the
|
|
649
|
+
// cached copy may not be there at all — the cache is narrowed by ownership, and a
|
|
650
|
+
// principal allowed to delete a folder is not necessarily one whose sidebar lists it.
|
|
651
|
+
let project = this._projects$.value.find(p => UUIDsEqual(p.ID, id));
|
|
652
|
+
if (!project) {
|
|
653
|
+
project = await md.GetEntityObject('MJ: Projects', contextUser);
|
|
654
|
+
const loaded = await project.Load(id);
|
|
655
|
+
if (!loaded) {
|
|
656
|
+
throw new Error('Folder not found');
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const newParentId = project.ParentID ?? null;
|
|
528
660
|
// 1. Unassign conversations directly in this folder
|
|
529
661
|
const directConversations = this._conversations$.value.filter(c => c.ProjectID && UUIDsEqual(c.ProjectID, id));
|
|
530
662
|
for (const conv of directConversations) {
|
|
531
663
|
await this.SaveConversation(conv.ID, { ProjectID: null }, contextUser);
|
|
532
664
|
}
|
|
533
|
-
// 2. Reparent direct child folders
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
665
|
+
// 2. Reparent direct child folders one level up.
|
|
666
|
+
//
|
|
667
|
+
// The child set comes from a READ keyed on ParentID, never from `_projects$`. That
|
|
668
|
+
// cache is narrowed to shared-plus-mine by BuildProjectVisibilityFilter, and a
|
|
669
|
+
// structural operation on the tree cannot be driven by a view of the tree that is
|
|
670
|
+
// missing rows. Someone else's personal folder under a shared parent does not appear
|
|
671
|
+
// in the cache, so it would never be reparented — and `Project.ParentID` is a
|
|
672
|
+
// RESTRICT foreign key with no ON DELETE clause, so the delete below would then fail
|
|
673
|
+
// with a raw constraint message, about a row the user is not allowed to know exists.
|
|
674
|
+
//
|
|
675
|
+
// The read carries NO ownership clause, deliberately. Deleting a folder needs
|
|
676
|
+
// CanDelete on the entity, which the UI role does not hold; the roles that do also
|
|
677
|
+
// hold unfiltered read, which makes them exempt from the row-level filter — so this
|
|
678
|
+
// read returns the complete child set for every principal that can actually reach
|
|
679
|
+
// here. Should a deployment grant delete to a filtered role anyway, the server still
|
|
680
|
+
// narrows the read, and step 3's guard turns the resulting FK failure into something
|
|
681
|
+
// legible rather than "Failed to save project".
|
|
682
|
+
const childFolders = await this.readChildFolders(id, contextUser);
|
|
537
683
|
if (childFolders.length > 0) {
|
|
538
684
|
this._selfMutating = true;
|
|
539
685
|
try {
|
|
@@ -548,18 +694,13 @@ export class ConversationEngine extends BaseEngine {
|
|
|
548
694
|
finally {
|
|
549
695
|
this._selfMutating = false;
|
|
550
696
|
}
|
|
551
|
-
// Children were mutated in place — re-emit so subscribers re-read the tree
|
|
697
|
+
// Children were mutated in place — re-emit so subscribers re-read the tree.
|
|
698
|
+
// Reparented children the caller cannot see are not in `_projects$` and stay out
|
|
699
|
+
// of it; the emit is for the ones that are.
|
|
552
700
|
this._projects$.next([...this._projects$.value]);
|
|
553
701
|
}
|
|
554
|
-
// 3. Delete the now-unreferenced folder
|
|
555
|
-
|
|
556
|
-
if (!project) {
|
|
557
|
-
project = await md.GetEntityObject('MJ: Projects', contextUser);
|
|
558
|
-
const loaded = await project.Load(id);
|
|
559
|
-
if (!loaded) {
|
|
560
|
-
throw new Error('Folder not found');
|
|
561
|
-
}
|
|
562
|
-
}
|
|
702
|
+
// 3. Delete the now-unreferenced folder.
|
|
703
|
+
//
|
|
563
704
|
// Remove from the cached list BEFORE calling Delete(). BaseEntity.Delete() calls
|
|
564
705
|
// NewRecord() which wipes the entity's fields — including ID — so filtering the list
|
|
565
706
|
// by ID *after* the delete wouldn't match the (now-blank) cached entity and the folder
|
|
@@ -578,10 +719,43 @@ export class ConversationEngine extends BaseEngine {
|
|
|
578
719
|
if (!deleted) {
|
|
579
720
|
// Restore the list on failure (the entity wasn't deleted, so its fields are intact)
|
|
580
721
|
this._projects$.next(projectsBeforeDelete);
|
|
581
|
-
throw new Error(project.LatestResult?.CompleteMessage
|
|
722
|
+
throw new Error(ExplainProjectDeleteFailure(project.LatestResult?.CompleteMessage));
|
|
582
723
|
}
|
|
583
724
|
return true;
|
|
584
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Every direct child of a folder, read by ParentID with no ownership clause.
|
|
728
|
+
*
|
|
729
|
+
* Separate from `LoadProjects` on purpose: that one answers "what may this user SEE",
|
|
730
|
+
* and this one answers "what actually REFERENCES this row". Only the second is a safe
|
|
731
|
+
* basis for a delete, because an unseen child still holds the foreign key.
|
|
732
|
+
*/
|
|
733
|
+
async readChildFolders(parentId, contextUser) {
|
|
734
|
+
// Validated rather than escaped-and-hoped: EscapeSQLString turns an empty value into
|
|
735
|
+
// `ParentID = ''`, which matches nothing — and "no children" is precisely the wrong
|
|
736
|
+
// answer here, because it reads as permission to delete.
|
|
737
|
+
const id = (parentId ?? '').trim();
|
|
738
|
+
if (!id) {
|
|
739
|
+
throw new Error('Cannot read subfolders without a folder id.');
|
|
740
|
+
}
|
|
741
|
+
// The engine's own provider, as DeleteProject uses for the folder itself. `new RunView()`
|
|
742
|
+
// would be the process-global default, which in a multi-provider client can be a
|
|
743
|
+
// different server from the one this delete runs against.
|
|
744
|
+
const rv = RunView.FromMetadataProvider(this.ProviderToUse);
|
|
745
|
+
const result = await rv.RunView({
|
|
746
|
+
EntityName: 'MJ: Projects',
|
|
747
|
+
ExtraFilter: `ParentID='${EscapeSQLString(id)}'`,
|
|
748
|
+
// This read has to be COMPLETE, not merely representative: one child left out
|
|
749
|
+
// is one FK left pointing at the row. Omitting MaxRows is not enough — the
|
|
750
|
+
// entity's own UserViewMaxRows would then apply and truncate silently.
|
|
751
|
+
IgnoreMaxRows: true,
|
|
752
|
+
ResultType: 'entity_object'
|
|
753
|
+
}, contextUser);
|
|
754
|
+
if (!result.Success) {
|
|
755
|
+
throw new Error(`Could not read the folder's subfolders, so it cannot be safely deleted: ${result.ErrorMessage}`);
|
|
756
|
+
}
|
|
757
|
+
return result.Results || [];
|
|
758
|
+
}
|
|
585
759
|
/**
|
|
586
760
|
* Creates a new conversation, saves it to the database, and adds it to the cached list.
|
|
587
761
|
*
|
|
@@ -907,6 +1081,106 @@ export class ConversationEngine extends BaseEngine {
|
|
|
907
1081
|
this.removeMultipleFromList(successful);
|
|
908
1082
|
return { Successful: successful, Failed: failed };
|
|
909
1083
|
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Moves multiple conversations into a folder (project), or out of every folder
|
|
1086
|
+
* when projectId is null. Emits the updated list once for the whole batch.
|
|
1087
|
+
*
|
|
1088
|
+
* @param ids - Conversation IDs to move
|
|
1089
|
+
* @param projectId - Target folder ID, or null for no folder
|
|
1090
|
+
* @param contextUser - The current user context
|
|
1091
|
+
* @returns Per-item successful and failed outcomes
|
|
1092
|
+
*/
|
|
1093
|
+
async MoveMultipleConversationsToProject(ids, projectId, contextUser) {
|
|
1094
|
+
return this.saveMultipleConversations(ids, { ProjectID: projectId }, contextUser);
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Pins or unpins multiple conversations. Emits the re-sorted list once for the
|
|
1098
|
+
* whole batch, so pinned conversations move to the top in a single UI update.
|
|
1099
|
+
*
|
|
1100
|
+
* @param ids - Conversation IDs to pin or unpin
|
|
1101
|
+
* @param isPinned - True to pin, false to unpin
|
|
1102
|
+
* @param contextUser - The current user context
|
|
1103
|
+
* @returns Per-item successful and failed outcomes
|
|
1104
|
+
*/
|
|
1105
|
+
async PinMultipleConversations(ids, isPinned, contextUser) {
|
|
1106
|
+
return this.saveMultipleConversations(ids, { IsPinned: isPinned }, contextUser);
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Applies the same field updates to several conversations, one save at a time so
|
|
1110
|
+
* a single rejection cannot fail the batch, then re-emits the list once.
|
|
1111
|
+
* A conversation whose save fails keeps its previous field values in memory.
|
|
1112
|
+
* Conversations the user holds only View access to are refused without a save.
|
|
1113
|
+
*/
|
|
1114
|
+
async saveMultipleConversations(ids, updates, contextUser) {
|
|
1115
|
+
const successful = [];
|
|
1116
|
+
const failed = [];
|
|
1117
|
+
if (ids.length === 0) {
|
|
1118
|
+
return { Successful: successful, Failed: failed };
|
|
1119
|
+
}
|
|
1120
|
+
const md = this.ProviderToUse;
|
|
1121
|
+
this._selfMutating = true;
|
|
1122
|
+
try {
|
|
1123
|
+
for (const id of ids) {
|
|
1124
|
+
let conversation = this.GetConversation(id);
|
|
1125
|
+
try {
|
|
1126
|
+
if (!conversation) {
|
|
1127
|
+
const entity = await md.GetEntityObject('MJ: Conversations', contextUser);
|
|
1128
|
+
const loaded = await entity.Load(id);
|
|
1129
|
+
if (!loaded) {
|
|
1130
|
+
failed.push({ ID: id, Name: 'Unknown', Error: 'Conversation not found' });
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
conversation = entity;
|
|
1134
|
+
}
|
|
1135
|
+
if (!this.CanEditConversation(conversation, contextUser.ID)) {
|
|
1136
|
+
failed.push({
|
|
1137
|
+
ID: id,
|
|
1138
|
+
Name: conversation.Name || 'Unknown',
|
|
1139
|
+
Error: 'You have View access only'
|
|
1140
|
+
});
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
const previous = {
|
|
1144
|
+
ProjectID: conversation.ProjectID,
|
|
1145
|
+
IsPinned: conversation.IsPinned
|
|
1146
|
+
};
|
|
1147
|
+
this.applyBulkUpdate(conversation, updates);
|
|
1148
|
+
const saved = await conversation.Save();
|
|
1149
|
+
if (saved) {
|
|
1150
|
+
successful.push(conversation.ID);
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
this.applyBulkUpdate(conversation, previous);
|
|
1154
|
+
failed.push({
|
|
1155
|
+
ID: id,
|
|
1156
|
+
Name: conversation.Name || 'Unknown',
|
|
1157
|
+
Error: conversation.LatestResult?.Message || 'Failed to update conversation'
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
catch (error) {
|
|
1162
|
+
failed.push({
|
|
1163
|
+
ID: id,
|
|
1164
|
+
Name: conversation?.Name || 'Unknown',
|
|
1165
|
+
Error: error instanceof Error ? error.message : 'Unknown error'
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
finally {
|
|
1171
|
+
this._selfMutating = false;
|
|
1172
|
+
}
|
|
1173
|
+
if (successful.length > 0) {
|
|
1174
|
+
this._conversations$.next(this.sortConversations(this._conversations$.value));
|
|
1175
|
+
}
|
|
1176
|
+
return { Successful: successful, Failed: failed };
|
|
1177
|
+
}
|
|
1178
|
+
applyBulkUpdate(conversation, updates) {
|
|
1179
|
+
if (updates.ProjectID !== undefined)
|
|
1180
|
+
conversation.ProjectID = updates.ProjectID;
|
|
1181
|
+
if (updates.IsPinned !== undefined)
|
|
1182
|
+
conversation.IsPinned = updates.IsPinned;
|
|
1183
|
+
}
|
|
910
1184
|
// ========================================================================
|
|
911
1185
|
// CONVERSATION DETAILS (Messages)
|
|
912
1186
|
// ========================================================================
|
|
@@ -1152,7 +1426,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1152
1426
|
for (const row of rawData) {
|
|
1153
1427
|
if (!row.ID)
|
|
1154
1428
|
continue;
|
|
1155
|
-
const parsed =
|
|
1429
|
+
const parsed = ParseConversationDetailComplete(row);
|
|
1156
1430
|
// Agent runs
|
|
1157
1431
|
if (parsed.agentRuns.length > 0) {
|
|
1158
1432
|
const agentRunData = parsed.agentRuns[0];
|
|
@@ -1363,7 +1637,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1363
1637
|
newDetail.LoadFromData(row);
|
|
1364
1638
|
existing.Details.push(newDetail);
|
|
1365
1639
|
}
|
|
1366
|
-
const parsed =
|
|
1640
|
+
const parsed = ParseConversationDetailComplete(row);
|
|
1367
1641
|
// Merge agent runs: update in-place or add.
|
|
1368
1642
|
//
|
|
1369
1643
|
// Important: `existingRun` may be a plain JSON object rather than a BaseEntity.
|
|
@@ -1477,6 +1751,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1477
1751
|
* the most recent N messages. Ignored when a boundary exists — the summary already
|
|
1478
1752
|
* covers everything before it, and cutting into the post-boundary tail would create
|
|
1479
1753
|
* a coverage gap.
|
|
1754
|
+
* @param options.historyFrom - A history floor: see {@link AssembleContextWindow}.
|
|
1480
1755
|
* @returns Messages in chronological order, each stamped with
|
|
1481
1756
|
* {@link ConversationContextMetadata}
|
|
1482
1757
|
*/
|
|
@@ -1497,14 +1772,21 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1497
1772
|
* @param conversationId - The conversation whose detail rows to load
|
|
1498
1773
|
* @param contextUser - The requesting user (entity RLS is applied under this user)
|
|
1499
1774
|
* @param provider - Optional per-request metadata provider; falls back to the global default
|
|
1775
|
+
* @param historyFrom - Optional history floor: only rows written at or after it are loaded,
|
|
1776
|
+
* so nothing before it leaves the database. Pair it with the same `historyFrom` on
|
|
1777
|
+
* {@link AssembleContextWindow}, which also drops the summary of earlier messages.
|
|
1500
1778
|
* @returns The conversation's rows in Sequence order, shaped for {@link AssembleContextWindow}
|
|
1501
|
-
* @throws When the underlying RunView reports failure (never returns a silent empty set)
|
|
1779
|
+
* @throws When the underlying RunView reports failure (never returns a silent empty set),
|
|
1780
|
+
* or when `historyFrom` is an invalid date
|
|
1502
1781
|
*/
|
|
1503
|
-
static async LoadWindowRowsFresh(conversationId, contextUser, provider) {
|
|
1782
|
+
static async LoadWindowRowsFresh(conversationId, contextUser, provider, historyFrom) {
|
|
1504
1783
|
const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
|
|
1784
|
+
const conversationFilter = `ConversationID='${conversationId}'`;
|
|
1505
1785
|
const rows = await rv.RunView({
|
|
1506
1786
|
EntityName: 'MJ: Conversation Details',
|
|
1507
|
-
ExtraFilter:
|
|
1787
|
+
ExtraFilter: historyFrom
|
|
1788
|
+
? `${conversationFilter} AND ${ConversationEngine.HistoryFromFilter(historyFrom)}`
|
|
1789
|
+
: conversationFilter,
|
|
1508
1790
|
OrderBy: 'Sequence ASC',
|
|
1509
1791
|
Fields: [...ConversationWindowFields],
|
|
1510
1792
|
ResultType: 'simple',
|
|
@@ -1525,13 +1807,22 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1525
1807
|
*
|
|
1526
1808
|
* Accepts the minimal {@link ConversationWindowSourceRow} shape, satisfied by full
|
|
1527
1809
|
* entities AND `ResultType: 'simple'` rows selecting {@link ConversationWindowFields}.
|
|
1810
|
+
*
|
|
1811
|
+
* **History floor.** With `options.historyFrom` set, the window starts there: rows written
|
|
1812
|
+
* before it are dropped, and so is any persisted summary. A summary folds in the
|
|
1813
|
+
* conversation from its first message — exactly what the floor excludes — so under a
|
|
1814
|
+
* floor none is used, and the window is the most recent `maxTailMessages` rows at or
|
|
1815
|
+
* after the floor. A row whose `__mj_CreatedAt` is missing or unreadable can't be shown
|
|
1816
|
+
* to be after the floor, so it is dropped too.
|
|
1528
1817
|
*/
|
|
1529
1818
|
static AssembleContextWindow(details, options) {
|
|
1530
1819
|
const excluded = new Set((options?.excludeDetailIds || []).map(id => NormalizeUUID(id)));
|
|
1820
|
+
const floor = options?.historyFrom ?? null;
|
|
1531
1821
|
const ordered = details
|
|
1532
1822
|
.filter(d => !excluded.has(NormalizeUUID(d.ID)))
|
|
1823
|
+
.filter(d => !floor || ConversationEngine.isAtOrAfter(d.__mj_CreatedAt, floor))
|
|
1533
1824
|
.sort((a, b) => a.Sequence - b.Sequence);
|
|
1534
|
-
const boundary = ConversationEngine.findSummaryBoundary(ordered);
|
|
1825
|
+
const boundary = floor ? undefined : ConversationEngine.findSummaryBoundary(ordered);
|
|
1535
1826
|
if (boundary) {
|
|
1536
1827
|
const tail = ordered.filter(d => d.Sequence >= boundary.Sequence);
|
|
1537
1828
|
return [ConversationEngine.buildSummaryMessage(boundary), ...tail.map(d => ConversationEngine.detailToContextMessage(d))];
|
|
@@ -1540,6 +1831,27 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1540
1831
|
const cap = options?.maxTailMessages;
|
|
1541
1832
|
return cap && all.length > cap ? all.slice(-cap) : all;
|
|
1542
1833
|
}
|
|
1834
|
+
/**
|
|
1835
|
+
* The `ExtraFilter` predicate for a history floor: rows written at or after `historyFrom`.
|
|
1836
|
+
* Shared by every reader that honours a floor, so they agree on what it means.
|
|
1837
|
+
*
|
|
1838
|
+
* @param historyFrom The first moment that may be read.
|
|
1839
|
+
* @param column The timestamp column to compare, when the predicate runs against a view
|
|
1840
|
+
* or subquery that names it differently.
|
|
1841
|
+
* @throws RangeError when `historyFrom` is an invalid date — a floor that can't be written
|
|
1842
|
+
* down must fail rather than silently read everything.
|
|
1843
|
+
*/
|
|
1844
|
+
static HistoryFromFilter(historyFrom, column = '__mj_CreatedAt') {
|
|
1845
|
+
return `${column} >= '${historyFrom.toISOString()}'`;
|
|
1846
|
+
}
|
|
1847
|
+
/** True when a row's timestamp is at or after the floor; false when it is missing or unreadable. */
|
|
1848
|
+
static isAtOrAfter(createdAt, floor) {
|
|
1849
|
+
if (createdAt == null) {
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
const time = createdAt instanceof Date ? createdAt.getTime() : Date.parse(createdAt);
|
|
1853
|
+
return !Number.isNaN(time) && time >= floor.getTime();
|
|
1854
|
+
}
|
|
1543
1855
|
/**
|
|
1544
1856
|
* Finds the summary boundary: the detail with the HIGHEST `Sequence` whose
|
|
1545
1857
|
* `SummaryOfEarlierConversation` is non-blank. Older summaries below it simply stop
|
|
@@ -1692,6 +2004,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1692
2004
|
this._detailCache.clear();
|
|
1693
2005
|
this._lastEnvironmentId = null;
|
|
1694
2006
|
this._lastProjectsEnvironmentId = null;
|
|
2007
|
+
this._lastProjectsUserId = null;
|
|
1695
2008
|
}
|
|
1696
2009
|
// ========================================================================
|
|
1697
2010
|
// CONVERSATION DETAIL CRUD
|
|
@@ -1815,46 +2128,130 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1815
2128
|
const effectiveType = event.type === 'remote-invalidate'
|
|
1816
2129
|
? event.payload?.action || 'save'
|
|
1817
2130
|
: event.type;
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
if (
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
2131
|
+
const handled = normalizedName === 'mj: conversations' ||
|
|
2132
|
+
normalizedName === 'mj: conversation details' ||
|
|
2133
|
+
normalizedName === 'mj: projects' ||
|
|
2134
|
+
normalizedName === 'mj: ai agent runs' ||
|
|
2135
|
+
normalizedName === 'mj: conversation detail artifacts' ||
|
|
2136
|
+
normalizedName === 'mj: conversation detail ratings';
|
|
2137
|
+
if (handled) {
|
|
2138
|
+
// Hydrate ONCE, here, because this is the only async frame on the path. A remote event
|
|
2139
|
+
// carries the row only for entities on the server's broadcast allowlist (see
|
|
2140
|
+
// `cacheSettings.recordDataBroadcastEntities`); otherwise this re-reads the single
|
|
2141
|
+
// record through the provider, as this user, so access control decides what comes
|
|
2142
|
+
// back. Handlers below stay synchronous and simply receive the row — passing it down
|
|
2143
|
+
// rather than letting each fetch its own keeps this to one read per event and avoids
|
|
2144
|
+
// turning five handlers async for a value the dispatcher can obtain once.
|
|
2145
|
+
// ...and only when something below will actually use it — see eventNeedsRow. On a
|
|
2146
|
+
// remote event without `recordData`, hydrating means a read through the provider, and
|
|
2147
|
+
// this dispatcher runs in EVERY connected browser for every save of these entities
|
|
2148
|
+
// anywhere in the system, whether or not this session has any claim to the record.
|
|
2149
|
+
const row = this.eventNeedsRow(event, normalizedName, effectiveType)
|
|
2150
|
+
? await ResolveEntityEventRow(event, this.ProviderToUse, this.ContextUser)
|
|
2151
|
+
: null;
|
|
2152
|
+
if (normalizedName === 'mj: conversations') {
|
|
2153
|
+
return this.handleConversationEntityEvent(event, effectiveType, row);
|
|
2154
|
+
}
|
|
2155
|
+
if (normalizedName === 'mj: conversation details') {
|
|
2156
|
+
return this.handleConversationDetailEntityEvent(event, effectiveType, row);
|
|
2157
|
+
}
|
|
2158
|
+
if (normalizedName === 'mj: projects') {
|
|
2159
|
+
return this.handleProjectEntityEvent(event, effectiveType, row);
|
|
2160
|
+
}
|
|
2161
|
+
if (normalizedName === 'mj: ai agent runs') {
|
|
2162
|
+
return this.handleAgentRunEntityEvent(event, effectiveType, row);
|
|
2163
|
+
}
|
|
2164
|
+
return this.handlePeripheralJunctionEntityEvent(event, row);
|
|
1832
2165
|
}
|
|
1833
2166
|
// Not a conversation entity — let BaseEngine handle it
|
|
1834
2167
|
return await super.HandleIndividualBaseEntityEvent(event);
|
|
1835
2168
|
}
|
|
1836
2169
|
/**
|
|
1837
|
-
*
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
1840
|
-
*
|
|
2170
|
+
* Will anything below actually use the row, or does the primary key suffice?
|
|
2171
|
+
*
|
|
2172
|
+
* Asked BEFORE hydrating, because for a remote event whose entity is not on the server's
|
|
2173
|
+
* broadcast allowlist, hydrating costs a read through the provider. This dispatcher runs in
|
|
2174
|
+
* every connected browser, for every save of these entities anywhere in the system — and
|
|
2175
|
+
* conversation details are the hottest write path in the product, so an unconditional read
|
|
2176
|
+
* here is one round trip per connected client per message, nearly all of them refused for a
|
|
2177
|
+
* session with no claim to the record.
|
|
2178
|
+
*
|
|
2179
|
+
* Every `false` below is a case where the handler already returns early without touching the
|
|
2180
|
+
* row, so skipping the read changes nothing a caller can observe.
|
|
1841
2181
|
*/
|
|
1842
|
-
|
|
1843
|
-
//
|
|
1844
|
-
|
|
1845
|
-
|
|
2182
|
+
eventNeedsRow(event, normalizedName, effectiveType) {
|
|
2183
|
+
// A row that costs nothing is never worth skipping. Free for a local event (the row IS the
|
|
2184
|
+
// live entity) and for a remote event whose payload already carries `recordData`, because
|
|
2185
|
+
// the entity is on the server's broadcast allowlist.
|
|
2186
|
+
//
|
|
2187
|
+
// This has to come first, and the per-entity reasoning below has to be read as being about
|
|
2188
|
+
// THE READ. Applying it to a free row is what dropped a remote project save: the save
|
|
2189
|
+
// branch uses EnvironmentID and IsArchived off the row, and with the row nulled it read
|
|
2190
|
+
// them as undefined/false, concluded the project was outside the loaded environment, and
|
|
2191
|
+
// filtered it out of the list — for exactly the sessions displaying it.
|
|
2192
|
+
if (EntityEventRowIsFree(event)) {
|
|
2193
|
+
return true;
|
|
1846
2194
|
}
|
|
1847
|
-
//
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
2195
|
+
// Projects: a DELETE needs only the id. A SAVE genuinely uses the row — `EnvironmentID` and
|
|
2196
|
+
// `IsArchived` are what decide whether the project stays in the list — so it has to be
|
|
2197
|
+
// hydrated whenever we hold the project.
|
|
2198
|
+
//
|
|
2199
|
+
// The earlier reasoning here ("a save that got this far has nothing to merge regardless")
|
|
2200
|
+
// was true of the MERGE and false of the branch beside it: without the row, `EnvironmentID`
|
|
2201
|
+
// reads as absent, `inLoadedEnvironment` comes out false for any session that has loaded an
|
|
2202
|
+
// environment, and the save REMOVES the project instead. That is precisely the cross-client
|
|
2203
|
+
// case this change exists to serve, so it is gated like conversations are: when we do not
|
|
2204
|
+
// hold the project the remote path does nothing anyway (the append branch below requires
|
|
2205
|
+
// `event.baseEntity`), and the row would be fetched only to be discarded.
|
|
2206
|
+
if (normalizedName === 'mj: projects') {
|
|
2207
|
+
if (effectiveType !== 'save') {
|
|
2208
|
+
return false;
|
|
1852
2209
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
2210
|
+
const id = this.eventRecordID(event, null);
|
|
2211
|
+
return !!id && this._projects$.value.some(p => UUIDsEqual(p.ID, id));
|
|
2212
|
+
}
|
|
2213
|
+
if (normalizedName === 'mj: conversations') {
|
|
2214
|
+
// A delete needs only the id. A save merges fields onto a conversation we already
|
|
2215
|
+
// hold — and when we do not hold it, the remote branch of the handler does nothing,
|
|
2216
|
+
// so the row would be fetched only to be discarded.
|
|
2217
|
+
if (effectiveType !== 'save') {
|
|
2218
|
+
return false;
|
|
1855
2219
|
}
|
|
2220
|
+
const id = this.eventRecordID(event, null);
|
|
2221
|
+
return !!id && !!this.GetConversation(id);
|
|
2222
|
+
}
|
|
2223
|
+
// Details, agent runs and the junction entities all resolve through the detail cache and
|
|
2224
|
+
// return early when the conversation they name is not in it.
|
|
2225
|
+
//
|
|
2226
|
+
// A remote DELETE can never be served: the record is gone, so the re-read comes back null
|
|
2227
|
+
// and every one of those handlers early-returns on the missing foreign key. The round trip
|
|
2228
|
+
// could not change an outcome, so it is not made. (That these handlers cannot act on a
|
|
2229
|
+
// remote delete at all is a pre-existing gap — deletes never carried `recordData` at either
|
|
2230
|
+
// publish site — and is not what this method is for.) Local deletes are unaffected: the
|
|
2231
|
+
// free-row check above already returned true for them.
|
|
2232
|
+
if (effectiveType === 'delete') {
|
|
2233
|
+
return false;
|
|
2234
|
+
}
|
|
2235
|
+
// With the cache empty — any session that has not opened a conversation — none of them can
|
|
2236
|
+
// do anything, whatever the row says. (A non-empty cache still needs the read:
|
|
2237
|
+
// ConversationID is a foreign key, so the primary key cannot tell us whether this detail
|
|
2238
|
+
// belongs to a conversation we hold.)
|
|
2239
|
+
return this._detailCache.size > 0;
|
|
2240
|
+
}
|
|
2241
|
+
/**
|
|
2242
|
+
* This record's id, from the primary key the event always carries.
|
|
2243
|
+
*
|
|
2244
|
+
* Prefers the key over the row: the key is broadcast unconditionally, whereas the row is only
|
|
2245
|
+
* present for allowlisted entities or after a re-read. Falls back to the row's `ID` so a
|
|
2246
|
+
* single-column entity still resolves if the key is ever absent.
|
|
2247
|
+
*/
|
|
2248
|
+
eventRecordID(event, data) {
|
|
2249
|
+
const key = ResolveEntityEventKey(event);
|
|
2250
|
+
const fromKey = key?.KeyValuePairs?.find(kv => kv.FieldName?.toLowerCase() === 'id')?.Value;
|
|
2251
|
+
if (fromKey != null && String(fromKey).length > 0) {
|
|
2252
|
+
return String(fromKey);
|
|
1856
2253
|
}
|
|
1857
|
-
return
|
|
2254
|
+
return data?.['ID'];
|
|
1858
2255
|
}
|
|
1859
2256
|
/**
|
|
1860
2257
|
* Safely merges data onto a target object that may or may not be a BaseEntity.
|
|
@@ -1873,12 +2270,21 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1873
2270
|
/**
|
|
1874
2271
|
* Handles save/delete events on Conversation entities from local or remote code.
|
|
1875
2272
|
*/
|
|
1876
|
-
handleConversationEntityEvent(event, action) {
|
|
1877
|
-
|
|
1878
|
-
|
|
2273
|
+
handleConversationEntityEvent(event, action, data) {
|
|
2274
|
+
// Identity comes from the primary key, which is broadcast unconditionally — a delete needs
|
|
2275
|
+
// nothing else, so it no longer depends on the row being available.
|
|
2276
|
+
const id = this.eventRecordID(event, data);
|
|
1879
2277
|
if (!id)
|
|
1880
2278
|
return true;
|
|
1881
2279
|
if (action === 'save') {
|
|
2280
|
+
// Same reasoning as handleProjectEntityEvent: the row can be null even when
|
|
2281
|
+
// `eventNeedsRow` said yes, because the re-read can be refused, find the record gone,
|
|
2282
|
+
// or simply fail — all of which `ResolveEntityEventRow` reports as null rather than
|
|
2283
|
+
// throwing. `mergeDataOntoRecord` would hand that null to `BaseEntity.SetMany`, which
|
|
2284
|
+
// throws, and nothing above this frame catches it. A conversation we cannot re-read
|
|
2285
|
+
// stays as it was; the next `LoadConversations` corrects it.
|
|
2286
|
+
if (!data)
|
|
2287
|
+
return true;
|
|
1882
2288
|
const existing = this.GetConversation(id);
|
|
1883
2289
|
if (existing) {
|
|
1884
2290
|
this.mergeDataOntoRecord(existing, data);
|
|
@@ -1912,10 +2318,11 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1912
2318
|
/**
|
|
1913
2319
|
* Handles save/delete events on ConversationDetail entities from local or remote code.
|
|
1914
2320
|
*/
|
|
1915
|
-
handleConversationDetailEntityEvent(event, action) {
|
|
2321
|
+
handleConversationDetailEntityEvent(event, action, data) {
|
|
1916
2322
|
const entity = event.baseEntity;
|
|
1917
|
-
const
|
|
1918
|
-
|
|
2323
|
+
const id = this.eventRecordID(event, data);
|
|
2324
|
+
// ConversationID is a foreign key, so the primary key cannot supply it — this is the field
|
|
2325
|
+
// the dispatcher's re-read exists to obtain.
|
|
1919
2326
|
const conversationId = data?.['ConversationID'];
|
|
1920
2327
|
if (!id || !conversationId)
|
|
1921
2328
|
return true;
|
|
@@ -1957,9 +2364,8 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1957
2364
|
* deleted via the project form modal. Only tracks projects in the currently-loaded
|
|
1958
2365
|
* environment; archived projects are dropped from the active list.
|
|
1959
2366
|
*/
|
|
1960
|
-
handleProjectEntityEvent(event, action) {
|
|
1961
|
-
const
|
|
1962
|
-
const id = data?.['ID'];
|
|
2367
|
+
handleProjectEntityEvent(event, action, data) {
|
|
2368
|
+
const id = this.eventRecordID(event, data);
|
|
1963
2369
|
if (!id)
|
|
1964
2370
|
return true;
|
|
1965
2371
|
const current = this._projects$.value;
|
|
@@ -1970,13 +2376,51 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1970
2376
|
}
|
|
1971
2377
|
return true;
|
|
1972
2378
|
}
|
|
1973
|
-
//
|
|
1974
|
-
|
|
1975
|
-
|
|
2379
|
+
// WITHOUT THE ROW WE KNOW NOTHING, and must not guess. Every field below would read as
|
|
2380
|
+
// absent: `IsArchived` becomes false, which is harmless, but `EnvironmentID` becomes
|
|
2381
|
+
// undefined — and "no environment" is indistinguishable from "moved to another one", so the
|
|
2382
|
+
// branch that DROPS the project is the one that runs. Leaving it untouched is the only
|
|
2383
|
+
// honest response to a row we do not have.
|
|
2384
|
+
//
|
|
2385
|
+
// `eventNeedsRow` is supposed to guarantee this never happens for a project we hold, but
|
|
2386
|
+
// that contract is enforced by a comment and `strictNullChecks` is off in this package, so
|
|
2387
|
+
// the guarantee is restated here where the damage would be done. Hydration can also simply
|
|
2388
|
+
// fail: `ResolveEntityEventRow` returns null rather than throwing.
|
|
2389
|
+
//
|
|
2390
|
+
// KEEPING IT IS DELIBERATE EVEN WHEN THE ROW WAS WITHHELD ON PURPOSE. A null can mean the
|
|
2391
|
+
// re-read was refused — the viewer may no longer read this project — or that it failed.
|
|
2392
|
+
// The two are indistinguishable here, and they want opposite responses, so this takes the
|
|
2393
|
+
// one whose wrong case is recoverable: a stale row in a sidebar is corrected by the next
|
|
2394
|
+
// `LoadProjects`, whereas a project deleted from the UI on a transient read failure is
|
|
2395
|
+
// gone until the user reloads and cannot be told why.
|
|
2396
|
+
if (!data) {
|
|
2397
|
+
return true;
|
|
2398
|
+
}
|
|
2399
|
+
// save — the cached list holds folders that are in the loaded environment, not
|
|
2400
|
+
// archived, AND visible to this user. All three are conditions the save can change,
|
|
2401
|
+
// so all three are re-evaluated here rather than only the first two.
|
|
2402
|
+
// No `?.` past the guard above: `data` is non-null here, and optional chaining would say
|
|
2403
|
+
// otherwise. It is also what let `mergeDataOntoRecord(…, data)` accept a nullable argument
|
|
2404
|
+
// without complaint, since `strictNullChecks` is off in this package.
|
|
2405
|
+
const environmentId = data['EnvironmentID'];
|
|
2406
|
+
const isArchived = data['IsArchived'] === true;
|
|
1976
2407
|
const inLoadedEnvironment = !this._lastProjectsEnvironmentId ||
|
|
1977
2408
|
(environmentId != null && UUIDsEqual(environmentId, this._lastProjectsEnvironmentId));
|
|
2409
|
+
// Ownership, the third condition, and the one the folder dialog's confirm promises.
|
|
2410
|
+
// When someone makes a shared folder personal, every OTHER session holding it must
|
|
2411
|
+
// drop it — merging and keeping would leave the folder in their sidebar until reload,
|
|
2412
|
+
// which is the opposite of what that confirm just told the user would happen.
|
|
2413
|
+
// Mirrors BuildProjectVisibilityFilter: no owner is shared, my id is mine, anything
|
|
2414
|
+
// else is someone else's. An unknown viewer sees shared only, never every personal
|
|
2415
|
+
// folder in the environment — widening on absent input is the failure mode worth
|
|
2416
|
+
// avoiding, and it is the same rule the read path applies.
|
|
2417
|
+
const ownerUserId = data['OwnerUserID'];
|
|
2418
|
+
const isVisibleToViewer = ownerUserId == null || ownerUserId === ''
|
|
2419
|
+
? true
|
|
2420
|
+
: !!this._lastProjectsUserId && UUIDsEqual(ownerUserId, this._lastProjectsUserId);
|
|
2421
|
+
const belongsInList = !isArchived && inLoadedEnvironment && isVisibleToViewer;
|
|
1978
2422
|
if (existingIdx >= 0) {
|
|
1979
|
-
if (
|
|
2423
|
+
if (!belongsInList) {
|
|
1980
2424
|
this._projects$.next(current.filter(p => !UUIDsEqual(p.ID, id)));
|
|
1981
2425
|
}
|
|
1982
2426
|
else {
|
|
@@ -1984,7 +2428,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1984
2428
|
this._projects$.next([...current]);
|
|
1985
2429
|
}
|
|
1986
2430
|
}
|
|
1987
|
-
else if (event.baseEntity &&
|
|
2431
|
+
else if (event.baseEntity && belongsInList) {
|
|
1988
2432
|
// New folder from a local event — append the entity object
|
|
1989
2433
|
this._projects$.next([...current, event.baseEntity]);
|
|
1990
2434
|
}
|
|
@@ -1994,9 +2438,9 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1994
2438
|
* Handles save/delete events on AI Agent Run entities from local or remote code.
|
|
1995
2439
|
* Updates the AgentRunsByDetailId map so timers and status reflect reality.
|
|
1996
2440
|
*/
|
|
1997
|
-
handleAgentRunEntityEvent(event, action) {
|
|
1998
|
-
const
|
|
1999
|
-
|
|
2441
|
+
handleAgentRunEntityEvent(event, action, data) {
|
|
2442
|
+
const id = this.eventRecordID(event, data);
|
|
2443
|
+
// Foreign key, not part of this row's primary key — supplied by the dispatcher's re-read.
|
|
2000
2444
|
const detailId = data?.['ConversationDetailID'];
|
|
2001
2445
|
if (!id || !detailId)
|
|
2002
2446
|
return true;
|
|
@@ -2033,8 +2477,9 @@ export class ConversationEngine extends BaseEngine {
|
|
|
2033
2477
|
* reconstructed from the entity event alone, so we flag the cache as stale.
|
|
2034
2478
|
* The UI component checks PeripheralDataStale and force-refreshes when needed.
|
|
2035
2479
|
*/
|
|
2036
|
-
handlePeripheralJunctionEntityEvent(event) {
|
|
2037
|
-
|
|
2480
|
+
handlePeripheralJunctionEntityEvent(event, data) {
|
|
2481
|
+
// The junction's own primary key is not useful here; what matters is which detail it hangs
|
|
2482
|
+
// off, which is a foreign key and therefore only available from the row.
|
|
2038
2483
|
const detailId = data?.['ConversationDetailID'];
|
|
2039
2484
|
if (!detailId)
|
|
2040
2485
|
return true;
|