@memberjunction/server 5.42.0 → 5.44.0

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 (101) hide show
  1. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  2. package/dist/agentSessions/SessionJanitor.js +5 -1
  3. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  4. package/dist/agentSessions/SessionManager.d.ts +15 -2
  5. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  6. package/dist/agentSessions/SessionManager.js +53 -11
  7. package/dist/agentSessions/SessionManager.js.map +1 -1
  8. package/dist/agentSessions/index.d.ts.map +1 -1
  9. package/dist/agentSessions/index.js +3 -1
  10. package/dist/agentSessions/index.js.map +1 -1
  11. package/dist/generated/generated.d.ts +1180 -51
  12. package/dist/generated/generated.d.ts.map +1 -1
  13. package/dist/generated/generated.js +35564 -29262
  14. package/dist/generated/generated.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +14 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
  20. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
  21. package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
  22. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
  23. package/dist/resolvers/FileResolver.d.ts +74 -0
  24. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  25. package/dist/resolvers/FileResolver.js +211 -2
  26. package/dist/resolvers/FileResolver.js.map +1 -1
  27. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  28. package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
  29. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  30. package/dist/resolvers/QueryResolver.d.ts +6 -4
  31. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  32. package/dist/resolvers/QueryResolver.js +29 -14
  33. package/dist/resolvers/QueryResolver.js.map +1 -1
  34. package/dist/resolvers/RealtimeBridgeResolver.d.ts +17 -1
  35. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  36. package/dist/resolvers/RealtimeBridgeResolver.js +78 -7
  37. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  38. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
  39. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  40. package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
  41. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  42. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  43. package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
  44. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  45. package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
  46. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RunAIAgentResolver.js +37 -15
  48. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  49. package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
  50. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RunTemplateResolver.js +8 -0
  52. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  53. package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
  54. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
  55. package/dist/resolvers/meetingRecordingRegistration.js +311 -0
  56. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
  57. package/dist/resolvers/peaksSidecar.d.ts +30 -0
  58. package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
  59. package/dist/resolvers/peaksSidecar.js +51 -0
  60. package/dist/resolvers/peaksSidecar.js.map +1 -0
  61. package/dist/rest/MediaAccessKeys.d.ts +68 -0
  62. package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
  63. package/dist/rest/MediaAccessKeys.js +96 -0
  64. package/dist/rest/MediaAccessKeys.js.map +1 -0
  65. package/dist/rest/MediaStreamHandler.d.ts +26 -0
  66. package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
  67. package/dist/rest/MediaStreamHandler.js +192 -0
  68. package/dist/rest/MediaStreamHandler.js.map +1 -0
  69. package/dist/rest/mediaRange.d.ts +41 -0
  70. package/dist/rest/mediaRange.d.ts.map +1 -0
  71. package/dist/rest/mediaRange.js +60 -0
  72. package/dist/rest/mediaRange.js.map +1 -0
  73. package/package.json +83 -82
  74. package/src/__tests__/FileResolverPeaks.test.ts +64 -0
  75. package/src/__tests__/MediaAccessKeys.test.ts +68 -0
  76. package/src/__tests__/MediaStreamHandler.test.ts +91 -0
  77. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
  78. package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
  79. package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
  80. package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
  81. package/src/__tests__/SessionManager.test.ts +2 -0
  82. package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
  83. package/src/agentSessions/SessionJanitor.ts +15 -1
  84. package/src/agentSessions/SessionManager.ts +62 -10
  85. package/src/agentSessions/index.ts +3 -1
  86. package/src/generated/generated.ts +17659 -13322
  87. package/src/index.ts +15 -1
  88. package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
  89. package/src/resolvers/FileResolver.ts +199 -1
  90. package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
  91. package/src/resolvers/QueryResolver.ts +33 -19
  92. package/src/resolvers/RealtimeBridgeResolver.ts +82 -7
  93. package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
  94. package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
  95. package/src/resolvers/RunAIAgentResolver.ts +44 -11
  96. package/src/resolvers/RunTemplateResolver.ts +8 -0
  97. package/src/resolvers/meetingRecordingRegistration.ts +432 -0
  98. package/src/resolvers/peaksSidecar.ts +52 -0
  99. package/src/rest/MediaAccessKeys.ts +121 -0
  100. package/src/rest/MediaStreamHandler.ts +220 -0
  101. package/src/rest/mediaRange.ts +76 -0
