This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new fe81cec9a fix(ai): emit non-null cluster version and bound message 
query timestamps (#2311)
fe81cec9a is described below

commit fe81cec9a5baec0c6974e7d5f316883aafa0e94d
Author: 0 <[email protected]>
AuthorDate: Wed Aug 19 14:27:13 2026 +0800

    fix(ai): emit non-null cluster version and bound message query timestamps 
(#2311)
    
    * fix(ai): emit non-null cluster version in cluster list and capabilities 
tools
    
    The Apache runtime provider never populates ClusterVO.version, so the
    rmq.cluster.list and rmq.capabilities tool projections emitted null into
    the output schema's required version string, causing output schema
    validation to throw and surface as HTTP 500. Project null id, type and
    version members to blank strings so the output schema is satisfied.
    
    * fix(ai): bound timestamp arguments in message query tool
    
    The rmq.message.query tool parsed startTime/endTime with
    Number.longValue(), which silently wraps values above Long.MAX_VALUE
    (Jackson delivers them as BigInteger) into a nonsense, possibly negative,
    epoch-millis timestamp, and Long.parseLong() propagated 
NumberFormatException
    as a 500. Convert with longValueExact, reject non-finite/out-of-range
    floating-point values, and map unparseable/unrepresentable strings and
    numbers to HTTP 400.
---
 .../ops/ai/tool/CapabilitiesToolHandler.java       | 15 +++-
 .../studio/ops/ai/tool/ClusterListToolHandler.java | 15 +++-
 .../ops/ai/tool/MessageQueryToolHandler.java       | 37 +++++++++-
 .../ops/ai/tool/CapabilitiesToolHandlerTest.java   | 60 ++++++++++++++++
 .../ops/ai/tool/ClusterListToolHandlerTest.java    | 82 ++++++++++++++++++++++
 .../ops/ai/tool/MessageQueryToolHandlerTest.java   | 45 ++++++++++++
 6 files changed, 246 insertions(+), 8 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.java
index e17505457..9b06eb25b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.java
@@ -46,10 +46,19 @@ public class CapabilitiesToolHandler implements ToolHandler 
{
         List<String> capabilities = capabilityResolver.resolve(cluster);
 
         Map<String, Object> result = new LinkedHashMap<>();
-        result.put("cluster", cluster.getId());
-        result.put("type", cluster.getType().name());
-        result.put("version", cluster.getVersion());
+        result.put("cluster", blankIfNull(cluster.getId()));
+        result.put("type", cluster.getType() == null ? "" : 
cluster.getType().name());
+        result.put("version", blankIfNull(cluster.getVersion()));
         result.put("capabilities", capabilities);
         return result;
     }
+
+    /**
+     * The output schema declares cluster/type/version as required strings; 
providers that
+     * do not populate them (notably the Apache runtime provider, which 
reports no cluster
+     * version) must not emit nulls or schema validation turns the tool call 
into a 500.
+     */
+    private static String blankIfNull(String value) {
+        return value == null ? "" : value;
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java
index 9276729bb..67e6836c1 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java
@@ -46,14 +46,23 @@ public class ClusterListToolHandler implements ToolHandler {
 
     private static Map<String, Object> safeProjection(ClusterVO cluster) {
         Map<String, Object> result = new LinkedHashMap<>();
-        result.put("id", cluster.getId());
-        result.put("name", cluster.getName());
+        result.put("id", blankIfNull(cluster.getId()));
+        result.put("name", blankIfNull(cluster.getName()));
         result.put("type", requiredEnumName(cluster.getType(), "type", 
cluster.getId()));
         result.put("status", requiredEnumName(cluster.getStatus(), "status", 
cluster.getId()));
-        result.put("version", cluster.getVersion());
+        result.put("version", blankIfNull(cluster.getVersion()));
         return result;
     }
 
+    /**
+     * The output schema declares id/name/version as required strings; 
providers that do
+     * not populate them (notably the Apache runtime provider, which reports 
no cluster
+     * version) must not emit nulls or schema validation turns the tool call 
into a 500.
+     */
+    private static String blankIfNull(String value) {
+        return value == null ? "" : value;
+    }
+
     private static String requiredEnumName(Enum<?> value, String field, String 
clusterId) {
         if (value == null) {
             throw new IllegalStateException(
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandler.java
index 6ea69ca04..569bbc00b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandler.java
@@ -16,11 +16,13 @@
  */
 package org.apache.rocketmq.studio.ops.ai.tool;
 
+import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
 import org.apache.rocketmq.studio.instance.message.MessageService;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
 
+import java.math.BigInteger;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
@@ -76,9 +78,40 @@ public class MessageQueryToolHandler implements ToolHandler {
             return null;
         }
         if (value instanceof Number number) {
-            return number.longValue();
+            return toEpochMillis(number);
         }
-        return Long.parseLong(value.toString());
+        try {
+            return Long.parseLong(value.toString().trim());
+        } catch (NumberFormatException ex) {
+            throw new BusinessException(
+                    400, "startTime and endTime must be integer 
epoch-milliseconds");
+        }
+    }
+
+    /**
+     * Convert a caller-supplied numeric timestamp to epoch-milliseconds 
without silent
+     * overflow. Jackson may deliver out-of-range values as {@link BigInteger} 
(or as a
+     * floating-point number); {@code Number.longValue()} would silently wrap 
these into a
+     * nonsense, possibly negative, timestamp. Reject values that do not fit 
instead.
+     */
+    private static long toEpochMillis(Number number) {
+        if (number instanceof BigInteger bigInteger) {
+            try {
+                return bigInteger.longValueExact();
+            } catch (ArithmeticException ex) {
+                throw new BusinessException(
+                        400, "startTime and endTime must fit in a 64-bit 
epoch-milliseconds value");
+            }
+        }
+        if (number instanceof Double || number instanceof Float) {
+            double doubleValue = number.doubleValue();
+            if (!Double.isFinite(doubleValue) || doubleValue > Long.MAX_VALUE
+                    || doubleValue < Long.MIN_VALUE) {
+                throw new BusinessException(
+                        400, "startTime and endTime must fit in a 64-bit 
epoch-milliseconds value");
+            }
+        }
+        return number.longValue();
     }
 
     private static String require(String value, String field) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandlerTest.java
new file mode 100644
index 000000000..d8362e576
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandlerTest.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.ai.tool;
+
+import org.apache.rocketmq.studio.cluster.broker.ClusterService;
+import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
+import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
+import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class CapabilitiesToolHandlerTest {
+
+    @Test
+    void nullClusterVersionIsEmittedAsBlankString() {
+        // The Apache runtime provider reports no cluster version; the 
projection must not
+        // emit a null into the schema-required "version" string.
+        ClusterVO cluster = ClusterVO.builder()
+                .name("DefaultCluster")
+                .type(ClusterType.V4_DIRECT)
+                .status(ClusterStatus.healthy)
+                .build();
+        cluster.setId("DefaultCluster");
+
+        ClusterService clusterService = mock(ClusterService.class);
+        when(clusterService.getCluster("DefaultCluster")).thenReturn(cluster);
+        CapabilityResolver capabilityResolver = mock(CapabilityResolver.class);
+        
when(capabilityResolver.resolve(cluster)).thenReturn(List.of("REMOTING"));
+
+        Object output = new CapabilitiesToolHandler(clusterService, 
capabilityResolver)
+                .execute(Map.of("cluster", "DefaultCluster"));
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> result = (Map<String, Object>) output;
+        assertThat(result.get("cluster")).isEqualTo("DefaultCluster");
+        assertThat(result.get("type")).isEqualTo("V4_DIRECT");
+        assertThat(result.get("version")).isEqualTo("");
+        assertThat(result.get("capabilities")).isEqualTo(List.of("REMOTING"));
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ClusterListToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ClusterListToolHandlerTest.java
new file mode 100644
index 000000000..4186f19a7
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ClusterListToolHandlerTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.ai.tool;
+
+import org.apache.rocketmq.studio.cluster.broker.ClusterService;
+import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
+import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
+import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ClusterListToolHandlerTest {
+
+    @Test
+    void nullClusterVersionIsEmittedAsBlankString() {
+        // The Apache runtime provider reports no cluster version; the 
projection must not
+        // emit a null into the schema-required "version" string.
+        ClusterVO cluster = ClusterVO.builder()
+                .name("DefaultCluster")
+                .type(ClusterType.V4_DIRECT)
+                .status(ClusterStatus.healthy)
+                .build();
+        cluster.setId("DefaultCluster");
+
+        ClusterService clusterService = mock(ClusterService.class);
+        when(clusterService.listClusters()).thenReturn(List.of(cluster));
+
+        Object output = new 
ClusterListToolHandler(clusterService).execute(Map.of());
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> rows = (List<Map<String, Object>>) output;
+        assertThat(rows).hasSize(1);
+        Map<String, Object> row = rows.get(0);
+        assertThat(row.get("id")).isEqualTo("DefaultCluster");
+        assertThat(row.get("name")).isEqualTo("DefaultCluster");
+        assertThat(row.get("type")).isEqualTo("V4_DIRECT");
+        assertThat(row.get("status")).isEqualTo("healthy");
+        assertThat(row.get("version")).isEqualTo("");
+    }
+
+    @Test
+    void populatedClusterVersionIsPassedThrough() {
+        ClusterVO cluster = ClusterVO.builder()
+                .name("VersionedCluster")
+                .type(ClusterType.V4_DIRECT)
+                .status(ClusterStatus.healthy)
+                .version("V5_3_1")
+                .build();
+        cluster.setId("VersionedCluster");
+
+        ClusterService clusterService = mock(ClusterService.class);
+        when(clusterService.listClusters()).thenReturn(List.of(cluster));
+
+        Object output = new 
ClusterListToolHandler(clusterService).execute(Map.of());
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> rows = (List<Map<String, Object>>) output;
+        @SuppressWarnings("unchecked")
+        Map<String, Object> row = rows.get(0);
+        assertThat(row.get("version")).isEqualTo("V5_3_1");
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandlerTest.java
index 067ca8c89..f9b7cd0d8 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandlerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandlerTest.java
@@ -85,4 +85,49 @@ class MessageQueryToolHandlerTest {
         verify(messageService)
                 .queryMessages(eq("instance-a"), any(), any(), any(), any(), 
eq(1000L), eq(2000L));
     }
+
+    @Test
+    void executeShouldRejectTimestampAboveLongRangeInsteadOfSilentlyWrapping() 
{
+        java.math.BigInteger overflow =
+                
java.math.BigInteger.valueOf(Long.MAX_VALUE).add(java.math.BigInteger.ONE);
+
+        org.assertj.core.api.Assertions.assertThatThrownBy(
+                        () -> handler.execute(Map.of(
+                                "cluster", "instance-a", "topic", "TopicA", 
"startTime", overflow)))
+                
.isInstanceOf(org.apache.rocketmq.studio.common.exception.BusinessException.class)
+                .hasMessageContaining("epoch-milliseconds");
+    }
+
+    @Test
+    void executeShouldRejectNonFiniteTimestampInsteadOfWrapping() {
+        org.assertj.core.api.Assertions.assertThatThrownBy(
+                        () -> handler.execute(Map.of(
+                                "cluster", "instance-a", "topic", "TopicA",
+                                "startTime", Double.POSITIVE_INFINITY)))
+                
.isInstanceOf(org.apache.rocketmq.studio.common.exception.BusinessException.class)
+                .hasMessageContaining("epoch-milliseconds");
+    }
+
+    @Test
+    void executeShouldRejectNonNumericTimestamp() {
+        org.assertj.core.api.Assertions.assertThatThrownBy(
+                        () -> handler.execute(Map.of(
+                                "cluster", "instance-a", "topic", "TopicA",
+                                "startTime", "not-a-number")))
+                
.isInstanceOf(org.apache.rocketmq.studio.common.exception.BusinessException.class)
+                .hasMessageContaining("epoch-milliseconds");
+    }
+
+    @Test
+    void executeShouldConvertInBigIntegerTimestampExactly() {
+        when(messageService.queryMessages(any(), any(), any(), any(), any(), 
any(), any()))
+                .thenReturn(List.of());
+
+        handler.execute(Map.of("cluster", "instance-a", "topic", "TopicA",
+                "startTime", java.math.BigInteger.valueOf(123456789L)));
+
+        verify(messageService)
+                .queryMessages(eq("instance-a"), any(), any(), any(), any(),
+                        eq(123456789L), any());
+    }
 }

Reply via email to