[BREAKING CHANGE] harden call recording failure handling (#22062)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22062?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-06-24 18:38:26 +05:30
committed by GitHub
parent 0df83eceb2
commit 73e9374ef8
33 changed files with 743 additions and 71 deletions
@@ -29,7 +29,7 @@ A workspace admin can adjust bot behavior through application variables:
### Configuring the webhook
The app exposes an unauthenticated route, `POST /webhook/recall`, that verifies the Recall/Svix signature and updates the matching `CallRecording`'s lifecycle status (`JOINING``RECORDING``PROCESSING`, or `FAILED_UNKNOWN`).
The app exposes an unauthenticated route, `POST /webhook/recall`, that verifies the Recall/Svix signature and updates the matching `CallRecording`'s lifecycle status (`JOINING``RECORDING``PROCESSING`, or `FAILED`).
1. In the Recall.ai dashboard, create a webhook endpoint (Status Change Webhooks) pointing at the public URL of this app's `POST /webhook/recall` route.
2. Copy the endpoint's signing secret — it starts with `whsec_`.
@@ -5,6 +5,10 @@ import { describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/application-universal-identifier';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import {
executeCurrentSchemaMutation,
type CurrentSchemaUpdateCallRecordingMutation,
} from 'src/logic-functions/data/execute-current-schema-mutation.util';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
@@ -28,8 +32,9 @@ describe('App installation', () => {
});
describe('CallRecording status contract', () => {
it('accepts every status and request status value the app mirrors', async () => {
it('accepts every app status supported by the current server and every request status value the app mirrors', async () => {
const client = new CoreApiClient();
const serverCallRecordingStatuses = await getServerCallRecordingStatuses();
const created = await client.mutation({
createCallRecording: {
@@ -52,13 +57,31 @@ describe('CallRecording status contract', () => {
throw new Error('Expected call recording creation to return an id');
}
for (const status of Object.values(CallRecordingStatus)) {
const updated = await client.mutation({
expect(serverCallRecordingStatuses).toEqual(
expect.arrayContaining([
CallRecordingStatus.SCHEDULED,
CallRecordingStatus.JOINING,
CallRecordingStatus.RECORDING,
CallRecordingStatus.PROCESSING,
CallRecordingStatus.COMPLETED,
]),
);
// TODO: Remove this compatibility filter once the released server/SDK
// exposes FAILED instead of FAILED_UNKNOWN.
const statusesAcceptedByCurrentServer = Object.values(
CallRecordingStatus,
).filter((status) => serverCallRecordingStatuses.includes(status));
for (const status of statusesAcceptedByCurrentServer) {
const mutation = {
updateCallRecording: {
__args: { id: callRecordingId, data: { status } },
status: true,
},
});
} satisfies CurrentSchemaUpdateCallRecordingMutation;
const updated = await executeCurrentSchemaMutation(client, mutation);
expect(updated.updateCallRecording?.status).toBe(status);
}
@@ -86,3 +109,15 @@ describe('CallRecording status contract', () => {
});
});
});
type GeneratedCoreSchemaRuntime = {
enumCallRecordingStatusEnum: Record<string, string>;
};
const getServerCallRecordingStatuses = async (): Promise<string[]> => {
const generatedCoreSchema = (await import(
'twenty-client-sdk/core'
)) as unknown as GeneratedCoreSchemaRuntime;
return Object.values(generatedCoreSchema.enumCallRecordingStatusEnum);
};
@@ -0,0 +1,2 @@
export const MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER =
'33a577e4-02f5-48bd-8bed-d365949caa72';
@@ -0,0 +1,22 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier';
export default defineField({
universalIdentifier:
MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.TEXT,
name: 'meetingBotFailureReason',
label: 'Meeting Bot Failure Reason',
description:
'Provider-specific reason the meeting bot could not produce a recording.',
icon: 'IconAlertTriangle',
isNullable: true,
isUIEditable: false,
});
@@ -5,5 +5,5 @@ export enum CallRecordingStatus {
RECORDING = 'RECORDING',
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
FAILED = 'FAILED',
}
@@ -0,0 +1,8 @@
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
export const NON_TERMINAL_CALL_RECORDING_STATUSES = [
CallRecordingStatus.SCHEDULED,
CallRecordingStatus.JOINING,
CallRecordingStatus.RECORDING,
CallRecordingStatus.PROCESSING,
] satisfies CallRecordingStatus[];
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { completeCallRecordingIngestion } from 'src/logic-functions/data/complete-call-recording-ingestion.util';
describe('completeCallRecordingIngestion', () => {
it('guards the flip with status != COMPLETED and returns true when the row is claimed', async () => {
it('guards the flip with non-terminal statuses and returns true when the row is claimed', async () => {
let capturedArgs: { filter: unknown; data: unknown } | undefined;
const mutation = vi.fn(async (mutationArg: any) => {
capturedArgs = mutationArg.updateCallRecordings.__args;
@@ -22,7 +22,7 @@ describe('completeCallRecordingIngestion', () => {
expect(mutation).toHaveBeenCalledTimes(1);
expect(capturedArgs?.filter).toEqual({
id: { eq: 'call-recording-1' },
status: { neq: 'COMPLETED' },
status: { in: ['SCHEDULED', 'JOINING', 'RECORDING', 'PROCESSING'] },
});
expect(capturedArgs?.data).toEqual({ status: 'COMPLETED' });
});
@@ -1,23 +1,30 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/non-terminal-call-recording-statuses';
import {
executeCurrentSchemaMutation,
type CurrentSchemaUpdateCallRecordingsMutation,
} from 'src/logic-functions/data/execute-current-schema-mutation.util';
export const completeCallRecordingIngestion = async (
client: CoreApiClient,
{ id }: { id: string },
): Promise<boolean> => {
const result = await client.mutation({
const mutation = {
updateCallRecordings: {
__args: {
filter: {
id: { eq: id },
status: { neq: CallRecordingStatus.COMPLETED },
status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES },
},
data: { status: CallRecordingStatus.COMPLETED },
},
id: true,
},
});
} satisfies CurrentSchemaUpdateCallRecordingsMutation;
const result = await executeCurrentSchemaMutation(client, mutation);
return (result.updateCallRecordings ?? []).length > 0;
};
@@ -0,0 +1,54 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util';
type CurrentSchemaMutationFunction = (
mutation: CurrentSchemaMutation,
) => Promise<CurrentSchemaMutationResult>;
export type CurrentSchemaUpdateCallRecordingMutation = {
updateCallRecording: {
__args: {
id: string;
data: CallRecordingUpdateFields;
};
id?: true;
status?: true;
};
};
export type CurrentSchemaUpdateCallRecordingsMutation = {
updateCallRecordings: {
__args: {
filter: {
id: { eq: string };
status?: { in: CallRecordingStatus[] };
};
data: Pick<CallRecordingUpdateFields, 'status'>;
};
id?: true;
};
};
type CurrentSchemaMutation =
| CurrentSchemaUpdateCallRecordingMutation
| CurrentSchemaUpdateCallRecordingsMutation;
type CurrentSchemaMutationResult = {
updateCallRecording?: { id?: string; status?: string | null } | null;
updateCallRecordings?: { id?: string }[] | null;
};
// TODO: Remove this bridge once the released SDK includes the current
// CallRecording schema with FAILED and meetingBotFailureReason.
export const executeCurrentSchemaMutation = (
client: CoreApiClient,
mutation: CurrentSchemaMutation,
): Promise<CurrentSchemaMutationResult> => {
const currentSchemaClient = client as {
mutation: CurrentSchemaMutationFunction;
};
return currentSchemaClient.mutation(mutation);
};
@@ -20,6 +20,7 @@ type CallRecordingNode = {
calendarEventId?: string | null;
externalBotId?: string | null;
externalRecordingId?: string | null;
meetingBotFailureReason?: string | null;
};
export const findCallRecordingsByFilter = async (
@@ -50,6 +51,7 @@ export const findCallRecordingsByFilter = async (
calendarEventId: true,
externalBotId: true,
externalRecordingId: true,
meetingBotFailureReason: true,
},
},
},
@@ -75,6 +77,9 @@ export const findCallRecordingsByFilter = async (
externalRecordingId: normalizeOptionalString(
callRecording.externalRecordingId,
),
meetingBotFailureReason: normalizeOptionalString(
callRecording.meetingBotFailureReason,
),
}));
};
@@ -2,6 +2,10 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import {
executeCurrentSchemaMutation,
type CurrentSchemaUpdateCallRecordingMutation,
} from 'src/logic-functions/data/execute-current-schema-mutation.util';
export type CallRecordingUpdateFields = Partial<{
// null clears a previously synced title when the calendar title disappears.
@@ -11,9 +15,10 @@ export type CallRecordingUpdateFields = Partial<{
startedAt: string;
endedAt: string;
calendarEventId: string;
// null clears the field on cancel/eject; the only field we ever write null to.
// null clears stale app-owned state on cancel/eject or reschedule.
externalBotId: string | null;
externalRecordingId: string;
meetingBotFailureReason: string | null;
transcript: Record<string, unknown>;
audio: { fileId: string; label: string }[];
video: { fileId: string; label: string }[];
@@ -29,7 +34,7 @@ export const updateCallRecording = async (
data: CallRecordingUpdateFields;
},
): Promise<void> => {
await client.mutation({
const mutation = {
updateCallRecording: {
__args: {
id,
@@ -37,5 +42,7 @@ export const updateCallRecording = async (
},
id: true,
},
});
} satisfies CurrentSchemaUpdateCallRecordingMutation;
await executeCurrentSchemaMutation(client, mutation);
};
@@ -7,12 +7,12 @@ describe('isCallRecordingStatusDowngrade', () => {
['SCHEDULED', 'JOINING', false],
['JOINING', 'RECORDING', false],
['RECORDING', 'PROCESSING', false],
['PROCESSING', 'FAILED_UNKNOWN', false],
['PROCESSING', 'FAILED', false],
['PROCESSING', 'COMPLETED', false],
['RECORDING', 'RECORDING', false],
['COMPLETED', 'RECORDING', true],
['PROCESSING', 'JOINING', true],
['FAILED_UNKNOWN', 'RECORDING', true],
['FAILED', 'RECORDING', true],
['JOINING', 'SCHEDULED', true],
])('from %s to %s -> %s', (fromStatus, toStatus, expected) => {
expect(isCallRecordingStatusDowngrade({ fromStatus, toStatus })).toBe(
@@ -65,4 +65,38 @@ describe('shouldCompleteCallRecordingIngestion', () => {
}),
).toBe(false);
});
it('does not complete a persisted failed recording', () => {
expect(
shouldCompleteCallRecordingIngestion({
current: {
status: CallRecordingStatus.FAILED,
startedAt: '2026-06-10T09:00:00.000Z',
endedAt: '2026-06-10T10:00:00.000Z',
transcript: filledTranscript,
audio: filledAudio,
video: filledVideo,
},
updateData: {},
}),
).toBe(false);
});
it('does not complete when the incoming update marks the recording as failed', () => {
expect(
shouldCompleteCallRecordingIngestion({
current: {
status: CallRecordingStatus.PROCESSING,
startedAt: '2026-06-10T09:00:00.000Z',
endedAt: '2026-06-10T10:00:00.000Z',
transcript: filledTranscript,
audio: filledAudio,
video: filledVideo,
},
updateData: {
status: CallRecordingStatus.FAILED,
},
}),
).toBe(false);
});
});
@@ -0,0 +1,7 @@
import { isNull } from '@sniptt/guards';
export const buildTranscriptFailureReason = (subCode: string | null): string => {
return isNull(subCode)
? 'transcript_failed'
: `transcript_failed:${subCode}`;
};
@@ -8,7 +8,7 @@ const CALL_RECORDING_STATUS_PROGRESSION: Record<CallRecordingStatus, number> = {
[CallRecordingStatus.JOINING]: 1,
[CallRecordingStatus.RECORDING]: 2,
[CallRecordingStatus.PROCESSING]: 3,
[CallRecordingStatus.FAILED_UNKNOWN]: 4,
[CallRecordingStatus.FAILED]: 4,
[CallRecordingStatus.COMPLETED]: 5,
};
@@ -4,4 +4,10 @@ export const isRecallRecordingDoneSignal = ({
}: {
event: string;
statusCode: string | undefined;
}): boolean => event === 'recording.done' || statusCode === 'done';
}): boolean => {
return (
event === 'recording.done' ||
event === 'recording.failed' ||
statusCode === 'done'
);
};
@@ -19,7 +19,7 @@ export const mapRecallStatusCodeToCallRecordingStatus = (
case 'fatal':
case 'analysis_failed':
case 'recording_permission_denied':
return CallRecordingStatus.FAILED_UNKNOWN;
return CallRecordingStatus.FAILED;
default:
return undefined;
}
@@ -19,6 +19,8 @@ export const shouldCompleteCallRecordingIngestion = ({
updateData: CallRecordingUpdateFields;
}): boolean =>
current.status !== CallRecordingStatus.COMPLETED &&
current.status !== CallRecordingStatus.FAILED &&
updateData.status !== CallRecordingStatus.FAILED &&
computeCallRecordingCharge({
startedAt: updateData.startedAt ?? current.startedAt,
endedAt: updateData.endedAt ?? current.endedAt,
@@ -184,6 +184,37 @@ describe('convergeDivergedCallRecordings', () => {
});
});
it('marks FAILED when Recall is done but has no recording artifact path', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
],
recordings: [],
},
});
const client = buildClient([buildStuckRecordingNode()]);
const result = await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(listRecallTranscriptsMock).not.toHaveBeenCalled();
expect(ingestCallRecordingMediaMock).not.toHaveBeenCalled();
expect(client.mutations).toEqual([
{
id: 'call-recording-1',
data: {
status: 'FAILED',
meetingBotFailureReason: 'recording_artifacts_unavailable',
},
},
]);
expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']);
});
it('completes and charges when convergence lands the last artifact', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
@@ -308,7 +339,7 @@ describe('convergeDivergedCallRecordings', () => {
expect(result.skippedNotStartedCallRecordingIds).toEqual([]);
});
it('marks FAILED_UNKNOWN without clearing the bot id when Recall returns 404', async () => {
it('marks FAILED without clearing the bot id when Recall returns 404', async () => {
getRecallBotMock.mockResolvedValue({
ok: false,
status: 404,
@@ -324,7 +355,10 @@ describe('convergeDivergedCallRecordings', () => {
expect(client.mutations).toEqual([
{
id: 'call-recording-1',
data: { status: 'FAILED_UNKNOWN' },
data: {
status: 'FAILED',
meetingBotFailureReason: 'recall_bot_not_found',
},
},
]);
expect(result.markedFailedCallRecordingIds).toEqual(['call-recording-1']);
@@ -658,12 +692,13 @@ describe('convergeDivergedCallRecordings', () => {
{
id: 'call-recording-1',
data: {
status: 'FAILED_UNKNOWN',
status: 'FAILED',
transcript: {
recallTranscriptId: 'recall-transcript-1',
status: 'FAILED',
subCode: 'audio_missing',
},
meetingBotFailureReason: 'transcript_failed:audio_missing',
},
},
]);
@@ -970,6 +970,56 @@ describe('handleRecallWebhook', () => {
expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled();
});
it('marks FAILED on recording.done when no recording artifact path exists', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: { id: 'recall-bot-1', recordings: [] },
});
const client = new FakeCoreApiClient([
{
id: 'call-recording-1',
status: 'PROCESSING',
externalBotId: 'recall-bot-1',
startedAt: '2026-01-01T13:02:00.000Z',
endedAt: '2026-01-01T14:05:00.000Z',
transcript: null,
},
]);
const result = await handleRecallWebhook({
client: client as unknown as CoreApiClient,
body: {
event: 'recording.done',
data: {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
},
},
},
},
});
expect(result).toEqual({
status: 'updated',
event: 'recording.done',
callRecordingId: 'call-recording-1',
callRecordingStatus: 'FAILED',
});
expect(client.mutations).toEqual([
{
id: 'call-recording-1',
data: {
status: 'FAILED',
externalBotId: 'recall-bot-1',
meetingBotFailureReason: 'recording_artifacts_unavailable',
},
},
]);
expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled();
});
it('completes and charges on transcript.done when media is already ingested', async () => {
const transcriptContent = [
{
@@ -1187,7 +1237,8 @@ describe('handleRecallWebhook', () => {
status: 'FAILED',
subCode: 'transcription_failed',
},
status: 'FAILED_UNKNOWN',
meetingBotFailureReason: 'transcript_failed:transcription_failed',
status: 'FAILED',
},
},
]);
@@ -3,6 +3,7 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/non-terminal-call-recording-statuses';
import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type';
import {
@@ -17,6 +18,7 @@ import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util
import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util';
import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util';
import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util';
import { reconcileCallRecordingTranscriptArtifact } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util';
import { type ConvergeDivergedCallRecordingsResult } from 'src/logic-functions/flows/converge-diverged-call-recordings-result.type';
@@ -28,13 +30,6 @@ import {
const CONVERGENCE_LOOKBACK_DAYS = 7;
const NON_TERMINAL_CALL_RECORDING_STATUSES = [
CallRecordingStatus.SCHEDULED,
CallRecordingStatus.JOINING,
CallRecordingStatus.RECORDING,
CallRecordingStatus.PROCESSING,
];
type DivergedCallRecordingCandidate = {
id: string;
status: string | undefined;
@@ -285,6 +280,18 @@ const convergeCallRecording = async ({
);
}
const terminalArtifactGateFailureUpdate =
buildTerminalArtifactGateFailureUpdate({
candidate,
convergence,
externalRecordingId,
updateData,
});
if (!isUndefined(terminalArtifactGateFailureUpdate)) {
Object.assign(updateData, terminalArtifactGateFailureUpdate);
}
const completesIngestion = shouldCompleteCallRecordingIngestion({
current: candidate,
updateData,
@@ -322,6 +329,11 @@ const buildConvergenceFieldUpdates = ({
})
) {
updateData.status = convergence.status;
if (convergence.status === CallRecordingStatus.FAILED) {
updateData.meetingBotFailureReason =
convergence.failureReason ?? 'recall_bot_failed';
}
}
if (isUndefined(candidate.startedAt) && !isUndefined(convergence.startedAt)) {
@@ -342,6 +354,63 @@ const buildConvergenceFieldUpdates = ({
return updateData;
};
type TerminalArtifactGateFailureUpdate = {
status: CallRecordingStatus.FAILED;
meetingBotFailureReason: string;
};
const buildTerminalArtifactGateFailureUpdate = ({
candidate,
convergence,
externalRecordingId,
updateData,
}: {
candidate: DivergedCallRecordingCandidate;
convergence: RecallBotConvergence;
externalRecordingId: string | undefined;
updateData: CallRecordingUpdateFields;
}): TerminalArtifactGateFailureUpdate | undefined => {
if (
candidate.status === CallRecordingStatus.COMPLETED ||
updateData.status === CallRecordingStatus.FAILED ||
!convergence.isRecallRecordingDone ||
!isUndefined(externalRecordingId) ||
hasRecordingArtifactPath({ candidate, updateData })
) {
return undefined;
}
return {
status: CallRecordingStatus.FAILED,
meetingBotFailureReason:
convergence.failureReason ?? 'recording_artifacts_unavailable',
};
};
const hasRecordingArtifactPath = ({
candidate,
updateData,
}: {
candidate: DivergedCallRecordingCandidate;
updateData: CallRecordingUpdateFields;
}): boolean => {
return (
isNonEmptyArray(updateData.audio ?? candidate.audio) ||
isNonEmptyArray(updateData.video ?? candidate.video) ||
hasReachableTranscript(updateData.transcript ?? candidate.transcript)
);
};
const hasReachableTranscript = (transcript: unknown): boolean => {
if (isUndefined(transcript)) {
return false;
}
const marker = parseTranscriptMarker(transcript);
return isUndefined(marker) || marker.status === 'PENDING';
};
const markCallRecordingFailedAfterBotLoss = async ({
client,
candidate,
@@ -361,7 +430,7 @@ const markCallRecordingFailedAfterBotLoss = async ({
if (
isCallRecordingStatusDowngrade({
fromStatus: candidate.status,
toStatus: CallRecordingStatus.FAILED_UNKNOWN,
toStatus: CallRecordingStatus.FAILED,
})
) {
result.unconvergeableCallRecordingIds.push(candidate.id);
@@ -371,7 +440,10 @@ const markCallRecordingFailedAfterBotLoss = async ({
await updateCallRecording(client, {
id: candidate.id,
data: { status: CallRecordingStatus.FAILED_UNKNOWN },
data: {
status: CallRecordingStatus.FAILED,
meetingBotFailureReason: 'recall_bot_not_found',
},
});
result.markedFailedCallRecordingIds.push(candidate.id);
};
@@ -4,6 +4,7 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type';
import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util';
import { buildTranscriptFailureReason } from 'src/logic-functions/domain/build-transcript-failure-reason.util';
import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util';
import { extractRecallBotConvergence } from 'src/logic-functions/recall-api/extract-recall-bot-convergence.util';
import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util';
@@ -36,6 +37,11 @@ type MatchedCallRecording = {
video?: FilesFieldValue;
};
type ExternalRecordingIdResolution = {
externalRecordingId: string | undefined;
providerLookupFailed: boolean;
};
type RecallWebhookHandlerResult =
| {
status: 'updated';
@@ -129,27 +135,51 @@ const handleRecallStatusEvent = async ({
}
const updateData: CallRecordingUpdateFields = {
status: callRecordingStatus,
...(isUndefined(webhookEvent.externalBotId)
? {}
: { externalBotId: webhookEvent.externalBotId }),
...buildExternalRecordingIdUpdate(webhookEvent),
...buildCallRecordingStatusUpdate({
reason: getRecallWebhookFailureReason(webhookEvent),
status: callRecordingStatus,
}),
...buildRecordingTimestampsUpdate({ webhookEvent, callRecording }),
};
if (isRecallRecordingDoneSignal({ event, statusCode })) {
const externalRecordingIdResolution = await resolveExternalRecordingId({
callRecording,
webhookEvent,
});
Object.assign(
updateData,
await buildTranscriptArtifactUpdate({
callRecording,
webhookEvent,
externalRecordingId: externalRecordingIdResolution.externalRecordingId,
}),
);
Object.assign(
updateData,
await buildMediaIngestionUpdate({ callRecording, webhookEvent }),
await buildMediaIngestionUpdate({
callRecording,
externalRecordingId: externalRecordingIdResolution.externalRecordingId,
}),
);
const terminalArtifactGateFailureUpdate =
buildTerminalArtifactGateFailureUpdate({
callRecording,
providerLookupFailed:
externalRecordingIdResolution.providerLookupFailed,
updateData,
webhookEvent,
});
if (!isUndefined(terminalArtifactGateFailureUpdate)) {
Object.assign(updateData, terminalArtifactGateFailureUpdate);
}
}
const { completesIngestion } = await persistCallRecordingProgress(client, {
@@ -245,7 +275,7 @@ const mapRecallEventToCallRecordingStatus = ({
}
if (event === 'recording.failed') {
return CallRecordingStatus.FAILED_UNKNOWN;
return CallRecordingStatus.FAILED;
}
return mapRecallStatusCodeToCallRecordingStatus(statusCode);
@@ -290,15 +320,113 @@ const buildExternalRecordingIdUpdate = (
? {}
: { externalRecordingId: webhookEvent.externalRecordingId };
type NonFailedCallRecordingStatus = Exclude<
CallRecordingStatus,
CallRecordingStatus.FAILED
>;
type CallRecordingStatusUpdate =
| {
status: NonFailedCallRecordingStatus;
}
| {
status: CallRecordingStatus.FAILED;
meetingBotFailureReason: string;
};
type TerminalArtifactGateFailureUpdate = {
status: CallRecordingStatus.FAILED;
meetingBotFailureReason: string;
};
const buildCallRecordingStatusUpdate = ({
reason,
status,
}: {
reason: string;
status: CallRecordingStatus;
}): CallRecordingStatusUpdate => {
if (status === CallRecordingStatus.FAILED) {
return { status, meetingBotFailureReason: reason };
}
return { status };
};
const buildTerminalArtifactGateFailureUpdate = ({
callRecording,
providerLookupFailed,
updateData,
webhookEvent,
}: {
callRecording: MatchedCallRecording;
providerLookupFailed: boolean;
updateData: CallRecordingUpdateFields;
webhookEvent: RecallWebhookEvent;
}): TerminalArtifactGateFailureUpdate | undefined => {
if (updateData.status === CallRecordingStatus.FAILED) {
return isUndefined(updateData.meetingBotFailureReason)
? {
status: CallRecordingStatus.FAILED,
meetingBotFailureReason: getRecallWebhookFailureReason(webhookEvent),
}
: undefined;
}
if (
providerLookupFailed ||
hasRecordingArtifactPath({ callRecording, updateData })
) {
return undefined;
}
return {
status: CallRecordingStatus.FAILED,
meetingBotFailureReason: 'recording_artifacts_unavailable',
};
};
const getRecallWebhookFailureReason = ({
event,
statusCode,
}: RecallWebhookEvent): string => statusCode ?? event;
const hasRecordingArtifactPath = ({
callRecording,
updateData,
}: {
callRecording: MatchedCallRecording;
updateData: CallRecordingUpdateFields;
}): boolean => {
return (
!isUndefined(
updateData.externalRecordingId ?? callRecording.externalRecordingId,
) ||
isNonEmptyArray(updateData.audio ?? callRecording.audio) ||
isNonEmptyArray(updateData.video ?? callRecording.video) ||
hasReachableTranscript(updateData.transcript ?? callRecording.transcript)
);
};
const hasReachableTranscript = (transcript: unknown): boolean => {
if (isNull(transcript) || isUndefined(transcript)) {
return false;
}
const marker = parseTranscriptMarker(transcript);
return isUndefined(marker) || marker.status === 'PENDING';
};
const isTranscriptUnset = (callRecording: MatchedCallRecording): boolean =>
isUndefined(callRecording.transcript);
const buildMediaIngestionUpdate = async ({
callRecording,
webhookEvent,
externalRecordingId,
}: {
callRecording: MatchedCallRecording;
webhookEvent: RecallWebhookEvent;
externalRecordingId: string | undefined;
}): Promise<Pick<CallRecordingUpdateFields, 'audio' | 'video'>> => {
const hasAudio = isNonEmptyArray(callRecording.audio);
const hasVideo = isNonEmptyArray(callRecording.video);
@@ -307,11 +435,6 @@ const buildMediaIngestionUpdate = async ({
return {};
}
const externalRecordingId = await resolveExternalRecordingId({
callRecording,
webhookEvent,
});
if (isUndefined(externalRecordingId)) {
console.warn(
`[twenty-meeting-bot] cannot ingest media for call recording ${callRecording.id}: no Recall recording id available`,
@@ -330,16 +453,11 @@ const buildMediaIngestionUpdate = async ({
const buildTranscriptArtifactUpdate = async ({
callRecording,
webhookEvent,
externalRecordingId,
}: {
callRecording: MatchedCallRecording;
webhookEvent: RecallWebhookEvent;
externalRecordingId: string | undefined;
}): Promise<CallRecordingUpdateFields> => {
const externalRecordingId = await resolveExternalRecordingId({
callRecording,
webhookEvent,
});
if (isUndefined(externalRecordingId)) {
console.warn(
`[twenty-meeting-bot] cannot reconcile transcript for call recording ${callRecording.id}: no Recall recording id available`,
@@ -371,16 +489,24 @@ const resolveExternalRecordingId = async ({
}: {
callRecording: MatchedCallRecording;
webhookEvent: RecallWebhookEvent;
}): Promise<string | undefined> =>
webhookEvent.externalRecordingId ??
callRecording.externalRecordingId ??
(isUndefined(webhookEvent.externalBotId)
? undefined
: await fetchExternalRecordingIdFromRecallBot(webhookEvent.externalBotId));
}): Promise<ExternalRecordingIdResolution> => {
const externalRecordingId =
webhookEvent.externalRecordingId ?? callRecording.externalRecordingId;
if (!isUndefined(externalRecordingId)) {
return { externalRecordingId, providerLookupFailed: false };
}
if (isUndefined(webhookEvent.externalBotId)) {
return { externalRecordingId: undefined, providerLookupFailed: false };
}
return fetchExternalRecordingIdFromRecallBot(webhookEvent.externalBotId);
};
const fetchExternalRecordingIdFromRecallBot = async (
externalBotId: string,
): Promise<string | undefined> => {
): Promise<ExternalRecordingIdResolution> => {
const botResult = await getRecallBot({ externalBotId });
if (!botResult.ok) {
@@ -388,10 +514,14 @@ const fetchExternalRecordingIdFromRecallBot = async (
`[twenty-meeting-bot] failed to fetch Recall bot ${externalBotId} while resolving a recording id: ${botResult.errorMessage}`,
);
return undefined;
return { externalRecordingId: undefined, providerLookupFailed: true };
}
return extractRecallBotConvergence(botResult.bot).externalRecordingId;
return {
externalRecordingId: extractRecallBotConvergence(botResult.bot)
.externalRecordingId,
providerLookupFailed: false,
};
};
const handleRecallTranscriptEvent = async ({
@@ -524,12 +654,13 @@ const applyTranscriptFailure = async ({
transcriptId ?? existingMarker?.recallTranscriptId ?? null,
subCode,
}),
meetingBotFailureReason: buildTranscriptFailureReason(subCode),
...(isCallRecordingStatusDowngrade({
fromStatus: callRecording.status,
toStatus: CallRecordingStatus.FAILED_UNKNOWN,
toStatus: CallRecordingStatus.FAILED,
})
? {}
: { status: CallRecordingStatus.FAILED_UNKNOWN }),
: { status: CallRecordingStatus.FAILED }),
},
});
@@ -44,6 +44,7 @@ export const persistCallRecordingProgress = async (
const nonStatusUpdate: CallRecordingUpdateFields = { ...updateData };
delete nonStatusUpdate.status;
delete nonStatusUpdate.meetingBotFailureReason;
if (Object.keys(nonStatusUpdate).length > 0) {
await updateCallRecording(client, { id, data: nonStatusUpdate });
@@ -2,7 +2,7 @@ import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-
type CallRecordingTranscriptArtifactUpdateFields = Pick<
CallRecordingUpdateFields,
'status' | 'transcript'
'meetingBotFailureReason' | 'status' | 'transcript'
>;
export type ReconcileCallRecordingTranscriptArtifactResult = {
@@ -3,6 +3,7 @@ import { isNull, isUndefined } from '@sniptt/guards';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util';
import { buildPendingTranscriptMarker } from 'src/logic-functions/domain/build-pending-transcript-marker.util';
import { buildTranscriptFailureReason } from 'src/logic-functions/domain/build-transcript-failure-reason.util';
import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util';
import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util';
import { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util';
@@ -172,10 +173,11 @@ const buildTranscriptFailureUpdate = ({
recallTranscriptId: transcriptId,
subCode,
}),
meetingBotFailureReason: buildTranscriptFailureReason(subCode),
...(isCallRecordingStatusDowngrade({
fromStatus: currentStatus,
toStatus: CallRecordingStatus.FAILED_UNKNOWN,
toStatus: CallRecordingStatus.FAILED,
})
? {}
: { status: CallRecordingStatus.FAILED_UNKNOWN }),
: { status: CallRecordingStatus.FAILED }),
});
@@ -457,14 +457,19 @@ const buildPolicyManagedCallRecordingUpdateFields = ({
calendarEvent: CalendarEventRecord;
}): CallRecordingUpdateFields =>
canResetCallRecordingStatusToScheduled(existingCallRecording.status)
? buildScheduledCallRecordingFields(calendarEvent)
? {
...buildScheduledCallRecordingFields(calendarEvent),
...(isUndefined(existingCallRecording.meetingBotFailureReason)
? {}
: { meetingBotFailureReason: null }),
}
: buildCalendarDrivenCallRecordingFields(calendarEvent);
const canResetCallRecordingStatusToScheduled = (
status: string | undefined,
): boolean =>
status === CallRecordingStatus.SCHEDULED ||
status === CallRecordingStatus.FAILED_UNKNOWN;
status === CallRecordingStatus.FAILED;
const buildRemovedCalendarEventIdsByMeetingKey = (
removedOccurrences: RemovedMeetingBotOccurrence[],
@@ -47,6 +47,7 @@ describe('extractRecallBotConvergence', () => {
expect(convergence).toEqual({
status: 'PROCESSING',
failureReason: undefined,
startedAt: '2026-01-01T13:02:00.000Z',
endedAt: '2026-01-01T14:00:00.000Z',
externalRecordingId: 'recall-recording-1',
@@ -65,6 +66,7 @@ describe('extractRecallBotConvergence', () => {
expect(convergence).toEqual({
status: 'PROCESSING',
failureReason: undefined,
startedAt: '2026-01-01T13:02:00.000Z',
endedAt: '2026-01-01T14:00:00.000Z',
externalRecordingId: 'recall-recording-1',
@@ -93,6 +95,7 @@ describe('extractRecallBotConvergence', () => {
it('returns nothing derivable from an empty bot response', () => {
expect(extractRecallBotConvergence({})).toEqual({
status: undefined,
failureReason: undefined,
startedAt: undefined,
endedAt: undefined,
externalRecordingId: undefined,
@@ -113,6 +116,7 @@ describe('extractRecallBotConvergence', () => {
expect(convergence).toEqual({
status: 'RECORDING',
failureReason: undefined,
startedAt: '2026-01-01T13:02:00.000Z',
endedAt: undefined,
externalRecordingId: undefined,
@@ -120,6 +124,21 @@ describe('extractRecallBotConvergence', () => {
});
});
it('carries the failing Recall status code as the failure reason', () => {
const convergence = extractRecallBotConvergence({
status_changes: [
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
{
code: 'recording_permission_denied',
created_at: '2026-01-01T13:02:00.000Z',
},
],
});
expect(convergence.status).toBe('FAILED');
expect(convergence.failureReason).toBe('recording_permission_denied');
});
it('leaves the status undefined for unknown latest codes', () => {
const convergence = extractRecallBotConvergence({
status_changes: [
@@ -1,6 +1,6 @@
import { isArray, isUndefined } from '@sniptt/guards';
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { getString } from 'src/logic-functions/utils/get-string.util';
import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util';
@@ -8,6 +8,7 @@ import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normali
export type RecallBotConvergence = {
status: CallRecordingStatus | undefined;
failureReason: string | undefined;
startedAt: string | undefined;
endedAt: string | undefined;
externalRecordingId: string | undefined;
@@ -25,10 +26,17 @@ export const extractRecallBotConvergence = (
): RecallBotConvergence => {
const statusChanges = extractStatusChanges(bot);
const latestStatusChange = getLatestStatusChange(statusChanges);
const status = mapRecallStatusCodeToCallRecordingStatus(
latestStatusChange?.code,
);
const recording = extractFirstRecording(bot);
return {
status: mapRecallStatusCodeToCallRecordingStatus(latestStatusChange?.code),
status,
failureReason:
status === CallRecordingStatus.FAILED
? latestStatusChange?.code
: undefined,
startedAt: normalizeRecallTimestamp(
recording?.startedAt ??
findStatusChangeTimestamp(statusChanges, 'in_call_recording'),
@@ -11,4 +11,5 @@ export type CallRecordingRecord = {
calendarEventId?: string;
externalBotId?: string;
externalRecordingId?: string;
meetingBotFailureReason?: string;
};
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillSearchFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command';
import { SyncCallRecordingStatusCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100001000-sync-call-recording-status.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -13,6 +14,9 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [BackfillSearchFieldMetadataCommand],
providers: [
BackfillSearchFieldMetadataCommand,
SyncCallRecordingStatusCommand,
],
})
export class V2_16_UpgradeVersionCommandModule {}
@@ -0,0 +1,154 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.fields.status.universalIdentifier;
const CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.universalIdentifier;
const LEGACY_FAILED_STATUS = 'FAILED_UNKNOWN';
const FAILED_STATUS = 'FAILED';
@RegisteredWorkspaceCommand('2.16.0', 1799100001000)
@Command({
name: 'upgrade:2-16:sync-call-recording-status',
description:
'Sync CallRecording status metadata from FAILED_UNKNOWN to FAILED in existing workspaces',
})
export class SyncCallRecordingStatusCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
]);
const callRecordingObject =
flatObjectMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER
];
if (!isDefined(callRecordingObject)) {
this.logger.log(
`CallRecording object metadata does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
const statusField =
flatFieldMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER
];
if (!isDefined(statusField)) {
this.logger.log(
`CallRecording status field metadata does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
if (statusField.type !== FieldMetadataType.SELECT) {
throw new Error(
`CallRecording status metadata is not a SELECT field for workspace ${workspaceId}`,
);
}
const selectStatusField =
statusField as FlatFieldMetadata<FieldMetadataType.SELECT>;
const optionsWithFailedStatus = (selectStatusField.options ?? []).map(
(option) =>
option.value === LEGACY_FAILED_STATUS
? { ...option, value: FAILED_STATUS }
: option,
);
const hasLegacyFailedStatus = optionsWithFailedStatus.some(
(option, index) =>
option.value !== selectStatusField.options?.[index]?.value,
);
if (!hasLegacyFailedStatus) {
this.logger.log(
`CallRecording status metadata already synced for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Syncing CallRecording status metadata from ${LEGACY_FAILED_STATUS} to ${FAILED_STATUS} for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const updatedStatusField: FlatFieldMetadata<FieldMetadataType.SELECT> = {
...selectStatusField,
options: optionsWithFailedStatus,
updatedAt: new Date().toISOString(),
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [updatedStatusField],
},
},
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to sync CallRecording status metadata for workspace ${workspaceId}: ${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
}
this.logger.log(
`Synced CallRecording status metadata from ${LEGACY_FAILED_STATUS} to ${FAILED_STATUS} for workspace ${workspaceId}`,
);
}
}
@@ -175,7 +175,7 @@ export const buildCallRecordingStandardFlatFieldMetadatas = ({
},
{
id: '4800777e-54a8-4464-9c01-07d6eefd04da',
value: CallRecordingStatus.FAILED_UNKNOWN,
value: CallRecordingStatus.FAILED,
label: i18nLabel(msg`Failed`),
position: 5,
color: 'gray',
@@ -4,5 +4,5 @@ export enum CallRecordingStatus {
RECORDING = 'RECORDING',
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
FAILED = 'FAILED',
}