From 73e9374ef8ae5d5902adf5e14c4e0a86957fc07f Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:38:26 +0530 Subject: [PATCH] [BREAKING CHANGE] harden call recording failure handling (#22062) Review in cubic --- .../internal/twenty-meeting-bot/README.md | 2 +- .../src/__tests__/schema.integration-test.ts | 43 +++- ...ll-recording-field-universal-identifier.ts | 2 + ...-failure-reason-on-call-recording.field.ts | 22 ++ .../constants/call-recording-status.ts | 2 +- .../non-terminal-call-recording-statuses.ts | 8 + .../complete-call-recording-ingestion.test.ts | 4 +- .../complete-call-recording-ingestion.util.ts | 13 +- .../execute-current-schema-mutation.util.ts | 54 +++++ .../find-call-recordings-by-filter.util.ts | 5 + .../data/update-call-recording.util.ts | 13 +- ...is-call-recording-status-downgrade.test.ts | 4 +- ...-complete-call-recording-ingestion.test.ts | 34 ++++ .../build-transcript-failure-reason.util.ts | 7 + ...is-call-recording-status-downgrade.util.ts | 2 +- .../is-recall-recording-done-signal.util.ts | 8 +- ...atus-code-to-call-recording-status.util.ts | 2 +- ...-complete-call-recording-ingestion.util.ts | 2 + .../converge-diverged-call-recordings.test.ts | 41 +++- .../__tests__/handle-recall-webhook.test.ts | 53 ++++- .../converge-diverged-call-recordings.util.ts | 90 ++++++++- .../flows/handle-recall-webhook.util.ts | 189 +++++++++++++++--- .../persist-call-recording-progress.util.ts | 1 + ...cording-transcript-artifact-result.type.ts | 2 +- ...call-recording-transcript-artifact.util.ts | 6 +- .../flows/reconcile-meeting-bot.util.ts | 9 +- .../extract-recall-bot-convergence.test.ts | 19 ++ .../extract-recall-bot-convergence.util.ts | 12 +- .../types/call-recording-record.type.ts | 1 + .../2-16-upgrade-version-command.module.ts | 6 +- ...1000-sync-call-recording-status.command.ts | 154 ++++++++++++++ ...rding-standard-flat-field-metadata.util.ts | 2 +- .../enums/call-recording-status.enum.ts | 2 +- 33 files changed, 743 insertions(+), 71 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/fields/meeting-bot-failure-reason-on-call-recording.field.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/non-terminal-call-recording-statuses.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/execute-current-schema-mutation.util.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-transcript-failure-reason.util.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100001000-sync-call-recording-status.command.ts diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/README.md b/packages/twenty-apps/internal/twenty-meeting-bot/README.md index 46dcf38154..a50675ee3f 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/README.md +++ b/packages/twenty-apps/internal/twenty-meeting-bot/README.md @@ -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_`. diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/__tests__/schema.integration-test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/__tests__/schema.integration-test.ts index a8e294cc78..b9623320cf 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/__tests__/schema.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/__tests__/schema.integration-test.ts @@ -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; +}; + +const getServerCallRecordingStatuses = async (): Promise => { + const generatedCoreSchema = (await import( + 'twenty-client-sdk/core' + )) as unknown as GeneratedCoreSchemaRuntime; + + return Object.values(generatedCoreSchema.enumCallRecordingStatusEnum); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier.ts new file mode 100644 index 0000000000..8de20ef3a7 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER = + '33a577e4-02f5-48bd-8bed-d365949caa72'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/fields/meeting-bot-failure-reason-on-call-recording.field.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/fields/meeting-bot-failure-reason-on-call-recording.field.ts new file mode 100644 index 0000000000..af87ce4b87 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/fields/meeting-bot-failure-reason-on-call-recording.field.ts @@ -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, +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts index 11b780e6e5..60b8b6f14f 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts @@ -5,5 +5,5 @@ export enum CallRecordingStatus { RECORDING = 'RECORDING', PROCESSING = 'PROCESSING', COMPLETED = 'COMPLETED', - FAILED_UNKNOWN = 'FAILED_UNKNOWN', + FAILED = 'FAILED', } diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/non-terminal-call-recording-statuses.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/non-terminal-call-recording-statuses.ts new file mode 100644 index 0000000000..dd604c7e85 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/non-terminal-call-recording-statuses.ts @@ -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[]; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts index 09a12ce43b..820f7d3b3a 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts @@ -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' }); }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts index 22d3da8299..b1f2dbc566 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts @@ -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 => { - 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; }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/execute-current-schema-mutation.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/execute-current-schema-mutation.util.ts new file mode 100644 index 0000000000..8e5dfcc03b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/execute-current-schema-mutation.util.ts @@ -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; + +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; + }; + 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 => { + const currentSchemaClient = client as { + mutation: CurrentSchemaMutationFunction; + }; + + return currentSchemaClient.mutation(mutation); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts index 98cc6994c0..fd2de1ab96 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts @@ -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, + ), })); }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts index 6519c5a15f..251e325702 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts @@ -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; audio: { fileId: string; label: string }[]; video: { fileId: string; label: string }[]; @@ -29,7 +34,7 @@ export const updateCallRecording = async ( data: CallRecordingUpdateFields; }, ): Promise => { - await client.mutation({ + const mutation = { updateCallRecording: { __args: { id, @@ -37,5 +42,7 @@ export const updateCallRecording = async ( }, id: true, }, - }); + } satisfies CurrentSchemaUpdateCallRecordingMutation; + + await executeCurrentSchemaMutation(client, mutation); }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts index 8d43cedad9..3e73245e19 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts @@ -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( diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts index 15ea0752f6..87c95e643a 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts @@ -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); + }); }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-transcript-failure-reason.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-transcript-failure-reason.util.ts new file mode 100644 index 0000000000..aab66570b5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-transcript-failure-reason.util.ts @@ -0,0 +1,7 @@ +import { isNull } from '@sniptt/guards'; + +export const buildTranscriptFailureReason = (subCode: string | null): string => { + return isNull(subCode) + ? 'transcript_failed' + : `transcript_failed:${subCode}`; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts index ad2371f4c9..45843102bb 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts @@ -8,7 +8,7 @@ const CALL_RECORDING_STATUS_PROGRESSION: Record = { [CallRecordingStatus.JOINING]: 1, [CallRecordingStatus.RECORDING]: 2, [CallRecordingStatus.PROCESSING]: 3, - [CallRecordingStatus.FAILED_UNKNOWN]: 4, + [CallRecordingStatus.FAILED]: 4, [CallRecordingStatus.COMPLETED]: 5, }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts index 53a4799526..50b2efef7c 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts @@ -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' + ); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts index ab3448e811..9af31afc32 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts @@ -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; } diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts index e2dfe4e218..5e2f827e45 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts @@ -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, diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts index 3dbbbd7841..7981d02a5b 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts @@ -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', }, }, ]); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts index 2b1e328970..ae2e85b9aa 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts @@ -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', }, }, ]); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts index f1082f9f42..0e27173f4e 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts @@ -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); }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts index 4b8719893b..3ef4ed12af 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts @@ -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> => { 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 => { - 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 => - webhookEvent.externalRecordingId ?? - callRecording.externalRecordingId ?? - (isUndefined(webhookEvent.externalBotId) - ? undefined - : await fetchExternalRecordingIdFromRecallBot(webhookEvent.externalBotId)); +}): Promise => { + 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 => { +): Promise => { 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 }), }, }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts index 336c1d9226..d72f9c402c 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts @@ -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 }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts index 15c4a27916..d7226cd243 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts @@ -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 = { diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts index b41c451c07..a657ae961a 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts @@ -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 }), }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts index 615c12a014..052e3b9629 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts @@ -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[], diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts index ca6c66958b..0b2ece8240 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts @@ -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: [ diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts index 6fac09eb47..d6af6a3a16 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts @@ -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'), diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts index 04d031de93..8d046052c8 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts @@ -11,4 +11,5 @@ export type CallRecordingRecord = { calendarEventId?: string; externalBotId?: string; externalRecordingId?: string; + meetingBotFailureReason?: string; }; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module.ts index 45c2797944..564e13c6eb 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100001000-sync-call-recording-status.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100001000-sync-call-recording-status.command.ts new file mode 100644 index 0000000000..5d983d6b12 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100001000-sync-call-recording-status.command.ts @@ -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 { + 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; + 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 = { + ...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}`, + ); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts index 584fb13d6a..b92246fedc 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts @@ -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', diff --git a/packages/twenty-server/src/modules/call-recording/common/enums/call-recording-status.enum.ts b/packages/twenty-server/src/modules/call-recording/common/enums/call-recording-status.enum.ts index 5fa56b0d35..cc9cff6af5 100644 --- a/packages/twenty-server/src/modules/call-recording/common/enums/call-recording-status.enum.ts +++ b/packages/twenty-server/src/modules/call-recording/common/enums/call-recording-status.enum.ts @@ -4,5 +4,5 @@ export enum CallRecordingStatus { RECORDING = 'RECORDING', PROCESSING = 'PROCESSING', COMPLETED = 'COMPLETED', - FAILED_UNKNOWN = 'FAILED_UNKNOWN', + FAILED = 'FAILED', }