Repository: kestra-io/kestra (java)

Issue:
[UI] Raise a warning if user is editing an outdated version of a flow
When a user is editing a flow for a while and at the same time the flow has been overwritten by a colleague or by CI/CD, it would be useful to display a warning that the currently edited revision is not the last one. 

![image](https://github.com/kestra-io/kestra/assets/86264395/43abd681-c882-4386-9ffa-e4546e78d00c)

## Next steps 
Normally, implementing this would require us to listen for changes. @brian-mulier-p had a 🧠 brilliant idea to include revision in the payload we already use for YAML validation. This way, we can implement it fairly quickly. 

Some notes about what's important to keep in mind:
- be deliberate about not losing existing work within the editor
- user should be able to still overwrite the changes despite new revision being present; however, we should make them confirm this e.g. by adding a confirmation dialogue: "Are you sure you want to overwrite the latest revision?" 

Pull request:
feat/warning-on-editing-outdated-flow-revision
closes #2953

Patch:
diff --git a/core/src/main/java/io/kestra/core/models/validations/ValidateConstraintViolation.java b/core/src/main/java/io/kestra/core/models/validations/ValidateConstraintViolation.java
index 1582376cd..f95482d39 100644
--- a/core/src/main/java/io/kestra/core/models/validations/ValidateConstraintViolation.java
+++ b/core/src/main/java/io/kestra/core/models/validations/ValidateConstraintViolation.java
@@ -31,6 +31,7 @@ public class ValidateConstraintViolation {
     private int index;
 
     private String constraints;
+    private boolean outdated;
     private List<String> deprecationPaths;
 
     @JsonIgnore
diff --git a/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java b/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java
index de6a5d411..f480ec463 100644
--- a/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java
+++ b/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java
@@ -54,6 +54,8 @@ public interface FlowRepositoryInterface {
 
     List<FlowWithSource> findRevisions(String tenantId, String namespace, String id);
 
+    Integer lastRevision(String tenantId, String namespace, String id);
+
     List<Flow> findAll(String tenantId);
 
     List<Flow> findAllForAllTenants();
diff --git a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java
index 56523e191..433fcebcc 100644
--- a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java
+++ b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java
@@ -463,4 +463,21 @@ public abstract class AbstractJdbcFlowRepository extends AbstractJdbcRepository
                 .map(record -> record.getValue("namespace", String.class))
             );
     }
+
+    @Override
+    public Integer lastRevision(String tenantId, String namespace, String id) {
+        return this.jdbcRepository
+            .getDslContextWrapper()
+            .transactionResult(configuration -> DSL
+                .using(configuration)
+                .fetchValue(
+                    DSL.select(field("revision", Integer.class))
+                        .from(fromLastRevision(true))
+                        .where(this.defaultFilter(tenantId))
+                        .and(field("namespace").eq(namespace))
+                        .and(field("id", String.class).eq(id))
+                        .limit(1)
+                )
+            );
+    }
 }
diff --git a/repository-memory/src/main/java/io/kestra/repository/memory/MemoryFlowRepository.java b/repository-memory/src/main/java/io/kestra/repository/memory/MemoryFlowRepository.java
index 2d67bf89b..81dd9939f 100644
--- a/repository-memory/src/main/java/io/kestra/repository/memory/MemoryFlowRepository.java
+++ b/repository-memory/src/main/java/io/kestra/repository/memory/MemoryFlowRepository.java
@@ -107,6 +107,12 @@ public class MemoryFlowRepository implements FlowRepositoryInterface {
             .collect(Collectors.toList());
     }
 
