@memberjunction/core-entities 6.2.0-edge.0 → 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 (60) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/artifact-extraction/artifact-extractor.d.ts +1 -1
  3. package/dist/artifact-extraction/artifact-extractor.js +2 -2
  4. package/dist/custom/MJConversationDetailEntityExtended.d.ts +8 -3
  5. package/dist/custom/MJConversationDetailEntityExtended.d.ts.map +1 -1
  6. package/dist/custom/MJConversationDetailEntityExtended.js +27 -17
  7. package/dist/custom/MJConversationDetailEntityExtended.js.map +1 -1
  8. package/dist/custom/MJTemplateEntityExtended.d.ts +2 -2
  9. package/dist/custom/MJTemplateEntityExtended.js +6 -6
  10. package/dist/custom/MJUserViewEntityExtended.d.ts +4 -4
  11. package/dist/custom/MJUserViewEntityExtended.js +22 -22
  12. package/dist/custom/MJUserViewEntityExtended.js.map +1 -1
  13. package/dist/custom/Permissions/BaseShareEntityExtended.d.ts +8 -0
  14. package/dist/custom/Permissions/BaseShareEntityExtended.d.ts.map +1 -1
  15. package/dist/custom/Permissions/BaseShareEntityExtended.js +20 -4
  16. package/dist/custom/Permissions/BaseShareEntityExtended.js.map +1 -1
  17. package/dist/custom/Permissions/MJAccessControlRuleEntityExtended.js +4 -4
  18. package/dist/custom/Permissions/MJArtifactPermissionEntityExtended.js +5 -5
  19. package/dist/custom/Permissions/MJCollectionPermissionEntityExtended.js +5 -5
  20. package/dist/custom/Permissions/MJDashboardPermissionEntityExtended.js +5 -5
  21. package/dist/custom/ResourcePermissions/MJResourcePermissionEntityExtended.js +2 -2
  22. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.d.ts +2 -2
  23. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js +5 -5
  24. package/dist/engines/ApplicationSettingEngine.d.ts +1 -1
  25. package/dist/engines/ApplicationSettingEngine.js +8 -8
  26. package/dist/engines/AuditLogTypeEngine.d.ts +1 -1
  27. package/dist/engines/AuditLogTypeEngine.js +4 -4
  28. package/dist/engines/InstanceConfigEngine.d.ts +1 -1
  29. package/dist/engines/InstanceConfigEngine.js +2 -2
  30. package/dist/engines/MCPEngine.d.ts +4 -4
  31. package/dist/engines/MCPEngine.js +24 -24
  32. package/dist/engines/PermissionEngine.d.ts +2 -0
  33. package/dist/engines/PermissionEngine.d.ts.map +1 -1
  34. package/dist/engines/PermissionEngine.js +5 -1
  35. package/dist/engines/PermissionEngine.js.map +1 -1
  36. package/dist/engines/ResourceTypeEngine.d.ts +1 -1
  37. package/dist/engines/ResourceTypeEngine.js +4 -4
  38. package/dist/engines/TypeTablesCache.d.ts +1 -1
  39. package/dist/engines/TypeTablesCache.js +3 -3
  40. package/dist/engines/UserInfoEngine.d.ts +8 -8
  41. package/dist/engines/UserInfoEngine.js +48 -48
  42. package/dist/engines/UserRoutineEngine.d.ts +3 -3
  43. package/dist/engines/UserRoutineEngine.js +15 -15
  44. package/dist/engines/conversations.d.ts +192 -12
  45. package/dist/engines/conversations.d.ts.map +1 -1
  46. package/dist/engines/conversations.js +547 -102
  47. package/dist/engines/conversations.js.map +1 -1
  48. package/dist/engines/knowledgeHubMetadata.d.ts +21 -6
  49. package/dist/engines/knowledgeHubMetadata.d.ts.map +1 -1
  50. package/dist/engines/knowledgeHubMetadata.js +32 -15
  51. package/dist/engines/knowledgeHubMetadata.js.map +1 -1
  52. package/dist/generated/entities/__mj.d.ts +5172 -50
  53. package/dist/generated/entities/__mj.d.ts.map +1 -1
  54. package/dist/generated/entities/__mj.js +52258 -46869
  55. package/dist/generated/entities/__mj.js.map +1 -1
  56. package/dist/generated/remote_operations.d.ts +423 -3
  57. package/dist/generated/remote_operations.d.ts.map +1 -1
  58. package/dist/generated/remote_operations.js +72 -0
  59. package/dist/generated/remote_operations.js.map +1 -1
  60. 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 parseConversationDetailComplete(queryResult) {
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
- // Include conversations the user has been granted access to via
321
- // `MJ: Resource Permissions`. ResourcePermissionEngine caches the full
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) for an environment and emits via Projects$.
428
- * Projects are environment-scoped (not user-scoped) and small, so the full active set
429
- * is cached. Skips reloading when already loaded for the same environment unless forced.
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
- if (!forceRefresh && this._lastProjectsEnvironmentId === environmentId) {
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 to this folder's parent
534
- const target = this._projects$.value.find(p => UUIDsEqual(p.ID, id));
535
- const newParentId = target?.ParentID ?? null;
536
- const childFolders = this._projects$.value.filter(p => p.ParentID && UUIDsEqual(p.ParentID, id));
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
- let project = target;
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 || 'Failed to delete folder');
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 = parseConversationDetailComplete(row);
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 = parseConversationDetailComplete(row);
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: `ConversationID='${conversationId}'`,
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
- if (normalizedName === 'mj: conversations') {
1819
- return this.handleConversationEntityEvent(event, effectiveType);
1820
- }
1821
- if (normalizedName === 'mj: conversation details') {
1822
- return this.handleConversationDetailEntityEvent(event, effectiveType);
1823
- }
1824
- if (normalizedName === 'mj: projects') {
1825
- return this.handleProjectEntityEvent(event, effectiveType);
1826
- }
1827
- if (normalizedName === 'mj: ai agent runs') {
1828
- return this.handleAgentRunEntityEvent(event, effectiveType);
1829
- }
1830
- if (normalizedName === 'mj: conversation detail artifacts' || normalizedName === 'mj: conversation detail ratings') {
1831
- return this.handlePeripheralJunctionEntityEvent(event);
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
- * Extracts record data from a BaseEntityEvent.
1838
- * For local events: uses baseEntity directly.
1839
- * For remote-invalidate events: parses recordData JSON from the payload.
1840
- * Returns null if no data is available.
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
- extractRecordData(event) {
1843
- // Local event — entity is available directly
1844
- if (event.baseEntity) {
1845
- return event.baseEntity.GetAll();
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
- // Remote event — parse from payload
1848
- const payload = event.payload;
1849
- if (payload?.recordData) {
1850
- try {
1851
- return JSON.parse(payload.recordData);
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
- catch {
1854
- return null;
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 null;
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
- const data = this.extractRecordData(event);
1878
- const id = data?.['ID'];
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 data = this.extractRecordData(event);
1918
- const id = data?.['ID'];
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 data = this.extractRecordData(event);
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
- // save — only track projects in the loaded environment; drop archived ones
1974
- const environmentId = data?.['EnvironmentID'];
1975
- const isArchived = data?.['IsArchived'] === true;
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 (isArchived || !inLoadedEnvironment) {
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 && !isArchived && inLoadedEnvironment) {
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 data = this.extractRecordData(event);
1999
- const id = data?.['ID'];
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
- const data = this.extractRecordData(event);
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;