From e548108fffe33f00b658bb4a65838171226e1eb7 Mon Sep 17 00:00:00 2001 From: Hz_ Date: Wed, 8 Jul 2026 18:47:01 +0800 Subject: [PATCH] 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 ``` image --- internal/dao/user_canvas_version.go | 126 +++++++++++++++------------- internal/service/agent.go | 49 +++++------ internal/service/agent_test.go | 85 +++++++++++++++++++ 3 files changed, 179 insertions(+), 81 deletions(-) diff --git a/internal/dao/user_canvas_version.go b/internal/dao/user_canvas_version.go index b04929018..cb01764c8 100644 --- a/internal/dao/user_canvas_version.go +++ b/internal/dao/user_canvas_version.go @@ -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) diff --git a/internal/service/agent.go b/internal/service/agent.go index b55f0a041..8a884bcda 100644 --- a/internal/service/agent.go +++ b/internal/service/agent.go @@ -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 { diff --git a/internal/service/agent_test.go b/internal/service/agent_test.go index be180e6f2..fcce6d002 100644 --- a/internal/service/agent_test.go +++ b/internal/service/agent_test.go @@ -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)