@memberjunction/ai-agents 6.2.0-edge.2 → 6.2.0-edge.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/AgentDecisionService.d.ts +8 -1
- package/dist/AgentDecisionService.d.ts.map +1 -1
- package/dist/AgentDecisionService.js +4 -0
- package/dist/AgentDecisionService.js.map +1 -1
- package/dist/AgentRunner.d.ts +51 -2
- package/dist/AgentRunner.d.ts.map +1 -1
- package/dist/AgentRunner.js +121 -23
- package/dist/AgentRunner.js.map +1 -1
- package/dist/ConversationCompactionManager.d.ts +9 -1
- package/dist/ConversationCompactionManager.d.ts.map +1 -1
- package/dist/ConversationCompactionManager.js +6 -3
- package/dist/ConversationCompactionManager.js.map +1 -1
- package/dist/ConversationToolManager.d.ts +13 -2
- package/dist/ConversationToolManager.d.ts.map +1 -1
- package/dist/ConversationToolManager.js +19 -3
- package/dist/ConversationToolManager.js.map +1 -1
- package/dist/KnowledgeAgent.d.ts.map +1 -1
- package/dist/KnowledgeAgent.js +6 -1
- package/dist/KnowledgeAgent.js.map +1 -1
- package/dist/MJAIAgentRequestEntityServer.d.ts +6 -0
- package/dist/MJAIAgentRequestEntityServer.d.ts.map +1 -1
- package/dist/MJAIAgentRequestEntityServer.js +26 -1
- package/dist/MJAIAgentRequestEntityServer.js.map +1 -1
- package/dist/PayloadFeedbackManager.d.ts +3 -0
- package/dist/PayloadFeedbackManager.d.ts.map +1 -1
- package/dist/PayloadFeedbackManager.js +2 -1
- package/dist/PayloadFeedbackManager.js.map +1 -1
- package/dist/agent-run-watchdog.d.ts +65 -5
- package/dist/agent-run-watchdog.d.ts.map +1 -1
- package/dist/agent-run-watchdog.js +143 -28
- package/dist/agent-run-watchdog.js.map +1 -1
- package/dist/agent-types/loop-agent-prompt-params.d.ts +7 -0
- package/dist/agent-types/loop-agent-prompt-params.d.ts.map +1 -1
- package/dist/agent-types/loop-agent-prompt-params.js +1 -0
- package/dist/agent-types/loop-agent-prompt-params.js.map +1 -1
- package/dist/agent-types/loop-agent-type.d.ts +20 -0
- package/dist/agent-types/loop-agent-type.d.ts.map +1 -1
- package/dist/agent-types/loop-agent-type.js +73 -2
- package/dist/agent-types/loop-agent-type.js.map +1 -1
- package/dist/artifact-tools/SearchResultSetToolLibrary.js +4 -4
- package/dist/artifact-tools/SearchResultSetToolLibrary.js.map +1 -1
- package/dist/base-agent.d.ts +310 -26
- package/dist/base-agent.d.ts.map +1 -1
- package/dist/base-agent.js +867 -244
- package/dist/base-agent.js.map +1 -1
- package/dist/client-tool-results.d.ts +45 -0
- package/dist/client-tool-results.d.ts.map +1 -0
- package/dist/client-tool-results.js +94 -0
- package/dist/client-tool-results.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/native-tools/action-tool-builder.d.ts +14 -0
- package/dist/native-tools/action-tool-builder.d.ts.map +1 -1
- package/dist/native-tools/action-tool-builder.js +62 -7
- package/dist/native-tools/action-tool-builder.js.map +1 -1
- package/dist/native-tools/control-tools.d.ts +17 -1
- package/dist/native-tools/control-tools.d.ts.map +1 -1
- package/dist/native-tools/control-tools.js +40 -3
- package/dist/native-tools/control-tools.js.map +1 -1
- package/dist/prior-turn-tool-result-cache.d.ts +31 -12
- package/dist/prior-turn-tool-result-cache.d.ts.map +1 -1
- package/dist/prior-turn-tool-result-cache.js +59 -16
- package/dist/prior-turn-tool-result-cache.js.map +1 -1
- package/dist/realtime/bridge-host-params.d.ts +22 -0
- package/dist/realtime/bridge-host-params.d.ts.map +1 -0
- package/dist/realtime/bridge-host-params.js +53 -0
- package/dist/realtime/bridge-host-params.js.map +1 -0
- package/dist/realtime/bridge-realtime-session-factory.d.ts +63 -3
- package/dist/realtime/bridge-realtime-session-factory.d.ts.map +1 -1
- package/dist/realtime/bridge-realtime-session-factory.js +83 -22
- package/dist/realtime/bridge-realtime-session-factory.js.map +1 -1
- package/dist/realtime/bridge-realtime-usage-recorder.d.ts +81 -0
- package/dist/realtime/bridge-realtime-usage-recorder.d.ts.map +1 -0
- package/dist/realtime/bridge-realtime-usage-recorder.js +194 -0
- package/dist/realtime/bridge-realtime-usage-recorder.js.map +1 -0
- package/dist/realtime/bridge-session-transcript-sink.d.ts +37 -0
- package/dist/realtime/bridge-session-transcript-sink.d.ts.map +1 -0
- package/dist/realtime/bridge-session-transcript-sink.js +58 -0
- package/dist/realtime/bridge-session-transcript-sink.js.map +1 -0
- package/dist/realtime/client-only-channel-server.d.ts +83 -0
- package/dist/realtime/client-only-channel-server.d.ts.map +1 -0
- package/dist/realtime/client-only-channel-server.js +107 -0
- package/dist/realtime/client-only-channel-server.js.map +1 -0
- package/dist/realtime/identity-verification-channel-server.d.ts +50 -0
- package/dist/realtime/identity-verification-channel-server.d.ts.map +1 -0
- package/dist/realtime/identity-verification-channel-server.js +66 -0
- package/dist/realtime/identity-verification-channel-server.js.map +1 -0
- package/dist/realtime/interactive-component-channel-server.d.ts +39 -0
- package/dist/realtime/interactive-component-channel-server.d.ts.map +1 -0
- package/dist/realtime/interactive-component-channel-server.js +55 -0
- package/dist/realtime/interactive-component-channel-server.js.map +1 -0
- package/dist/realtime/realtime-avatar-resolution.d.ts +194 -0
- package/dist/realtime/realtime-avatar-resolution.d.ts.map +1 -0
- package/dist/realtime/realtime-avatar-resolution.js +208 -0
- package/dist/realtime/realtime-avatar-resolution.js.map +1 -0
- package/dist/realtime/realtime-channel-policy.d.ts +81 -0
- package/dist/realtime/realtime-channel-policy.d.ts.map +1 -0
- package/dist/realtime/realtime-channel-policy.js +91 -0
- package/dist/realtime/realtime-channel-policy.js.map +1 -0
- package/dist/realtime/realtime-channel-server-host.d.ts +14 -0
- package/dist/realtime/realtime-channel-server-host.d.ts.map +1 -1
- package/dist/realtime/realtime-channel-server-host.js +35 -0
- package/dist/realtime/realtime-channel-server-host.js.map +1 -1
- package/dist/realtime/realtime-client-session-service.d.ts +493 -30
- package/dist/realtime/realtime-client-session-service.d.ts.map +1 -1
- package/dist/realtime/realtime-client-session-service.js +825 -98
- package/dist/realtime/realtime-client-session-service.js.map +1 -1
- package/dist/realtime/realtime-coagent-config.d.ts +94 -15
- package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
- package/dist/realtime/realtime-coagent-config.js +109 -4
- package/dist/realtime/realtime-coagent-config.js.map +1 -1
- package/dist/realtime/realtime-coagent-resolution.d.ts +90 -0
- package/dist/realtime/realtime-coagent-resolution.d.ts.map +1 -0
- package/dist/realtime/realtime-coagent-resolution.js +186 -0
- package/dist/realtime/realtime-coagent-resolution.js.map +1 -0
- package/dist/realtime/realtime-delegation-narrator.d.ts +97 -0
- package/dist/realtime/realtime-delegation-narrator.d.ts.map +1 -0
- package/dist/realtime/realtime-delegation-narrator.js +162 -0
- package/dist/realtime/realtime-delegation-narrator.js.map +1 -0
- package/dist/realtime/realtime-recording-store.d.ts +136 -16
- package/dist/realtime/realtime-recording-store.d.ts.map +1 -1
- package/dist/realtime/realtime-recording-store.js +505 -39
- package/dist/realtime/realtime-recording-store.js.map +1 -1
- package/dist/realtime/realtime-session-runner.d.ts +2 -82
- package/dist/realtime/realtime-session-runner.d.ts.map +1 -1
- package/dist/realtime/realtime-session-runner.js +16 -191
- package/dist/realtime/realtime-session-runner.js.map +1 -1
- package/dist/realtime/realtime-vendor-resolution.d.ts +9 -0
- package/dist/realtime/realtime-vendor-resolution.d.ts.map +1 -1
- package/dist/realtime/realtime-vendor-resolution.js +16 -5
- package/dist/realtime/realtime-vendor-resolution.js.map +1 -1
- package/dist/realtime/realtime-video-output-gate.d.ts +83 -0
- package/dist/realtime/realtime-video-output-gate.d.ts.map +1 -0
- package/dist/realtime/realtime-video-output-gate.js +79 -0
- package/dist/realtime/realtime-video-output-gate.js.map +1 -0
- package/dist/rubric-evaluation-agent-runner.js +17 -8
- package/dist/rubric-evaluation-agent-runner.js.map +1 -1
- package/dist/self-check.d.ts +6 -3
- package/dist/self-check.d.ts.map +1 -1
- package/dist/self-check.js +3 -3
- package/dist/self-check.js.map +1 -1
- package/dist/tool-result-format.d.ts +14 -0
- package/dist/tool-result-format.d.ts.map +1 -1
- package/dist/tool-result-format.js.map +1 -1
- package/package.json +19 -19
|
@@ -5,9 +5,16 @@
|
|
|
5
5
|
* recording storage account, upload the audio, link it to the `AIAgentSession`, and stamp the session's
|
|
6
6
|
* recording fields. Keeps the storage policy in one place rather than duplicated per topology.
|
|
7
7
|
*
|
|
8
|
+
* Also hosts server-side recovery ({@link RecoverRealtimeRecordingFromSegments}): rebuilding a recording
|
|
9
|
+
* from its crash-recovery shards when the end-of-call upload never arrived. Recovery defers to a
|
|
10
|
+
* recording that already exists on a best-effort basis: it re-checks the session immediately before
|
|
11
|
+
* its stamp, but `BaseEntity` has no compare-and-set, so a writer landing inside that one load -> save
|
|
12
|
+
* window can still be overwritten.
|
|
13
|
+
*
|
|
8
14
|
* @module @memberjunction/ai-agents
|
|
9
15
|
*/
|
|
10
|
-
import {
|
|
16
|
+
import { randomBytes } from 'crypto';
|
|
17
|
+
import { LogError, LogStatus } from '@memberjunction/core';
|
|
11
18
|
import { FileStorageEngine } from '@memberjunction/storage';
|
|
12
19
|
/**
|
|
13
20
|
* Resolves the storage account a session recording should be written to: the agent's
|
|
@@ -39,20 +46,41 @@ export async function ResolveRecordingStorageAccountID(agent, contextUser, provi
|
|
|
39
46
|
export async function resolveRecordingStorageAccountID(agent, contextUser, provider) {
|
|
40
47
|
return ResolveRecordingStorageAccountID(agent, contextUser, provider);
|
|
41
48
|
}
|
|
42
|
-
/** A short, stable file extension for the recording's MIME type. */
|
|
49
|
+
/** A short, stable file extension for the recording's MIME type (MIME types are case-insensitive). */
|
|
43
50
|
function extensionForMime(mimeType) {
|
|
44
|
-
|
|
51
|
+
const type = mimeType.toLowerCase();
|
|
52
|
+
if (type.includes('webm'))
|
|
45
53
|
return 'webm';
|
|
46
|
-
if (
|
|
54
|
+
if (type.includes('ogg'))
|
|
47
55
|
return 'ogg';
|
|
48
|
-
if (
|
|
56
|
+
if (type.includes('mp4') || type.includes('m4a'))
|
|
49
57
|
return 'm4a';
|
|
50
58
|
// Header-less raw PCM crash-recovery shards (audio/L16 / audio/pcm) — NOT individually playable;
|
|
51
59
|
// recovery concatenates them in order and WAV-wraps. The consolidated file is always WAV.
|
|
52
|
-
if (
|
|
60
|
+
if (type.includes('l16') || type.includes('pcm'))
|
|
53
61
|
return 'pcm';
|
|
54
62
|
return 'wav';
|
|
55
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Sample rate (Hz) carried by a raw-PCM MIME type such as `audio/L16;rate=48000`. Case-insensitive on
|
|
66
|
+
* the type and parameter name; whitespace and extra parameters are tolerated. Returns `null` for any
|
|
67
|
+
* other type, a missing `rate`, or a rate that is not a positive integer. Shared by the shard-key
|
|
68
|
+
* writer and the recovery's content-type fallback so the two cannot disagree on what counts as a rate.
|
|
69
|
+
*/
|
|
70
|
+
function parsePcmRate(mimeType) {
|
|
71
|
+
const [type, ...params] = mimeType.split(';').map(part => part.trim());
|
|
72
|
+
if (!/^audio\/(l16|pcm)$/i.test(type)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
for (const param of params) {
|
|
76
|
+
const match = /^rate\s*=\s*(\d+)$/i.exec(param);
|
|
77
|
+
const rate = match ? Number(match[1]) : 0;
|
|
78
|
+
if (Number.isSafeInteger(rate) && rate > 0) {
|
|
79
|
+
return rate;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
56
84
|
/** The per-session folder all of a session's recording artifacts live in (shards + final file). */
|
|
57
85
|
function recordingFolder(sessionID) {
|
|
58
86
|
return `realtime-recordings/${sessionID}`;
|
|
@@ -99,12 +127,24 @@ export async function readRealtimeRecordingFile(fileID, contextUser, provider) {
|
|
|
99
127
|
return ReadRealtimeRecordingFile(fileID, contextUser, provider);
|
|
100
128
|
}
|
|
101
129
|
/**
|
|
102
|
-
*
|
|
130
|
+
* `seg-NNNN.<ext>`, or `seg-NNNN.r<rate>.pcm` for a PCM shard whose MIME carries a rate. Header-less
|
|
131
|
+
* PCM has no way to say how fast to play it back, and recovery runs after the browser (and its
|
|
132
|
+
* `RecordingStartedAt`/rate bookkeeping) is gone, so the rate travels in the key itself.
|
|
133
|
+
*/
|
|
134
|
+
function segmentFileName(segmentIndex, mimeType) {
|
|
135
|
+
const index = String(segmentIndex).padStart(4, '0');
|
|
136
|
+
const extension = extensionForMime(mimeType);
|
|
137
|
+
const rate = extension === 'pcm' ? parsePcmRate(mimeType) : null;
|
|
138
|
+
return rate === null ? `seg-${index}.${extension}` : `seg-${index}.r${rate}.${extension}`;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Writes ONE crash-recovery segment shard (`seg-NNNN[.r<rate>].<ext>`) into the session's folder as a RAW
|
|
103
142
|
* storage object — no `MJ: Files` row, no session stamping. Shards are durability insurance during a
|
|
104
143
|
* live call (so a browser/tab death loses at most the last window); they are byte-slices of one
|
|
105
144
|
* continuous stream (only the first carries the container header), so they are NOT individually
|
|
106
|
-
* playable — recovery
|
|
107
|
-
* canonical consolidated file lands
|
|
145
|
+
* playable — recovery (see {@link RecoverRealtimeRecordingFromSegments}) concatenates them in index order,
|
|
146
|
+
* filling absent indexes with silence. They are deleted once the canonical consolidated file lands
|
|
147
|
+
* ({@link DeleteRealtimeRecordingSegments}), or by the recovery path once it has stamped its own file. Never throws.
|
|
108
148
|
*
|
|
109
149
|
* @returns `true` on success.
|
|
110
150
|
*/
|
|
@@ -112,7 +152,7 @@ export async function WriteRealtimeRecordingSegment(input) {
|
|
|
112
152
|
const { SessionID, SegmentIndex, Audio, MimeType, StorageAccountID, ContextUser } = input;
|
|
113
153
|
try {
|
|
114
154
|
const driver = await FileStorageEngine.Instance.GetDriver(StorageAccountID, ContextUser);
|
|
115
|
-
const name =
|
|
155
|
+
const name = segmentFileName(SegmentIndex, MimeType);
|
|
116
156
|
return await driver.PutObject(`${recordingFolder(SessionID)}/${name}`, Audio, MimeType);
|
|
117
157
|
}
|
|
118
158
|
catch (error) {
|
|
@@ -151,8 +191,10 @@ export async function writeRecordingPeaksSidecar(sessionID, storageAccountID, pe
|
|
|
151
191
|
return WriteRecordingPeaksSidecar(sessionID, storageAccountID, peaks, contextUser);
|
|
152
192
|
}
|
|
153
193
|
/**
|
|
154
|
-
* Deletes
|
|
155
|
-
* after {@link
|
|
194
|
+
* Deletes every `seg-*` shard in a session's folder, leaving the consolidated `recording.*` file. Called
|
|
195
|
+
* after {@link StoreRealtimeRecording} writes the canonical file at end of call. The recovery path
|
|
196
|
+
* ({@link RecoverRealtimeRecordingFromSegments}) does not use this: it deletes only the shards it listed
|
|
197
|
+
* and assembled, so a shard written after its listing is not lost. Never throws.
|
|
156
198
|
*
|
|
157
199
|
* @returns The number of shards deleted.
|
|
158
200
|
*/
|
|
@@ -160,7 +202,9 @@ export async function DeleteRealtimeRecordingSegments(sessionID, storageAccountI
|
|
|
160
202
|
try {
|
|
161
203
|
const driver = await FileStorageEngine.Instance.GetDriver(storageAccountID, contextUser);
|
|
162
204
|
const folder = recordingFolder(sessionID);
|
|
163
|
-
|
|
205
|
+
// List the folder's CONTENTS: without the trailing slash, delimiter-based drivers (S3, GCS)
|
|
206
|
+
// return the folder itself as a common prefix and no objects, so no shard was ever deleted.
|
|
207
|
+
const listed = await driver.ListObjects(`${folder}/`);
|
|
164
208
|
let deleted = 0;
|
|
165
209
|
for (const obj of listed.objects ?? []) {
|
|
166
210
|
const base = (obj.name.split('/').pop() ?? obj.name);
|
|
@@ -190,13 +234,14 @@ export async function deleteRealtimeRecordingSegments(sessionID, storageAccountI
|
|
|
190
234
|
* @returns The created `MJ: Files` id, or the failure reason.
|
|
191
235
|
*/
|
|
192
236
|
export async function StoreRealtimeRecording(input) {
|
|
193
|
-
const { Audio, MimeType,
|
|
237
|
+
const { Audio, MimeType, StorageAccountID, SessionID, ContextUser, Provider, Peaks } = input;
|
|
194
238
|
try {
|
|
195
239
|
// Canonical consolidated file in the session's own folder, alongside (then replacing) its shards.
|
|
196
240
|
const uploaded = await FileStorageEngine.Instance.UploadFile({
|
|
197
241
|
content: Audio,
|
|
198
|
-
fileName: `recording.${extensionForMime(MimeType)}`,
|
|
242
|
+
fileName: input.FileName ?? `recording.${extensionForMime(MimeType)}`,
|
|
199
243
|
mimeType: MimeType,
|
|
244
|
+
description: input.Description,
|
|
200
245
|
contextUser: ContextUser,
|
|
201
246
|
storageAccountId: StorageAccountID,
|
|
202
247
|
provider: Provider,
|
|
@@ -205,38 +250,459 @@ export async function StoreRealtimeRecording(input) {
|
|
|
205
250
|
// Best-effort waveform-peaks sidecar (peaks.json) next to the recording, for fast waveform
|
|
206
251
|
// rendering without re-decoding the audio. A sidecar failure never fails the recording itself.
|
|
207
252
|
await WriteRecordingPeaksSidecar(SessionID, StorageAccountID, Peaks, ContextUser);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const link = await Provider.GetEntityObject('MJ: File Entity Record Links', ContextUser);
|
|
212
|
-
link.NewRecord();
|
|
213
|
-
link.FileID = uploaded.FileID;
|
|
214
|
-
link.EntityID = sessionEntityID;
|
|
215
|
-
link.RecordID = SessionID;
|
|
216
|
-
if (!await link.Save()) {
|
|
217
|
-
LogError(`storeRealtimeRecording: failed to link recording to session ${SessionID}: ${link.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
// Stamp the recording fields on the session (file + media kind + t0).
|
|
221
|
-
const session = await Provider.GetEntityObject('MJ: AI Agent Sessions', ContextUser);
|
|
222
|
-
if (await session.Load(SessionID)) {
|
|
223
|
-
session.RecordingFileID = uploaded.FileID;
|
|
224
|
-
session.RecordingMedia = Media;
|
|
225
|
-
session.RecordingStartedAt = StartedAt;
|
|
226
|
-
if (!await session.Save()) {
|
|
227
|
-
LogError(`storeRealtimeRecording: failed to stamp recording fields on session ${SessionID}: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return { FileID: uploaded.FileID, ErrorMessage: null };
|
|
253
|
+
return input.PreserveExistingRecording
|
|
254
|
+
? await stampPreservingExisting(input, uploaded.FileID)
|
|
255
|
+
: await linkAndStamp(input, uploaded.FileID);
|
|
231
256
|
}
|
|
232
257
|
catch (error) {
|
|
233
258
|
const message = error instanceof Error ? error.message : String(error);
|
|
234
259
|
LogError(`storeRealtimeRecording failed for session ${SessionID}: ${message}`);
|
|
235
|
-
return { FileID: null, ErrorMessage: message };
|
|
260
|
+
return { FileID: null, ErrorMessage: message, Superseded: false };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* End-of-call path: link, then stamp unconditionally, so the browser's own recording replaces whatever
|
|
265
|
+
* the session pointed at. A session that cannot be loaded is still linked but not stamped (unchanged
|
|
266
|
+
* long-standing behaviour; the upload is reported as stored).
|
|
267
|
+
*/
|
|
268
|
+
async function linkAndStamp(input, fileID) {
|
|
269
|
+
const { Media, StartedAt, SessionID, ContextUser, Provider } = input;
|
|
270
|
+
const session = await Provider.GetEntityObject('MJ: AI Agent Sessions', ContextUser);
|
|
271
|
+
const sessionLoaded = await session.Load(SessionID);
|
|
272
|
+
await linkRecordingToSession(fileID, SessionID, ContextUser, Provider);
|
|
273
|
+
if (sessionLoaded) {
|
|
274
|
+
await stampRecordingOnSession(session, fileID, Media, StartedAt);
|
|
275
|
+
}
|
|
276
|
+
return { FileID: fileID, ErrorMessage: null, Superseded: false };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Recovery path: re-check -> stamp -> link. The re-check is the last read before the stamp, so the
|
|
280
|
+
* window in which a late recording can still be overwritten is one load -> save. The link is written
|
|
281
|
+
* only once the stamp saved, so a `Superseded` or failed result never leaves a link row behind.
|
|
282
|
+
*/
|
|
283
|
+
async function stampPreservingExisting(input, fileID) {
|
|
284
|
+
const { Media, StartedAt, SessionID, ContextUser, Provider } = input;
|
|
285
|
+
const session = await Provider.GetEntityObject('MJ: AI Agent Sessions', ContextUser);
|
|
286
|
+
if (!await session.Load(SessionID)) {
|
|
287
|
+
const message = `could not re-read session ${SessionID} before stamping; file ${fileID} is orphaned and was not linked or stamped`;
|
|
288
|
+
LogError(`storeRealtimeRecording: ${message}`);
|
|
289
|
+
return { FileID: fileID, ErrorMessage: message, Superseded: false };
|
|
290
|
+
}
|
|
291
|
+
if (session.RecordingFileID) {
|
|
292
|
+
LogStatus(`storeRealtimeRecording: session ${SessionID} already has recording file ${session.RecordingFileID}; the just-uploaded file ${fileID} is orphaned and was not linked or stamped`);
|
|
293
|
+
return { FileID: fileID, ErrorMessage: null, Superseded: true };
|
|
294
|
+
}
|
|
295
|
+
if (!await stampRecordingOnSession(session, fileID, Media, StartedAt)) {
|
|
296
|
+
const reason = session.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
297
|
+
return { FileID: fileID, ErrorMessage: `could not stamp file ${fileID} on session ${SessionID} (file orphaned, not linked): ${reason}`, Superseded: false };
|
|
298
|
+
}
|
|
299
|
+
await linkRecordingToSession(fileID, SessionID, ContextUser, Provider);
|
|
300
|
+
return { FileID: fileID, ErrorMessage: null, Superseded: false };
|
|
301
|
+
}
|
|
302
|
+
/** Links the file to the session record so it's discoverable via MJ: File Entity Record Links. */
|
|
303
|
+
async function linkRecordingToSession(fileID, sessionID, contextUser, provider) {
|
|
304
|
+
const sessionEntityID = provider.EntityByName('MJ: AI Agent Sessions')?.ID;
|
|
305
|
+
if (!sessionEntityID) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const link = await provider.GetEntityObject('MJ: File Entity Record Links', contextUser);
|
|
309
|
+
link.NewRecord();
|
|
310
|
+
link.FileID = fileID;
|
|
311
|
+
link.EntityID = sessionEntityID;
|
|
312
|
+
link.RecordID = sessionID;
|
|
313
|
+
if (!await link.Save()) {
|
|
314
|
+
LogError(`storeRealtimeRecording: failed to link recording to session ${sessionID}: ${link.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Stamps the recording fields on an already-loaded session (file + media kind + t0). Logs and returns false on a failed save. */
|
|
318
|
+
async function stampRecordingOnSession(session, fileID, media, startedAt) {
|
|
319
|
+
session.RecordingFileID = fileID;
|
|
320
|
+
session.RecordingMedia = media;
|
|
321
|
+
session.RecordingStartedAt = startedAt;
|
|
322
|
+
if (!await session.Save()) {
|
|
323
|
+
LogError(`storeRealtimeRecording: failed to stamp recording fields on session ${session.ID}: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
324
|
+
return false;
|
|
236
325
|
}
|
|
326
|
+
return true;
|
|
237
327
|
}
|
|
238
328
|
/** @deprecated Use {@link StoreRealtimeRecording}. */
|
|
239
329
|
export async function storeRealtimeRecording(input) {
|
|
240
330
|
return StoreRealtimeRecording(input);
|
|
241
331
|
}
|
|
332
|
+
const RECORDING_WAV_HEADER_BYTES = 44;
|
|
333
|
+
const RECORDING_BYTES_PER_SAMPLE = 2; // mono, 16-bit PCM
|
|
334
|
+
/** WAV stores sizes as uint32; the RIFF chunk size is `36 + dataSize`, so data tops out 36 bytes short of the max. */
|
|
335
|
+
const RECORDING_MAX_DATA_BYTES = 0xFFFFFFFF - 36;
|
|
336
|
+
/**
|
|
337
|
+
* Assembles recovered recording shards into one WAV, in index order, filling every absent index with
|
|
338
|
+
* silence. Silence (rather than skipping the gap) keeps all later audio at its true time offset, so it
|
|
339
|
+
* stays aligned with transcript cues, which are offsets from the session's `RecordingStartedAt`.
|
|
340
|
+
*
|
|
341
|
+
* The gap length is the median length of the present shards excluding the highest present index (that
|
|
342
|
+
* one is normally a partial window); with a single shard, that shard's length. It is rounded down to
|
|
343
|
+
* an even byte count (whole 16-bit samples), minimum 2.
|
|
344
|
+
*
|
|
345
|
+
* The byte ceiling does not bound the index range on its own: under the default ceiling a single shard
|
|
346
|
+
* at index ~10^9 still fits, and the missing-index list grows with the highest index. Callers handling
|
|
347
|
+
* untrusted shards must cap the shard count and highest index themselves (recovery does, via
|
|
348
|
+
* {@link MAX_RECOVERY_SEGMENTS}) and pass a smaller `maxDataBytes`.
|
|
349
|
+
*
|
|
350
|
+
* @param segments Recovered shards; any order, unique non-negative integer indexes starting from 0.
|
|
351
|
+
* @param sampleRate Sample rate of the PCM data, in Hz.
|
|
352
|
+
* @param maxDataBytes Ceiling on the PCM data size (shards plus gap fill), checked before anything is
|
|
353
|
+
* allocated. Defaults to the WAV format's own limit; callers handling untrusted shards pass less.
|
|
354
|
+
* @throws Error on no segments, an invalid sample rate or index, a duplicate index, an empty or
|
|
355
|
+
* odd-length shard, or a result too large for a WAV.
|
|
356
|
+
*/
|
|
357
|
+
export function BuildRecordingFromSegments(segments, sampleRate, maxDataBytes = RECORDING_MAX_DATA_BYTES) {
|
|
358
|
+
validateRecordingInputs(segments, sampleRate);
|
|
359
|
+
const ordered = [...segments].sort((a, b) => a.Index - b.Index);
|
|
360
|
+
const gapBytes = computeRecordingGapBytes(ordered);
|
|
361
|
+
// Count gaps arithmetically (indexes are unique and non-negative) so an absurd index fails the size
|
|
362
|
+
// check below instead of first enumerating billions of missing indexes.
|
|
363
|
+
const missingCount = ordered[ordered.length - 1].Index - (ordered.length - 1);
|
|
364
|
+
const dataSize = ordered.reduce((sum, s) => sum + s.Bytes.length, 0) + missingCount * gapBytes;
|
|
365
|
+
const limit = Math.min(maxDataBytes, RECORDING_MAX_DATA_BYTES);
|
|
366
|
+
if (dataSize > limit) {
|
|
367
|
+
throw new Error(`BuildRecordingFromSegments: data of ${dataSize} bytes exceeds the maximum WAV data size of ${limit} bytes`);
|
|
368
|
+
}
|
|
369
|
+
// Buffer.alloc zero-fills, so gaps need no explicit write.
|
|
370
|
+
const wav = Buffer.alloc(RECORDING_WAV_HEADER_BYTES + dataSize);
|
|
371
|
+
writeRecordingWavHeader(wav, sampleRate, dataSize);
|
|
372
|
+
let offset = RECORDING_WAV_HEADER_BYTES;
|
|
373
|
+
let expectedIndex = 0;
|
|
374
|
+
for (const segment of ordered) {
|
|
375
|
+
offset += (segment.Index - expectedIndex) * gapBytes;
|
|
376
|
+
offset += segment.Bytes.copy(wav, offset);
|
|
377
|
+
expectedIndex = segment.Index + 1;
|
|
378
|
+
}
|
|
379
|
+
return { Wav: wav, MissingIndexes: findMissingIndexes(ordered), GapBytes: gapBytes };
|
|
380
|
+
}
|
|
381
|
+
function validateRecordingInputs(segments, sampleRate) {
|
|
382
|
+
if (segments.length === 0) {
|
|
383
|
+
throw new Error('BuildRecordingFromSegments: no segments to assemble');
|
|
384
|
+
}
|
|
385
|
+
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
|
|
386
|
+
throw new Error(`BuildRecordingFromSegments: sample rate must be a positive integer, got ${sampleRate}`);
|
|
387
|
+
}
|
|
388
|
+
const seen = new Set();
|
|
389
|
+
for (const { Index, Bytes } of segments) {
|
|
390
|
+
if (!Number.isInteger(Index) || Index < 0) {
|
|
391
|
+
throw new Error(`BuildRecordingFromSegments: segment index must be a non-negative integer, got ${Index}`);
|
|
392
|
+
}
|
|
393
|
+
if (seen.has(Index)) {
|
|
394
|
+
throw new Error(`BuildRecordingFromSegments: duplicate segment index ${Index}`);
|
|
395
|
+
}
|
|
396
|
+
seen.add(Index);
|
|
397
|
+
if (Bytes.length === 0) {
|
|
398
|
+
throw new Error(`BuildRecordingFromSegments: segment ${Index} is empty`);
|
|
399
|
+
}
|
|
400
|
+
if (Bytes.length % RECORDING_BYTES_PER_SAMPLE !== 0) {
|
|
401
|
+
throw new Error(`BuildRecordingFromSegments: segment ${Index} has odd length ${Bytes.length}; PCM16 needs an even byte count`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/** Median length of the present shards minus the highest-indexed one (a partial window), floored to even, min 2. */
|
|
406
|
+
function computeRecordingGapBytes(ordered) {
|
|
407
|
+
const basis = ordered.length > 1 ? ordered.slice(0, -1) : ordered;
|
|
408
|
+
const lengths = basis.map(s => s.Bytes.length).sort((a, b) => a - b);
|
|
409
|
+
const mid = Math.floor(lengths.length / 2);
|
|
410
|
+
const median = lengths.length % 2 === 1 ? lengths[mid] : (lengths[mid - 1] + lengths[mid]) / 2;
|
|
411
|
+
const even = Math.floor(median / RECORDING_BYTES_PER_SAMPLE) * RECORDING_BYTES_PER_SAMPLE;
|
|
412
|
+
return Math.max(RECORDING_BYTES_PER_SAMPLE, even);
|
|
413
|
+
}
|
|
414
|
+
/** Indexes in [0, highest present) with no shard. `ordered` is sorted ascending. */
|
|
415
|
+
function findMissingIndexes(ordered) {
|
|
416
|
+
const highestIndex = ordered[ordered.length - 1].Index;
|
|
417
|
+
const present = new Set(ordered.map(s => s.Index));
|
|
418
|
+
const missing = [];
|
|
419
|
+
for (let i = 0; i < highestIndex; i++) {
|
|
420
|
+
if (!present.has(i)) {
|
|
421
|
+
missing.push(i);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return missing;
|
|
425
|
+
}
|
|
426
|
+
/** Same canonical header layout as `RealtimeRecordingCapture.encodeWavBuffer` (mono, 16-bit, PCM). */
|
|
427
|
+
function writeRecordingWavHeader(buffer, sampleRate, dataSize) {
|
|
428
|
+
buffer.write('RIFF', 0, 'ascii');
|
|
429
|
+
buffer.writeUInt32LE(36 + dataSize, 4);
|
|
430
|
+
buffer.write('WAVE', 8, 'ascii');
|
|
431
|
+
buffer.write('fmt ', 12, 'ascii');
|
|
432
|
+
buffer.writeUInt32LE(16, 16);
|
|
433
|
+
buffer.writeUInt16LE(1, 20);
|
|
434
|
+
buffer.writeUInt16LE(1, 22);
|
|
435
|
+
buffer.writeUInt32LE(sampleRate, 24);
|
|
436
|
+
buffer.writeUInt32LE(sampleRate * RECORDING_BYTES_PER_SAMPLE, 28);
|
|
437
|
+
buffer.writeUInt16LE(RECORDING_BYTES_PER_SAMPLE, 32);
|
|
438
|
+
buffer.writeUInt16LE(16, 34);
|
|
439
|
+
buffer.write('data', 36, 'ascii');
|
|
440
|
+
buffer.writeUInt32LE(dataSize, 40);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Most shards recovery will assemble. At ~15 s per shard this is an hour of audio, far past any real
|
|
444
|
+
* call; a listing at or beyond it (by count or by highest index) is treated as corrupt or hostile and
|
|
445
|
+
* fails closed rather than allocating a huge WAV.
|
|
446
|
+
*/
|
|
447
|
+
export const MAX_RECOVERY_SEGMENTS = 240;
|
|
448
|
+
/**
|
|
449
|
+
* Ceiling on the PCM data a recovery will assemble: 384 MiB covers an hour of 48 kHz mono PCM16
|
|
450
|
+
* (345.6 MB) with headroom. Shard sizes are client-controlled (the GraphQL body limit is 50 MB, so one
|
|
451
|
+
* shard can be ~37 MB) and gaps are filled with silence, so without this a session owner could make the
|
|
452
|
+
* janitor allocate gigabytes. Checked against listed sizes, running actual bytes, and the assembled size.
|
|
453
|
+
*
|
|
454
|
+
* It caps the data, not the process's memory: the shard buffers are still held while the WAV is
|
|
455
|
+
* assembled (2x), and storage drivers can copy on read (the S3 driver copies each shard once more), so
|
|
456
|
+
* peak memory for one recovery is up to about three times this: two 288 MB recoveries raised the API
|
|
457
|
+
* process's RSS by 730 MB and 920 MB.
|
|
458
|
+
*/
|
|
459
|
+
export const MAX_RECOVERY_BYTES = 384 * 1024 * 1024;
|
|
460
|
+
/**
|
|
461
|
+
* Seconds of audio in one full shard written before shard keys carried a rate. Those clients flushed a
|
|
462
|
+
* shard every 15 s. The legacy format is frozen, so this deliberately does not follow the client's
|
|
463
|
+
* current flush interval: it is only used to infer the rate of shards that declare none.
|
|
464
|
+
*/
|
|
465
|
+
const LEGACY_SHARD_SECONDS = 15;
|
|
466
|
+
/** Sample rates a legacy shard's rate may be inferred as. */
|
|
467
|
+
const STANDARD_PCM_RATES = [8000, 11025, 16000, 22050, 24000, 32000, 44100, 48000];
|
|
468
|
+
/**
|
|
469
|
+
* How far a full legacy shard's size-implied rate may be from a standard rate (relative). Real full
|
|
470
|
+
* shards are within ~0.2% of 15 s; the closest pair of standard rates is ~9% apart.
|
|
471
|
+
*/
|
|
472
|
+
const RATE_SNAP_TOLERANCE = 0.02;
|
|
473
|
+
/** `seg-0003.pcm` or `seg-0003.r48000.pcm`: index, optional keyed rate, extension. */
|
|
474
|
+
const SEGMENT_KEY_PATTERN = /^seg-(\d+)(?:\.r(\d+))?\.([A-Za-z0-9]+)$/;
|
|
475
|
+
/**
|
|
476
|
+
* A fresh name per recovery attempt (`recording-recovered-<epoch ms>-<random hex>.wav`). Storage keys are
|
|
477
|
+
* `<folder>/<file name>`, so a fixed name would make retries and concurrent instances write the SAME
|
|
478
|
+
* object behind several `MJ: Files` rows: deleting an orphan row would delete the stamped recording, and
|
|
479
|
+
* a late attempt could overwrite it with a mostly silent WAV.
|
|
480
|
+
*/
|
|
481
|
+
function recoveredFileName() {
|
|
482
|
+
return `recording-recovered-${Date.now()}-${randomBytes(4).toString('hex')}.wav`;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Rebuilds a session's recording from the `seg-*` crash-recovery shards left in its storage folder —
|
|
486
|
+
* for a call whose browser died or whose end-of-call upload never ran, so no `recording.*` exists.
|
|
487
|
+
* Shards are concatenated in order (silence for absent ones), uploaded under a per-attempt
|
|
488
|
+
* `recording-recovered-<epoch ms>-<random hex>.wav`, stamped on the session, linked, and only then
|
|
489
|
+
* deleted. Deletion happens only after a reload confirms the session points at the recovered file, so a
|
|
490
|
+
* failure at any step leaves the shards for the next try. If the session has (or gains) another
|
|
491
|
+
* recording, the outcome is `Superseded` and the shards are kept. Deferring to that recording is best
|
|
492
|
+
* effort: the session is re-checked immediately before the stamp, but a recording stamped inside that
|
|
493
|
+
* one load -> save window is overwritten (there is no compare-and-set).
|
|
494
|
+
*
|
|
495
|
+
* The sample rate comes from the shard keys (`seg-NNNN.r<rate>.pcm`) or, for legacy `seg-NNNN.pcm`
|
|
496
|
+
* shards, from each object's stored content type. When no shard declares a rate (a legacy shard on a
|
|
497
|
+
* driver that keeps no content type, such as Box), it is inferred from the size of the full shards; see
|
|
498
|
+
* {@link LEGACY_SHARD_SECONDS}.
|
|
499
|
+
*
|
|
500
|
+
* Once the stamp has saved, the outcome is `Recovered` even if the confirming reload or a shard delete
|
|
501
|
+
* fails; the shards it could not remove are named in a `LogError` (nothing revisits a stamped session).
|
|
502
|
+
*
|
|
503
|
+
* Never throws: every failure is logged with the session id and returned as `Failed`.
|
|
504
|
+
*
|
|
505
|
+
* @param input Session, storage account, recording `t0`, and server context.
|
|
506
|
+
*/
|
|
507
|
+
export async function RecoverRealtimeRecordingFromSegments(input) {
|
|
508
|
+
try {
|
|
509
|
+
return await recoverFromSegments(input);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
513
|
+
LogError(`RecoverRealtimeRecordingFromSegments failed (session ${input.SessionID}): ${message}`);
|
|
514
|
+
return recoveryResult('Failed', { ErrorMessage: message });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function recoveryResult(outcome, fields = {}) {
|
|
518
|
+
return { Outcome: outcome, FileID: null, SegmentCount: 0, MissingIndexes: [], ErrorMessage: null, ...fields };
|
|
519
|
+
}
|
|
520
|
+
async function recoverFromSegments(input) {
|
|
521
|
+
const { SessionID, StorageAccountID, StartedAt, ContextUser, Provider } = input;
|
|
522
|
+
const driver = await FileStorageEngine.Instance.GetDriver(StorageAccountID, ContextUser);
|
|
523
|
+
const shards = await listRecordingShards(driver, SessionID);
|
|
524
|
+
if (shards.length === 0) {
|
|
525
|
+
LogStatus(`RecoverRealtimeRecordingFromSegments: no shards for session ${SessionID} in storage account ${StorageAccountID}`);
|
|
526
|
+
return recoveryResult('NoSegments');
|
|
527
|
+
}
|
|
528
|
+
assertRecoverable(shards);
|
|
529
|
+
const declaredRate = await resolveDeclaredSampleRate(driver, shards);
|
|
530
|
+
const segments = await readShardBytes(driver, shards);
|
|
531
|
+
const sampleRate = declaredRate ?? inferSampleRateFromShardSizes(segments, shards);
|
|
532
|
+
if (declaredRate === null) {
|
|
533
|
+
LogStatus(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): no shard declares a sample rate; inferred ${sampleRate} Hz from the size of its full shards`);
|
|
534
|
+
}
|
|
535
|
+
const assembled = BuildRecordingFromSegments(segments, sampleRate, MAX_RECOVERY_BYTES);
|
|
536
|
+
const description = describeRecovery(shards.length, assembled, sampleRate);
|
|
537
|
+
if (assembled.MissingIndexes.length > 0) {
|
|
538
|
+
LogStatus(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): ${description}`);
|
|
539
|
+
}
|
|
540
|
+
const stored = await StoreRealtimeRecording({
|
|
541
|
+
Audio: assembled.Wav, MimeType: 'audio/wav', Media: 'Audio', StartedAt, StorageAccountID, SessionID,
|
|
542
|
+
ContextUser, Provider, FileName: recoveredFileName(), Description: description, PreserveExistingRecording: true,
|
|
543
|
+
});
|
|
544
|
+
const found = { FileID: stored.FileID, SegmentCount: shards.length, MissingIndexes: assembled.MissingIndexes };
|
|
545
|
+
if (stored.Superseded) {
|
|
546
|
+
return recoveryResult('Superseded', found);
|
|
547
|
+
}
|
|
548
|
+
// An ErrorMessage with a FileID is an uploaded-but-unstamped orphan (re-read or stamp failed).
|
|
549
|
+
if (!stored.FileID || stored.ErrorMessage) {
|
|
550
|
+
throw new Error(stored.ErrorMessage ?? 'storing the recovered recording failed');
|
|
551
|
+
}
|
|
552
|
+
return finishRecovery(input, stored.FileID, found, shards);
|
|
553
|
+
}
|
|
554
|
+
/** Confirms the stamp stuck, then (and only then) deletes the shards. */
|
|
555
|
+
async function finishRecovery(input, fileID, found, shards) {
|
|
556
|
+
const { SessionID, StorageAccountID, ContextUser, Provider } = input;
|
|
557
|
+
const session = await Provider.GetEntityObject('MJ: AI Agent Sessions', ContextUser);
|
|
558
|
+
if (!await session.Load(SessionID)) {
|
|
559
|
+
// The stamp and link already saved, so the recording is in place; only the overwrite check could
|
|
560
|
+
// not run. Keep the shards (they might belong to a recording that replaced ours) and name them.
|
|
561
|
+
LogError(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): recovered file ${fileID} is stamped, but the session ` +
|
|
562
|
+
`could not be reloaded to confirm it before deleting shards; kept for manual cleanup: ${shards.map(s => s.Path).join(', ')}`);
|
|
563
|
+
return recoveryResult('Recovered', found);
|
|
564
|
+
}
|
|
565
|
+
if (session.RecordingFileID && session.RecordingFileID !== fileID) {
|
|
566
|
+
LogStatus(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): session now points at ${session.RecordingFileID}, not recovered file ${fileID}; keeping shards`);
|
|
567
|
+
return recoveryResult('Superseded', found);
|
|
568
|
+
}
|
|
569
|
+
if (session.RecordingFileID !== fileID) {
|
|
570
|
+
throw new Error(`recovered file ${fileID} uploaded but not stamped on session ${SessionID}; shards kept`);
|
|
571
|
+
}
|
|
572
|
+
const notDeleted = await deleteListedShards(StorageAccountID, ContextUser, shards);
|
|
573
|
+
if (notDeleted.length > 0) {
|
|
574
|
+
LogError(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): deleted ${shards.length - notDeleted.length} of ${shards.length} ` +
|
|
575
|
+
`shards after recovery; left for manual cleanup: ${notDeleted.join(', ')}`);
|
|
576
|
+
}
|
|
577
|
+
return recoveryResult('Recovered', found);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Deletes exactly the shards that were assembled. A re-list would also take shards written after our
|
|
581
|
+
* listing, whose audio is not in the recovered file. Returns the paths it could not delete.
|
|
582
|
+
*/
|
|
583
|
+
async function deleteListedShards(storageAccountID, contextUser, shards) {
|
|
584
|
+
const driver = await FileStorageEngine.Instance.GetDriver(storageAccountID, contextUser);
|
|
585
|
+
const notDeleted = [];
|
|
586
|
+
for (const shard of shards) {
|
|
587
|
+
if (!await driver.DeleteObject(shard.Path)) {
|
|
588
|
+
notDeleted.push(shard.Path);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return notDeleted;
|
|
592
|
+
}
|
|
593
|
+
/** Lists the session folder's `seg-*` objects. Other objects (recording.*, peaks.json) are ignored. */
|
|
594
|
+
async function listRecordingShards(driver, sessionID) {
|
|
595
|
+
const folder = recordingFolder(sessionID);
|
|
596
|
+
const listed = await driver.ListObjects(`${folder}/`);
|
|
597
|
+
const shards = [];
|
|
598
|
+
for (const obj of listed.objects ?? []) {
|
|
599
|
+
const name = obj.name.split('/').pop() ?? obj.name;
|
|
600
|
+
const match = SEGMENT_KEY_PATTERN.exec(name);
|
|
601
|
+
if (match) {
|
|
602
|
+
const keyedRate = match[2] === undefined ? null : Number(match[2]);
|
|
603
|
+
if (keyedRate !== null && !(Number.isSafeInteger(keyedRate) && keyedRate > 0)) {
|
|
604
|
+
throw new Error(`shard ${name} has an invalid sample rate in its key`);
|
|
605
|
+
}
|
|
606
|
+
shards.push({
|
|
607
|
+
Name: name, Path: `${folder}/${name}`, Index: Number(match[1]),
|
|
608
|
+
KeyedRate: keyedRate, Extension: match[3], ListedSize: obj.size ?? 0,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return shards;
|
|
613
|
+
}
|
|
614
|
+
/** Fail closed on an oversized listing and on anything that is not raw PCM (webm/ogg shards cannot be concatenated). */
|
|
615
|
+
function assertRecoverable(shards) {
|
|
616
|
+
const highestIndex = Math.max(...shards.map(s => s.Index));
|
|
617
|
+
if (shards.length >= MAX_RECOVERY_SEGMENTS || highestIndex >= MAX_RECOVERY_SEGMENTS) {
|
|
618
|
+
throw new Error(`refusing to recover ${shards.length} shards (highest index ${highestIndex}); limit is ${MAX_RECOVERY_SEGMENTS}`);
|
|
619
|
+
}
|
|
620
|
+
const listedBytes = shards.reduce((sum, s) => sum + s.ListedSize, 0);
|
|
621
|
+
if (listedBytes > MAX_RECOVERY_BYTES) {
|
|
622
|
+
throw new Error(`shards total ${listedBytes} bytes, over the recovery limit of ${MAX_RECOVERY_BYTES} bytes`);
|
|
623
|
+
}
|
|
624
|
+
const notPcm = shards.find(s => s.Extension !== 'pcm');
|
|
625
|
+
if (notPcm) {
|
|
626
|
+
throw new Error(`shard ${notPcm.Name} is not raw PCM; only .pcm shards can be reassembled`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* The one sample rate the shards declare: from the key when present, else the object's stored content
|
|
631
|
+
* type. Null when no shard declares one (left to {@link inferSampleRateFromShardSizes}). Throws when
|
|
632
|
+
* shards disagree, or when some declare a rate and others do not.
|
|
633
|
+
*/
|
|
634
|
+
async function resolveDeclaredSampleRate(driver, shards) {
|
|
635
|
+
const declared = [];
|
|
636
|
+
for (const shard of shards) {
|
|
637
|
+
const rate = shard.KeyedRate ?? parsePcmRate((await driver.GetObjectMetadata({ fullPath: shard.Path })).contentType ?? '');
|
|
638
|
+
declared.push({ shard, rate });
|
|
639
|
+
}
|
|
640
|
+
const withRate = declared.filter(d => d.rate !== null);
|
|
641
|
+
if (withRate.length === 0) {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
const undeclared = declared.find(d => d.rate === null);
|
|
645
|
+
if (undeclared) {
|
|
646
|
+
throw new Error(`cannot determine the sample rate of shard ${undeclared.shard.Name}`);
|
|
647
|
+
}
|
|
648
|
+
const first = withRate[0];
|
|
649
|
+
const disagreeing = withRate.find(d => d.rate !== first.rate);
|
|
650
|
+
if (disagreeing) {
|
|
651
|
+
throw new Error(`shards disagree on sample rate: ${first.shard.Name} is ${first.rate} Hz but ${disagreeing.shard.Name} is ${disagreeing.rate} Hz`);
|
|
652
|
+
}
|
|
653
|
+
return first.rate;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Infers the sample rate of shards that declare none from the size of their full shards: every shard
|
|
657
|
+
* but the highest-indexed (normally a partial window) holds {@link LEGACY_SHARD_SECONDS} of mono PCM16.
|
|
658
|
+
* Each full shard's implied rate must be within {@link RATE_SNAP_TOLERANCE} of a standard rate, and all
|
|
659
|
+
* must agree. A lone shard is refused, since it may be a partial window.
|
|
660
|
+
*/
|
|
661
|
+
function inferSampleRateFromShardSizes(segments, shards) {
|
|
662
|
+
const nameOf = (index) => shards.find(s => s.Index === index)?.Name ?? `index ${index}`;
|
|
663
|
+
const ordered = [...segments].sort((a, b) => a.Index - b.Index);
|
|
664
|
+
const full = ordered.slice(0, -1);
|
|
665
|
+
if (full.length === 0) {
|
|
666
|
+
throw new Error(`cannot determine the sample rate of shard ${nameOf(ordered[0].Index)}: neither its key nor its content type carries one, ` +
|
|
667
|
+
`and a lone shard may be a partial window, so its size cannot be trusted`);
|
|
668
|
+
}
|
|
669
|
+
const rates = full.map(segment => {
|
|
670
|
+
const implied = segment.Bytes.length / RECORDING_BYTES_PER_SAMPLE / LEGACY_SHARD_SECONDS;
|
|
671
|
+
const snapped = STANDARD_PCM_RATES.find(r => Math.abs(implied - r) / r <= RATE_SNAP_TOLERANCE);
|
|
672
|
+
if (snapped === undefined) {
|
|
673
|
+
throw new Error(`cannot determine the sample rate of shard ${nameOf(segment.Index)}: its size (${segment.Bytes.length} bytes) ` +
|
|
674
|
+
`implies ${Math.round(implied)} Hz, not a standard rate`);
|
|
675
|
+
}
|
|
676
|
+
return snapped;
|
|
677
|
+
});
|
|
678
|
+
const disagreeing = rates.findIndex(r => r !== rates[0]);
|
|
679
|
+
if (disagreeing !== -1) {
|
|
680
|
+
throw new Error(`shards disagree on sample rate: ${nameOf(full[0].Index)}'s size implies ${rates[0]} Hz ` +
|
|
681
|
+
`but ${nameOf(full[disagreeing].Index)}'s implies ${rates[disagreeing]} Hz`);
|
|
682
|
+
}
|
|
683
|
+
return rates[0];
|
|
684
|
+
}
|
|
685
|
+
/** Sequential on purpose: bounds memory and request concurrency against the storage account. */
|
|
686
|
+
async function readShardBytes(driver, shards) {
|
|
687
|
+
const segments = [];
|
|
688
|
+
let total = 0;
|
|
689
|
+
for (const shard of shards) {
|
|
690
|
+
const bytes = await driver.GetObject({ fullPath: shard.Path });
|
|
691
|
+
total += bytes.length;
|
|
692
|
+
// Listed sizes can be 0 or wrong, so the real total is enforced as bytes arrive.
|
|
693
|
+
if (total > MAX_RECOVERY_BYTES) {
|
|
694
|
+
throw new Error(`shards read so far total ${total} bytes (at ${shard.Name}), over the recovery limit of ${MAX_RECOVERY_BYTES} bytes`);
|
|
695
|
+
}
|
|
696
|
+
segments.push({ Index: shard.Index, Bytes: bytes });
|
|
697
|
+
}
|
|
698
|
+
return segments;
|
|
699
|
+
}
|
|
700
|
+
function describeRecovery(shardCount, assembled, sampleRate) {
|
|
701
|
+
const head = `Recovered from ${shardCount} crash-recovery segment(s)`;
|
|
702
|
+
if (assembled.MissingIndexes.length === 0) {
|
|
703
|
+
return `${head}.`;
|
|
704
|
+
}
|
|
705
|
+
const seconds = assembled.GapBytes / RECORDING_BYTES_PER_SAMPLE / sampleRate;
|
|
706
|
+
return `${head}; missing segment(s) ${assembled.MissingIndexes.join(', ')} filled with estimated silence (~${seconds.toFixed(1)} s each).`;
|
|
707
|
+
}
|
|
242
708
|
//# sourceMappingURL=realtime-recording-store.js.map
|