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
12 changes: 12 additions & 0 deletions docs/api/orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ run 状态通常为 `running|waiting|succeeded|failed|cancelled`。响应中的

返回全部 run 摘要数组,`200`,不分页。摘要省略 artifacts 内容、遥测、日志、事件和 workflow snapshot,截断 input/错误,并省略已完成节点的大输出。

可传 `projectId` 限定项目,`responsible=me` 只返回当前登录用户负责执行或关联需求责任人为当前用户的任务;`responsible=me` 未登录时返回 `401`。两项筛选可以组合。

### `POST /api/task-groups`

创建自由协作任务,`201` 返回 Run。必填 `projectId`、`input`;不传 `workflowId` 时无需 SOP 模板,Main Bot 可直接协作,角色在创建后按需邀请。可传 `sandboxProfileId` 选择本项目或共享运行环境(省略时使用 `local`)。传 `workflowId` 时仅继承可访问模板的角色与环境,不自动执行 SOP,不能同时覆盖 `sandboxProfileId`。

`repositoryIds` 可指定本项目的一个或多个仓库,按配置主分支创建任务工作分支;不属于本项目或不存在的仓库会拒绝整个请求。显式 `[]` 表示不使用代码仓库;省略或 `null` 保持旧接口选择项目全部仓库的行为。

```json
{"projectId":"platform","input":"协作优化前后端接口","sandboxProfileId":"project-sandbox","repositoryIds":["frontend","backend"]}
```

### `POST /api/runs`

直接启动 SOP:
Expand Down
2 changes: 2 additions & 0 deletions docs/api/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

认证和通用状态码见 [README](README.md)。同步服务使用注册表中的 MCP Server 和项目/仓库配置访问外部系统。

首次导入工作项时,按工蜂 / TAPD 的状态初始化平台需求状态。已有需求再次同步(自动、手动、定时任务拉取或单条重新同步)时,保留平台状态、关闭来源与自动执行状态,只刷新外部内容和 `externalRef` 中的原始状态及同步时间。外部关闭仍通过原始状态阻止执行,但不会覆盖已有需求的平台状态;外部重新打开也不会自动重开平台需求。

## 同步配置

`SyncConfig` 字段:
Expand Down
2 changes: 1 addition & 1 deletion internal/application/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func PublicOperations() []Operation {
{Name: "deployment_metrics_get", Description: "Query optional configured deployment duration metrics", Method: "GET", Path: "/api/deployment-metrics", ReadOnly: true},
{Name: "observability_spec_get", Description: "Get the AgentFlow observability contract", Method: "GET", Path: "/api/observability/spec", ReadOnly: true},
{Name: "runs_list", Description: "List lightweight Run summaries", Method: "GET", Path: "/api/runs", ReadOnly: true},
{Name: "task_groups_start", Description: "Create a shared free collaboration task. Body: {workflowId, projectId, input}; workflow only provides environment and initial roles, no automatic SOP sequence.", Method: "POST", Path: "/api/task-groups"},
{Name: "task_groups_start", Description: "Create a shared free collaboration task. Body requires {projectId, input}; optional repositoryIds selects project repositories ([] means none, omitted means all), sandboxProfileId selects the environment. Optional workflowId supplies environment and initial roles instead of sandboxProfileId; no automatic SOP sequence.", Method: "POST", Path: "/api/task-groups"},
{Name: "runs_group_invite", Description: "Invite a project or shared role to the current free collaboration group. Body: {agentId}. Joining does not start work.", Method: "POST", Path: "/api/runs/{id}/group/members"},
{Name: "runs_group_dispatch", Description: "Dispatch an independent Agent task in a free collaboration group. Body: {agentId, prompt, requestId, files?}. Can run concurrently in the shared workspace; requestId is required for idempotency.", Method: "POST", Path: "/api/runs/{id}/group/tasks"},
{Name: "runs_start", Description: "Start an SOP Run", Method: "POST", Path: "/api/runs"},
Expand Down
16 changes: 11 additions & 5 deletions internal/engine/run_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ func (e *Engine) StartWithRepositoriesContext(workflowID, input string, reposito
}

func (e *Engine) startWorkflow(workflowID, input string, repositories []domain.RepositoryContext, executionContext domain.TelemetryContext, group bool) (domain.Run, error) {
wf, ok := e.repo.Workflow(workflowID)
if !ok {
return domain.Run{}, fmt.Errorf("workflow not found")
}
return e.startWorkflowDefinition(wf, input, repositories, executionContext, group)
}

// startWorkflowDefinition also accepts a run-only free collaboration definition.
// It never writes a template to the catalog.
func (e *Engine) startWorkflowDefinition(wf domain.WorkflowDefinition, input string, repositories []domain.RepositoryContext, executionContext domain.TelemetryContext, group bool) (domain.Run, error) {
workflowID := wf.ID
repositories = normalizeRepositories(repositories)
domain.AssignRepositoryDirs(repositories)
e.mu.Lock()
Expand All @@ -38,11 +49,6 @@ func (e *Engine) startWorkflow(workflowID, input string, repositories []domain.R
if draining {
return domain.Run{}, ErrExecutionDisabled
}
stored, ok := e.repo.Workflow(workflowID)
if !ok {
return domain.Run{}, fmt.Errorf("workflow not found")
}
wf := stored
wf.CollaborationMode = ""
wf.TaskTitle = ""
wf.GroupMembers = nil
Expand Down
30 changes: 30 additions & 0 deletions internal/engine/task_group.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,22 @@ func truncateGroupText(s string, n int) string {
}

func (e *Engine) StartTaskGroup(workflowID, input string, repos []domain.RepositoryContext, actor domain.TelemetryContext) (domain.Run, error) {
return e.StartTaskGroupWithSandbox(workflowID, input, repos, actor, "")
}

// An empty workflow starts a free collaboration with no SOP or initial roles.
// The sandbox is frozen just like a template-backed run; roles can be invited later.
func (e *Engine) StartTaskGroupWithSandbox(workflowID, input string, repos []domain.RepositoryContext, actor domain.TelemetryContext, sandboxID string) (domain.Run, error) {
if strings.TrimSpace(actor.ProjectID) == "" || strings.TrimSpace(input) == "" {
return domain.Run{}, fmt.Errorf("请选择项目并填写任务目标")
}
if strings.TrimSpace(workflowID) == "" {
if _, ok := e.repo.Project(actor.ProjectID); !ok {
return domain.Run{}, fmt.Errorf("项目不存在")
}
wf := domain.WorkflowDefinition{ID: "free-collaboration", Name: "自由协作", Version: 1, ProjectID: actor.ProjectID, SandboxProfileID: sandboxID, Nodes: []domain.WorkflowNode{}, Edges: []domain.WorkflowEdge{}}
return e.startWorkflowDefinition(wf, input, repos, actor, true)
}
return e.startWorkflow(workflowID, input, repos, actor, true)
}

Expand Down Expand Up @@ -433,6 +446,23 @@ func (e *Engine) restoreGroupSOPWithContext(id, nodeID string, complete bool, ha
if err != nil {
return err
}
if complete && len(checkpoint.Workflow.Nodes) == 0 {
// A task created without a SOP stays a group, including its real task
// outputs and membership, so it can be reopened for another round.
r.Status, r.PauseRequested, r.CancelRequested = "succeeded", false, false
for _, nr := range r.Nodes {
if nr.Status == "pending" || nr.Status == "waiting_approval" || nr.Status == "waiting_retry" {
nr.Status = "skipped"
nr.Activity = "自由协作已标记完成,未继续执行"
}
}
e.event(&r, "", "group.completed", "用户标记自由协作完成,保留执行记录")
if err := e.repo.RestoreRunSOP(r, r.Revision, true); err != nil {
return err
}
e.publish(id)
return nil
}
restored := r
restored.WorkflowSnapshot = checkpoint.Workflow
restored.WorkflowSnapshot.CollaborationMode = ""
Expand Down
49 changes: 49 additions & 0 deletions internal/engine/task_group_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,55 @@ func TestGroupDispatchDurableScopedAndIdempotent(t *testing.T) {
}
}

func TestNativeTaskGroupLifecyclePreservesRolesAndOutputs(t *testing.T) {
e, s := newRecoveryEngine(t)
e.ConfigureExecution("test", "test", time.Minute, false)
if err := s.PutProject(domain.Project{ID: "native-group", Name: "自由协作"}); err != nil {
t.Fatal(err)
}
if err := s.PutAgent(domain.AgentDefinition{ID: "native-dev", ProjectID: "native-group", Name: "开发", ExecutorID: "mock"}); err != nil {
t.Fatal(err)
}
before := len(s.Workflows())
r, err := e.StartTaskGroup("", "跨仓库协作", []domain.RepositoryContext{{RepositoryID: "a", CloneURL: "https://example.invalid/a.git"}, {RepositoryID: "b", CloneURL: "https://example.invalid/b.git"}}, domain.TelemetryContext{ProjectID: "native-group", ResponsibleUserID: 7})
if err != nil {
t.Fatal(err)
}
if len(s.Workflows()) != before || r.Status != "waiting" || r.WorkflowSnapshot.CollaborationMode != "group" || len(r.WorkflowSnapshot.Nodes) != 0 || len(r.WorkflowSnapshot.GroupMembers) != 0 || len(r.Repositories) != 2 {
t.Fatalf("native group: %+v", r)
}
if err := e.InviteGroupMember(r.ID, "native-dev"); err != nil {
t.Fatal(err)
}
n, err := e.DispatchGroupTask(r.ID, "", GroupTaskRequest{AgentID: "native-dev", Prompt: "实现功能", RequestID: "native-task"})
if err != nil {
t.Fatal(err)
}
r, _ = s.RunState(r.ID)
r.Status = "waiting"
r.Nodes[n.ID].Status, r.Nodes[n.ID].Output, r.Nodes[n.ID].Attempt = "succeeded", "实现与验证已完成", 1
if err := s.PutRun(r); err != nil {
t.Fatal(err)
}
if err := e.CompleteGroupSOP(r.ID); err != nil {
t.Fatal(err)
}
r, _ = s.RunState(r.ID)
if r.Status != "succeeded" || r.WorkflowSnapshot.CollaborationMode != "group" || r.Nodes[n.ID].Output != "实现与验证已完成" || !r.WorkflowSnapshot.HasGroupMember("native-dev") {
t.Fatalf("completion lost history: %+v", r)
}
if err := e.EnableTaskGroup(r.ID); err != nil {
t.Fatal(err)
}
r, _ = s.RunState(r.ID)
if r.Status != "waiting" || r.Nodes[n.ID].Output != "实现与验证已完成" {
t.Fatalf("reopen lost history: %+v", r)
}
if _, err := e.DispatchGroupTask(r.ID, "", GroupTaskRequest{AgentID: "native-dev", Prompt: "继续改进", RequestID: "native-next"}); err != nil {
t.Fatal(err)
}
}

// Both scripts wait for the other's write: serialization cannot pass this test.
func TestGroupWorkersOverlapAndAcceptNewWorkAfterIdle(t *testing.T) {
e, s := newRecoveryEngine(t)
Expand Down
63 changes: 63 additions & 0 deletions internal/gitwork/baseline_security_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package gitwork

import (
"os"
"path/filepath"
"testing"
)

func TestRecordBaselineRejectsSymlinks(t *testing.T) {
for _, rel := range []string{".git", ".git/agentflow-baseline"} {
t.Run(rel, func(t *testing.T) {
workspace, outside := t.TempDir(), t.TempDir()
target := filepath.Join(workspace, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
t.Fatal(err)
}
victim := outside
if rel != ".git" {
victim = filepath.Join(outside, "keep")
if err := os.WriteFile(victim, []byte("keep"), 0600); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(victim, target); err != nil {
t.Fatal(err)
}
if err := RecordBaseline(workspace, "master"); err == nil {
t.Fatal("symlink accepted")
}
if rel != ".git" {
raw, err := os.ReadFile(victim)
if err != nil || string(raw) != "keep" {
t.Fatalf("outside file changed: %q, %v", raw, err)
}
}
})
}
}

func TestRecordBaselineKeepsFrozenCheckout(t *testing.T) {
_, workspace := workBranchFixture(t)
want := workBranchGit(t, workspace, "rev-parse", "origin/main")
if err := RecordBaseline(workspace, "main"); err != nil {
t.Fatal(err)
}
path := BaselinePath(workspace)
raw, err := os.ReadFile(path)
if err != nil || string(raw) != want {
t.Fatalf("baseline = %q, %v; want %q", raw, err, want)
}
if err := os.WriteFile(filepath.Join(workspace, "new.txt"), []byte("new"), 0600); err != nil {
t.Fatal(err)
}
workBranchGit(t, workspace, "add", "new.txt")
workBranchGit(t, workspace, "commit", "-qm", "advance HEAD")
if err := RecordBaseline(workspace, "missing"); err != nil {
t.Fatal(err)
}
raw, err = os.ReadFile(path)
if err != nil || string(raw) != want {
t.Fatalf("frozen baseline changed: %q, %v", raw, err)
}
}
36 changes: 32 additions & 4 deletions internal/gitwork/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,26 @@ func BaselinePath(workspace string) string {
// against this frozen checkout rather than the moving HEAD. An existing
// record is kept.
func RecordBaseline(workspace, baseBranch string) error {
path := BaselinePath(workspace)
if _, err := os.Stat(path); err == nil {
root, err := os.OpenRoot(workspace)
if err != nil {
return err
}
defer root.Close()
const path = ".git/agentflow-baseline"
if info, err := root.Lstat(".git"); err != nil {
return err
} else if !info.IsDir() {
return fmt.Errorf("repository .git is not a real directory")
}
if info, err := root.Lstat(path); err == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("repository baseline is not a regular file")
}
return nil
} else if !os.IsNotExist(err) {
return err
}
var sha []byte
var err error
if ref := strings.TrimSpace(baseBranch); ref != "" {
sha, err = exec.Command("git", "-C", workspace, "rev-parse", "origin/"+ref).Output()
}
Expand All @@ -47,9 +61,23 @@ func RecordBaseline(workspace, baseBranch string) error {
if err != nil {
return fmt.Errorf("resolve repository baseline: %w", err)
}
if err := os.WriteFile(path, []byte(strings.TrimSpace(string(sha))), 0o600); err != nil {
file, err := root.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if os.IsExist(err) {
if info, statErr := root.Lstat(path); statErr == nil && info.Mode().IsRegular() {
return nil
}
}
if err != nil {
return fmt.Errorf("record repository baseline: %w", err)
}
_, err = file.WriteString(strings.TrimSpace(string(sha)))
closeErr := file.Close()
if err != nil {
return fmt.Errorf("record repository baseline: %w", err)
}
if closeErr != nil {
return closeErr
}
return nil
}

Expand Down
11 changes: 10 additions & 1 deletion internal/httpapi/runs.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,16 @@ func (h *runsHandlers) workflowMetrics(c *gin.Context) {
func (h *runsHandlers) runs(c *gin.Context) {
r := c.Request
if r.Method == http.MethodGet {
runs, err := h.repo.RunSummariesFor(domain.AnalyticsFilter{ProjectID: c.Query("projectId")}, "")
filter := domain.AnalyticsFilter{ProjectID: c.Query("projectId")}
if c.Query("responsible") == "me" {
user, ok := h.requestUser(r, true)
if !ok {
c.JSON(401, gin.H{"error": "请先登录"})
return
}
filter.UserID = fmt.Sprint(user.ID)
}
runs, err := h.repo.RunSummariesFor(filter, "")
if err != nil {
respond(c, err)
return
Expand Down
53 changes: 43 additions & 10 deletions internal/httpapi/task_group.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ func (h *runsHandlers) groupUser(c *gin.Context, project string) (int64, bool) {
}
func (h *runsHandlers) taskGroups(c *gin.Context) {
var req struct {
WorkflowID string `json:"workflowId"`
Input string `json:"input"`
ProjectID string `json:"projectId"`
WorkflowID string `json:"workflowId"`
Input string `json:"input"`
ProjectID string `json:"projectId"`
RepositoryIDs []string `json:"repositoryIds"`
SandboxProfileID string `json:"sandboxProfileId"`
}
if !decode(c, &req) {
return
Expand All @@ -41,19 +43,50 @@ func (h *runsHandlers) taskGroups(c *gin.Context) {
if !ok {
return
}
wf, ok := h.repo.Workflow(req.WorkflowID)
if !ok || !(wf.ProjectID == req.ProjectID || wf.Shared || wf.Generic) {
c.JSON(403, gin.H{"error": "请选择当前项目可用的流程作为角色与环境模板"})
req.WorkflowID = strings.TrimSpace(req.WorkflowID)
if strings.TrimSpace(req.Input) == "" {
c.JSON(400, gin.H{"error": "请填写任务目标"})
return
}
// Freeze project repositories exactly as a normal run, without editing catalog definitions.
if req.WorkflowID != "" {
wf, ok := h.repo.Workflow(req.WorkflowID)
if !ok || !(wf.ProjectID == req.ProjectID || wf.Shared || wf.Generic) {
c.JSON(403, gin.H{"error": "请选择当前项目可用的流程作为角色与环境模板"})
return
}
if req.SandboxProfileID != "" {
c.JSON(400, gin.H{"error": "使用模板时由模板提供运行环境"})
return
}
} else {
req.SandboxProfileID = strings.TrimSpace(req.SandboxProfileID)
if req.SandboxProfileID == "" {
req.SandboxProfileID = "local"
}
sandbox, ok := h.repo.Sandbox(req.SandboxProfileID)
if !ok || !(sandbox.ProjectID == req.ProjectID || sandbox.Shared) {
c.JSON(403, gin.H{"error": "请选择当前项目可用的运行环境"})
return
}
}
// nil preserves the old API's all-project-repositories default. An explicit
// empty list means no code repository; a nonempty list must be project scoped.
selected := map[string]bool{}
for _, id := range req.RepositoryIDs {
selected[id] = true
}
repos := []domain.RepositoryContext{}
for _, r := range h.repo.Repositories() {
if r.ProjectID == req.ProjectID {
repos = append(repos, domain.RepositoryContext{RepositoryID: r.ID, Provider: r.Provider, Name: r.Name, GongfengProjectID: r.GongfengProjectID, WebURL: r.WebURL, CloneURL: r.CloneURL, BaseBranch: r.DefaultBranch, DefaultBranch: r.DefaultBranch, LocalPath: r.LocalPath})
if r.ProjectID == req.ProjectID && (req.RepositoryIDs == nil || selected[r.ID]) {
repos = append(repos, domain.RepositoryContext{RepositoryID: r.ID, Provider: r.Provider, Name: r.Name, GongfengProjectID: r.GongfengProjectID, WebURL: r.WebURL, CloneURL: r.CloneURL, BaseBranch: r.DefaultBranch, DefaultBranch: r.DefaultBranch, LocalPath: r.LocalPath, PipelineProjectID: r.PipelineProjectID, PipelineDefID: r.PipelineDefID, PipelineURLPattern: r.PipelineURLPattern})
delete(selected, r.ID)
}
}
r, err := h.engine.StartTaskGroup(req.WorkflowID, req.Input, repos, domain.TelemetryContext{ProjectID: req.ProjectID, ResponsibleUserID: uid})
if len(selected) > 0 {
c.JSON(400, gin.H{"error": "所选仓库不存在或不属于当前项目,请刷新后重试"})
return
}
r, err := h.engine.StartTaskGroupWithSandbox(req.WorkflowID, req.Input, repos, domain.TelemetryContext{ProjectID: req.ProjectID, ResponsibleUserID: uid}, req.SandboxProfileID)
if err != nil {
respond(c, err)
return
Expand Down
Loading
Loading