feat(agent): 运行详情分页与增量读取契约 (#600)
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -5,7 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -195,7 +197,14 @@ func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
run, err := svc.CloudAgentRun(user.ID, c.Param("id"))
|
||||
// 运行详情只返回一页事件,分页参数越界或非法一律拒绝:静默夹取会让客户端
|
||||
// 以为拿到的是它请求的那一页,从而把缺口当成"没有更多记录"。
|
||||
options, err := agentRunViewOptions(c)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
run, err := svc.CloudAgentRun(user.ID, c.Param("id"), options)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -297,16 +306,19 @@ func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
run, err := svc.CloudAgentRun(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
// 游标就是事件 seq:先解析它,才能只把 after 之后的增量交给视图,
|
||||
// 断线重连不必重新载入前面已经发过的事件。
|
||||
after, err := taskTextEventCursor(c)
|
||||
if err != nil {
|
||||
fail(c, 400, err)
|
||||
return
|
||||
}
|
||||
options := service.CloudAgentRunViewOptions{SinceSeq: int(after)}
|
||||
run, err := svc.CloudAgentRun(user.ID, c.Param("id"), options)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
@@ -345,7 +357,7 @@ func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run, err = svc.CloudAgentRunIfChanged(user.ID, c.Param("id"), revision)
|
||||
run, err = svc.CloudAgentRunIfChanged(user.ID, c.Param("id"), revision, options)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -362,3 +374,31 @@ func writeAgentSSE(c *gin.Context, event string, id int64, value any) {
|
||||
_, _ = fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// agentRunEventQueryLimit 是 eventLimit 允许的最大值,与 app 侧的增量上限一致:
|
||||
// 请求更大的页没有意义(单次响应体积),直接拒绝比静默截断更容易被发现。
|
||||
const agentRunEventQueryLimit = 500
|
||||
|
||||
func agentRunViewOptions(c *gin.Context) (service.CloudAgentRunViewOptions, error) {
|
||||
options := service.CloudAgentRunViewOptions{}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value *int
|
||||
min int
|
||||
max int
|
||||
}{
|
||||
{name: "sinceSeq", value: &options.SinceSeq, min: 0, max: math.MaxInt32},
|
||||
{name: "eventLimit", value: &options.EventLimit, min: 1, max: agentRunEventQueryLimit},
|
||||
} {
|
||||
raw := strings.TrimSpace(c.Query(item.name))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < item.min || parsed > item.max {
|
||||
return options, fmt.Errorf("%s 必须是 %d–%d 之间的整数", item.name, item.min, item.max)
|
||||
}
|
||||
*item.value = parsed
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
63
backend/internal/handler/agent_run_pagination_test.go
Normal file
63
backend/internal/handler/agent_run_pagination_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 运行详情分页参数(?sinceSeq= / ?eventLimit=)。合法值按原样进入视图选项,
|
||||
// 非法或越界一律拒绝:静默夹取会让客户端以为拿到的是它请求的那一页,
|
||||
// 从而把缺口当成"没有更多记录"。
|
||||
func TestAgentRunViewOptionsParsesPaginationQuery(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, testCase := range []struct {
|
||||
query string
|
||||
wantSinceSeq, wantEventLimit int
|
||||
}{
|
||||
{query: "", wantSinceSeq: 0, wantEventLimit: 0},
|
||||
{query: "?sinceSeq=0", wantSinceSeq: 0, wantEventLimit: 0},
|
||||
{query: "?sinceSeq=12", wantSinceSeq: 12, wantEventLimit: 0},
|
||||
{query: "?eventLimit=1", wantSinceSeq: 0, wantEventLimit: 1},
|
||||
{query: "?eventLimit=500", wantSinceSeq: 0, wantEventLimit: 500},
|
||||
{query: "?sinceSeq=12&eventLimit=50", wantSinceSeq: 12, wantEventLimit: 50},
|
||||
{query: "?sinceSeq=%203%20&eventLimit=%207%20", wantSinceSeq: 3, wantEventLimit: 7},
|
||||
} {
|
||||
t.Run(testCase.query, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/agent/runs/run-1"+testCase.query, nil)
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
options, err := agentRunViewOptions(context)
|
||||
if err != nil {
|
||||
t.Fatalf("%s 被拒绝: %v", testCase.query, err)
|
||||
}
|
||||
if options.SinceSeq != testCase.wantSinceSeq || options.EventLimit != testCase.wantEventLimit {
|
||||
t.Fatalf("%s 解析为 %+v,期望 sinceSeq=%d eventLimit=%d", testCase.query, options, testCase.wantSinceSeq, testCase.wantEventLimit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRunViewOptionsRejectsInvalidPaginationQuery(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, query := range []string{
|
||||
"?sinceSeq=abc",
|
||||
"?sinceSeq=-1",
|
||||
"?sinceSeq=1.5",
|
||||
"?sinceSeq=99999999999999999999",
|
||||
"?eventLimit=0",
|
||||
"?eventLimit=501",
|
||||
"?eventLimit=-5",
|
||||
"?eventLimit=abc",
|
||||
} {
|
||||
t.Run(query, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/agent/runs/run-1"+query, nil)
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
if options, err := agentRunViewOptions(context); err == nil {
|
||||
t.Fatalf("%s 未被拒绝,解析为 %+v", query, options)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -679,9 +679,23 @@ paths:
|
||||
get:
|
||||
tags: [agent]
|
||||
summary: 读取当前用户的 Agent 轮次
|
||||
description: data.run 只返回一页事件(默认最新 40 条)并带上 eventSeqBase、eventCount、latestSeq、eventsTruncated:eventSeqBase 是本次首条事件之前已入库的条数(不变量 events[i].seq == eventSeqBase + i + 1),latestSeq 可直接作为下次 sinceSeq,eventsTruncated 表示还有更早的记录未随本次返回。读取走 (run_id, sequence) 的 keyset 分页,不使用 offset;升级前把事件放在 state_json 里的轮次在事件表中没有行,读取回退到内存窗口。
|
||||
parameters:
|
||||
- in: query
|
||||
name: sinceSeq
|
||||
required: false
|
||||
description: 只返回该事件序号之后的事件(非负整数,单次最多 500 条);追平或越界时返回空 events
|
||||
schema: { type: integer, minimum: 0 }
|
||||
- in: query
|
||||
name: eventLimit
|
||||
required: false
|
||||
description: 单次返回的事件条数上限,1–500,默认为尾部窗口大小
|
||||
schema: { type: integer, minimum: 1, maximum: 500 }
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.run
|
||||
description: 信封 data.run(含 events 与 eventSeqBase / eventCount / latestSeq / eventsTruncated)
|
||||
"400":
|
||||
description: sinceSeq 或 eventLimit 不是整数或超出范围
|
||||
"404":
|
||||
description: 轮次不存在或不属于当前用户
|
||||
/agent/runs/{id}/messages:
|
||||
|
||||
Reference in New Issue
Block a user