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)
---
.../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',
}