fix(agent): 画布审批 - 修复归属校验与大事件刷新
This commit is contained in:
155
backend/internal/app/cloud_agent_canvas_event_size_test.go
Normal file
155
backend/internal/app/cloud_agent_canvas_event_size_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yingce/backend/internal/database"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// TestAgentApprovalLargeCanvasEvent 验证完整审批路径,不只检查事件生成 helper。
|
||||
// 所有数据保存在临时数据库中,禁止启动 Pi 或调用收费模型。
|
||||
func TestAgentApprovalLargeCanvasEvent(t *testing.T) {
|
||||
for _, large := range []bool{false, true} {
|
||||
name := "small"
|
||||
if large {
|
||||
name = "large"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db := agentPermissionsDB(t)
|
||||
if err := db.AutoMigrate(database.Models()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Service{repo: repository.New(db), dataDir: t.TempDir(), disablePiRuntime: true}
|
||||
if err := os.WriteFile(filepath.Join(s.dataDir, ".settings-key"), make([]byte, 32), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "保留正文"
|
||||
if large {
|
||||
content = strings.Repeat("分镜正文", 18000)
|
||||
}
|
||||
doc := map[string]any{"nodes": []any{map[string]any{"id": "story", "type": "text", "title": "原标题", "content": content}}, "connections": []any{}}
|
||||
raw, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := mustEncodeModelCapabilityConfig(t, DefaultModelCapabilityConfigForModel("chat-completion", "text-test"))
|
||||
for _, row := range []any{
|
||||
&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: string(raw)},
|
||||
&model.ModelChannel{ID: "channel", Scope: model.ChannelScopeSystem, Name: "测试", Enabled: true},
|
||||
&model.ChannelModel{ID: "cm", ChannelID: "channel", ModelKey: "text-test", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, CapabilityConfigJSON: profile, BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true},
|
||||
&model.ChannelModelPriceTier{ID: "tier", ChannelModelID: "cm", SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true},
|
||||
&model.CreditAccount{UserID: "user", AvailableMicrocredits: 10000},
|
||||
} {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := agentTestRequest()
|
||||
req.PermissionMode = "request_approval"
|
||||
root, err := s.CreateCloudAgentRun("user", req, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, state := agentInterjectionState(t, s, root.ID)
|
||||
call := cloudAgentCall{ID: "rename-story"}
|
||||
call.Function.Name = "canvas_apply_ops"
|
||||
args, err := json.Marshal(map[string]any{"snapshotHash": cloudAgentCanvasHash(doc), "ops": []any{map[string]any{"type": "update_node", "id": "story", "patch": map[string]any{"title": "新标题"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call.Function.Arguments = string(args)
|
||||
state.Calls = []cloudAgentCall{call}
|
||||
state.CallIndex = 0
|
||||
run.Status = "running"
|
||||
approvalPauseSaveState(t, s, db, run, &state)
|
||||
run, state = agentInterjectionState(t, s, root.ID)
|
||||
if err := s.advanceCloudAgentTool(run, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, state = agentInterjectionState(t, s, root.ID)
|
||||
if state.Approval == nil {
|
||||
t.Fatal("missing approval")
|
||||
}
|
||||
approvalID := state.Approval.ID
|
||||
if err := s.DecideCloudAgentApproval("user", root.ID, approvalID, "approve", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, state = agentInterjectionState(t, s, root.ID)
|
||||
if state.Approval != nil || state.CallIndex != 1 {
|
||||
t.Fatal("approval did not complete")
|
||||
}
|
||||
payload := agentCanvasPatchForOperation(t, state, "canvas_apply_ops")
|
||||
if large {
|
||||
if payload["requiresRefresh"] != true || payload["canvasPatch"] != nil {
|
||||
t.Fatal("large event must request a snapshot refresh")
|
||||
}
|
||||
if len(creationMaps(payload["actions"])) != 1 || payload["preview"] == nil {
|
||||
t.Fatal("refresh notification lost its bounded operation summary")
|
||||
}
|
||||
} else if payload["canvasPatch"] == nil || payload["requiresRefresh"] == true {
|
||||
t.Fatal("small event must retain its delta")
|
||||
}
|
||||
for _, event := range state.Events {
|
||||
body, err := json.Marshal(event.Payload)
|
||||
if err != nil || len(body) > cloudAgentEventPayloadLimit {
|
||||
t.Fatal("event exceeded checkpoint limit")
|
||||
}
|
||||
}
|
||||
canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := creationDocument(canvas.PayloadJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node := creationMaps(after["nodes"])[0]
|
||||
if node["title"] != "新标题" || node["content"] != content {
|
||||
t.Fatal("saved canvas lost its title or full content")
|
||||
}
|
||||
if canvas.Revision != 2 {
|
||||
t.Fatalf("unexpected canvas revision: %d", canvas.Revision)
|
||||
}
|
||||
// 审批重送必须复用已经完成的结果,不再写一次画布。
|
||||
if err := s.DecideCloudAgentApproval("user", root.ID, approvalID, "approve", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canvas, err = s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
if err != nil || canvas.Revision != 2 {
|
||||
t.Fatal("duplicate approval changed the canvas")
|
||||
}
|
||||
var mutations int64
|
||||
if err := db.Model(&model.CloudAgentCanvasMutation{}).Where("run_id = ?", root.ID).Count(&mutations).Error; err != nil || mutations != 1 {
|
||||
t.Fatalf("unexpected mutation count: %d, error: %v", mutations, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentOversizedCanvasPreviewRequestsRefresh(t *testing.T) {
|
||||
db := agentPermissionsDB(t)
|
||||
repo := repository.New(db)
|
||||
canvas := &model.CanvasProject{ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[],"connections":[]}`}
|
||||
if err := db.Create(canvas).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := cloudAgentRuntime{}
|
||||
input := cloudAgentMutationInput{UserID: "user", CanvasID: canvas.ID, BeforeJSON: canvas.PayloadJSON, Operation: "canvas_apply_ops", Preview: &cloudAgentApprovalPreview{Description: strings.Repeat("摘要", cloudAgentEventPayloadLimit)}}
|
||||
if err := emitCloudAgentCanvasChange(repo, "run", &state, input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := agentCanvasPatchForOperation(t, state, input.Operation)
|
||||
if payload["requiresRefresh"] != true || payload["canvasPatch"] != nil || payload["preview"] != nil {
|
||||
t.Fatal("oversized summary must use a bounded refresh notification")
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil || len(raw) > cloudAgentEventPayloadLimit {
|
||||
t.Fatal("refresh notification exceeded the event limit")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"yingce/backend/internal/repository"
|
||||
@@ -85,6 +86,8 @@ func cloudAgentShrinkChange(before, after map[string]any) (map[string]any, map[s
|
||||
return shrunkBefore, shrunkAfter
|
||||
}
|
||||
|
||||
// emitCloudAgentCanvasChange 生成与画布写入同事务保存的事件。
|
||||
// 增量无法完整表达变更或超过事件上限时通知客户端重新读取,不截断真实画布内容。
|
||||
func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state *cloudAgentRuntime, input cloudAgentMutationInput) error {
|
||||
before, err := creationDocument(input.BeforeJSON)
|
||||
if err != nil {
|
||||
@@ -190,6 +193,24 @@ func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state
|
||||
if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) {
|
||||
payload["callId"] = state.Calls[state.CallIndex].ID
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > cloudAgentEventPayloadLimit {
|
||||
// 修改标题或关联素材也可能携带整份分镜正文的 before/after,导致审批事务回滚。
|
||||
// 大增量沿用已有的快照刷新路径;画布、撤销记录和工具回执仍由原事务完整保存。
|
||||
delete(payload, "canvasPatch")
|
||||
payload["requiresRefresh"] = true
|
||||
raw, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > cloudAgentEventPayloadLimit {
|
||||
// 摘要本身也超限时不截断字段,退回既有的完整刷新通知。
|
||||
payload = map[string]any{"canvasId": input.CanvasID, "operation": input.Operation, "requiresRefresh": true}
|
||||
}
|
||||
}
|
||||
state.event(runID, "canvas_updated", payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
111
backend/internal/app/cloud_agent_permissions_test.go
Normal file
111
backend/internal/app/cloud_agent_permissions_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// agentPermissionsDB 为权限测试创建数据库,只迁移真实画布表。
|
||||
// CI 使用临时 SQLite;Windows 可提供 PostgreSQL DSN,数据只写入独立临时 schema。
|
||||
func agentPermissionsDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
options := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
if dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN"); dsn != "" {
|
||||
admin, openErr := gorm.Open(postgres.Open(dsn), options)
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
adminSQL, sqlErr := admin.DB()
|
||||
if sqlErr != nil {
|
||||
t.Fatal(sqlErr)
|
||||
}
|
||||
t.Cleanup(func() { _ = adminSQL.Close() })
|
||||
schema := fmt.Sprintf("agent_permissions_%d", time.Now().UnixNano())
|
||||
if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
parsed, parseErr := url.Parse(dsn)
|
||||
if parseErr != nil {
|
||||
t.Fatal(parseErr)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("search_path", schema)
|
||||
parsed.RawQuery = query.Encode()
|
||||
db, err = gorm.Open(postgres.Open(parsed.String()), options)
|
||||
} else {
|
||||
db, err = gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "permissions.db")), options)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&model.CanvasProject{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAgentPermissionsUseCanvasProjects(t *testing.T) {
|
||||
db := agentPermissionsDB(t)
|
||||
s := &Service{repo: repository.New(db)}
|
||||
for _, canvas := range []model.CanvasProject{
|
||||
{ID: "independent", UserID: "owner", PayloadJSON: `{}`},
|
||||
{ID: "associated", UserID: "owner", ProjectID: "domain-project", PayloadJSON: `{}`},
|
||||
} {
|
||||
if err := db.Create(&canvas).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("canvas") {
|
||||
t.Fatal("权限测试不能创建旧 canvas 表")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
user, canvas string
|
||||
allowed bool
|
||||
}{
|
||||
{"owner", "independent", true}, {"owner", "associated", true},
|
||||
{"other", "independent", false}, {"other", "associated", false},
|
||||
{"owner", "missing", false}, {"owner", "domain-project", false},
|
||||
} {
|
||||
t.Run(tc.user+"/"+tc.canvas, func(t *testing.T) {
|
||||
permissions := s.buildPermissionsConfig(tc.user, tc.canvas)
|
||||
for _, key := range []string{"canReadCanvas", "canWriteCanvas", "canDeleteNodes", "canCreateNodes", "canMoveNodes", "canDuplicateNodes", "canManageRelations", "canInviteUsers", "canExportCanvas"} {
|
||||
if permissions[key] != tc.allowed {
|
||||
t.Errorf("%s=%v, want %t", key, permissions[key], tc.allowed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// 缺表模拟数据库读取故障,不能把读取失败当成只读或导出许可。
|
||||
if err := db.Migrator().DropTable(&model.CanvasProject{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissions := s.buildPermissionsConfig("owner", "independent")
|
||||
for _, key := range []string{"canReadCanvas", "canWriteCanvas", "canExportCanvas"} {
|
||||
if permissions[key] != false {
|
||||
t.Errorf("数据库故障时 %s=%v", key, permissions[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,15 +170,16 @@ func (s *Service) buildFeaturesConfig(state *cloudAgentRuntime) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// buildPermissionsConfig 构建权限配置
|
||||
// buildPermissionsConfig 根据当前账号的画布归属生成运行时权限。
|
||||
// userID 是登录账号,canvasID 是画布 ID(不是所属项目 ID);查询失败时拒绝访问。
|
||||
func (s *Service) buildPermissionsConfig(userID, canvasID string) map[string]any {
|
||||
// 从数据库读取实际权限
|
||||
canvas, err := s.repo.GetCanvas(userID, canvasID)
|
||||
// 与画布读写工具共用账号归属查询,不访问旧的 Canvas 扩展模型。
|
||||
_, err := s.repo.CanvasProjectForUser(userID, canvasID)
|
||||
if err != nil {
|
||||
log.Printf("[Agent] failed to get canvas for permissions: %v", err)
|
||||
// 返回最小权限集
|
||||
return map[string]any{
|
||||
"canReadCanvas": true,
|
||||
"canReadCanvas": false,
|
||||
"canWriteCanvas": false,
|
||||
"canDeleteNodes": false,
|
||||
"canCreateNodes": false,
|
||||
@@ -186,51 +187,21 @@ func (s *Service) buildPermissionsConfig(userID, canvasID string) map[string]any
|
||||
"canDuplicateNodes": false,
|
||||
"canManageRelations": false,
|
||||
"canInviteUsers": false,
|
||||
"canExportCanvas": true,
|
||||
"canExportCanvas": false,
|
||||
"maxTokenBudget": 200000,
|
||||
"maxSteps": 50,
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户是否是画布所有者
|
||||
isOwner := canvas.UserID == userID
|
||||
|
||||
// 检查协作权限
|
||||
canWrite := isOwner
|
||||
canDelete := isOwner
|
||||
canInvite := isOwner
|
||||
|
||||
if canvas.Metadata != nil {
|
||||
if collaborators, ok := canvas.Metadata["collaborators"].([]any); ok {
|
||||
for _, collab := range collaborators {
|
||||
if collabMap, ok := collab.(map[string]any); ok {
|
||||
if collabUserID, _ := collabMap["userId"].(string); collabUserID == userID {
|
||||
role, _ := collabMap["role"].(string)
|
||||
switch role {
|
||||
case "admin":
|
||||
canWrite = true
|
||||
canDelete = true
|
||||
canInvite = true
|
||||
case "editor":
|
||||
canWrite = true
|
||||
case "viewer":
|
||||
// 只读权限
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"canReadCanvas": true,
|
||||
"canWriteCanvas": canWrite,
|
||||
"canDeleteNodes": canDelete,
|
||||
"canCreateNodes": canWrite,
|
||||
"canMoveNodes": canWrite,
|
||||
"canDuplicateNodes": canWrite,
|
||||
"canManageRelations": canWrite,
|
||||
"canInviteUsers": canInvite,
|
||||
"canWriteCanvas": true,
|
||||
"canDeleteNodes": true,
|
||||
"canCreateNodes": true,
|
||||
"canMoveNodes": true,
|
||||
"canDuplicateNodes": true,
|
||||
"canManageRelations": true,
|
||||
"canInviteUsers": true,
|
||||
"canExportCanvas": true,
|
||||
"maxTokenBudget": 200000,
|
||||
"maxSteps": 50,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -19,6 +20,8 @@ import (
|
||||
"yingce/backend/internal/prompts"
|
||||
)
|
||||
|
||||
const cloudAgentEventPayloadLimit = 128 << 10
|
||||
|
||||
func cloudAgentDecode(run *model.CloudAgentExecution) (cloudAgentRuntime, error) {
|
||||
var state cloudAgentRuntime
|
||||
if run == nil || strings.TrimSpace(run.StateJSON) == "" {
|
||||
@@ -262,7 +265,7 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(event.Payload)
|
||||
if err != nil || len(raw) > 128<<10 {
|
||||
if err != nil || len(raw) > cloudAgentEventPayloadLimit {
|
||||
return errors.New("Agent runtime event payload is too large")
|
||||
}
|
||||
}
|
||||
@@ -471,6 +474,9 @@ func cloudAgentSave(run *model.CloudAgentExecution, state *cloudAgentRuntime) er
|
||||
}
|
||||
if run.ID != "" {
|
||||
if err := validateCloudAgentRuntime(run, state); err != nil {
|
||||
// HTTP 日志只记录错误类型;在首次校验失败处保留原因,避免被后续模型错误掩盖。
|
||||
// 不记录完整检查点、提示词或工具参数,日志仅用于定位失败步骤。
|
||||
log.Printf("[Agent] checkpoint validation failed run=%s step=%d call_index=%d: %v", run.ID, state.Step, state.CallIndex, err)
|
||||
return cloudAgentCheckpointFailure("runtime validation", err)
|
||||
}
|
||||
for index, event := range state.Events {
|
||||
|
||||
Reference in New Issue
Block a user