+    @Override
+    public Integer lastRevision(String tenantId, String namespace, String id) {
+        List<FlowWithSource> flowRevisions = findRevisions(tenantId, namespace, id);
+        return flowRevisions.isEmpty() ? null : flowRevisions.get(flowRevisions.size() - 1).getRevision();
+    }
+
     @Override
     public List<Flow> findAll(String tenantId) {
         return flows.values().stream()
diff --git a/ui/src/components/flows/FlowCreate.vue b/ui/src/components/flows/FlowCreate.vue
index c4952876e..441a59acc 100644
--- a/ui/src/components/flows/FlowCreate.vue
+++ b/ui/src/components/flows/FlowCreate.vue
@@ -9,9 +9,9 @@
             :is-read-only="false"
             :total="total"
             :guided-properties="guidedProperties"
-            :flow-error="flowError"
-            :flow-deprecations="flowDeprecations"
+            :flow-validation="flowValidation"
             :flow="sourceWrapper"
+            :next-revision="1"
         />
     </div>
     <div id="guided-right" />
@@ -30,8 +30,7 @@
             TopNavBar
         },
         beforeUnmount() {
-            this.$store.commit("flow/setFlowError", undefined);
-            this.$store.commit("flow/setFlowDeprecations", undefined);
+            this.$store.commit("flow/setFlowValidation", undefined);
         },
         computed: {
             sourceWrapper() {
@@ -53,7 +52,7 @@ tasks:
             ...mapState("auth", ["user"]),
             ...mapState("plugin", ["pluginSingleList", "pluginsDocumentation"]),
             ...mapGetters("core", ["guidedProperties"]),
-            ...mapGetters("flow", ["flow", "flowError", "flowDeprecations"]),
+            ...mapGetters("flow", ["flow", "flowValidation"]),
             routeInfo() {
                 return {
                     title: this.$t("flows")
diff --git a/ui/src/components/flows/FlowEditor.vue b/ui/src/components/flows/FlowEditor.vue
index 2458cc281..4869a69c9 100644
--- a/ui/src/components/flows/FlowEditor.vue
+++ b/ui/src/components/flows/FlowEditor.vue
@@ -6,10 +6,10 @@
         :flow-graph="flowGraph"
         :flow="flow"
         :is-read-only="isReadOnly"
-        :flow-error="flowError"
-        :flow-deprecations="flowDeprecations"
+        :flow-validation="flowValidation"
         :expanded-subflows="expandedSubflows"
         @expand-subflow="$emit('expand-subflow', $event)"
+        :next-revision="flow.revision + 1"
     />
 </template>
 <script>
@@ -35,11 +35,10 @@
         },
         computed: {
             ...mapState("flow", ["flow", "flowGraph"]),
-            ...mapGetters("flow", ["flowError", "flowDeprecations"]),
+            ...mapGetters("flow", ["flowValidation"]),
         },
         beforeUnmount() {
-            this.$store.commit("flow/setFlowError", undefined);
-            this.$store.commit("flow/setFlowDeprecations", undefined);
+            this.$store.commit("flow/setFlowValidation", undefined);
         },
     };
 </script>
diff --git a/ui/src/components/flows/TaskEdit.vue b/ui/src/components/flows/TaskEdit.vue
index 2db56f8be..c617abc02 100644
--- a/ui/src/components/flows/TaskEdit.vue
+++ b/ui/src/components/flows/TaskEdit.vue
@@ -19,13 +19,13 @@
             </template>
             <template #footer>
                 <div v-loading="isLoading">
-                    <ValidationError class="me-2" link :error="taskError" />
+                    <ValidationError class="me-2" link :errors="errors" />
 
                     <el-button
                         :icon="ContentSave"
                         @click="saveTask"
                         v-if="canSave && !readOnly"
-                        :disabled="taskError !== undefined"
+                        :disabled="errors"
                         type="primary"
                     >
                         {{ $t("save") }}
@@ -275,6 +275,9 @@
             ...mapGetters("flow", ["taskError"]),
             ...mapState("auth", ["user"]),
             ...mapState("plugin", ["plugin"]),
+            errors() {
+                return this.taskError?.split(/, ?/)
+            },
             pluginMardown() {
                 if (this.plugin && this.plugin.markdown && YamlUtils.parse(this.taskYaml)?.type) {
                     return this.plugin.markdown
diff --git a/ui/src/components/flows/Topology.vue b/ui/src/components/flows/Topology.vue
index 7021e7320..8a5441cc7 100644
--- a/ui/src/components/flows/Topology.vue
+++ b/ui/src/components/flows/Topology.vue
@@ -43,7 +43,7 @@
             ...mapState("flow", ["flow", "flowGraph"]),
         },
         beforeUnmount() {
-            this.$store.commit("flow/setFlowError", undefined);
+            this.$store.commit("flow/setFlowValidation", undefined);
         },
         methods: {
             onExpandSubflow(event) {
diff --git a/ui/src/components/flows/ValidationError.vue b/ui/src/components/flows/ValidationError.vue
index 26beedf48..1042cec7e 100644
--- a/ui/src/components/flows/ValidationError.vue
+++ b/ui/src/components/flows/ValidationError.vue
@@ -1,19 +1,19 @@
 <template>
     <span>
         <!-- Valid -->
-        <el-button v-if="!error && !warnings" v-bind="$attrs" :link="link" :size="size" type="default" class="success">
+        <el-button v-if="!errors && !warnings" v-bind="$attrs" :link="link" :size="size" type="default" class="success">
             <check-circle class="text-success" />
         </el-button>
 
         <!-- Errors -->
         <el-tooltip
-            v-if="error"
+            v-if="errors"
             popper-class="p-0 bg-transparent"
             :placement="tooltipPlacement"
             :show-arrow="false"
             raw-content
             transition=""
-            :persistent="false"
+            :persistent="true"
             :hide-after="0"
         >
             <template #content>
@@ -24,7 +24,7 @@
                             {{ $t("error detected") }}
                         </span>
                     </el-header>
-                    <el-main>{{ error.split(/, ?/).join("\n") }}</el-main>
+                    <el-main>{{ errors.join("\n") }}</el-main>
                 </el-container>
             </template>
             <el-button v-bind="$attrs" :link="link" :size="size" type="default" class="error">
@@ -41,7 +41,7 @@
             :show-arrow="false"
             raw-content
             transition=""
-            :persistent="false"
+            :persistent="true"
             :hide-after="0"
         >
             <template #content>
@@ -76,8 +76,8 @@
             Alert
         },
         props: {
-            error: {
-                type: String,
+            errors: {
+                type: Array,
                 default: undefined
             },
             warnings: {
diff --git a/ui/src/components/inputs/Editor.vue b/ui/src/components/inputs/Editor.vue
index 4790fb268..e08332931 100644
--- a/ui/src/components/inputs/Editor.vue
+++ b/ui/src/components/inputs/Editor.vue
@@ -108,7 +108,7 @@
         },
         computed: {
             ...mapGetters("core", ["guidedProperties"]),
-            ...mapGetters("flow", ["flowError"]),
+            ...mapGetters("flow", ["flowValidation"]),
             themeComputed() {
                 const darkTheme = document.getElementsByTagName("html")[0].className.indexOf("dark") >= 0;
 
diff --git a/ui/src/components/inputs/EditorButtons.vue b/ui/src/components/inputs/EditorButtons.vue
index 457234d58..0b009c683 100644
--- a/ui/src/components/inputs/EditorButtons.vue
+++ b/ui/src/components/inputs/EditorButtons.vue
@@ -60,7 +60,7 @@
                 :icon="ContentSave"
                 @click="forwardEvent('save', $event)"
                 v-if="isAllowedEdit"
-                :type="flowError ? 'danger' : 'primary'"
+                :type="buttonType"
                 :disabled="!haveChange && !isCreating"
                 class="edit-flow-save-button"
             >
@@ -107,9 +107,24 @@
                 type: Boolean,
                 default: false
             },
-            flowError: {
-                type: String,
-                default: null
+            errors: {
+                type: Array,
+                default: undefined
+            },
+            warnings: {
+                type: Array,
+                default: undefined
+            }
+        },
+        computed: {
+            buttonType() {
+                if (this.errors) {
+                    return "danger";
+                }
+
+                return this.warnings
+                    ? "warning"
+                    : "primary";
             }
         },
         methods: {
diff --git a/ui/src/components/inputs/EditorView.vue b/ui/src/components/inputs/EditorView.vue
index 165a91e7e..ac8112ce7 100644
--- a/ui/src/components/inputs/EditorView.vue
+++ b/ui/src/components/inputs/EditorView.vue
@@ -80,23 +80,59 @@
                 return {tourStarted: false};
             }
         },
-        flowError: {
-            type: String,
-            default: undefined
-        },
-        flowDeprecations: {
-            type: Array,
+        flowValidation: {
+            type: Object,
             default: undefined
         },
         expandedSubflows: {
             type: Array,
             default: () => []
+        },
+        nextRevision: {
+            type: Number,
+            default: 1
         }
     })
 
+    const flowErrors = computed(() => {
+        const flowExistsError = props.flowValidation?.outdated && props.isCreating
+            ? [outdatedMessage.value]
+            : [];
+
+        const constraintsError = props.flowValidation?.constraints?.split(/, ?/) ?? [];
+
+        const errors = [
+            ...flowExistsError,
+            ...constraintsError
+        ];
+
+        return errors.length === 0 ? undefined : errors;
+    })
+
+    const baseOutdatedTranslationKey = computed(() => {
+        const createOrUpdateKey = props.isCreating ? "create" : "update";
+        return "outdated revision save confirmation." + createOrUpdateKey;
+    })
+
+    const outdatedMessage = computed(() => {
+        return `${t(baseOutdatedTranslationKey.value + ".description")} ${t(baseOutdatedTranslationKey.value + ".details")}`
+    })
+
     const flowWarnings = computed(() => {
-        const warnings = props.flowDeprecations?.map(f => `${f} is deprecated.`);
-        return warnings?.length > 0 ? warnings : undefined;
+        const outdatedWarning = props.flowValidation?.outdated && !props.isCreating
+            ? [outdatedMessage.value]
+            : [];
+
+        const deprecationWarnings = props.flowValidation?.deprecationPaths
+            ?.map(f => `${f} ${t("is deprecated")}.`)
+            ?? [];
+
+        const warnings = [
+            ...outdatedWarning,
+            ...deprecationWarnings
+        ];
+
+        return warnings.length === 0 ? undefined : warnings;
     })
 
     const loadViewType = () => {
@@ -144,6 +180,7 @@
     const user = store.getters["auth/user"];
     const routeParams = router.currentRoute.value.params;
     const blueprintsLoaded = ref(false);
+    const confirmOutdatedSaveDialog = ref(false);
 
     const persistViewType = (value) => {
         viewType.value = value;
@@ -162,6 +199,10 @@
         taskError.value = store.getters["flow/taskError"];
     });
 
+    const taskErrors = computed(() => {
+        return taskError.value?.split(/, ?/);
+    })
+
     watch(() => props.expandedSubflows, (_, oldValue) => {
         fetchGraph().catch(() => {
             emit("expand-subflow", oldValue);
@@ -173,6 +214,10 @@
         return flow ? YamlUtils.flowHaveTasks(flow) : false;
     }
 
+    const yamlWithNextRevision = computed(() => {
+        return `revision: ${props.nextRevision}\n${flowYaml.value}`;
+    })
+
     const initYamlSource = async () => {
         flowYaml.value = props.flow.source;
 
@@ -197,7 +242,7 @@
         }
 
         // validate flow on first load
-        store.dispatch("flow/validateFlow", {flow: flowYaml.value})
+        store.dispatch("flow/validateFlow", {flow: yamlWithNextRevision.value})
             .then(value => {
                 if(validationDomElement.value) {
                     validationDomElement.value.onResize(editorDomElement.value.$el.offsetWidth);
@@ -318,21 +363,25 @@
         haveChange.value = false;
     }
 
-    const singleErrorToast = (title, message, detailedError) => {
+    const errorsToast = (title, message, errors) => {
         store.dispatch("core/showMessage", {
             title: title,
             message: message,
             content: {
                 _embedded: {
-                    errors: [{message: detailedError}]
+                    errors: errors.map(error => {
+                        return {
+                            message: error
+                        }
+                    })
                 }
             },
             variant: "error"
         });
     }
 
-    const saveAsDraft = (errorMessage) => {
-        singleErrorToast(t("save draft.message"), t("invalid flow"), errorMessage);
+    const saveAsDraft = (errors) => {
+        errorsToast(t("save draft.message"), t("invalid flow"), errors);
         persistEditorContent(false);
     }
 
@@ -356,7 +405,7 @@
         haveChange.value = true;
         store.dispatch("core/isUnsaved", true);
         clearTimeout(timer.value)
-        return store.dispatch("flow/validateFlow", {flow: event})
+        return store.dispatch("flow/validateFlow", {flow: yamlWithNextRevision.value})
             .then(value => {
                 if (flowHaveTasks() && [editorViewTypes.TOPOLOGY, editorViewTypes.SOURCE_TOPOLOGY].includes(viewType.value)) {
                     generateGraph()
@@ -475,10 +524,61 @@
         }
     }
 
+    const saveWithoutRevisionGuard = async () => {
+        let flowParsed;
+        try {
+            flowParsed = YamlUtils.parse(flowYaml.value);
+        } catch (e) {
+            console.log("Error while parsing flow : ", e);
+        }
+        if (flowErrors.value) {
+            saveAsDraft(flowErrors.value);
+            return;
+        }
+
+        if (props.isCreating) {
+            await store.dispatch("flow/createFlow", {flow: flowYaml.value})
+                .then((response) => {
+                    toast.saved(response.id);
+                    store.dispatch("core/isUnsaved", false);
+                    router.push({
+                        name: "flows/update",
+                        params: {
+                            id: flowParsed.id,
+                            namespace: flowParsed.namespace,
+                            tab: "editor",
+                            tenant: routeParams.tenant
+                        }
+                    });
+                })
+        } else {
+            if (routeParams.id !== flowParsed.id || routeParams.namespace !== flowParsed.namespace) {
+                store.dispatch("core/showMessage", {
+                    variant: "error",
+                    title: t("can not save"),
+                    message: t("namespace and id readonly"),
+                })
+                flowYaml.value = YamlUtils.replaceIdAndNamespace(flowYaml.value, routeParams.id, routeParams.namespace);
+                return;
+            }
+            await store.dispatch("flow/saveFlow", {flow: flowYaml.value})
+                .then((response) => {
+                    toast.saved(response.id);
+                    store.dispatch("core/isUnsaved", false);
+                })
+        }
+
+        haveChange.value = false;
+        await store.dispatch("flow/validateFlow", {flow: yamlWithNextRevision.value})
+    }
+
     const save = (e) => {
+        if (!haveChange.value) {
+            return;
+        }
         if (e) {
             if (e.type === "keydown") {
-                if (!(e.keyCode === 83 && e.ctrlKey) || !haveChange.value) {
+                if (!(e.keyCode === 83 && e.ctrlKey)) {
                     return;
                 }
                 e.preventDefault();
@@ -498,50 +598,11 @@
         }
 
         onEdit(flowYaml.value).then(validation => {
-            let flowParsed;
-            try {
-                flowParsed = YamlUtils.parse(flowYaml.value);
-            } catch (e) {
-                console.log("Error while parsing flow : ", e);
-            }
-            if (validation[0].constraints) {
-                saveAsDraft(validation[0].constraints);
+            if (validation.outdated && !props.isCreating) {
+                confirmOutdatedSaveDialog.value = true;
                 return;
             }
-
-            if (props.isCreating) {
-                store.dispatch("flow/createFlow", {flow: flowYaml.value})
-                    .then((response) => {
-                        toast.saved(response.id);
-                        store.dispatch("core/isUnsaved", false);
-                        router.push({
-                            name: "flows/update",
-                            params: {
-                                id: flowParsed.id,
-                                namespace: flowParsed.namespace,
-                                tab: "editor",
-                                tenant: routeParams.tenant
-                            }
-                        });
-                    })
-            } else {
-                if (routeParams.id !== flowParsed.id || routeParams.namespace !== flowParsed.namespace) {
-                    store.dispatch("core/showMessage", {
-                        variant: "error",
-                        title: t("can not save"),
-                        message: t("namespace and id readonly"),
-                    })
-                    flowYaml.value = YamlUtils.replaceIdAndNamespace(flowYaml.value, routeParams.id, routeParams.namespace);
-                    return;
-                }
-                store.dispatch("flow/saveFlow", {flow: flowYaml.value})
-                    .then((response) => {
-                        toast.saved(response.id);
-                        store.dispatch("core/isUnsaved", false);
-                    })
-            }
-
-            haveChange.value = false;
+            saveWithoutRevisionGuard();
         })
     };
 
@@ -675,7 +736,7 @@
             :read-only="isReadOnly"
         >
             <template #extends-navbar>
-                <ValidationError ref="validationDomElement" tooltip-placement="bottom-start" size="small" class="ms-2" :error="flowError" :warnings="flowWarnings" />
+                <ValidationError ref="validationDomElement" tooltip-placement="bottom-start" size="small" class="ms-2" :errors="flowErrors" :warnings="flowWarnings" />
             </template>
             <template #buttons>
                 <EditorButtons
@@ -686,7 +747,8 @@
                     :is-allowed-edit="isAllowedEdit()"
                     :have-change="haveChange"
                     :flow-have-tasks="flowHaveTasks()"
-                    :flow-error="flowError"
+                    :errors="flowErrors"
+                    :warnings="flowWarnings"
                     @delete-flow="deleteFlow"
                     @save="save"
                     @copy="() => router.push({name: 'flows/create', query: {copy: true}, params: {tenant: routeParams.tenant}})"
@@ -722,7 +784,7 @@
                 :expanded-subflows="props.expandedSubflows"
             >
                 <template #top-bar v-if="viewType === editorViewTypes.TOPOLOGY">
-                    <ValidationError tooltip-placement="bottom-start" size="small" class="ms-2" :error="flowError" :warnings="flowWarnings" />
+                    <ValidationError tooltip-placement="bottom-start" size="small" class="ms-2" :errors="flowErrors" :warnings="flowWarnings" />
                 </template>
             </LowCodeEditor>
             <el-alert v-else type="warning" :closable="false">
@@ -748,8 +810,8 @@
                 />
             </el-form>
             <template #footer>
-                <ValidationError :error="taskError" />
-                <el-button :icon="ContentSave" @click="onSaveNewError()" type="primary" :disabled="taskError">
+                <ValidationError :errors="taskErrors" />
+                <el-button :icon="ContentSave" @click="onSaveNewError()" type="primary" :disabled="taskErrors">
                     {{ $t("save") }}
                 </el-button>
             </template>
@@ -769,8 +831,8 @@
                 />
             </el-form>
             <template #footer>
-                <ValidationError :error="taskError" />
-                <el-button :icon="ContentSave" @click="onSaveNewTrigger()" type="primary" :disabled="taskError">
+                <ValidationError :errors="taskErrors" />
+                <el-button :icon="ContentSave" @click="onSaveNewTrigger()" type="primary" :disabled="taskErrors">
                     {{ $t("save") }}
                 </el-button>
             </template>
@@ -818,7 +880,8 @@
             :is-allowed-edit="isAllowedEdit()"
             :have-change="haveChange"
             :flow-have-tasks="flowHaveTasks()"
-            :flow-error="flowError"
+            :errors="flowErrors"
+            :warnings="flowWarnings"
             @delete-flow="deleteFlow"
             @save="save"
             @copy="() => router.push({name: 'flows/create', query: {copy: true}, params: {tenant: routeParams.tenant}})"
@@ -827,6 +890,23 @@
             @open-edit-metadata="isEditMetadataOpen = true;"
         />
     </el-card>
+    <el-dialog v-if="confirmOutdatedSaveDialog" v-model="confirmOutdatedSaveDialog" destroy-on-close :append-to-body="true">
+        <template #header>
+            <h5>{{ $t(`${baseOutdatedTranslationKey}.title`) }}</h5>
+        </template>
+        {{ $t(`${baseOutdatedTranslationKey}.description`) }} {{ $t(`${baseOutdatedTranslationKey}.details`) }}
+        <template #footer>
+            <el-button @click="confirmOutdatedSaveDialog = false">
+                {{ $t('cancel') }}
+            </el-button>
+            <el-button
+                type="warning"
+                @click="saveWithoutRevisionGuard(); confirmOutdatedSaveDialog = false"
+            >
+                {{ $t('ok') }}
+            </el-button>
+        </template>
+    </el-dialog>
 </template>
 
 <style lang="scss" scoped>
diff --git a/ui/src/stores/flow.js b/ui/src/stores/flow.js
index 14a896f68..f6893737a 100644
--- a/ui/src/stores/flow.js
+++ b/ui/src/stores/flow.js
@@ -20,8 +20,7 @@ export default {
         flowGraph: undefined,
         flowGraphParam: undefined,
         revisions: undefined,
-        flowError: undefined,
-        flowDeprecations: undefined,
+        flowValidation: undefined,
         taskError: undefined,
         metrics: [],
         aggregatedMetrics: undefined,
@@ -244,9 +243,8 @@ export default {
         validateFlow({commit}, options) {
             return axios.post(`${apiUrl(this)}/flows/validate`, options.flow, textYamlHeader)
                 .then(response => {
-                    commit("setFlowError", response.data[0] ? response.data[0].constraints : undefined)
-                    commit("setFlowDeprecations", response.data[0] ? response.data[0].deprecationPaths : undefined)
-                    return response.data
+                    commit("setFlowValidation", response.data[0])
+                    return response.data[0]
                 })
         },
         validateTask({commit}, options) {
@@ -363,11 +361,8 @@ export default {
         setFlowGraph(state, flowGraph) {
             state.flowGraph = flowGraph
         },
-        setFlowError(state, flowError) {
-            state.flowError = flowError
-        },
-        setFlowDeprecations(state, flowDeprecations) {
-            state.flowDeprecations = flowDeprecations
+        setFlowValidation(state, flowValidation) {
+            state.flowValidation = flowValidation
         },
         setTaskError(state, taskError) {
             state.taskError = taskError
@@ -388,14 +383,9 @@ export default {
                 return state.flow;
             }
         },
-        flowError(state) {
-            if (state.flowError) {
-                return state.flowError;
-            }
-        },
-        flowDeprecations(state) {
-            if (state.flowDeprecations) {
-                return state.flowDeprecations;
+        flowValidation(state) {
+            if (state.flowValidation) {
+                return state.flowValidation;
             }
         },
         taskError(state) {
diff --git a/ui/src/translations.json b/ui/src/translations.json
index ff2508119..9e0167402 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -21,6 +21,20 @@
     "Default page": "Default page",
     "confirmation": "Confirmation",
     "delete confirm": "Are you sure to delete <code>{name}</code>?",
+    "outdated revision save confirmation": {
+      "confirm": "Do you want to overwrite it?",
+      "update": {
+        "title": "Outdated revision",
+        "description": "The revision you are editing is outdated.",
+        "details": "Check the Revisions tab for more details about the latest version."
+      },
+      "create": {
+        "title": "Flow already exists",
+        "description": "A Flow with the same id / namespace already exists.",
+        "details": "Check the Flows menu for more details about the existing flow."
+      }
+    },
+    "is deprecated": "is deprecated",
     "success": "Success",
     "save": "Save",
     "saved": "Successfully saved",
@@ -567,6 +581,19 @@
     "Default page": "Page par défaut",
     "confirmation": "Confirmation",
     "delete confirm": "Êtes-vous sur de vouloir effacer <code>{name}</code> ?",
+    "outdated revision save confirmation": {
+        "confirm": "Êtes-vous sûr de vouloir l'écraser ?",
+        "update": {
+          "title": "Révision dépassée",
+          "description": "Une révision plus récente du Flow existe.",
+          "details": "Regardez l'onglet Révisions pour plus de détails sur la dernière version."
+        },
+        "create": {
+          "description": "Un Flow avec le même id / namespace existe déjà.",
+          "details": "Regardez le menu Flows pour plus de détails sur le Flow existant."
+        }
+    },
+    "is deprecated": "est déprécié(e)",
     "success": "Succès",
     "save": "Enregistrer",
     "saved": "Enregistrement réussi",
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java b/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java
index 3b79dc7a6..4e09da364 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java
@@ -500,6 +500,12 @@ public class FlowController {
 
                 try {
                     Flow flowParse = yamlFlowParser.parse(flow, Flow.class);
+                    Integer sentRevision = flowParse.getRevision();
+                    if (sentRevision != null) {
+                        Integer lastRevision = Optional.ofNullable(flowRepository.lastRevision(tenantService.resolveTenant(), flowParse.getNamespace(), flowParse.getId()))
+                            .orElse(0);
+                        validateConstraintViolationBuilder.outdated(!sentRevision.equals(lastRevision + 1));
+                    }
 
                     validateConstraintViolationBuilder.deprecationPaths(flowService.deprecationPaths(flowParse));
 


Test patch:
diff --git a/core/src/test/java/io/kestra/core/repositories/AbstractFlowRepositoryTest.java b/core/src/test/java/io/kestra/core/repositories/AbstractFlowRepositoryTest.java
index 0eb692648..bd8c81e12 100644
--- a/core/src/test/java/io/kestra/core/repositories/AbstractFlowRepositoryTest.java
+++ b/core/src/test/java/io/kestra/core/repositories/AbstractFlowRepositoryTest.java
@@ -432,6 +432,42 @@ public abstract class AbstractFlowRepositoryTest {
         assertThat(((FlowWithException) found.get()).getException(), containsString("Templates are disabled"));
     }
 
+    @Test
+    protected void lastRevision() {
+        String namespace = "io.kestra.unittest";
+        String flowId = IdUtils.create();
+        String tenantId = "tenant";
+
+        assertThat(flowRepository.lastRevision(tenantId, namespace, flowId), nullValue());
+
+        // create with builder
+        Flow first = Flow.builder()
+            .tenantId(tenantId)
+            .id(flowId)
+            .namespace(namespace)
+            .tasks(Collections.singletonList(Return.builder().id("test").type(Return.class.getName()).format("test").build()))
+            .inputs(ImmutableList.of(StringInput.builder().type(Input.Type.STRING).id("a").build()))
+            .build();
+        // create with repository
+        flowRepository.create(first, first.generateSource(), taskDefaultService.injectDefaults(first));
+        assertThat(flowRepository.lastRevision(tenantId, namespace, flowId), is(1));
+
+        // submit new one with change
+
+        Flow flowRev2 = first.toBuilder()
+            .tasks(Collections.singletonList(
+                Log.builder()
+                    .id(IdUtils.create())
+                    .type(Log.class.getName())
+                    .message("Hello World")
+                    .build()
+            ))
+            .inputs(ImmutableList.of(StringInput.builder().type(Input.Type.STRING).id("b").build()))
+            .build();
+
+        flowRepository.update(flowRev2, first, flowRev2.generateSource(), taskDefaultService.injectDefaults(flowRev2));
+        assertThat(flowRepository.lastRevision(tenantId, namespace, flowId), is(2));
+    }
 
     @Singleton
     public static class FlowListener implements ApplicationEventListener<CrudEvent<Flow>> {
diff --git a/repository-memory/src/test/java/io/kestra/repository/memory/MemoryFlowRepositoryTest.java b/repository-memory/src/test/java/io/kestra/repository/memory/MemoryFlowRepositoryTest.java
index 494d39ac1..f04b1ec3b 100644
--- a/repository-memory/src/test/java/io/kestra/repository/memory/MemoryFlowRepositoryTest.java
+++ b/repository-memory/src/test/java/io/kestra/repository/memory/MemoryFlowRepositoryTest.java
@@ -16,7 +16,7 @@ public class MemoryFlowRepositoryTest extends AbstractFlowRepositoryTest {
 
     @BeforeEach
     protected void init() throws IOException, URISyntaxException {
-        memoryFlowRepository.findAll(null).forEach(flow -> memoryFlowRepository.delete(flow));
+        memoryFlowRepository.findAllForAllTenants().forEach(flow -> memoryFlowRepository.delete(flow));
         super.init();
     }
 
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java
index 86b964883..e5f979b6e 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java
@@ -703,12 +703,19 @@ class FlowControllerTest extends JdbcH2ControllerTest {
         URL resource = TestsUtils.class.getClassLoader().getResource("flows/validateMultipleValidFlows.yaml");
         String flow = Files.readString(Path.of(Objects.requireNonNull(resource).getPath()), Charset.defaultCharset());
 
+        String firstFlowSource = flow.split("(?m)^---")[0];
+        Flow firstFlow = parseFlow(firstFlowSource);
+        jdbcFlowRepository.create(firstFlow, firstFlowSource, firstFlow);
+
         HttpResponse<List<ValidateConstraintViolation>> response = client.toBlocking().exchange(POST("/api/v1/flows/validate", flow).contentType(MediaType.APPLICATION_YAML), Argument.listOf(ValidateConstraintViolation.class));
 
         List<ValidateConstraintViolation> body = response.body();
         assertThat(body.size(), is(2));
+        // We don't send any revision while the flow already exists so it's outdated
+        assertThat(body.get(0).isOutdated(), is(true));
         assertThat(body.get(0).getDeprecationPaths(), hasSize(3));
         assertThat(body.get(0).getDeprecationPaths(), containsInAnyOrder("tasks[1]", "tasks[1].additionalProperty", "listeners"));
+        assertThat(body.get(1).isOutdated(), is(false));
         assertThat(body.get(1).getDeprecationPaths(), empty());
         assertThat(body, everyItem(
             Matchers.hasProperty("constraints", is(nullValue()))
diff --git a/webserver/src/test/resources/flows/validateMultipleValidFlows.yaml b/webserver/src/test/resources/flows/validateMultipleValidFlows.yaml
index 50980dd1c..94533561f 100644
--- a/webserver/src/test/resources/flows/validateMultipleValidFlows.yaml
+++ b/webserver/src/test/resources/flows/validateMultipleValidFlows.yaml
@@ -1,5 +1,6 @@
 id: "first_flow"
 namespace: "validation"
+revision: 1
 tasks:
   - id: task_one
     type: io.kestra.core.tasks.log.Log