feat(agent): 运行详情分页与增量读取契约 (#600)

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
AetherNo2332
2026-09-24 11:31:09 +08:00
committed by ddcat666
parent c4b9a426ab
commit b701b3efd1
11 changed files with 761 additions and 25 deletions

View File

@@ -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
}

View 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)
}
})
}
}

View File

@@ -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: