fix(automations): harden delayed rules against races, stale edits, and unsupported configs

- episode_key_for falls back to the incoming message's created_at when
  waiting_since is still nil (it is written after MESSAGE_CREATED dispatches),
  so awaiting-agent episodes no longer arm as awaiting_agent:0 and get skipped
  as episode_moved on the first customer message after an agent reply.
- Editing a rule's trigger, conditions, or actions (not just the delay) now
  discards its armed pending rows so they can't fire against a definition they
  were never armed under; deleting rather than skipping frees the episode slot
  so the new definition re-arms on the next matching event.
- The rule form only offers a delay when the config supports it: the delayed
  option is disabled with an explanation for attribute_changed conditions and
  for non-status conditions on conversation events, and the backend's specific
  error is surfaced on save instead of a generic message.
This commit is contained in:
Tanmay Deep Sharma
2026-07-15 13:00:03 +05:30
parent 7a73a68753
commit 1e6291616d
5 changed files with 78 additions and 12 deletions
@@ -38,7 +38,11 @@
"DAYS": "Days"
},
"ERROR": "Delay must be between 10 minutes and 30 days",
"HELP_TEXT": "Delayed rules run only if the conditions still hold when the delay ends, and apply to conversations with activity after the rule is created."
"HELP_TEXT": "Delayed rules run only if the conditions still hold when the delay ends, and apply to conversations with activity after the rule is created.",
"RESTRICTED": {
"ATTRIBUTE_CHANGED": "A delay can't be used with an \"attribute changed\" condition, because the change can't be re-checked when the delay ends.",
"CONVERSATION_NON_STATUS": "For conversation events, a delay is only available when every condition uses the Status attribute."
}
},
"CONDITIONS": {
"LABEL": "Conditions"
@@ -197,6 +197,30 @@ const allowsDelayedExecution = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS)
);
// Mirrors the backend's execution_delay validations so we never offer an unsupported delay.
const delayRestrictionReason = computed(() => {
const conditions = automation.value?.conditions || [];
if (
conditions.some(
condition => condition.filter_operator === 'attribute_changed'
)
) {
return 'ATTRIBUTE_CHANGED';
}
if (
eventName.value !== 'message_created' &&
conditions.some(
condition =>
condition.attribute_key && condition.attribute_key !== 'status'
)
) {
return 'CONVERSATION_NON_STATUS';
}
return null;
});
const isDelaySupported = computed(() => delayRestrictionReason.value === null);
const executeMode = ref('immediate');
const delayValue = ref(4);
const delayUnit = ref('HOURS');
@@ -237,6 +261,13 @@ watch([executeMode, delayInMinutes], () => {
executeMode.value === 'delayed' ? delayInMinutes.value : null;
});
// Editing the event/conditions into an unsupported shape reverts the delay to immediate.
watch(isDelaySupported, supported => {
if (!supported && executeMode.value === 'delayed') {
executeMode.value = 'immediate';
}
});
watch(
() => automation.value,
() => {
@@ -361,11 +392,23 @@ defineExpose({ open, close });
<input v-model="executeMode" type="radio" value="immediate" />
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.IMMEDIATELY') }}
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input v-model="executeMode" type="radio" value="delayed" />
<label
class="flex items-center gap-1.5 text-sm"
:class="
isDelaySupported
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50'
"
>
<input
v-model="executeMode"
type="radio"
value="delayed"
:disabled="!isDelaySupported"
/>
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.AFTER_DELAY') }}
</label>
<template v-if="executeMode === 'delayed'">
<template v-if="executeMode === 'delayed' && isDelaySupported">
<input
v-model.number="delayValue"
type="number"
@@ -386,6 +429,16 @@ defineExpose({ open, close });
<span v-if="errors.execution_delay" class="text-xs text-n-ruby-9">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }}
</span>
<p
v-else-if="!isDelaySupported"
class="text-xs text-n-amber-11 pt-1 mb-0"
>
{{
$t(
`AUTOMATION.ADD.FORM.EXECUTE.RESTRICTED.${delayRestrictionReason}`
)
}}
</p>
<p
v-else-if="executeMode === 'delayed'"
class="text-xs text-n-slate-11 pt-1 mb-0"
@@ -136,11 +136,11 @@ const submitAutomation = async (payload, mode) => {
hideAddPopup();
hideEditPopup();
} catch (error) {
const errorMessage =
const fallbackMessage =
mode === 'edit'
? t('AUTOMATION.EDIT.API.ERROR_MESSAGE')
: t('AUTOMATION.ADD.API.ERROR_MESSAGE');
useAlert(errorMessage);
useAlert(error?.response?.data?.error || fallbackMessage);
}
};
const toggleAutomation = async ({ id, name, status }) => {