@@ -376,7 +376,12 @@ export class RunAIAgentResolver extends ResolverBase {
376
376
  conversationId?: string,
377
377
  /** Optional external ref the caller can read to observe the agent run as it becomes available
378
378
  * (used by the fire-and-forget liveness pulse to enrich heartbeats with the run id/status). */
379
- runRef?: { current: MJAIAgentRunEntityExtended | null }
379
+ runRef?: { current: MJAIAgentRunEntityExtended | null },
380
+ /** Per-request Plan Mode toggle — threaded into ExecuteAgentParams.planMode (root-agent HITL gate). */
381
+ planMode?: boolean,
382
+ /** Skill IDs the user requested (via `/skill-name`) — threaded into ExecuteAgentParams.requestedSkillIDs.
383
+ * The framework intersects them with the agent's accepted skills AND the user's Run permission. */
384
+ requestedSkillIDs?: string[]
380
385
  ): Promise<AIAgentRunResult> {
381
386
  const startTime = Date.now();
382
387
 
@@ -428,6 +433,8 @@ export class RunAIAgentResolver extends ResolverBase {
428
433
  lastRunId: lastRunId,
429
434
  autoPopulateLastRunPayload: autoPopulateLastRunPayload,
430
435
  configurationId: configurationId,
436
+ planMode: planMode,
437
+ requestedSkillIDs: requestedSkillIDs,
431
438
  data: parsedData,
432
439
  context: {
433
440
  dataSource: dataSource
@@ -614,7 +621,12 @@ export class RunAIAgentResolver extends ResolverBase {
614
621
  @Arg('createNotification', { nullable: true }) createNotification?: boolean,
615
622
  @Arg('sourceArtifactId', { nullable: true }) sourceArtifactId?: string,
616
623
  @Arg('sourceArtifactVersionId', { nullable: true }) sourceArtifactVersionId?: string,
617
- @Arg('fireAndForget', { nullable: true }) fireAndForget?: boolean
624
+ @Arg('fireAndForget', { nullable: true }) fireAndForget?: boolean,
625
+ /** Per-request Plan Mode toggle — symmetric with RunAIAgentFromConversationDetail. */
626
+ @Arg('planMode', { nullable: true }) planMode?: boolean,
627
+ /** Skill IDs the user requested — symmetric with RunAIAgentFromConversationDetail. Intersected
628
+ * server-side with the agent's accepted skills AND the user's Run permission. */
629
+ @Arg('requestedSkillIDs', () => [String], { nullable: true }) requestedSkillIDs?: string[]
618
630
  ): Promise<AIAgentRunResult> {
619
631
  // Check API key scope authorization for agent execution
620
632
  await this.CheckAPIKeyScopeAuthorization('agent:execute', agentId, userPayload);
@@ -628,7 +640,7 @@ export class RunAIAgentResolver extends ResolverBase {
628
640
  p, dataSource, agentId, userPayload, messagesJson, sessionId, pubSub,
629
641
  data, payload, lastRunId, autoPopulateLastRunPayload, configurationId,
630
642
  conversationDetailId, createArtifacts || false, createNotification || false,
631
- sourceArtifactId, sourceArtifactVersionId
643
+ sourceArtifactId, sourceArtifactVersionId, undefined /*conversationId*/, planMode, requestedSkillIDs
632
644
  );
633
645
 
634
646
  LogStatus(`🔥 Fire-and-forget: Agent ${agentId} execution started in background for session ${sessionId}`);
@@ -658,7 +670,11 @@ export class RunAIAgentResolver extends ResolverBase {
658
670
  createArtifacts || false,
659
671
  createNotification || false,
660
672
  sourceArtifactId,
661
- sourceArtifactVersionId
673
+ sourceArtifactVersionId,
674
+ undefined, // conversationId (not pre-resolved on this path)
675
+ undefined, // runRef
676
+ planMode,
677
+ requestedSkillIDs
662
678
  );
663
679
  }
664
680
 
@@ -684,7 +700,11 @@ export class RunAIAgentResolver extends ResolverBase {
684
700
  @Arg('createArtifacts', { nullable: true }) createArtifacts?: boolean,
685
701
  @Arg('createNotification', { nullable: true }) createNotification?: boolean,
686
702
  @Arg('sourceArtifactId', { nullable: true }) sourceArtifactId?: string,
687
- @Arg('sourceArtifactVersionId', { nullable: true }) sourceArtifactVersionId?: string
703
+ @Arg('sourceArtifactVersionId', { nullable: true }) sourceArtifactVersionId?: string,
704
+ /** Per-request Plan Mode toggle — symmetric with the other run mutations. */
705
+ @Arg('planMode', { nullable: true }) planMode?: boolean,
706
+ /** User-requested skill IDs — symmetric with the other run mutations. */
707
+ @Arg('requestedSkillIDs', () => [String], { nullable: true }) requestedSkillIDs?: string[]
688
708
  ): Promise<AIAgentRunResult> {
689
709
  const p = GetReadWriteProvider(providers);
690
710
  return this.executeAIAgent(
@@ -705,7 +725,11 @@ export class RunAIAgentResolver extends ResolverBase {
705
725
  createArtifacts || false,
706
726
  createNotification || false,
707
727
  sourceArtifactId,
708
- sourceArtifactVersionId
728
+ sourceArtifactVersionId,
729
+ undefined, // conversationId (not pre-resolved on this path)
730
+ undefined, // runRef
731
+ planMode,
732
+ requestedSkillIDs
709
733
  );
710
734
  }
711
735
 
@@ -932,7 +956,9 @@ export class RunAIAgentResolver extends ResolverBase {
932
956
  @Arg('createNotification', { nullable: true }) createNotification?: boolean,
933
957
  @Arg('sourceArtifactId', { nullable: true }) sourceArtifactId?: string,
934
958
  @Arg('sourceArtifactVersionId', { nullable: true }) sourceArtifactVersionId?: string,
935
- @Arg('fireAndForget', { nullable: true }) fireAndForget?: boolean
959
+ @Arg('fireAndForget', { nullable: true }) fireAndForget?: boolean,
960
+ @Arg('planMode', { nullable: true }) planMode?: boolean,
961
+ @Arg('requestedSkillIDs', () => [String], { nullable: true }) requestedSkillIDs?: string[]
936
962
  ): Promise<AIAgentRunResult> {
937
963
  // Check API key scope authorization for agent execution
938
964
  await this.CheckAPIKeyScopeAuthorization('agent:execute', agentId, userPayload);
@@ -981,7 +1007,7 @@ export class RunAIAgentResolver extends ResolverBase {
981
1007
  p, dataSource, agentId, userPayload, messagesJson, sessionId, pubSub,
982
1008
  data, payload, lastRunId, autoPopulateLastRunPayload, configurationId,
983
1009
  conversationDetailId, createArtifacts || false, createNotification || false,
984
- sourceArtifactId, sourceArtifactVersionId, conversationId
1010
+ sourceArtifactId, sourceArtifactVersionId, conversationId, planMode, requestedSkillIDs
985
1011
  );
986
1012
 
987
1013
  LogStatus(`🔥 Fire-and-forget: Agent ${agentId} execution started in background for session ${sessionId}`);
@@ -1012,7 +1038,10 @@ export class RunAIAgentResolver extends ResolverBase {
1012
1038
  createNotification || false,
1013
1039
  sourceArtifactId,
1014
1040
  sourceArtifactVersionId,
1015
- conversationId // LATENCY OPT #2: pass pre-resolved conversationId
1041
+ conversationId, // LATENCY OPT #2: pass pre-resolved conversationId
1042
+ undefined, // runRef
1043
+ planMode,
1044
+ requestedSkillIDs
1016
1045
  );
1017
1046
  } catch (error) {
1018
1047
  const errorMessage = (error as Error).message || 'Unknown error loading conversation history';
@@ -1227,7 +1256,11 @@ export class RunAIAgentResolver extends ResolverBase {
1227
1256
  sourceArtifactId?: string,
1228
1257
  sourceArtifactVersionId?: string,
1229
1258
  /** LATENCY OPT #2: Pre-resolved conversationId avoids redundant DB load in AgentRunner */
1230
- conversationId?: string
1259
+ conversationId?: string,
1260
+ /** Per-request Plan Mode toggle — threaded through to ExecuteAgentParams.planMode. */
1261
+ planMode?: boolean,
1262
+ /** Skill IDs the user requested — threaded through to ExecuteAgentParams.requestedSkillIDs. */
1263
+ requestedSkillIDs?: string[]
1231
1264
  ): void {
1232
1265
  // Ref the liveness pulse reads to enrich heartbeats once the run is created.
1233
1266
  const runRef: { current: MJAIAgentRunEntityExtended | null } = { current: null };
@@ -1245,7 +1278,7 @@ export class RunAIAgentResolver extends ResolverBase {
1245
1278
  p, dataSource, agentId, userPayload, messagesJson, sessionId, pubSub,
1246
1279
  data, payload, undefined, lastRunId, autoPopulateLastRunPayload,
1247
1280
  configurationId, conversationDetailId, createArtifacts, createNotification,
1248
- sourceArtifactId, sourceArtifactVersionId, conversationId, runRef
1281
+ sourceArtifactId, sourceArtifactVersionId, conversationId, runRef, planMode, requestedSkillIDs
1249
1282
  ).catch((error: unknown) => {
1250
1283
  // Background execution failed unexpectedly (executeAIAgent has its own try-catch,
1251
1284
  // so this would only fire for truly unexpected errors).
@@ -21,8 +21,16 @@ export class TemplateRunResult {
21
21
  executionTimeMs?: number;
22
22
  }
23
23
 
24
+ /**
25
+ * @deprecated Prefer the `Template.Run` Remote Operation (`@memberjunction/templates-base-types`
26
+ * `TemplateRunOperation`) — one typed call site that routes over the generic `ExecuteRemoteOperation`
27
+ * transport on the client and in-process on the server. This bespoke `RunTemplate` mutation is retained
28
+ * only for backward compatibility with existing external API consumers and may be removed in a future
29
+ * major version.
30
+ */
24
31
  @Resolver()
25
32
  export class RunTemplateResolver extends ResolverBase {
33
+ /** @deprecated Use the `Template.Run` Remote Operation instead. Retained for backcompat. */
26
34
  @Mutation(() => TemplateRunResult)
27
35
  async RunTemplate(
28
36
  @Arg('templateId') templateId: string,
@@ -0,0 +1,432 @@
1
+ /**
2
+ * @fileoverview Meeting-Room recording registration — the server-side path that closes the LiveKit
3
+ * meeting-room recording loop. After a room's composite egress completes, this module:
4
+ *
5
+ * 1. Resolves the **Meeting-Room Conversation** for the room (prefer by `EgressID`, fall back to the
6
+ * room name → `ExternalID` + `Type='Meeting Room'`, else create one via the sink's pattern).
7
+ * 2. Resolves the **meeting-recording storage account** — a configured `MJStorage` account that points
8
+ * at the same sink LiveKit egress wrote the MP4 to, so MJ can read/stream it.
9
+ * 3. Creates the `MJ: Files` row for the recording (v1 points DIRECTLY at the egress output — no byte
10
+ * copy — so playback streams straight from the sink). An OPTIONAL copy-to-canonical path (OFF by
11
+ * default, behind config) reads the bytes and re-uploads into a separate canonical provider.
12
+ * 4. Stamps `Conversation.RecordingFileID` (+ `EgressID`) and saves.
13
+ *
14
+ * Lives in `@memberjunction/server` (the one package that depends on the egress service, MJStorage, and
15
+ * core-entities). It is invoked from `RealtimeBridgeResolver.StopLiveKitRecording`. Everything is
16
+ * best-effort: any failure returns a non-throwing failure result and logs — a recording that fails to
17
+ * register never crashes the stop-recording mutation.
18
+ *
19
+ * ## Required configuration
20
+ *
21
+ * The egress sink and the MJStorage account must point at the **same** bucket/container so MJ can read the
22
+ * file LiveKit wrote. Configure the **provider** whose accounts target that sink via either:
23
+ *
24
+ * - env: `MJ_MEETING_RECORDING_STORAGE_PROVIDER=<MJ: File Storage Providers ID>`
25
+ * - config (`mj.config.cjs`): `meetingRecordingStorageProviderID: '<provider id>'`
26
+ *
27
+ * When unset, registration returns a clear, non-throwing failure explaining the config is required (the
28
+ * recording still stopped; it just isn't registered as a `MJ: Files` row).
29
+ *
30
+ * ### Optional copy-to-canonical ("copy into Box")
31
+ *
32
+ * To copy the MP4 OUT of the egress sink into a separate canonical provider (e.g. Box) instead of pointing
33
+ * the Files row at the sink, set a DIFFERENT provider via:
34
+ *
35
+ * - env: `MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER=<provider id>`
36
+ * - config: `meetingRecordingCanonicalStorageProviderID: '<provider id>'`
37
+ *
38
+ * When set and different from the sink provider, the bytes are read from the sink and uploaded into the
39
+ * canonical provider, and the Files row points there. OFF by default.
40
+ *
41
+ * @module @memberjunction/server
42
+ * @author MemberJunction.com
43
+ */
44
+
45
+ import { IMetadataProvider, UserInfo, RunView, LogError, LogStatus } from '@memberjunction/core';
46
+ import { MJConversationEntity, MJFileEntity } from '@memberjunction/core-entities';
47
+ import { FileStorageEngine } from '@memberjunction/storage';
48
+
49
+ const CONVERSATION_ENTITY = 'MJ: Conversations';
50
+ const FILES_ENTITY = 'MJ: Files';
51
+ const MEETING_ROOM_CONVERSATION_TYPE = 'Meeting Room';
52
+
53
+ /** The egress output a completed recording produced — the subset of `RecordingInfo` registration needs. */
54
+ export interface MeetingRecordingEgressResult {
55
+ /** The LiveKit egress session id (correlates the recording back to the conversation). */
56
+ EgressID: string;
57
+ /** The room the recording belongs to (the LiveKit room name = the Conversation's `ExternalID`). */
58
+ RoomName: string;
59
+ /** The output MP4's path/key in the egress storage sink. Required to register; `undefined` if egress is still in progress. */
60
+ OutputLocation?: string;
61
+ /** The output file size in bytes, when the egress reported it. */
62
+ OutputSizeBytes?: number;
63
+ }
64
+
65
+ /** The outcome of registering a meeting recording. */
66
+ export interface MeetingRecordingRegistrationResult {
67
+ /** Whether the recording was registered (Files row created + Conversation stamped). */
68
+ Success: boolean;
69
+ /** A clear, human-readable explanation when {@link Success} is false (config missing, no output, save failed). */
70
+ ErrorMessage?: string;
71
+ /** The newly created `MJ: Files` row id, when {@link Success} is true. */
72
+ RecordingFileID?: string;
73
+ /** The resolved Meeting-Room Conversation id, when one was resolved/created. */
74
+ ConversationID?: string;
75
+ }
76
+
77
+ /** Config inputs for registration — env vars are read here; a host may override via the optional params. */
78
+ export interface MeetingRecordingRegistrationConfig {
79
+ /**
80
+ * The `MJ: File Storage Providers` id whose accounts target the egress sink (where LiveKit wrote the
81
+ * MP4). Falls back to `process.env.MJ_MEETING_RECORDING_STORAGE_PROVIDER`.
82
+ */
83
+ sinkStorageProviderID?: string;
84
+ /**
85
+ * OPTIONAL canonical provider id — when set AND different from the sink, the MP4 is copied into it and
86
+ * the Files row points there (the "copy into Box" option). Falls back to
87
+ * `process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER`. OFF by default.
88
+ */
89
+ canonicalStorageProviderID?: string;
90
+ }
91
+
92
+ /**
93
+ * Registers a completed meeting recording: resolves the Meeting-Room Conversation, creates the `MJ: Files`
94
+ * row for the egress MP4, and stamps `Conversation.RecordingFileID` (+ `EgressID`). Best-effort — never
95
+ * throws; returns a failure result with a clear message instead.
96
+ *
97
+ * @param egress The completed egress result (must carry an `OutputLocation`).
98
+ * @param contextUser The authenticated user (for DB writes + credential access).
99
+ * @param provider The metadata provider servicing this request.
100
+ * @param config Optional config overrides (otherwise read from env).
101
+ */
102
+ export async function registerMeetingRecordingFile(
103
+ egress: MeetingRecordingEgressResult,
104
+ contextUser: UserInfo,
105
+ provider: IMetadataProvider,
106
+ config?: MeetingRecordingRegistrationConfig,
107
+ ): Promise<MeetingRecordingRegistrationResult> {
108
+ try {
109
+ if (!egress.OutputLocation) {
110
+ return {
111
+ Success: false,
112
+ ErrorMessage:
113
+ 'The recording has no output yet (egress still in progress) — nothing to register. ' + 'Register only after the egress has stopped/completed.',
114
+ };
115
+ }
116
+
117
+ const sinkProviderID = config?.sinkStorageProviderID ?? process.env.MJ_MEETING_RECORDING_STORAGE_PROVIDER;
118
+ if (!sinkProviderID) {
119
+ return {
120
+ Success: false,
121
+ ErrorMessage:
122
+ 'No meeting-recording storage provider is configured. Set MJ_MEETING_RECORDING_STORAGE_PROVIDER ' +
123
+ '(or meetingRecordingStorageProviderID in mj.config.cjs) to the MJ: File Storage Providers id ' +
124
+ 'whose accounts target the same sink LiveKit egress writes to.',
125
+ };
126
+ }
127
+
128
+ await FileStorageEngine.Instance.Config(false, contextUser, provider);
129
+
130
+ const sinkAccountID = resolveStorageAccountForProvider(sinkProviderID);
131
+ if (!sinkAccountID) {
132
+ return {
133
+ Success: false,
134
+ ErrorMessage:
135
+ `No active MJStorage account is linked to the configured meeting-recording provider '${sinkProviderID}'. ` +
136
+ 'Create a File Storage Account for that provider pointing at the egress sink bucket/container.',
137
+ };
138
+ }
139
+
140
+ const conversationID = await resolveMeetingConversation(egress, contextUser, provider);
141
+ if (!conversationID) {
142
+ return { Success: false, ErrorMessage: 'Could not resolve or create the Meeting-Room Conversation for the recording.' };
143
+ }
144
+
145
+ // v1: point the Files row directly at the egress output in the sink account (no byte copy). The
146
+ // OPTIONAL copy-to-canonical path overrides both the provider+key when a differing canonical
147
+ // provider is configured.
148
+ const fileLocation = await resolveFileLocation(egress, sinkProviderID, sinkAccountID, contextUser, config);
149
+ if (!fileLocation.Success) {
150
+ return { Success: false, ErrorMessage: fileLocation.ErrorMessage, ConversationID: conversationID };
151
+ }
152
+
153
+ const fileID = await createRecordingFileRow(egress, fileLocation.ProviderID, fileLocation.ProviderKey, contextUser, provider);
154
+ if (!fileID) {
155
+ return { Success: false, ErrorMessage: 'Failed to create the MJ: Files row for the recording.', ConversationID: conversationID };
156
+ }
157
+
158
+ const stamped = await stampConversationRecording(conversationID, fileID, egress.EgressID, contextUser, provider);
159
+ if (!stamped) {
160
+ return {
161
+ Success: false,
162
+ ErrorMessage: 'Created the recording file but failed to stamp it onto the Conversation.',
163
+ ConversationID: conversationID,
164
+ RecordingFileID: fileID,
165
+ };
166
+ }
167
+
168
+ LogStatus(`registerMeetingRecordingFile: registered recording ${fileID} for room '${egress.RoomName}' on conversation ${conversationID}.`);
169
+ return { Success: true, RecordingFileID: fileID, ConversationID: conversationID };
170
+ } catch (error) {
171
+ const msg = error instanceof Error ? error.message : String(error);
172
+ LogError(`registerMeetingRecordingFile failed: ${msg}`);
173
+ return { Success: false, ErrorMessage: msg };
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Best-effort correlation when a recording STARTS: stamps `Conversation.EgressID` onto the room's
179
+ * Meeting-Room Conversation (if it already exists) so a live recording is tracked against the room. If the
180
+ * conversation doesn't exist yet, this resolves to `false` silently — the stop-flow will resolve/create it.
181
+ * Never throws.
182
+ *
183
+ * @param roomName The LiveKit room name (= the Conversation's `ExternalID`).
184
+ * @param egressID The egress id that just started.
185
+ * @param contextUser The authenticated user.
186
+ * @param provider The metadata provider.
187
+ * @returns `true` when an existing conversation was stamped, else `false`.
188
+ */
189
+ export async function correlateRecordingStart(roomName: string, egressID: string, contextUser: UserInfo, provider: IMetadataProvider): Promise<boolean> {
190
+ try {
191
+ const conversationID = await findConversationByRoomName(roomName, contextUser, provider);
192
+ if (!conversationID) {
193
+ return false; // not created yet — stop-flow resolves/creates it
194
+ }
195
+ const conversation = await provider.GetEntityObject<MJConversationEntity>(CONVERSATION_ENTITY, contextUser);
196
+ if (!(await conversation.Load(conversationID))) {
197
+ return false;
198
+ }
199
+ conversation.EgressID = egressID;
200
+ if (await conversation.Save()) {
201
+ return true;
202
+ }
203
+ LogError(`correlateRecordingStart: failed to stamp EgressID: ${conversation.LatestResult?.CompleteMessage ?? 'unknown error'}`);
204
+ return false;
205
+ } catch (error) {
206
+ // Never fail the recording start on correlation.
207
+ LogError(`correlateRecordingStart (best-effort) failed: ${error instanceof Error ? error.message : String(error)}`);
208
+ return false;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Resolves the Meeting-Room Conversation for a completed recording: prefer matching by `EgressID` (set on
214
+ * start), fall back to the room name (`ExternalID` + `Type`), else create one following the transcript
215
+ * sink's pattern (scoped `Application` so it stays out of the normal chat list).
216
+ */
217
+ export async function resolveMeetingConversation(
218
+ egress: MeetingRecordingEgressResult,
219
+ contextUser: UserInfo,
220
+ provider: IMetadataProvider,
221
+ ): Promise<string | null> {
222
+ const byEgress = await findConversationByEgressID(egress.EgressID, contextUser, provider);
223
+ if (byEgress) {
224
+ return byEgress;
225
+ }
226
+ const byRoom = await findConversationByRoomName(egress.RoomName, contextUser, provider);
227
+ if (byRoom) {
228
+ return byRoom;
229
+ }
230
+ return createMeetingConversation(egress.RoomName, contextUser, provider);
231
+ }
232
+
233
+ /** Finds a non-archived Meeting-Room Conversation by its `EgressID`. */
234
+ async function findConversationByEgressID(egressID: string, contextUser: UserInfo, provider: IMetadataProvider): Promise<string | null> {
235
+ if (!egressID) {
236
+ return null;
237
+ }
238
+ const rv = RunView.FromMetadataProvider(provider);
239
+ const found = await rv.RunView<{ ID: string }>(
240
+ {
241
+ EntityName: CONVERSATION_ENTITY,
242
+ ExtraFilter: `EgressID='${escapeSql(egressID)}' AND Type='${escapeSql(MEETING_ROOM_CONVERSATION_TYPE)}' AND (IsArchived IS NULL OR IsArchived=0)`,
243
+ Fields: ['ID'],
244
+ OrderBy: '__mj_CreatedAt DESC',
245
+ MaxRows: 1,
246
+ ResultType: 'simple',
247
+ },
248
+ contextUser,
249
+ );
250
+ return found.Success && found.Results.length > 0 ? found.Results[0].ID : null;
251
+ }
252
+
253
+ /** Finds a non-archived Meeting-Room Conversation by room name (`ExternalID` + `Type`). */
254
+ async function findConversationByRoomName(roomName: string, contextUser: UserInfo, provider: IMetadataProvider): Promise<string | null> {
255
+ if (!roomName) {
256
+ return null;
257
+ }
258
+ const rv = RunView.FromMetadataProvider(provider);
259
+ const found = await rv.RunView<{ ID: string }>(
260
+ {
261
+ EntityName: CONVERSATION_ENTITY,
262
+ ExtraFilter: `ExternalID='${escapeSql(roomName)}' AND Type='${escapeSql(MEETING_ROOM_CONVERSATION_TYPE)}' AND (IsArchived IS NULL OR IsArchived=0)`,
263
+ Fields: ['ID'],
264
+ OrderBy: '__mj_CreatedAt DESC',
265
+ MaxRows: 1,
266
+ ResultType: 'simple',
267
+ },
268
+ contextUser,
269
+ );
270
+ return found.Success && found.Results.length > 0 ? found.Results[0].ID : null;
271
+ }
272
+
273
+ /** Creates a Meeting-Room Conversation for a room (scoped `Application`, keyed by room name) — mirrors the transcript sink. */
274
+ async function createMeetingConversation(roomName: string, contextUser: UserInfo, provider: IMetadataProvider): Promise<string | null> {
275
+ const conversation = await provider.GetEntityObject<MJConversationEntity>(CONVERSATION_ENTITY, contextUser);
276
+ conversation.NewRecord();
277
+ conversation.UserID = contextUser.ID;
278
+ conversation.Name = `Meeting Room ${roomName}`;
279
+ conversation.Type = MEETING_ROOM_CONVERSATION_TYPE;
280
+ conversation.ExternalID = roomName;
281
+ conversation.ApplicationScope = 'Application';
282
+ if (await conversation.Save()) {
283
+ return conversation.ID;
284
+ }
285
+ LogError(
286
+ `registerMeetingRecordingFile: failed to create Meeting-Room conversation for room '${roomName}': ${conversation.LatestResult?.CompleteMessage ?? 'unknown error'}`,
287
+ );
288
+ return null;
289
+ }
290
+
291
+ /** Result of resolving where the Files row should point (sink directly, or copied into a canonical provider). */
292
+ interface FileLocationResult {
293
+ Success: boolean;
294
+ ErrorMessage?: string;
295
+ ProviderID: string;
296
+ ProviderKey: string;
297
+ }
298
+
299
+ /**
300
+ * Resolves the `(ProviderID, ProviderKey)` the Files row should point at. v1 default: the egress output in
301
+ * the sink provider (no copy). When a DIFFERING canonical provider is configured, copies the bytes into it
302
+ * and points there instead.
303
+ */
304
+ async function resolveFileLocation(
305
+ egress: MeetingRecordingEgressResult,
306
+ sinkProviderID: string,
307
+ sinkAccountID: string,
308
+ contextUser: UserInfo,
309
+ config?: MeetingRecordingRegistrationConfig,
310
+ ): Promise<FileLocationResult> {
311
+ const outputLocation = egress.OutputLocation!;
312
+ const canonicalProviderID = config?.canonicalStorageProviderID ?? process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER;
313
+
314
+ // Default (v1): point directly at the sink output — playback streams straight from it, no byte copy.
315
+ if (!canonicalProviderID || canonicalProviderID === sinkProviderID) {
316
+ return { Success: true, ProviderID: sinkProviderID, ProviderKey: outputLocation };
317
+ }
318
+
319
+ // OPTIONAL "copy into Box": a separate canonical provider IS configured and differs from the sink —
320
+ // read the bytes from the sink and upload them into the canonical provider, then point there.
321
+ const canonicalKey = await copyEgressOutputToCanonical(outputLocation, sinkAccountID, canonicalProviderID, contextUser);
322
+ if (!canonicalKey.Success) {
323
+ return { Success: false, ErrorMessage: canonicalKey.ErrorMessage, ProviderID: '', ProviderKey: '' };
324
+ }
325
+ return { Success: true, ProviderID: canonicalProviderID, ProviderKey: canonicalKey.ProviderKey };
326
+ }
327
+
328
+ /** Result of a canonical copy. */
329
+ interface CanonicalCopyResult {
330
+ Success: boolean;
331
+ ErrorMessage?: string;
332
+ ProviderKey: string;
333
+ }
334
+
335
+ /**
336
+ * OPTIONAL — copies the egress MP4 OUT of the sink account into a configured canonical provider (the "copy
337
+ * into Box" option). Reads the bytes via the sink driver's `GetObject({ fullPath })` and re-uploads them
338
+ * into the canonical provider via `FileStorageEngine.UploadFile`. Returns the canonical `ProviderKey`.
339
+ */
340
+ export async function copyEgressOutputToCanonical(
341
+ outputLocation: string,
342
+ sinkAccountID: string,
343
+ canonicalProviderID: string,
344
+ contextUser: UserInfo,
345
+ ): Promise<CanonicalCopyResult> {
346
+ const engine = FileStorageEngine.Instance;
347
+ const canonicalAccountID = resolveStorageAccountForProvider(canonicalProviderID);
348
+ if (!canonicalAccountID) {
349
+ return {
350
+ Success: false,
351
+ ErrorMessage: `No active MJStorage account is linked to the configured canonical provider '${canonicalProviderID}'.`,
352
+ ProviderKey: '',
353
+ };
354
+ }
355
+
356
+ const sinkDriver = await engine.GetDriver(sinkAccountID, contextUser);
357
+ const bytes = await sinkDriver.GetObject({ fullPath: outputLocation });
358
+
359
+ const fileName = outputLocation.split('/').pop() || `recording-${Date.now()}.mp4`;
360
+ const uploaded = await engine.UploadFile({
361
+ content: bytes,
362
+ fileName,
363
+ mimeType: 'video/mp4',
364
+ contextUser,
365
+ storageAccountId: canonicalAccountID,
366
+ pathPrefix: `meeting-recordings/${new Date().toISOString().slice(0, 10)}`,
367
+ });
368
+ return { Success: true, ProviderKey: uploaded.StoragePath };
369
+ }
370
+
371
+ /** Creates the `MJ: Files` row pointing at the recording (no byte copy in the default v1 path). */
372
+ async function createRecordingFileRow(
373
+ egress: MeetingRecordingEgressResult,
374
+ providerID: string,
375
+ providerKey: string,
376
+ contextUser: UserInfo,
377
+ provider: IMetadataProvider,
378
+ ): Promise<string | null> {
379
+ const file = await provider.GetEntityObject<MJFileEntity>(FILES_ENTITY, contextUser);
380
+ file.NewRecord();
381
+ file.Name = `Meeting Recording — ${egress.RoomName} — ${new Date().toISOString().slice(0, 10)}`;
382
+ file.ContentType = 'video/mp4';
383
+ file.ProviderID = providerID;
384
+ file.ProviderKey = providerKey;
385
+ file.Status = 'Uploaded';
386
+ // NOTE: `MJ: Files` has no size column, so `egress.OutputSizeBytes` is not persisted here (it is logged
387
+ // and used by the player's range-streaming on demand). If a size column is added, set it here.
388
+ if (await file.Save()) {
389
+ return file.ID;
390
+ }
391
+ LogError(`registerMeetingRecordingFile: failed to save MJ: Files row: ${file.LatestResult?.CompleteMessage ?? 'unknown error'}`);
392
+ return null;
393
+ }
394
+
395
+ /** Loads the Conversation and stamps `RecordingFileID` (+ `EgressID` if unset), then saves. */
396
+ async function stampConversationRecording(
397
+ conversationID: string,
398
+ fileID: string,
399
+ egressID: string,
400
+ contextUser: UserInfo,
401
+ provider: IMetadataProvider,
402
+ ): Promise<boolean> {
403
+ const conversation = await provider.GetEntityObject<MJConversationEntity>(CONVERSATION_ENTITY, contextUser);
404
+ if (!(await conversation.Load(conversationID))) {
405
+ LogError(`registerMeetingRecordingFile: could not load conversation ${conversationID} to stamp the recording.`);
406
+ return false;
407
+ }
408
+ conversation.RecordingFileID = fileID;
409
+ if (!conversation.EgressID) {
410
+ conversation.EgressID = egressID;
411
+ }
412
+ if (await conversation.Save()) {
413
+ return true;
414
+ }
415
+ LogError(`registerMeetingRecordingFile: failed to stamp the Conversation: ${conversation.LatestResult?.CompleteMessage ?? 'unknown error'}`);
416
+ return false;
417
+ }
418
+
419
+ /**
420
+ * Resolves an MJStorage account linked to a provider id (the first one). `IsActive` lives on the
421
+ * *provider*, not the account, so provider-level inactivity is handled upstream by the engine's driver
422
+ * cache; here we just pick the provider's account.
423
+ */
424
+ function resolveStorageAccountForProvider(providerID: string): string | null {
425
+ const accounts = FileStorageEngine.Instance.GetAccountsByProviderID(providerID);
426
+ return accounts.length > 0 ? accounts[0].ID : null;
427
+ }
428
+
429
+ /** Escapes single quotes for safe embedding in an `ExtraFilter` literal. */
430
+ function escapeSql(value: string): string {
431
+ return value.replace(/'/g, "''");
432
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @fileoverview Pure helpers for the `peaks.json` waveform sidecar that rides alongside a stored
3
+ * recording. Kept free of type-graphql / resolver / DB dependencies so they can be unit-tested in
4
+ * isolation (the resolver class itself can't be imported under vitest without standing up the full
5
+ * decorated schema). {@link FileResolver.tryReadPeaksSidecar} composes these with a storage driver.
6
+ *
7
+ * @module @memberjunction/server/resolvers/peaksSidecar
8
+ */
9
+
10
+ /**
11
+ * Derives the `peaks.json` sidecar path that sits in the SAME folder as a file's `ProviderKey`:
12
+ * the final path segment of `providerKey` is replaced with `peaks.json`. Returns `null` when
13
+ * `providerKey` is empty (no folder to derive from).
14
+ *
15
+ * @example deriveSidecarPath('realtime-recordings/sess-1/recording.wav') === 'realtime-recordings/sess-1/peaks.json'
16
+ * @example deriveSidecarPath('recording.wav') === 'peaks.json'
17
+ */
18
+ export function deriveSidecarPath(providerKey: string | null | undefined): string | null {
19
+ if (!providerKey) {
20
+ return null;
21
+ }
22
+ const lastSlash = providerKey.lastIndexOf('/');
23
+ return lastSlash >= 0 ? `${providerKey.slice(0, lastSlash)}/peaks.json` : 'peaks.json';
24
+ }
25
+
26
+ /** Generous ceiling on the number of peaks returned — matches the capture-side cap. */
27
+ export const MAX_PEAKS = 4096;
28
+
29
+ /**
30
+ * Parses + sanitizes a `peaks.json` payload (UTF-8 bytes). The content must be a JSON array of
31
+ * finite numbers; each value is clamped to `[0, 1]` and the array is capped at {@link MAX_PEAKS}
32
+ * entries so a malformed/hostile sidecar can't bloat the response. Returns `undefined` for anything
33
+ * that isn't a non-empty array of finite numbers, and NEVER throws (a parse error → `undefined`).
34
+ *
35
+ * @param bytes The raw sidecar bytes (e.g. from a storage `GetObject`).
36
+ * @returns Sanitized `0..1` peaks, or `undefined`.
37
+ */
38
+ export function parsePeaksSidecar(bytes: Buffer): number[] | undefined {
39
+ try {
40
+ const parsed: unknown = JSON.parse(bytes.toString('utf8'));
41
+ if (!Array.isArray(parsed) || parsed.length === 0) {
42
+ return undefined;
43
+ }
44
+ const cleaned = parsed
45
+ .slice(0, MAX_PEAKS)
46
+ .filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
47
+ .map((v) => (v < 0 ? 0 : v > 1 ? 1 : v));
48
+ return cleaned.length > 0 ? cleaned : undefined;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }