fixed(go-agent): agent-publish (#16713)

## Summary

This PR updates Go agent publish logic to persist the parent canvas
update and canvas-version save in the same transaction.

## Changes:

  - Reuse SaveOrReplaceLatest semantics for published versions
  - Add SaveOrReplaceLatestTx for transactional publish flow
  - Keep canvas release update and version persistence atomic
- Add a focused publish test covering canvas and released version state

## Tested:

```
  bash build.sh --test -run 'Test(PublishAgentUpdatesCanvasAndReleasedVersion|UpdateAgentDSLCreatesAndReplacesDraftVersion|
  UpdateAgentDSLDoesNotOverwriteLatestReleasedVersion)$' ./internal/service ./internal/dao
```

<img width="1476" height="850" alt="image"
src="https://github.com/user-attachments/assets/2c576581-1143-420b-8750-a77aa3c4292d"
/>
This commit is contained in:
Hz_
2026-07-08 18:47:01 +08:00
committed by GitHub
parent 330033d7c2
commit e548108fff
3 changed files with 179 additions and 81 deletions

View File

@@ -141,73 +141,85 @@ func (dao *UserCanvasVersionDAO) SaveOrReplaceLatest(opts SaveOrReplaceLatestVer
}
var saved *entity.UserCanvasVersion
if err := DB.Transaction(func(tx *gorm.DB) error {
var parent struct {
ID string
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Table((&entity.UserCanvas{}).TableName()).
Select("id").
Where("id = ?", opts.UserCanvasID).
Take(&parent).Error; err != nil {
return err
}
var latest entity.UserCanvasVersion
err := tx.Where("user_canvas_id = ?", opts.UserCanvasID).
Order("create_time DESC, id DESC").
First(&latest).Error
row, err := dao.SaveOrReplaceLatestTx(tx, opts)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
} else if opts.sameDSL(latest.DSL) {
if !latest.Release || opts.Release {
updates := map[string]interface{}{
"dsl": opts.DSL,
"release": opts.Release,
}
if opts.Title != nil {
updates["title"] = opts.Title
}
if opts.Description != nil {
updates["description"] = opts.Description
}
if err := tx.Model(&entity.UserCanvasVersion{}).
Where("id = ?", latest.ID).
Updates(updates).Error; err != nil {
return err
}
latest.DSL = opts.DSL
latest.Release = opts.Release
if opts.Title != nil {
latest.Title = opts.Title
}
if opts.Description != nil {
latest.Description = opts.Description
}
saved = &latest
return dao.deleteAllUnpublishedExcessTx(tx, opts.UserCanvasID, opts.KeepUnpublished)
}
}
row := &entity.UserCanvasVersion{
ID: opts.NewID,
UserCanvasID: opts.UserCanvasID,
Title: opts.Title,
Description: opts.Description,
Release: opts.Release,
DSL: opts.DSL,
}
if err := tx.Create(row).Error; err != nil {
return err
}
saved = row
return dao.deleteAllUnpublishedExcessTx(tx, opts.UserCanvasID, opts.KeepUnpublished)
return nil
}); err != nil {
return nil, err
}
return saved, nil
}
// SaveOrReplaceLatestTx inserts or refreshes a version using the caller's
// transaction. Its write semantics match SaveOrReplaceLatest exactly.
func (dao *UserCanvasVersionDAO) SaveOrReplaceLatestTx(tx *gorm.DB, opts SaveOrReplaceLatestVersionOptions) (*entity.UserCanvasVersion, error) {
if opts.KeepUnpublished <= 0 {
opts.KeepUnpublished = 20
}
var parent struct {
ID string
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Table((&entity.UserCanvas{}).TableName()).
Select("id").
Where("id = ?", opts.UserCanvasID).
Take(&parent).Error; err != nil {
return nil, err
}
var latest entity.UserCanvasVersion
err := tx.Where("user_canvas_id = ?", opts.UserCanvasID).
Order("create_time DESC, id DESC").
First(&latest).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
} else if opts.sameDSL(latest.DSL) {
if !latest.Release || opts.Release {
updates := map[string]interface{}{
"dsl": opts.DSL,
"release": opts.Release,
}
if opts.Title != nil {
updates["title"] = opts.Title
}
if opts.Description != nil {
updates["description"] = opts.Description
}
if err := tx.Model(&entity.UserCanvasVersion{}).
Where("id = ?", latest.ID).
Updates(updates).Error; err != nil {
return nil, err
}
latest.DSL = opts.DSL
latest.Release = opts.Release
if opts.Title != nil {
latest.Title = opts.Title
}
if opts.Description != nil {
latest.Description = opts.Description
}
return &latest, dao.deleteAllUnpublishedExcessTx(tx, opts.UserCanvasID, opts.KeepUnpublished)
}
}
row := &entity.UserCanvasVersion{
ID: opts.NewID,
UserCanvasID: opts.UserCanvasID,
Title: opts.Title,
Description: opts.Description,
Release: opts.Release,
DSL: opts.DSL,
}
if err := tx.Create(row).Error; err != nil {
return nil, err
}
return row, dao.deleteAllUnpublishedExcessTx(tx, opts.UserCanvasID, opts.KeepUnpublished)
}
func (opts SaveOrReplaceLatestVersionOptions) sameDSL(dsl entity.JSONMap) bool {
if opts.SameDSL != nil {
return opts.SameDSL(dsl)

View File

@@ -422,9 +422,7 @@ func (s *AgentService) UpdateAgent(ctx context.Context, userID, canvasID string,
return err
}
updates := map[string]interface{}{
"release": false,
}
updates := map[string]interface{}{}
for _, key := range []string{"title", "avatar", "description", "permission", "canvas_type", "canvas_category"} {
if value, ok := patch[key]; ok && value != nil {
if key == "title" {
@@ -543,10 +541,6 @@ type PublishAgentRequest struct {
DSL entity.JSONMap `json:"dsl,omitempty"`
}
// PublishAgent appends a new user_canvas_version row and marks the parent
// canvas as released in a single transaction. Existing versions are never
// overwritten (§2.9); the parent canvas DSL/title/description/release
// fields are updated atomically with the new version row.
func (s *AgentService) PublishAgent(ctx context.Context, userID, canvasID string, req *PublishAgentRequest) (*entity.UserCanvasVersion, error) {
canvasInstance, err := s.loadCanvasForUser(ctx, userID, canvasID)
if err != nil {
@@ -560,30 +554,33 @@ func (s *AgentService) PublishAgent(ctx context.Context, userID, canvasID string
dsl = dslpkg.NormalizeForCanvas(req.DSL)
}
if req.Title != nil {
title = req.Title
trimmed := strings.TrimSpace(*req.Title)
title = &trimmed
}
if req.Description != nil {
description = req.Description
}
}
row := &entity.UserCanvasVersion{
ID: utility.GenerateUUID(),
UserCanvasID: canvasID,
Title: title,
Description: description,
DSL: dsl,
canvasInstance.DSL = dsl
canvasInstance.Title = title
canvasInstance.Description = description
canvasInstance.Release = true
titleStr := ""
if title != nil {
titleStr = *title
}
opts := s.saveOrReplaceVersionOptions(ctx, userID, canvasID, dsl, titleStr, description, true)
var row *entity.UserCanvasVersion
if err = dao.DB.Transaction(func(tx *gorm.DB) error {
if err = s.versionDAO.CreateTx(tx, row); err != nil {
return fmt.Errorf("publish agent %s: insert version: %w", canvasID, err)
}
canvasInstance.DSL = dsl
canvasInstance.Title = title
canvasInstance.Description = description
canvasInstance.Release = true
if err = s.canvasDAO.UpdateTx(tx, canvasInstance); err != nil {
if err := s.canvasDAO.UpdateTx(tx, canvasInstance); err != nil {
return fmt.Errorf("publish agent %s: update parent: %w", canvasID, err)
}
saved, err := s.versionDAO.SaveOrReplaceLatestTx(tx, opts)
if err != nil {
return fmt.Errorf("publish agent %s: save version: %w", canvasID, err)
}
row = saved
return nil
}); err != nil {
return nil, err
@@ -592,12 +589,16 @@ func (s *AgentService) PublishAgent(ctx context.Context, userID, canvasID string
}
func (s *AgentService) saveOrReplaceVersion(ctx context.Context, userID, canvasID string, dsl entity.JSONMap, title string, description *string, release bool) (*entity.UserCanvasVersion, error) {
return s.versionDAO.SaveOrReplaceLatest(s.saveOrReplaceVersionOptions(ctx, userID, canvasID, dsl, title, description, release))
}
func (s *AgentService) saveOrReplaceVersionOptions(ctx context.Context, userID, canvasID string, dsl entity.JSONMap, title string, description *string, release bool) dao.SaveOrReplaceLatestVersionOptions {
nickname, err := s.userDAO.GetNicknameByID(ctx, userID)
if err != nil || strings.TrimSpace(nickname) == "" {
nickname = userID
}
versionTitle := buildVersionTitle(nickname, title, time.Now())
return s.versionDAO.SaveOrReplaceLatest(dao.SaveOrReplaceLatestVersionOptions{
return dao.SaveOrReplaceLatestVersionOptions{
NewID: utility.GenerateUUID(),
UserCanvasID: canvasID,
Title: &versionTitle,
@@ -611,7 +612,7 @@ func (s *AgentService) saveOrReplaceVersion(ctx context.Context, userID, canvasI
dsl,
)
},
})
}
}
func buildVersionTitle(userNickname, agentTitle string, ts time.Time) string {

View File

@@ -1338,6 +1338,91 @@ func TestUpdateAgentDSLCreatesAndReplacesDraftVersion(t *testing.T) {
}
}
func TestPublishAgentUpdatesCanvasAndReleasedVersion(t *testing.T) {
setupAgentSessionServiceTest(t)
if err := dao.DB.Create(&entity.User{ID: "user-1", Nickname: "owner", Email: "owner@test.com"}).Error; err != nil {
t.Fatalf("failed to seed user: %v", err)
}
initialDSL := entity.JSONMap{
"components": map[string]any{},
}
if err := dao.DB.Create(&entity.UserCanvas{
ID: "canvas-publish",
UserID: "user-1",
Title: sptr("Draft Agent"),
CanvasCategory: "agent_canvas",
DSL: initialDSL,
}).Error; err != nil {
t.Fatalf("failed to seed canvas: %v", err)
}
description := "published description"
publishTitle := " Published Agent "
publishDSL := entity.JSONMap{
"graph": map[string]any{
"nodes": []any{map[string]any{"id": "begin"}},
"edges": []any{},
},
"components": map[string]any{
"begin": map[string]any{
"obj": map[string]any{"component_name": "Begin"},
},
},
}
row, err := NewAgentService().PublishAgent(context.Background(), "user-1", "canvas-publish", &PublishAgentRequest{
Title: &publishTitle,
Description: &description,
DSL: publishDSL,
})
if err != nil {
t.Fatalf("PublishAgent failed: %v", err)
}
if row == nil {
t.Fatal("PublishAgent returned nil version")
}
if !row.Release {
t.Fatal("published version release flag is false")
}
if row.Description == nil || *row.Description != description {
t.Fatalf("published version description = %v", row.Description)
}
if row.Title == nil || !strings.HasPrefix(*row.Title, "owner_Published Agent_") {
t.Fatalf("unexpected published version title: %v", row.Title)
}
persisted, err := dao.NewUserCanvasDAO().GetByID("canvas-publish")
if err != nil {
t.Fatalf("failed to reload canvas: %v", err)
}
if !persisted.Release {
t.Fatal("publish did not mark canvas released")
}
if persisted.Title == nil || *persisted.Title != "Published Agent" {
t.Fatalf("published canvas title = %v", persisted.Title)
}
if persisted.Description == nil || *persisted.Description != description {
t.Fatalf("published canvas description = %v", persisted.Description)
}
if _, ok := persisted.DSL["graph"]; !ok {
t.Fatalf("published canvas DSL was not persisted: %#v", persisted.DSL)
}
versions, err := dao.NewUserCanvasVersionDAO().ListByCanvasID("canvas-publish")
if err != nil {
t.Fatalf("failed to list versions: %v", err)
}
if len(versions) != 1 {
t.Fatalf("expected one published version, got %d", len(versions))
}
if versions[0].ID != row.ID {
t.Fatalf("listed version id = %q, want %q", versions[0].ID, row.ID)
}
if !versions[0].Release {
t.Fatal("listed version release flag is false")
}
}
func TestUpdateAgentDSLDoesNotOverwriteLatestReleasedVersion(t *testing.T) {
setupAgentSessionServiceTest(t)