From 57dd0e87d3d5807b5ed082a37a35d12d9674a445 Mon Sep 17 00:00:00 2001 From: crispinhu Date: Wed, 11 Nov 2026 15:15:41 +0800 Subject: [PATCH] fix(web): publish a workflow only after its saved content changes --- design.md | 4 + internal/domain/workflow.go | 1 + internal/httpapi/catalog.go | 6 +- internal/repo/catalog.go | 63 +++++++++++++ internal/repo/generic_store.go | 29 +++++- internal/repo/generic_store_test.go | 4 + internal/repo/workflow_publish_test.go | 125 +++++++++++++++++++++++++ web/app.js | 42 +++------ web/app/assets-harness.css | 2 +- web/app/flows.js | 23 ++++- web/index.html | 2 +- web/platform.css | 8 +- webtest/tests/app-common.test.mjs | 17 ++++ webtest/tests/flows-editor.test.mjs | 33 +++++++ 14 files changed, 318 insertions(+), 41 deletions(-) create mode 100644 internal/repo/workflow_publish_test.go diff --git a/design.md b/design.md index f832cb0a..1c453373 100644 --- a/design.md +++ b/design.md @@ -67,6 +67,10 @@ ## 图标与通用组件 +流程设计的发布按钮在发布期间和当前内容已发布时禁用;保存相同内容不新增修订,服务端重复发布保持版本不变。保存后的新修改可继续发布。流程切换遇到未保存修改时,按钮明确为「继续编辑 / 放弃修改并切换」。脚本编辑器的输入层、行号与高亮层共用字体、字距和 Tab 宽度,长行横向滚动,高亮随输入层同步滚动。 + +技能详情的文件树限高并独立滚动。 + ### Harness 图标 `HarnessIcon.jsx` 与原生页面的 `harness-motion.js` 使用同一来源的 SVG 定义。上游版本为 `da2e476895331ef4afee30878af0fb3fb1c73131`,MIT 许可随源码保留。 diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 19cc7251..02e3920b 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -174,6 +174,7 @@ type WorkflowDefinition struct { Name string `json:"name"` Version int `json:"version"` Revision int `json:"revision"` + PublishedRevision int `json:"publishedRevision,omitempty"` Status string `json:"status"` // draft, published, archived ProjectID string `json:"projectId,omitempty"` Shared bool `json:"shared,omitempty"` diff --git a/internal/httpapi/catalog.go b/internal/httpapi/catalog.go index 596f16ce..4ef925ea 100644 --- a/internal/httpapi/catalog.go +++ b/internal/httpapi/catalog.go @@ -1209,13 +1209,11 @@ func (h *catalogHandlers) workflow(c *gin.Context) { c.JSON(200, stored) return } - v.Status = "published" - v.Version++ - if err := h.repo.PutWorkflow(v); err != nil { + stored, err := h.repo.PublishWorkflow(id) + if err != nil { respond(c, err) return } - stored, _ := h.repo.Workflow(id) c.JSON(200, stored) case "/api/workflows/:id/clone": var req struct { diff --git a/internal/repo/catalog.go b/internal/repo/catalog.go index f847e549..6f80eed3 100644 --- a/internal/repo/catalog.go +++ b/internal/repo/catalog.go @@ -414,6 +414,61 @@ func (r *CatalogRepo) storedWorkflow(id string) (domain.WorkflowDefinition, bool } func (r *CatalogRepo) PutWorkflow(v domain.WorkflowDefinition) error { + return r.putWorkflow(v, false) +} + +func workflowContent(v domain.WorkflowDefinition) string { + v.Revision, v.PublishedRevision = 0, 0 + v.CreatedAt, v.UpdatedAt = time.Time{}, time.Time{} + return string(mustJSON(v)) +} + +func workflowPublishedRevision(v domain.WorkflowDefinition) int { + if v.PublishedRevision == 0 && v.Status == "published" { + return v.Revision // Legacy published definitions predate this marker. + } + return v.PublishedRevision +} + +func workflowNeedsPublish(v domain.WorkflowDefinition) bool { + return v.Status != "published" || workflowPublishedRevision(v) != v.Revision +} + +// PublishWorkflow is idempotent until the saved definition changes. +func (r *CatalogRepo) PublishWorkflow(id string) (domain.WorkflowDefinition, error) { + var published domain.WorkflowDefinition + err := r.DB.Transaction(func(tx *gorm.DB) error { + var row struct{ ID string } + if err := r.locking(tx.Table("workflows")).Select("id").Where("id = ?", id).Take(&row).Error; err != nil { + return err + } + catalog := *r + catalog.deps = &deps{DB: tx} + var err error + published, err = catalog.publishStoredWorkflow(id) + return err + }) + return published, err +} + +func (r *CatalogRepo) publishStoredWorkflow(id string) (domain.WorkflowDefinition, error) { + v, ok := r.storedWorkflow(id) + if !ok { + return domain.WorkflowDefinition{}, errors.New("workflow not found") + } + if !workflowNeedsPublish(v) { + return v, nil + } + v.Status = "published" + v.Version++ + if err := r.putWorkflow(v, true); err != nil { + return domain.WorkflowDefinition{}, err + } + stored, _ := r.storedWorkflow(id) + return stored, nil +} + +func (r *CatalogRepo) putWorkflow(v domain.WorkflowDefinition, publishing bool) error { if strings.TrimSpace(v.ID) == "" || strings.TrimSpace(v.Name) == "" { return errors.New("workflow id and name are required") } @@ -437,12 +492,17 @@ func (r *CatalogRepo) PutWorkflow(v domain.WorkflowDefinition) error { if v.Revision != old.Revision { return fmt.Errorf("workflow revision conflict: current revision is %d", old.Revision) } + if !publishing && workflowContent(v) == workflowContent(old) { + return nil + } + v.PublishedRevision = workflowPublishedRevision(old) v.Revision = old.Revision + 1 v.CreatedAt = old.CreatedAt if v.Version == 0 { v.Version = old.Version } } else { + v.PublishedRevision = 0 v.Revision = 1 v.CreatedAt = now if v.Version == 0 { @@ -455,6 +515,9 @@ func (r *CatalogRepo) PutWorkflow(v domain.WorkflowDefinition) error { if v.Status == "" { v.Status = "draft" } + if publishing { + v.PublishedRevision = v.Revision + } v.UpdatedAt = now if ok { if err := r.snapshotRevision("workflow", old.ID, old.Name, old.Version, old.Revision, old); err != nil { diff --git a/internal/repo/generic_store.go b/internal/repo/generic_store.go index 68a4dee0..568c67de 100644 --- a/internal/repo/generic_store.go +++ b/internal/repo/generic_store.go @@ -8,6 +8,8 @@ import ( "time" "agentflow/internal/domain" + + "gorm.io/gorm" ) func (r *CatalogRepo) loadGenericTemplate(id string) (GenericTemplate, bool) { @@ -115,6 +117,10 @@ func (r *CatalogRepo) UpdateGenericWorkflow(workflow domain.WorkflowDefinition) workflow.SandboxProfileID = template.Workflow.SandboxProfileID } workflow.UpdatedAt = now + if workflowContent(workflow) == workflowContent(old) { + return old, nil + } + workflow.PublishedRevision = workflowPublishedRevision(old) template.Workflow = workflow if err := r.snapshotRevision("workflow", old.ID, old.Name, old.Version, old.Revision, old); err != nil { return domain.WorkflowDefinition{}, err @@ -165,14 +171,35 @@ func (r *CatalogRepo) UpdateGenericAgent(workflowID string, agent domain.AgentDe return agent, nil } -// PublishGenericWorkflow bumps the stored template version and marks it published. +// PublishGenericWorkflow publishes only when the stored template has changed. func (r *CatalogRepo) PublishGenericWorkflow(id string) (domain.WorkflowDefinition, error) { + var published domain.WorkflowDefinition + err := r.DB.Transaction(func(tx *gorm.DB) error { + var row struct{ ID string } + if err := r.locking(tx.Table("generic_templates")).Select("id").Where("id = ?", id).Take(&row).Error; err != nil { + return err + } + catalog := *r + catalog.deps = &deps{DB: tx} + var err error + published, err = catalog.publishGenericWorkflow(id) + return err + }) + return published, err +} + +func (r *CatalogRepo) publishGenericWorkflow(id string) (domain.WorkflowDefinition, error) { template, ok := r.GenericTemplateByWorkflow(id) if !ok { return domain.WorkflowDefinition{}, errors.New("generic template not found") } + if !workflowNeedsPublish(template.Workflow) { + return template.Workflow, nil + } template.Workflow.Status = "published" template.Workflow.Version++ + template.Workflow.Revision++ + template.Workflow.PublishedRevision = template.Workflow.Revision if template.Workflow.Version < 1 { template.Workflow.Version = 1 } diff --git a/internal/repo/generic_store_test.go b/internal/repo/generic_store_test.go index 5244d774..3e1dc6ac 100644 --- a/internal/repo/generic_store_test.go +++ b/internal/repo/generic_store_test.go @@ -165,6 +165,10 @@ func TestUpdateGenericAgentAcceptsUnversionedWrite(t *testing.T) { func TestPublishGenericWorkflow(t *testing.T) { s := testStoreWithGenericTemplates(t) before, _ := s.GenericWorkflowByID(GenericBackendDeliveryWorkflowID) + before.Description += " updated" + if _, err := s.UpdateGenericWorkflow(before); err != nil { + t.Fatal(err) + } published, err := s.PublishGenericWorkflow(GenericBackendDeliveryWorkflowID) if err != nil { t.Fatal(err) diff --git a/internal/repo/workflow_publish_test.go b/internal/repo/workflow_publish_test.go new file mode 100644 index 00000000..f4a44ad8 --- /dev/null +++ b/internal/repo/workflow_publish_test.go @@ -0,0 +1,125 @@ +package repo + +import ( + "os" + "strconv" + "sync" + "testing" + "time" + + "agentflow/internal/domain" +) + +func TestMySQLConcurrentWorkflowPublish(t *testing.T) { + dsn := os.Getenv("AGENTFLOW_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable MySQL") + } + s, err := Open(dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + prefix := "publish-" + strconv.FormatInt(time.Now().UnixNano(), 36) + if err := s.PutSandbox(domain.SandboxProfile{ID: prefix, Name: "Local test", Driver: "local"}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.DB.Table("sandboxes").Where("id = ?", prefix).Delete(nil) }) + for _, generic := range []bool{false, true} { + id := prefix + "-project" + wf := domain.WorkflowDefinition{ID: id, Name: "Concurrent publish", Status: "draft", SandboxProfileID: prefix, Version: 1, Revision: 1} + publish := s.PublishWorkflow + table := "workflows" + if generic { + id = prefix + "-generic" + table = "generic_templates" + wf.ID = id + wf.Generic = true + if err := s.PutGenericTemplate(GenericTemplate{Workflow: wf}); err != nil { + t.Fatal(err) + } + publish = s.PublishGenericWorkflow + } else if err := s.PutWorkflow(wf); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + s.DB.Table(table).Where("id = ?", id).Delete(nil) + s.DB.Table("definition_revisions").Where("entity_id = ?", id).Delete(nil) + }) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < 12; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + published, err := publish(id) + if err != nil || published.Version != 2 || published.PublishedRevision != published.Revision { + t.Errorf("concurrent publish %s: version=%d revision=%d marker=%d err=%v", id, published.Version, published.Revision, published.PublishedRevision, err) + } + }() + } + close(start) + wg.Wait() + } +} + +func TestWorkflowPublishOnlyAfterChanges(t *testing.T) { + for _, generic := range []bool{false, true} { + t.Run(map[bool]string{false: "project", true: "generic"}[generic], func(t *testing.T) { + s := testStoreWithGenericTemplates(t) + id := "publish-check" + save := func(v domain.WorkflowDefinition) error { return s.PutWorkflow(v) } + publish := s.PublishWorkflow + if generic { + id = GenericBackendDeliveryWorkflowID + save = func(v domain.WorkflowDefinition) error { _, err := s.UpdateGenericWorkflow(v); return err } + publish = s.PublishGenericWorkflow + } else if err := save(domain.WorkflowDefinition{ID: id, Name: "Publish check", Status: "published"}); err != nil { + t.Fatal(err) + } + before, _ := s.Workflow(id) + unchanged, err := publish(id) + if err != nil || unchanged.Version != before.Version || unchanged.Revision != before.Revision { + t.Fatalf("legacy repeat publication = %+v, %v", unchanged, err) + } + if err := save(unchanged); err != nil { + t.Fatal(err) + } + unchanged, _ = s.Workflow(id) + if unchanged.Revision != before.Revision { + t.Fatal("saving unchanged content created a revision") + } + unchanged.Description = "edited script configuration" + if err := save(unchanged); err != nil { + t.Fatal(err) + } + changed, _ := s.Workflow(id) + if !workflowNeedsPublish(changed) { + t.Fatal("saved edits must be publishable") + } + published, err := publish(id) + if err != nil || published.Version != before.Version+1 || published.PublishedRevision != published.Revision { + t.Fatalf("changed publication = %+v, %v", published, err) + } + for i := 0; i < 3; i++ { + if err := save(published); err != nil { + t.Fatal(err) + } + again, err := publish(id) + if err != nil || again.Version != published.Version || again.Revision != published.Revision { + t.Fatalf("repeat publication = %+v, %v", again, err) + } + } + published.Description = "another edit" + published.PublishedRevision = published.Revision + 1 // Clients cannot mark edits as published. + if err := save(published); err != nil { + t.Fatal(err) + } + again, _ := s.Workflow(id) + if !workflowNeedsPublish(again) { + t.Fatal("client publication marker was trusted") + } + }) + } +} diff --git a/web/app.js b/web/app.js index 4eab91ae..1f9d62d0 100644 --- a/web/app.js +++ b/web/app.js @@ -375,35 +375,17 @@ function highlightShell(src){ } function highlightPython(src){ - const lines=src.split('\n'); - return lines.map(line=>{ - let commentIdx=-1; - let inSingle=false, inDouble=false; - for(let i=0;i=0?line.slice(0, commentIdx):line; - let comment=commentIdx>=0?line.slice(commentIdx):''; - - let html=escapeHTML(codePart); - html=html.replace(/("[\s\S]*?"|'[\s\S]*?')/g, '$1'); - html=html.replace(/\b(def|class|import|from|return|if|elif|else|for|while|try|except|finally|with|as|lambda|pass|raise|yield|async|await|assert|break|continue|global|nonlocal|del|in|is|not|and|or)\b/g, '$1'); - html=html.replace(/\b(print|len|range|str|int|float|dict|list|set|tuple|bool|open|isinstance|issubclass|type|enumerate|zip|map|filter|sum|min|max|any|all|abs|round|input)\b/g, '$1'); - html=html.replace(/\b(True|False|None)\b/g, '$1'); - html=html.replace(/(@[a-zA-Z_0-9.]+)/g, '$1'); - html=html.replace(/\b(\d+(?:\.\d+)?)\b/g, '$1'); - - if(comment){ - html+=`${escapeHTML(comment)}`; - } - return html; - }).join('\n'); + // Tokenize the source once: later replacements must never rewrite generated + // span attributes (Python's "class" and "str" are also words in that markup). + const tokens=/(#[^\n]*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(\b(?:def|class|import|from|return|if|elif|else|for|while|try|except|finally|with|as|lambda|pass|raise|yield|async|await|assert|break|continue|global|nonlocal|del|in|is|not|and|or)\b)|(\b(?:print|len|range|str|int|float|dict|list|set|tuple|bool|open|isinstance|issubclass|type|enumerate|zip|map|filter|sum|min|max|any|all|abs|round|input)\b)|(\b(?:True|False|None)\b)|(@[a-zA-Z_0-9.]+)|(\b\d+(?:\.\d+)?\b)/g; + const classes=['comment','str','kw','built','cmd','var','num']; + let html='',offset=0; + for(const match of src.matchAll(tokens)){ + const kind=match.slice(1).findIndex(value=>value!==undefined); + html+=escapeHTML(src.slice(offset,match.index))+`${escapeHTML(match[0])}`; + offset=match.index+match[0].length; + } + return html+escapeHTML(src.slice(offset)); } function highlightJSON(src){ @@ -626,6 +608,7 @@ function openCodeEditorModal({ title, subtitle, language='shell', value='', onSa card?.classList.remove('fullscreen'); textarea.value=value; + textarea.scrollTop=textarea.scrollLeft=0; function updateView(){ const text=textarea.value; @@ -641,6 +624,7 @@ function openCodeEditorModal({ title, subtitle, language='shell', value='', onSa const curLine=beforeCursor.split('\n').length; const curCol=selStart - beforeCursor.lastIndexOf('\n'); stats.textContent=`行 ${curLine},列 ${curCol} · 共 ${lineCount} 行 · ${text.length} 字符`; + syncScroll(); } function syncScroll(){ diff --git a/web/app/assets-harness.css b/web/app/assets-harness.css index f5680a26..86f857a1 100644 --- a/web/app/assets-harness.css +++ b/web/app/assets-harness.css @@ -321,7 +321,7 @@ #assetsView .skill-upload-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; } #assetsView .skill-upload-status:empty { display: none; } #assetsView .skill-upload-status { margin: 0 0 12px; font-size: 12.5px; } -#assetsView .skill-file-tree { margin: 0; overflow: hidden; border: 1px solid var(--hui-border); border-radius: 8px; background: var(--hui-surface); } +#assetsView .skill-file-tree { margin: 0; overflow: auto; overscroll-behavior: contain; flex-shrink: 0; border: 1px solid var(--hui-border); border-radius: 8px; background: var(--hui-surface); } #assetsView .skill-file-tree :is(.skill-file-row, .skill-tree-row) { min-height: 38px; border-bottom: 1px solid var(--hui-border-soft); color: var(--hui-secondary); font-size: 13px; } #assetsView .skill-file-tree > :last-child { border-bottom: 0; } #assetsView .skill-file-tree :is(.skill-file-path, .skill-tree-dirname) { background: none; color: var(--hui-text); font: 400 12.5px/1.4 var(--hui-mono); } diff --git a/web/app/flows.js b/web/app/flows.js index 6dda9523..da7fe6a1 100644 --- a/web/app/flows.js +++ b/web/app/flows.js @@ -103,6 +103,10 @@ function flowStatusPill(wf){ if(wf.status==='archived')return {cls:'ro',label:'已归档'}; return {cls:'draft',label:'草稿'}; } +let workflowPublishing=false; +function workflowNeedsPublish(wf=state.workflow){ + return !!wf&&(state.workflowDirty||wf.status!=='published'||(wf.publishedRevision||wf.revision)!==wf.revision); +} function renderFlowHeader(){ const wf=state.workflow; const title=$('#flowCommandTitle'); @@ -127,7 +131,9 @@ function renderFlowHeader(){ } const label=$('#saveStateLabel');if(label)label.textContent=pending?'保存':'已保存'; const top=$('#saveState');if(top){top.textContent=state.workflowDirty?'有未保存更改':'已同步';top.style.color=state.workflowDirty?'#e59a24':''} - ['publishWorkflow','flowSettingsQuickSave'].forEach(id=>{const btn=$('#'+id);if(btn)btn.disabled=ro}); + const publish=$('#publishWorkflow'); + if(publish){publish.disabled=ro||workflowPublishing||!workflowNeedsPublish(wf);publish.dataset.tip=workflowPublishing?'正在发布':!workflowNeedsPublish(wf)?'当前内容已发布,修改后可发布新版本':'发布为新版本';publish.setAttribute('aria-busy',String(workflowPublishing))} + const quickSave=$('#flowSettingsQuickSave');if(quickSave)quickSave.disabled=ro; const discard=$('#discardWorkflow');if(discard)discard.hidden=!(wf&&state.workflowDirty); $('#flowView')?.classList.toggle('fd-readonly',!!wf&&ro); if(typeof flowPalette!=='undefined'&&flowPalette.isSwitcherOpen())flowPalette.renderSwitcher(); @@ -162,7 +168,7 @@ async function switchFlowWorkflow(id){ if(!id||id===state.workflow?.id)return false; const next=(state.data?.workflows||[]).find(w=>w.id===id); if(!next){toast('SOP 不存在或已删除');return false} - if(state.workflowDirty&&!(await afConfirm({title:'未保存更改',message:'当前流程有未保存修改,切换将放弃这些修改。继续?',danger:true}))){renderWorkflowMeta();return false} + if(state.workflowDirty&&!(await afConfirm({title:'放弃修改并切换流程?',message:'当前流程有未保存修改。切换后将丢弃这些修改。',danger:true,confirmText:'放弃修改并切换',cancelText:'继续编辑'}))){renderWorkflowMeta();return false} state.workflow=clone(next);state.selectedNode=null;state.connectFrom=null;state.connectCursor=null;state.renamingNode=null; markWorkflowClean();flowHistory.clear();rememberOpenWorkflow();renderAll();refreshGenericWorkflowRoles(); return true; @@ -1142,5 +1148,16 @@ async function deleteWorkflow(id){ toast('SOP 已删除') }catch(e){toast(e.message||'删除失败')}} async function publishWorkflow(){ + if(workflowPublishing||!workflowNeedsPublish())return; if(catalogGeneric(state.workflow)&&!isPlatformAdmin())return toast('通用模板为只读,请先「复制到本项目」再发布'); - try{await saveWorkflow();const published=await api(`/api/workflows/${state.workflow.id}/publish`,{method:'POST',body:'{}'});state.workflow=clone(published);const i=state.data.workflows.findIndex(w=>w.id===published.id);if(i>=0)state.data.workflows[i]=clone(published);markWorkflowClean();renderWorkflowMeta();toast(`已发布 v${published.version}`)}catch(e){if(!e.message.includes('revision conflict'))toast(e.message)}} + workflowPublishing=true;renderFlowHeader(); + try{ + const id=state.workflow.id; + if(state.workflowDirty||!state.workflow.revision)await saveWorkflow(); + const published=await api(`/api/workflows/${encodeURIComponent(id)}/publish`,{method:'POST',body:'{}'}); + const i=state.data.workflows.findIndex(w=>w.id===published.id);if(i>=0)state.data.workflows[i]=clone(published); + if(state.workflow?.id===id){state.workflow=clone(published);markWorkflowClean();renderWorkflowMeta()} + renderSOPCatalog();toast(`已发布 v${published.version}`); + }catch(e){if(!e.message.includes('revision conflict'))toast(e.message)} + finally{workflowPublishing=false;renderFlowHeader()} +} diff --git a/web/index.html b/web/index.html index b5265c86..b66c910f 100644 --- a/web/index.html +++ b/web/index.html @@ -1313,7 +1313,7 @@

代码编辑

- +