Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions design.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@

## 图标与通用组件

流程设计的发布按钮在发布期间和当前内容已发布时禁用;保存相同内容不新增修订,服务端重复发布保持版本不变。保存后的新修改可继续发布。流程切换遇到未保存修改时,按钮明确为「继续编辑 / 放弃修改并切换」。脚本编辑器的输入层、行号与高亮层共用字体、字距和 Tab 宽度,长行横向滚动,高亮随输入层同步滚动。

技能详情的文件树限高并独立滚动。

### Harness 图标

`HarnessIcon.jsx` 与原生页面的 `harness-motion.js` 使用同一来源的 SVG 定义。上游版本为 `da2e476895331ef4afee30878af0fb3fb1c73131`,MIT 许可随源码保留。
Expand Down
1 change: 1 addition & 0 deletions internal/domain/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
6 changes: 2 additions & 4 deletions internal/httpapi/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
63 changes: 63 additions & 0 deletions internal/repo/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
29 changes: 28 additions & 1 deletion internal/repo/generic_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"time"

"agentflow/internal/domain"

"gorm.io/gorm"
)

func (r *CatalogRepo) loadGenericTemplate(id string) (GenericTemplate, bool) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions internal/repo/generic_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
125 changes: 125 additions & 0 deletions internal/repo/workflow_publish_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
42 changes: 13 additions & 29 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<line.length;i++){
const ch=line[i];
if(ch==="'"&&!inDouble) inSingle=!inSingle;
else if(ch==='"'&&!inSingle&&(i===0||line[i-1]!=='\\')) inDouble=!inDouble;
else if(ch==='#'&&!inSingle&&!inDouble){
commentIdx=i;
break;
}
}
let codePart=commentIdx>=0?line.slice(0, commentIdx):line;
let comment=commentIdx>=0?line.slice(commentIdx):'';

let html=escapeHTML(codePart);
html=html.replace(/(&quot;[\s\S]*?&quot;|&#39;[\s\S]*?&#39;)/g, '<span class="tok-str">$1</span>');
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, '<span class="tok-kw">$1</span>');
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, '<span class="tok-built">$1</span>');
html=html.replace(/\b(True|False|None)\b/g, '<span class="tok-cmd">$1</span>');
html=html.replace(/(@[a-zA-Z_0-9.]+)/g, '<span class="tok-var">$1</span>');
html=html.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="tok-num">$1</span>');

if(comment){
html+=`<span class="tok-comment">${escapeHTML(comment)}</span>`;
}
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))+`<span class="tok-${classes[kind]}">${escapeHTML(match[0])}</span>`;
offset=match.index+match[0].length;
}
return html+escapeHTML(src.slice(offset));
}

function highlightJSON(src){
Expand Down Expand Up @@ -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;
Expand All @@ -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(){
Expand Down
2 changes: 1 addition & 1 deletion web/app/assets-harness.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
Loading
Loading