diff --git a/docs/api/orchestration.md b/docs/api/orchestration.md index c579e83..d13c97f 100644 --- a/docs/api/orchestration.md +++ b/docs/api/orchestration.md @@ -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: diff --git a/docs/api/sync.md b/docs/api/sync.md index cd024f8..89932d1 100644 --- a/docs/api/sync.md +++ b/docs/api/sync.md @@ -2,6 +2,8 @@ 认证和通用状态码见 [README](README.md)。同步服务使用注册表中的 MCP Server 和项目/仓库配置访问外部系统。 +首次导入工作项时,按工蜂 / TAPD 的状态初始化平台需求状态。已有需求再次同步(自动、手动、定时任务拉取或单条重新同步)时,保留平台状态、关闭来源与自动执行状态,只刷新外部内容和 `externalRef` 中的原始状态及同步时间。外部关闭仍通过原始状态阻止执行,但不会覆盖已有需求的平台状态;外部重新打开也不会自动重开平台需求。 + ## 同步配置 `SyncConfig` 字段: diff --git a/internal/application/operations.go b/internal/application/operations.go index 2227e22..d6c896a 100644 --- a/internal/application/operations.go +++ b/internal/application/operations.go @@ -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"}, diff --git a/internal/engine/run_start.go b/internal/engine/run_start.go index 6963eb3..52baec0 100644 --- a/internal/engine/run_start.go +++ b/internal/engine/run_start.go @@ -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() @@ -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 diff --git a/internal/engine/task_group.go b/internal/engine/task_group.go index 53bdd0f..a1575af 100644 --- a/internal/engine/task_group.go +++ b/internal/engine/task_group.go @@ -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) } @@ -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 = "" diff --git a/internal/engine/task_group_test.go b/internal/engine/task_group_test.go index bac5b49..6a0ad15 100644 --- a/internal/engine/task_group_test.go +++ b/internal/engine/task_group_test.go @@ -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) diff --git a/internal/gitwork/baseline_security_test.go b/internal/gitwork/baseline_security_test.go new file mode 100644 index 0000000..f7f5b5f --- /dev/null +++ b/internal/gitwork/baseline_security_test.go @@ -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) + } +} diff --git a/internal/gitwork/patch.go b/internal/gitwork/patch.go index 75fd145..f89daa6 100644 --- a/internal/gitwork/patch.go +++ b/internal/gitwork/patch.go @@ -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() } @@ -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 } diff --git a/internal/httpapi/runs.go b/internal/httpapi/runs.go index cd78652..d95ae33 100644 --- a/internal/httpapi/runs.go +++ b/internal/httpapi/runs.go @@ -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 diff --git a/internal/httpapi/task_group.go b/internal/httpapi/task_group.go index ca41c9e..6f1b743 100644 --- a/internal/httpapi/task_group.go +++ b/internal/httpapi/task_group.go @@ -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 @@ -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 diff --git a/internal/httpapi/task_group_creation_test.go b/internal/httpapi/task_group_creation_test.go new file mode 100644 index 0000000..146de9f --- /dev/null +++ b/internal/httpapi/task_group_creation_test.go @@ -0,0 +1,193 @@ +package httpapi + +import ( + "agentflow/internal/domain" + "agentflow/internal/engine" + "agentflow/internal/repo" + "agentflow/internal/runtime" + "bytes" + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "testing" + "time" +) + +func TestTaskGroupCreationScopeAndResponsibleFilter(t *testing.T) { + st, err := repo.OpenTest() + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + checkTaskGroupCreation(t, st) +} + +func TestMySQLTaskGroupCreationScopeAndResponsibleFilter(t *testing.T) { + dsn := os.Getenv("AGENTFLOW_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable local MySQL") + } + st, err := repo.Open(dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + checkTaskGroupCreation(t, st) +} + +func checkTaskGroupCreation(t *testing.T, st *repo.Store) { + t.Helper() + t.Setenv("AGENTFLOW_ASSISTANT_SESSION_DIR", t.TempDir()) + projectID := fmt.Sprintf("collab-create-%d", time.Now().UnixNano()) + if err := st.PutUser(domain.User{LoginName: projectID}); err != nil { + t.Fatal(err) + } + u, _ := st.UserByLoginName(projectID) + if err := st.PutProject(domain.Project{ID: projectID, Name: "协作", OwnerID: u.ID}); err != nil { + t.Fatal(err) + } + if err := st.PutProject(domain.Project{ID: projectID + "-other", Name: "其他项目", OwnerID: u.ID}); err != nil { + t.Fatal(err) + } + sandboxID := projectID + "-sandbox" + if err := st.PutSandbox(domain.SandboxProfile{ID: sandboxID, Name: "测试环境", ProjectID: projectID, Driver: "local"}); err != nil { + t.Fatal(err) + } + templateID := projectID + "-template" + if err := st.PutWorkflow(domain.WorkflowDefinition{ID: templateID, Name: "测试模板", ProjectID: projectID, SandboxProfileID: sandboxID, Nodes: []domain.WorkflowNode{{ID: "prepare", Type: "script", Script: "true"}}}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"a", "b", "c", "foreign"} { + pid := projectID + if id == "foreign" { + pid += "-other" + } + if err := st.PutRepository(domain.Repository{ID: projectID + id, ProjectID: pid, Name: id, DefaultBranch: "master", CloneURL: "https://example.invalid/" + id + ".git", PipelineProjectID: 42}); err != nil { + t.Fatal(err) + } + } + e := engine.New(st, runtime.NewManagerWithLimit(t.TempDir(), 2)) + e.ConfigureExecution("test", "test", time.Minute, false) + h := &runsHandlers{deps: &deps{repo: st, engine: e}} + for _, tc := range []struct { + name string + ids []string + omit, template bool + want int + }{ + {name: "subset", ids: []string{projectID + "b"}, want: 1}, + {name: "multiple", ids: []string{projectID + "a", projectID + "c"}, want: 2}, + {name: "no repositories", ids: []string{}, want: 0}, + {name: "legacy all", omit: true, template: true, want: 3}, + {name: "template subset", ids: []string{projectID + "c"}, template: true, want: 1}, + {name: "duplicate IDs", ids: []string{projectID + "b", projectID + "b"}, want: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + body := map[string]any{"projectId": projectID, "input": "一起优化任务"} + if !tc.omit { + body["repositoryIds"] = tc.ids + } + if tc.template { + body["workflowId"] = templateID + } else { + body["sandboxProfileId"] = sandboxID + } + raw, _ := json.Marshal(body) + c, rec := workbenchContext(u, "POST", "/api/task-groups", bytes.NewBuffer(raw)) + h.taskGroups(c) + if rec.Code != 201 { + t.Fatalf("status=%d %s", rec.Code, rec.Body) + } + var run domain.Run + if err := json.Unmarshal(rec.Body.Bytes(), &run); err != nil { + t.Fatal(err) + } + if run.Status != "waiting" || run.WorkflowSnapshot.CollaborationMode != "group" || run.ExecutionContext.ResponsibleUserID != u.ID || len(run.Repositories) != tc.want { + t.Fatalf("unexpected group: %+v", run) + } + if !tc.template && (len(run.WorkflowSnapshot.Nodes) != 0 || len(run.WorkflowSnapshot.GroupMembers) != 0 || run.WorkflowSnapshot.SandboxSnapshot == nil) { + t.Fatalf("native group inherited SOP or lost environment: %+v", run.WorkflowSnapshot) + } + for _, repository := range run.Repositories { + if !tc.omit { + found := false + for _, id := range tc.ids { + found = found || id == repository.RepositoryID + } + if !found { + t.Fatalf("unselected repository: %+v", repository) + } + } + if repository.BaseBranch != "master" || repository.WorkBranch == "" || repository.PipelineProjectID != 42 { + t.Fatalf("repository metadata lost: %+v", repository) + } + } + }) + } + for _, tc := range []struct { + name string + extra map[string]any + status int + }{ + {"foreign repository", map[string]any{"repositoryIds": []string{projectID + "a", projectID + "foreign"}}, 400}, + {"missing repository", map[string]any{"repositoryIds": []string{"missing"}}, 400}, + {"missing template", map[string]any{"workflowId": "missing"}, 403}, + {"missing sandbox", map[string]any{"sandboxProfileId": "missing"}, 403}, + {"empty goal", map[string]any{"input": " "}, 400}, + } { + t.Run(tc.name, func(t *testing.T) { + body := map[string]any{"projectId": projectID, "input": "目标", "sandboxProfileId": sandboxID} + for key, value := range tc.extra { + body[key] = value + } + before := len(st.Runs()) + raw, _ := json.Marshal(body) + c, rec := workbenchContext(u, "POST", "/api/task-groups", bytes.NewBuffer(raw)) + h.taskGroups(c) + if rec.Code != tc.status || len(st.Runs()) != before { + t.Fatalf("invalid request wrote run: %d %s", rec.Code, rec.Body) + } + }) + } + // Both the execution's responsible user and the linked issue's current + // assignee count as mine, even if a different user triggered the run. + issueID := projectID + "-issue" + if err := st.PutIssue(domain.Issue{ID: issueID, ProjectID: projectID, Title: "Assigned to me", AssigneeUserID: u.ID}); err != nil { + t.Fatal(err) + } + for _, r := range []domain.Run{ + {ID: projectID + "-mine", ExecutionContext: domain.TelemetryContext{ProjectID: projectID, ResponsibleUserID: u.ID}}, + {ID: projectID + "-assigned", ExecutionContext: domain.TelemetryContext{ProjectID: projectID, IssueID: issueID, ResponsibleUserID: u.ID + 100}}, + {ID: projectID + "-other", ExecutionContext: domain.TelemetryContext{ProjectID: projectID, ResponsibleUserID: u.ID + 100}}, + {ID: projectID + "-foreign", ExecutionContext: domain.TelemetryContext{ProjectID: projectID + "-other", ResponsibleUserID: u.ID}}, + } { + r.Status = "waiting" + if err := st.PutRun(r); err != nil { + t.Fatal(err) + } + } + c, rec := workbenchContext(u, "GET", "/api/runs?projectId="+projectID+"&responsible=me", bytes.NewBuffer(nil)) + h.runs(c) + if rec.Code != 200 { + t.Fatalf("filter: %d %s", rec.Code, rec.Body) + } + var runs []domain.Run + if err := json.Unmarshal(rec.Body.Bytes(), &runs); err != nil { + t.Fatal(err) + } + ids := map[string]bool{} + for _, run := range runs { + ids[run.ID] = true + } + if !ids[projectID+"-mine"] || !ids[projectID+"-assigned"] || ids[projectID+"-other"] || ids[projectID+"-foreign"] { + t.Fatalf("responsible filter returned %v", ids) + } + // Do not let an anonymous caller accidentally interpret "me" as everybody. + c, rec = workbenchContext(domain.User{}, "GET", "/api/runs?responsible=me", bytes.NewBuffer(nil)) + c.Request = httptest.NewRequest("GET", "/api/runs?responsible=me", nil) + h.runs(c) + if rec.Code != 401 { + t.Fatalf("anonymous filter: %d %s", rec.Code, rec.Body) + } +} diff --git a/internal/repo/issues.go b/internal/repo/issues.go index c5b3e37..5c691bb 100644 --- a/internal/repo/issues.go +++ b/internal/repo/issues.go @@ -159,6 +159,15 @@ func (r *IssuesRepo) PutIssue(v domain.Issue) error { return r.putIssueLocked(v) } +// PutSyncedIssue refreshes tracker data without changing an existing issue's +// local lifecycle. Reload under the write lock so an edit made after the +// tracker snapshot was read is preserved too. +func (r *IssuesRepo) PutSyncedIssue(v domain.Issue) error { + r.mu.Lock() + defer r.mu.Unlock() + return r.saveIssueLocked(v, true) +} + // PatchIssue loads the current row, applies fn, and writes it back under the // same lock as PutIssue so a clarification job cannot clobber a concurrent save. func (r *IssuesRepo) PatchIssue(id string, fn func(*domain.Issue) error) (domain.Issue, error) { @@ -189,9 +198,19 @@ func (r *IssuesRepo) PatchIssue(id string, fn func(*domain.Issue) error) (domain } func (r *IssuesRepo) putIssueLocked(v domain.Issue) error { + return r.saveIssueLocked(v, false) +} + +func (r *IssuesRepo) saveIssueLocked(v domain.Issue, fromSync bool) error { now := time.Now().UTC() old, ok := r.Issue(v.ID) + preserveStatus := fromSync && ok if ok { + if preserveStatus { + v.Status = old.Status + v.CloseSource = old.CloseSource + v.AutomationState = old.AutomationState + } v.CreatedAt = old.CreatedAt if v.Number == 0 { v.Number = old.Number @@ -219,28 +238,30 @@ func (r *IssuesRepo) putIssueLocked(v domain.Issue) error { } } } - if v.TrackerClosed() && v.Status != "closed" { - v.Status = "closed" - v.CloseSource = "external" - } - if v.Status == "closed" { - if v.CloseSource == "" { - if ok && old.Status == "closed" { - v.CloseSource = old.CloseSource - if v.CloseSource == "" { - v.CloseSource = "manual" // pre-CloseSource manual close + if !preserveStatus { + if v.TrackerClosed() && v.Status != "closed" { + v.Status = "closed" + v.CloseSource = "external" + } + if v.Status == "closed" { + if v.CloseSource == "" { + if ok && old.Status == "closed" { + v.CloseSource = old.CloseSource + if v.CloseSource == "" { + v.CloseSource = "manual" // pre-CloseSource manual close + } + } else if v.TrackerClosed() { + v.CloseSource = "external" + } else { + v.CloseSource = "manual" } - } else if v.TrackerClosed() { - v.CloseSource = "external" - } else { - v.CloseSource = "manual" } + } else { + v.CloseSource = "" + } + if v.Status == "done" || v.Status == "closed" || v.Status == "cancelled" || v.GongfengClosed() { + v.AutomationState = "ignored" } - } else { - v.CloseSource = "" - } - if v.Status == "done" || v.Status == "closed" || v.Status == "cancelled" || v.GongfengClosed() { - v.AutomationState = "ignored" } if v.AssigneeUserID == 0 { if project, found := r.projects.Project(v.ProjectID); found { @@ -269,7 +290,6 @@ func (r *IssuesRepo) writeIssue(v domain.Issue) error { }) } - // ResponsibleUser resolves the explicit issue responsibility first and falls // back to the project's separately configured owner. func (r *IssuesRepo) ResponsibleUser(issue domain.Issue) (domain.User, error) { diff --git a/internal/repo/issues_sync_test.go b/internal/repo/issues_sync_test.go new file mode 100644 index 0000000..c8d6328 --- /dev/null +++ b/internal/repo/issues_sync_test.go @@ -0,0 +1,74 @@ +package repo + +import ( + "fmt" + "os" + "testing" + + "agentflow/internal/domain" +) + +func TestPutSyncedIssuePreservesCurrentLifecycle(t *testing.T) { + for _, driver := range []string{"sqlite", "mysql"} { + t.Run(driver, func(t *testing.T) { + var s *Store + var err error + if driver == "mysql" { + dsn := os.Getenv("AGENTFLOW_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("AGENTFLOW_TEST_MYSQL_DSN is not set") + } + s, err = Open(dsn) + } else { + s, err = OpenTest() + } + if err != nil { + t.Fatal(err) + } + defer s.Close() + for _, source := range []string{"tapd", "gongfeng"} { + for _, status := range []string{"backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled", "closed"} { + for _, remote := range []string{"new", "open", "done", "closed"} { + t.Run(source+"/"+status+"/"+remote, func(t *testing.T) { + id := fmt.Sprintf("sync-status-%s-%s-%s", source, status, remote) + t.Cleanup(func() { s.DB.Exec("DELETE FROM issues WHERE id=?", id) }) + issue := domain.Issue{ID: id, ProjectID: "platform", Source: source, Title: "before", Status: "backlog", AutomationState: "eligible"} + if err := s.PutIssue(issue); err != nil { + t.Fatal(err) + } + // The tracker snapshot predates a local status edit. + snapshot, _ := s.Issue(id) + issue.Status = status + if err := s.PutIssue(issue); err != nil { + t.Fatal(err) + } + current, _ := s.Issue(id) + snapshot.Title = "refreshed" + snapshot.ExternalRef = &domain.ExternalRef{Source: source, ExternalState: remote} + if err := s.PutSyncedIssue(snapshot); err != nil { + t.Fatal(err) + } + got, _ := s.Issue(id) + if got.Status != current.Status || got.CloseSource != current.CloseSource || got.AutomationState != current.AutomationState { + t.Fatalf("sync changed lifecycle: before=%+v after=%+v", current, got) + } + if got.Title != "refreshed" || got.ExternalRef.ExternalState != remote { + t.Fatalf("tracker metadata not refreshed: %+v", got) + } + var storedStatus string + if err := s.DB.Table("issues").Select("status").Where("id=?", id).Scan(&storedStatus).Error; err != nil { + t.Fatal(err) + } + if storedStatus != status { + t.Fatalf("status column=%q, want %q", storedStatus, status) + } + if remote == "closed" && !got.TrackerClosed() { + t.Fatal("external closure must still prevent execution") + } + }) + } + } + } + }) + } +} diff --git a/internal/runtime/command_binding.go b/internal/runtime/command_binding.go index b2e0aae..1ae7f99 100644 --- a/internal/runtime/command_binding.go +++ b/internal/runtime/command_binding.go @@ -179,7 +179,7 @@ func bindGitEnvironment(sandbox Sandbox, workspace string, environment map[strin // rewrites git@ to https://git.example.com/ without credentials. insteadOf does // not chain; GIT_ASKPASS answers the resulting username/password prompt. askpassHost := filepath.Join(workspace, ".agentflow", "runtime", "git-askpass") - if err := writeGitAskPass(askpassHost); err != nil { + if err := writeGitAskPass(workspace, askpassHost); err != nil { return err } askpass := askpassHost @@ -200,15 +200,12 @@ case "$1" in esac ` -func writeGitAskPass(path string) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { +func writeGitAskPass(workspace, path string) error { + if _, err := EnsureControlDirectory(workspace, "runtime"); err != nil { return fmt.Errorf("create git askpass dir: %w", err) } - if err := os.WriteFile(path, []byte(gitAskPassScript), 0o700); err != nil { + if err := writeManagedFile(workspace, path, []byte(gitAskPassScript), 0o700); err != nil { return fmt.Errorf("write git askpass: %w", err) } - if err := os.Chmod(path, 0o700); err != nil { - return fmt.Errorf("chmod git askpass: %w", err) - } return nil } diff --git a/internal/runtime/gate_mcp.go b/internal/runtime/gate_mcp.go index 91b81c5..31e5292 100644 --- a/internal/runtime/gate_mcp.go +++ b/internal/runtime/gate_mcp.go @@ -18,7 +18,7 @@ const gateMCPServerID = "agentflow-gate" var gateMCPBuildMu sync.Mutex -func materializeGateMCP(configRoot, binaryRel, resultPath, driver string) (domain.MCPServerDefinition, error) { +func materializeGateMCP(workspace, configRoot, binaryRel, resultPath, driver string) (domain.MCPServerDefinition, error) { resultPath = filepath.ToSlash(filepath.Clean(strings.TrimSpace(resultPath))) if resultPath == "." || filepath.IsAbs(resultPath) || resultPath == ".." || strings.HasPrefix(resultPath, "../") { return domain.MCPServerDefinition{}, fmt.Errorf("invalid gate result path %q", resultPath) @@ -36,7 +36,7 @@ func materializeGateMCP(configRoot, binaryRel, resultPath, driver string) (domai return domain.MCPServerDefinition{}, fmt.Errorf("read gate MCP binary: %w", err) } binaryPath := filepath.Join(configRoot, "gate-mcp") - if err := writeManagedFile(binaryPath, raw, 0o700); err != nil { + if err := writeManagedFile(workspace, binaryPath, raw, 0o700); err != nil { return domain.MCPServerDefinition{}, err } return domain.MCPServerDefinition{ diff --git a/internal/runtime/gate_mcp_test.go b/internal/runtime/gate_mcp_test.go index e94cbbe..f57d208 100644 --- a/internal/runtime/gate_mcp_test.go +++ b/internal/runtime/gate_mcp_test.go @@ -19,7 +19,7 @@ func TestGateMCPSubmitWritesStructuredResult(t *testing.T) { t.Fatal(err) } resultPath := ".agentflow/gate-results/review/attempt-1.json" - server, err := materializeGateMCP(configRoot, ".agentflow/config/reviewer/gate-mcp", resultPath, "local") + server, err := materializeGateMCP(workspace, configRoot, ".agentflow/config/reviewer/gate-mcp", resultPath, "local") if err != nil { t.Fatal(err) } @@ -127,7 +127,7 @@ func TestMaterializeGateMCPSelectsLinuxForDocker(t *testing.T) { if err := os.MkdirAll(configRoot, 0o700); err != nil { t.Fatal(err) } - if _, err := materializeGateMCP(configRoot, ".agentflow/config/reviewer/gate-mcp", "result.json", "docker"); err != nil { + if _, err := materializeGateMCP(dir, configRoot, ".agentflow/config/reviewer/gate-mcp", "result.json", "docker"); err != nil { t.Fatal(err) } raw, err := os.ReadFile(filepath.Join(configRoot, "gate-mcp")) diff --git a/internal/runtime/git_isolation.go b/internal/runtime/git_isolation.go index 08d9497..da2a393 100644 --- a/internal/runtime/git_isolation.go +++ b/internal/runtime/git_isolation.go @@ -74,25 +74,30 @@ func EnsureAgentFlowGitExcludes(workspace string) error { } func appendGitExcludeBlock(workspace, block, label string) error { - gitDir := filepath.Join(workspace, ".git") - if _, err := os.Stat(gitDir); err != nil { + root, excludePath, err := openWorkspacePath(workspace, filepath.Join(workspace, ".git", "info", "exclude")) + if err != nil { + return err + } + defer root.Close() + if _, err := root.Stat(".git"); err != nil { if os.IsNotExist(err) { return nil } return err } - excludePath := filepath.Join(gitDir, "info", "exclude") - existing, err := os.ReadFile(excludePath) + existing, err := root.ReadFile(excludePath) if err != nil && !os.IsNotExist(err) { return err } if bytes.Contains(existing, []byte(block)) { return nil } - if err := os.MkdirAll(filepath.Dir(excludePath), 0o700); err != nil { + if err := root.MkdirAll(filepath.Dir(excludePath), 0o700); err != nil { return err } - file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + // Keep append semantics: separate preparation paths may add different + // managed blocks concurrently. The held root confines the open operation. + file, err := root.OpenFile(excludePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return err } diff --git a/internal/runtime/hooks.go b/internal/runtime/hooks.go index 0c85853..6daeb33 100644 --- a/internal/runtime/hooks.go +++ b/internal/runtime/hooks.go @@ -78,7 +78,7 @@ func startHookObservation(ctx context.Context, compiled compiledConfig, sink Eve } } -func materializeAgentHooks(configRoot, agentRel, provider string, hooks []domain.AgentHook) ([]compiledHook, error) { +func materializeAgentHooks(workspace, configRoot, agentRel, provider string, hooks []domain.AgentHook) ([]compiledHook, error) { if len(hooks) == 0 { return nil, nil } @@ -103,12 +103,12 @@ func materializeAgentHooks(configRoot, agentRel, provider string, hooks []domain if !strings.HasSuffix(body, "\n") { body += "\n" } - if err := writeManagedFile(file, []byte(body), 0o700); err != nil { + if err := writeManagedFile(workspace, file, []byte(body), 0o700); err != nil { return nil, err } wrapperName := name + "-agentflow.py" wrapper := hookWrapperPython(name+ext, interpreter, provider, hook) - if err := writeManagedFile(filepath.Join(root, wrapperName), []byte(wrapper), 0o700); err != nil { + if err := writeManagedFile(workspace, filepath.Join(root, wrapperName), []byte(wrapper), 0o700); err != nil { return nil, err } rel := filepath.ToSlash(filepath.Join(agentRel, "..", "config", filepath.Base(configRoot), "hooks", wrapperName)) @@ -244,19 +244,19 @@ func hookSettings(hooks []compiledHook) map[string]any { return map[string]any{"hooks": events} } -func writeHookSettings(path string, hooks []compiledHook) error { +func writeHookSettings(workspace, path string, hooks []compiledHook) error { raw, err := json.MarshalIndent(hookSettings(hooks), "", " ") if err != nil { return err } - return writeManagedFile(path, raw, 0o600) + return writeManagedFile(workspace, path, raw, 0o600) } -func mergeHookSettings(path string, hooks []compiledHook) error { +func mergeHookSettings(workspace, path string, hooks []compiledHook) error { if len(hooks) == 0 { return nil } - raw, err := os.ReadFile(path) + raw, err := readWorkspaceFile(workspace, path) if err != nil { return err } @@ -278,7 +278,7 @@ func mergeHookSettings(path string, hooks []compiledHook) error { if err != nil { return err } - return writeManagedFile(path, merged, 0o600) + return writeManagedFile(workspace, path, merged, 0o600) } func codexHookConfigArgs(hooks []compiledHook) []string { diff --git a/internal/runtime/managed_files.go b/internal/runtime/managed_files.go new file mode 100644 index 0000000..78155d2 --- /dev/null +++ b/internal/runtime/managed_files.go @@ -0,0 +1,101 @@ +package runtime + +import ( + "bytes" + "crypto/rand" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// openWorkspacePath anchors operations before resolving any untrusted components. +// Reject symlinks as a policy; os.Root also confines operations if a component is +// replaced between validation and use. The workspace itself is a trusted root. +func openWorkspacePath(workspace, target string) (*os.Root, string, error) { + base, err := filepath.Abs(workspace) + if err != nil { + return nil, "", err + } + target, err = filepath.Abs(target) + if err != nil { + return nil, "", err + } + rel, err := filepath.Rel(base, target) + if err != nil || !filepath.IsLocal(rel) || rel == "." { + return nil, "", fmt.Errorf("invalid workspace file path %q", target) + } + root, err := os.OpenRoot(base) + if err != nil { + return nil, "", err + } + if err := rejectWorkspaceSymlinks(root, rel); err != nil { + root.Close() + return nil, "", err + } + return root, rel, nil +} + +func rejectWorkspaceSymlinks(root *os.Root, rel string) error { + current := "" + for _, part := range strings.Split(rel, string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := root.Lstat(current) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("workspace path is a symlink: %s", current) + } + } + return nil +} + +func readWorkspaceFile(workspace, target string) ([]byte, error) { + root, rel, err := openWorkspacePath(workspace, target) + if err != nil { + return nil, err + } + defer root.Close() + return root.ReadFile(rel) +} + +func writeManagedFile(workspace, target string, content []byte, mode os.FileMode) error { + return writeManagedReader(workspace, target, bytes.NewReader(content), mode) +} + +func writeManagedReader(workspace, target string, content io.Reader, mode os.FileMode) error { + root, rel, err := openWorkspacePath(workspace, target) + if err != nil { + return err + } + defer root.Close() + if info, err := root.Lstat(rel); err == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf("managed path is not a regular file: %s", target) + } + } else if !os.IsNotExist(err) { + return err + } + // Never truncate an existing inode: a sandbox may have hard-linked it to + // another file. A fresh inode also applies the requested permissions on reuse. + temp := filepath.Join(filepath.Dir(rel), ".agentflow-write-"+rand.Text()) + file, err := root.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + defer root.Remove(temp) + _, err = io.Copy(file, content) + closeErr := file.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return root.Rename(temp, rel) +} diff --git a/internal/runtime/managed_files_test.go b/internal/runtime/managed_files_test.go new file mode 100644 index 0000000..3a7a63a --- /dev/null +++ b/internal/runtime/managed_files_test.go @@ -0,0 +1,275 @@ +package runtime + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + + "agentflow/internal/domain" +) + +func putSecurityFixture(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } +} +func checkSecurityFixture(t *testing.T, path, want string) { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil || string(raw) != want { + t.Fatalf("%s: got %q, %v; want %q", path, raw, err, want) + } +} + +func TestManagedFileRejectsEscapesAndSymlinks(t *testing.T) { + for _, kind := range []string{"outside", "parent", "file"} { + t.Run(kind, func(t *testing.T) { + workspace, outside := t.TempDir(), t.TempDir() + victim := filepath.Join(outside, "keep") + putSecurityFixture(t, victim, "keep") + target := victim + switch kind { + case "parent": + if err := os.Symlink(outside, filepath.Join(workspace, "link")); err != nil { + t.Fatal(err) + } + target = filepath.Join(workspace, "link", "keep") + case "file": + target = filepath.Join(workspace, "link") + if err := os.Symlink(victim, target); err != nil { + t.Fatal(err) + } + } + if err := writeManagedFile(workspace, target, []byte("bad"), 0700); err == nil { + t.Fatal("unsafe write accepted") + } + if _, err := readWorkspaceFile(workspace, target); err == nil { + t.Fatal("unsafe read accepted") + } + checkSecurityFixture(t, victim, "keep") + }) + } +} + +func TestManagedFileReplacesHardlinkAndRestrictsPermissions(t *testing.T) { + workspace := t.TempDir() + victim := filepath.Join(t.TempDir(), "keep") + putSecurityFixture(t, victim, "keep") + target := filepath.Join(workspace, "managed") + if err := os.Link(victim, target); err != nil { + t.Fatal(err) + } + if err := os.Chmod(target, 0777); err != nil { + t.Fatal(err) + } + if err := writeManagedFile(workspace, target, []byte("new"), 0600); err != nil { + t.Fatal(err) + } + checkSecurityFixture(t, victim, "keep") + checkSecurityFixture(t, target, "new") + info, err := os.Stat(target) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("permissions: %v, %v", info, err) + } +} + +func TestGitExcludesRejectSymlinks(t *testing.T) { + for _, rel := range []string{".git", ".git/info", ".git/info/exclude"} { + 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/info/exclude" { + victim = filepath.Join(outside, "keep") + putSecurityFixture(t, victim, "keep") + } + if err := os.Symlink(victim, target); err != nil { + t.Fatal(err) + } + if err := EnsureAgentFlowGitExcludes(workspace); err == nil { + t.Fatal("symlink accepted") + } + if rel == ".git/info/exclude" { + checkSecurityFixture(t, victim, "keep") + } + if _, err := os.Stat(filepath.Join(outside, "info")); !os.IsNotExist(err) { + t.Fatalf("outside directory modified: %v", err) + } + }) + } +} + +func TestMCPFilesRejectUnsafePathsBeforeDeleting(t *testing.T) { + for _, name := range []string{"../escape", "/escape", "sub/../../escape"} { + t.Run(name, func(t *testing.T) { + workspace := t.TempDir() + old := filepath.Join(workspace, ".agentflow", "mcp", "server", "old") + putSecurityFixture(t, old, "old") + server := domain.MCPServerDefinition{ID: "server", FileRefs: []domain.MCPFileRef{{Path: name}}} + if err := materializeMCPFiles(workspace, server, "server", map[string][]byte{name: []byte("bad")}, "hash"); err == nil { + t.Fatal("unsafe upload accepted") + } + checkSecurityFixture(t, old, "old") + }) + } +} + +func TestMCPFilesRejectSymlinkedTreesAndMarkers(t *testing.T) { + for _, rel := range []string{".agentflow", ".agentflow/mcp", ".agentflow/mcp/server", ".agentflow/runtime/mcp-sources", ".agentflow/runtime/mcp-sources/server.sha256"} { + 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 filepath.Ext(rel) == ".sha256" { + victim = filepath.Join(outside, "marker") + putSecurityFixture(t, victim, "hash") + } + keep := filepath.Join(outside, "keep") + putSecurityFixture(t, keep, "keep") + if err := os.Symlink(victim, target); err != nil { + t.Fatal(err) + } + server := domain.MCPServerDefinition{ID: "server", FileRefs: []domain.MCPFileRef{{Path: "sub/main.py"}}} + if err := materializeMCPFiles(workspace, server, "server", map[string][]byte{"sub/main.py": []byte("bad")}, "hash"); err == nil { + t.Fatal("symlink accepted") + } + checkSecurityFixture(t, keep, "keep") + if _, err := os.Stat(filepath.Join(outside, "sub", "main.py")); !os.IsNotExist(err) { + t.Fatalf("outside file written: %v", err) + } + }) + } +} + +func TestPrepareMCPCallRejectsSymlinks(t *testing.T) { + for _, rel := range []string{".agentflow", ".agentflow/runtime", ".agentflow/runtime/mcp-servers.json", ".agentflow/runtime/bin/agentflow-helper", ".agentflow/runtime/bin/agentflow"} { + 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 != ".agentflow" && rel != ".agentflow/runtime" { + victim = filepath.Join(outside, "keep") + putSecurityFixture(t, victim, "keep") + } + if err := os.Symlink(victim, target); err != nil { + t.Fatal(err) + } + if _, err := PrepareMCPCall(nil, workspace, nil); err == nil { + t.Fatal("symlink accepted") + } + if victim != outside { + checkSecurityFixture(t, victim, "keep") + } + }) + } +} + +func TestGitAskPassRejectsSymlink(t *testing.T) { + workspace := t.TempDir() + target := filepath.Join(workspace, ".agentflow", "runtime", "git-askpass") + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + t.Fatal(err) + } + victim := filepath.Join(t.TempDir(), "keep") + putSecurityFixture(t, victim, "keep") + if err := os.Symlink(victim, target); err != nil { + t.Fatal(err) + } + if err := writeGitAskPass(workspace, target); err == nil { + t.Fatal("symlink accepted") + } + checkSecurityFixture(t, victim, "keep") +} + +func TestWorkspacePreparationDoesNotReadOutsideState(t *testing.T) { + workspace, outside := t.TempDir(), t.TempDir() + putSecurityFixture(t, filepath.Join(outside, "workspace-preparation", "state.json"), `{"status":"ready"}`) + if err := os.Symlink(outside, filepath.Join(workspace, ".agentflow")); err != nil { + t.Fatal(err) + } + if state := ReadWorkspacePreparationStatus(workspace); state.Status != "unprepared" { + t.Fatalf("read outside state: %#v", state) + } +} + +func TestEnvironmentInstallRejectsSymlinkStamp(t *testing.T) { + workspace := t.TempDir() + root := filepath.Join(workspace, ".agentflow", "environment", "env") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + victim := filepath.Join(t.TempDir(), "keep") + putSecurityFixture(t, victim, "keep") + if err := os.Symlink(victim, filepath.Join(root, installStampName)); err != nil { + t.Fatal(err) + } + sb := &provisionSandbox{workspace: workspace} + activated := activatedEnvironment{Root: root, InstallRel: "install.sh", InstallScript: "echo bad"} + if err := runEnvironmentInstall(context.Background(), sb, activated); err == nil { + t.Fatal("symlink accepted") + } + if sb.commands != 0 { + t.Fatal("installation ran before validating marker") + } + checkSecurityFixture(t, victim, "keep") +} + +func TestTerminalHomeCleanupRestrictsLegacyPaths(t *testing.T) { + victim := filepath.Join(t.TempDir(), "keep") + putSecurityFixture(t, victim, "keep") + if err := removeTerminalHome(filepath.Dir(victim)); err == nil { + t.Fatal("arbitrary cleanup accepted") + } + checkSecurityFixture(t, victim, "keep") + home, err := os.MkdirTemp("", "agentflow-terminal-home-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(home) }) + putSecurityFixture(t, filepath.Join(home, "data"), "temporary") + if err := removeTerminalHome(home); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(home); !os.IsNotExist(err) { + t.Fatalf("legacy home remains: %v", err) + } +} + +func TestControlDirectoryConcurrentCreation(t *testing.T) { + workspace := t.TempDir() + var wg sync.WaitGroup + start := make(chan struct{}) + errors := make(chan error, 64) + for i := 0; i < cap(errors); i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := EnsureControlDirectory(workspace, "runtime", "bin") + if err != nil { + errors <- err + } + }() + } + close(start) + wg.Wait() + close(errors) + for err := range errors { + t.Errorf("concurrent directory creation: %v", err) + } +} diff --git a/internal/runtime/materialize.go b/internal/runtime/materialize.go index 36067cb..c67f5d9 100644 --- a/internal/runtime/materialize.go +++ b/internal/runtime/materialize.go @@ -88,7 +88,7 @@ func materializeInvocationForDriver(workspace, invocationID string, ex domain.Ex if err != nil { return compiled, err } - compiled.Hooks, err = materializeAgentHooks(configRoot, agentRel, ex.Kind, effectiveHookDefinitions) + compiled.Hooks, err = materializeAgentHooks(workspace, configRoot, agentRel, ex.Kind, effectiveHookDefinitions) if err != nil { return compiled, err } @@ -112,7 +112,7 @@ func materializeInvocationForDriver(workspace, invocationID string, ex domain.Ex return compiled, fmt.Errorf("rule id is required") } ruleFile := filepath.Join(workspace, ".agentflow", "rules", ruleID+".md") - if err := writeManagedFile(ruleFile, []byte(v.Content), 0o600); err != nil { + if err := writeManagedFile(workspace, ruleFile, []byte(v.Content), 0o600); err != nil { return compiled, err } if err := replaceRelativeSymlink(filepath.Join(bindingRoot, "rules", ruleID+".md"), filepath.Join("..", "..", "..", "rules", ruleID+".md")); err != nil { @@ -204,7 +204,7 @@ func materializeInvocationForDriver(workspace, invocationID string, ex domain.Ex // Agent definition is the sole allowlist for tools in unattended runs. mergedMCP := mergeMCPServers(assets.MCPServers, nil) if strings.TrimSpace(options.GateResultPath) != "" { - gateServer, err := materializeGateMCP(configRoot, filepath.Join(".agentflow", "config", agentID, "gate-mcp"), options.GateResultPath, driver) + gateServer, err := materializeGateMCP(workspace, configRoot, filepath.Join(".agentflow", "config", agentID, "gate-mcp"), options.GateResultPath, driver) if err != nil { return compiled, err } @@ -223,7 +223,7 @@ func materializeInvocationForDriver(workspace, invocationID string, ex domain.Ex return compiled, fmt.Errorf("MCP server id is required") } definition, _ := json.MarshalIndent(server, "", " ") - if err := writeManagedFile(filepath.Join(workspace, ".agentflow", "mcp", serverID+".json"), definition, 0o600); err != nil { + if err := writeManagedFile(workspace, filepath.Join(workspace, ".agentflow", "mcp", serverID+".json"), definition, 0o600); err != nil { return compiled, err } if err := replaceRelativeSymlink(filepath.Join(bindingRoot, "mcp", serverID+".json"), filepath.Join("..", "..", "..", "mcp", serverID+".json")); err != nil { @@ -287,33 +287,27 @@ func resetControlDirectory(workspace string, elements ...string) (string, error) return "", fmt.Errorf("invalid AgentFlow control path segment %q", name) } target := filepath.Join(parent, name) - if info, err := os.Lstat(target); err == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + root, rel, err := openWorkspacePath(workspace, target) + if err != nil { + return "", err + } + defer root.Close() + if info, err := root.Lstat(rel); err == nil { + if !info.IsDir() { return "", fmt.Errorf("managed path is not a real directory: %s", target) } - if err := os.RemoveAll(target); err != nil { + if err := root.RemoveAll(rel); err != nil { return "", err } } else if !os.IsNotExist(err) { return "", err } - if err := os.Mkdir(target, 0o700); err != nil { + if err := root.Mkdir(rel, 0o700); err != nil { return "", err } return target, nil } -func writeManagedFile(path string, content []byte, mode os.FileMode) error { - if info, err := os.Lstat(path); err == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return fmt.Errorf("managed path is not a regular file: %s", path) - } - } else if !os.IsNotExist(err) { - return err - } - return os.WriteFile(path, content, mode) -} - func replaceRelativeSymlink(path, target string) error { if filepath.IsAbs(target) { return fmt.Errorf("AgentFlow link target must be relative: %s", target) @@ -334,33 +328,68 @@ func replaceRelativeSymlink(path, target string) error { // EnsureControlRoot validates the only repository-adjacent directory the // control plane writes before the sandboxed executor starts. func EnsureControlRoot(workspace string) error { - workspace, err := filepath.Abs(workspace) + root, err := os.OpenRoot(workspace) if err != nil { return err } - if err := ensureDirectoryNoSymlink(filepath.Join(workspace, ".agentflow"), 0o700); err != nil { + defer root.Close() + if err := ensureRootDirectory(root, ".agentflow"); err != nil { return err } return EnsureAgentFlowGitExcludes(workspace) } +func ensureRootDirectory(root *os.Root, rel string) error { + info, err := root.Lstat(rel) + if os.IsNotExist(err) { + err = root.Mkdir(rel, 0o700) + if err == nil { + return nil + } + if !os.IsExist(err) { + return err + } + // Another worker may have created it after Lstat. Revalidate the winner; + // an existing symlink or non-directory must still fail closed. + info, err = root.Lstat(rel) + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("runtime path is not a real directory: %s", rel) + } + return nil +} + // EnsureControlDirectory creates a directory below .agentflow while rejecting -// symlinks in every control-plane-owned path component. +// symlinks in every control-plane-owned path component. os.Root confines mkdir +// even if a sandbox replaces a checked ancestor before it is used. func EnsureControlDirectory(workspace string, elements ...string) (string, error) { - if err := EnsureControlRoot(workspace); err != nil { - return "", err - } - current := filepath.Join(workspace, ".agentflow") for _, element := range elements { if element == "" || element == "." || element == ".." || filepath.Base(element) != element { return "", fmt.Errorf("invalid AgentFlow control path segment %q", element) } + } + if err := EnsureControlRoot(workspace); err != nil { + return "", err + } + root, err := os.OpenRoot(workspace) + if err != nil { + return "", err + } + defer root.Close() + current := ".agentflow" + if err := ensureRootDirectory(root, current); err != nil { + return "", err + } + for _, element := range elements { current = filepath.Join(current, element) - if err := ensureDirectoryNoSymlink(current, 0o700); err != nil { + if err := ensureRootDirectory(root, current); err != nil { return "", err } } - return current, nil + return filepath.Join(workspace, current), nil } func prepareRuntimeRoot(workspace, invocationID string) (string, error) { diff --git a/internal/runtime/mcp_call.go b/internal/runtime/mcp_call.go index 61cb788..465c4a2 100644 --- a/internal/runtime/mcp_call.go +++ b/internal/runtime/mcp_call.go @@ -6,13 +6,11 @@ import ( "context" "encoding/json" "fmt" - "io" "net/url" "os" "os/exec" "path" "path/filepath" - goruntime "runtime" "strings" "time" @@ -85,7 +83,7 @@ func mcpCallGuestPath(rel string) string { func PrepareMCPCall(sandbox Sandbox, workspace string, servers []domain.MCPServerDefinition) (map[string]string, error) { hostRoot := mcpCallHostDir(workspace) hostBin := filepath.Join(hostRoot, "bin") - if err := os.MkdirAll(hostBin, 0o700); err != nil { + if _, err := EnsureControlDirectory(workspace, "runtime", "bin"); err != nil { return nil, fmt.Errorf("create mcp call dir: %w", err) } driver := sandboxDriver(sandbox) @@ -126,11 +124,11 @@ func PrepareMCPCall(sandbox Sandbox, workspace string, servers []domain.MCPServe return nil, err } configHost := filepath.Join(hostRoot, "mcp-servers.json") - if err := os.WriteFile(configHost, raw, 0o600); err != nil { + if err := writeManagedFile(workspace, configHost, raw, 0o600); err != nil { return nil, fmt.Errorf("write mcp call config: %w", err) } helperHost := filepath.Join(hostBin, "agentflow-helper") - if err := installMCPCallHelper(helperHost); err != nil { + if err := installMCPCallHelper(workspace, helperHost); err != nil { return nil, err } wrapperHost := filepath.Join(hostBin, "agentflow") @@ -147,12 +145,9 @@ func PrepareMCPCall(sandbox Sandbox, workspace string, servers []domain.MCPServe wrapperEnv = mcpCallGuestPath("bin/agentflow") fallback = "" } - if err := os.WriteFile(wrapperHost, []byte(mcpCallWrapperScript(helperExec, fallback)), 0o700); err != nil { + if err := writeManagedFile(workspace, wrapperHost, []byte(mcpCallWrapperScript(helperExec, fallback)), 0o700); err != nil { return nil, fmt.Errorf("write mcp call wrapper: %w", err) } - if err := os.Chmod(wrapperHost, 0o700); err != nil { - return nil, err - } return map[string]string{ mcpCallConfigEnv: configEnv, mcpCallBinEnv: wrapperEnv, @@ -177,63 +172,40 @@ func mcpCallWrapperScript(helperPath, fallback string) string { return b.String() } -func installMCPCallHelper(dest string) error { +func installMCPCallHelper(workspace, dest string) error { src, err := os.Executable() if err != nil { - return nil + return err } srcInfo, err := os.Stat(src) if err != nil { - return nil + return err } - if dstInfo, err := os.Stat(dest); err == nil { - same, linkErr := sameFile(src, dest) - if linkErr == nil && same { - return nil + root, rel, err := openWorkspacePath(workspace, dest) + if err != nil { + return err + } + defer root.Close() + if dstInfo, err := root.Stat(rel); err == nil { + if !dstInfo.Mode().IsRegular() { + return fmt.Errorf("MCP helper is not a regular file") } if dstInfo.Size() == srcInfo.Size() && !srcInfo.ModTime().After(dstInfo.ModTime()) { return nil } - _ = os.Remove(dest) - } - if err := os.Link(src, dest); err == nil { - return nil - } - if goruntime.GOOS != "linux" { - // Local macOS/Windows sandboxes re-exec AGENTFLOW_BIN. Linux docker - // sandboxes cannot see the host executable path, so they need a copy. - return nil - } - return copyFileMode(src, dest, 0o755) -} - -func sameFile(a, b string) (bool, error) { - ai, err := os.Stat(a) - if err != nil { - return false, err - } - bi, err := os.Stat(b) - if err != nil { - return false, err + } else if !os.IsNotExist(err) { + return err } - return os.SameFile(ai, bi), nil + return copyFileMode(workspace, src, dest, 0o755) } -func copyFileMode(src, dest string, mode os.FileMode) error { +func copyFileMode(workspace, src, dest string, mode os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() - out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) - if err != nil { - return err - } - defer out.Close() - if _, err := io.Copy(out, in); err != nil { - return err - } - return os.Chmod(dest, mode) + return writeManagedReader(workspace, dest, in, mode) } func mcpCallAliases(server domain.MCPServerDefinition) []string { diff --git a/internal/runtime/mcp_call_test.go b/internal/runtime/mcp_call_test.go index 6b1fdb4..d06f1fe 100644 --- a/internal/runtime/mcp_call_test.go +++ b/internal/runtime/mcp_call_test.go @@ -253,7 +253,7 @@ func TestHookScriptCanCallMCP(t *testing.T) { t.Fatal(err) } hook := domain.AgentHook{ID: "audit", Event: "Stop", Language: "shell", Script: "agentflow mcp list > hook-mcp.json\n"} - compiled, err := materializeAgentHooks(configRoot, ".agentflow/reviewer", "codebuddy", []domain.AgentHook{hook}) + compiled, err := materializeAgentHooks(workspace, configRoot, ".agentflow/reviewer", "codebuddy", []domain.AgentHook{hook}) if err != nil || len(compiled) != 1 { t.Fatalf("compile hook: %v %#v", err, compiled) } diff --git a/internal/runtime/mcp_provision.go b/internal/runtime/mcp_provision.go index b11c312..c946034 100644 --- a/internal/runtime/mcp_provision.go +++ b/internal/runtime/mcp_provision.go @@ -189,9 +189,12 @@ func ProvisionMCPAssets(ctx context.Context, sandbox Sandbox, workspace string, } func prepareMCPProcessRuntime(workspace string, server domain.MCPServerDefinition) error { - root := filepath.Join(workspace, filepath.FromSlash(mcpRuntimeRootRel(server))) + root, err := EnsureControlDirectory(workspace, "mcp-runtime", safeID(server.ID)) + if err != nil { + return err + } for _, dir := range []string{"bin", "home", "tmp", "cache", "config", "data"} { - if err := os.MkdirAll(filepath.Join(root, dir), 0o700); err != nil { + if _, err := EnsureControlDirectory(workspace, "mcp-runtime", safeID(server.ID), dir); err != nil { return err } } @@ -217,45 +220,60 @@ func prepareMCPProcessRuntime(workspace string, server domain.MCPServerDefinitio b.WriteString(" " + shellSingleQuote(arg)) } b.WriteString("\n") - return writeManagedFile(filepath.Join(root, "launch"), []byte(b.String()), 0o700) + return writeManagedFile(workspace, filepath.Join(root, "launch"), []byte(b.String()), 0o700) } func materializeMCPFiles(workspace string, server domain.MCPServerDefinition, serverID string, files map[string][]byte, hash string) error { if len(server.FileRefs) == 0 { return nil } - root := filepath.Join(workspace, ".agentflow", "mcp", serverID) - stateDir := filepath.Join(workspace, ".agentflow", "runtime", "mcp-sources") - state := filepath.Join(stateDir, serverID+".sha256") - if raw, err := os.ReadFile(state); err == nil && strings.TrimSpace(string(raw)) == hash { - return nil - } - if err := os.RemoveAll(root); err != nil { - return err - } - if err := os.MkdirAll(root, 0o700); err != nil { - return err + if serverID == "" || safeID(serverID) != serverID { + return fmt.Errorf("invalid MCP runtime id %q", serverID) } for _, ref := range server.FileRefs { if err := domain.ValidateRelativeFilePath(ref.Path); err != nil { return err } - data, ok := files[ref.Path] - if !ok { + if _, ok := files[ref.Path]; !ok { return fmt.Errorf("MCP %s missing uploaded file %s", server.ID, ref.Path) } - target := filepath.Join(root, filepath.FromSlash(ref.Path)) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + } + target := filepath.Join(workspace, ".agentflow", "mcp", serverID) + base, root, err := openWorkspacePath(workspace, target) + if err != nil { + return err + } + defer base.Close() + state := filepath.Join(".agentflow", "runtime", "mcp-sources", serverID+".sha256") + if err := rejectWorkspaceSymlinks(base, state); err != nil { + return err + } + raw, err := base.ReadFile(state) + if err != nil && !os.IsNotExist(err) { + return err + } + if err == nil && strings.TrimSpace(string(raw)) == hash { + return nil + } + if err := base.RemoveAll(root); err != nil { + return err + } + if err := base.MkdirAll(root, 0o700); err != nil { + return err + } + for _, ref := range server.FileRefs { + name := filepath.Join(root, filepath.FromSlash(ref.Path)) + if err := base.MkdirAll(filepath.Dir(name), 0o755); err != nil { return err } - if err := os.WriteFile(target, data, os.FileMode(domain.NormalizeSkillFileMode(ref.Mode))); err != nil { + if err := writeManagedFile(workspace, filepath.Join(workspace, name), files[ref.Path], os.FileMode(domain.NormalizeSkillFileMode(ref.Mode))); err != nil { return err } } - if err := os.MkdirAll(stateDir, 0o700); err != nil { + if err := base.MkdirAll(filepath.Dir(state), 0o700); err != nil { return err } - return os.WriteFile(state, []byte(hash+"\n"), 0o600) + return writeManagedFile(workspace, filepath.Join(workspace, state), []byte(hash+"\n"), 0o600) } func mcpProvisionHash(server domain.MCPServerDefinition, files map[string][]byte) string { diff --git a/internal/runtime/provider_claude.go b/internal/runtime/provider_claude.go index 41ec994..71bdfba 100644 --- a/internal/runtime/provider_claude.go +++ b/internal/runtime/provider_claude.go @@ -24,7 +24,7 @@ func (claudeProvider) Materialize(ctx *providerMaterializeContext) error { ) if len(ctx.Compiled.Hooks) > 0 { settingsPath := filepath.Join(ctx.ConfigRoot, "claude-settings.json") - if err := writeHookSettings(settingsPath, ctx.Compiled.Hooks); err != nil { + if err := writeHookSettings(ctx.Workspace, settingsPath, ctx.Compiled.Hooks); err != nil { return err } ctx.Compiled.Args = append(ctx.Compiled.Args, "--settings", filepath.ToSlash(filepath.Join(".agentflow", "config", ctx.AgentID, "claude-settings.json"))) diff --git a/internal/runtime/provider_codebuddy.go b/internal/runtime/provider_codebuddy.go index cda72be..02d1958 100644 --- a/internal/runtime/provider_codebuddy.go +++ b/internal/runtime/provider_codebuddy.go @@ -19,13 +19,13 @@ func (codeBuddyProvider) Materialize(ctx *providerMaterializeContext) error { if _, err := resetControlDirectory(ctx.Workspace, "hooks", "codebuddy"); err != nil { return err } - if err := writeManagedFile(filepath.Join(ctx.Workspace, ".agentflow", "hooks", "codebuddy-settings.json"), nil, 0o600); err != nil { + if err := writeManagedFile(ctx.Workspace, filepath.Join(ctx.Workspace, ".agentflow", "hooks", "codebuddy-settings.json"), nil, 0o600); err != nil { return err } if err := observability.WriteCodeBuddyHookBridgeAt(ctx.Workspace, filepath.Join(".agentflow", "hooks", "codebuddy")); err != nil { return err } - if err := mergeHookSettings(filepath.Join(ctx.Workspace, ".agentflow", "hooks", "codebuddy-settings.json"), ctx.Compiled.Hooks); err != nil { + if err := mergeHookSettings(ctx.Workspace, filepath.Join(ctx.Workspace, ".agentflow", "hooks", "codebuddy-settings.json"), ctx.Compiled.Hooks); err != nil { return err } if err := replaceRelativeSymlink(filepath.Join(ctx.AgentRoot, "hooks"), filepath.Join("..", "hooks", "codebuddy")); err != nil { diff --git a/internal/runtime/runner.go b/internal/runtime/runner.go index ef44e39..456d05a 100644 --- a/internal/runtime/runner.go +++ b/internal/runtime/runner.go @@ -221,7 +221,7 @@ func writeTaskPromptFile(workspace, runtimeRel, prompt string) (string, error) { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return "", err } - if err := writeManagedFile(path, []byte(prompt), 0o600); err != nil { + if err := writeManagedFile(workspace, path, []byte(prompt), 0o600); err != nil { return "", err } return rel, nil diff --git a/internal/runtime/sandbox_docker.go b/internal/runtime/sandbox_docker.go index 79be900..51e2aaa 100644 --- a/internal/runtime/sandbox_docker.go +++ b/internal/runtime/sandbox_docker.go @@ -686,7 +686,7 @@ func prepareDockerfileBuildContext(workspace string, dockerfile domain.Dockerfil if err != nil { return "", err } - if err := writeManagedFile(filepath.Join(root, "Dockerfile"), []byte(dockerfile.Content), 0o600); err != nil { + if err := writeManagedFile(workspace, filepath.Join(root, "Dockerfile"), []byte(dockerfile.Content), 0o600); err != nil { return "", err } for name, data := range dockerfile.Files { @@ -704,7 +704,7 @@ func prepareDockerfileBuildContext(workspace string, dockerfile domain.Dockerfil if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return "", err } - if err := writeManagedFile(target, []byte(data), 0o644); err != nil { + if err := writeManagedFile(workspace, target, []byte(data), 0o644); err != nil { return "", err } } diff --git a/internal/runtime/sandbox_environment.go b/internal/runtime/sandbox_environment.go index ca1a365..a5c91e8 100644 --- a/internal/runtime/sandbox_environment.go +++ b/internal/runtime/sandbox_environment.go @@ -104,7 +104,7 @@ func prepareSandboxEnvironment(workspace string, profile domain.SandboxProfile, } if len(pathDirs) > 0 { overlay["PATH"] = strings.Join(append(append([]string{}, pathDirs...), basePath), string(os.PathListSeparator)) - zdot, bashEnv, err := writeEnvironmentShellHooks(root, pathDirs) + zdot, bashEnv, err := writeEnvironmentShellHooks(workspace, root, pathDirs) if err != nil { return activatedEnvironment{}, err } @@ -144,7 +144,7 @@ func writeEnvironmentInstall(workspace string, env domain.EnvironmentDefinition) script = "#!/bin/sh\n" + script } target := filepath.Join(root, installScriptName) - if err := writeManagedFile(target, []byte(script+"\n"), 0o700); err != nil { + if err := writeManagedFile(workspace, target, []byte(script+"\n"), 0o700); err != nil { return "", "", err } rel := filepath.ToSlash(filepath.Join(".agentflow", managedEnvironmentDir, envID, installScriptName)) @@ -155,16 +155,21 @@ func runEnvironmentInstall(ctx context.Context, sandbox Sandbox, activated activ if strings.TrimSpace(activated.InstallRel) == "" || strings.TrimSpace(activated.InstallScript) == "" { return nil } + workspace := sandbox.Workspace() stamp := filepath.Join(activated.Root, installStampName) sum := sha256.Sum256([]byte(activated.InstallScript)) want := hex.EncodeToString(sum[:]) - if current, err := os.ReadFile(stamp); err == nil && strings.TrimSpace(string(current)) == want { + current, err := readWorkspaceFile(workspace, stamp) + if err != nil && !os.IsNotExist(err) { + return err + } + if err == nil && strings.TrimSpace(string(current)) == want { return nil } if _, err := sandbox.Exec(ctx, CommandSpec{Argv: []string{"sh", activated.InstallRel}}, nil); err != nil { return fmt.Errorf("environment install.sh: %w", err) } - return writeManagedFile(stamp, []byte(want+"\n"), 0o600) + return writeManagedFile(workspace, stamp, []byte(want+"\n"), 0o600) } func discoverEnvironmentRoot(workspace, envID string) (string, bool) { @@ -185,7 +190,7 @@ func discoverEnvironmentRoot(workspace, envID string) (string, bool) { return "", false } -func writeEnvironmentShellHooks(root string, pathDirs []string) (zdot, bashEnv string, err error) { +func writeEnvironmentShellHooks(workspace, root string, pathDirs []string) (zdot, bashEnv string, err error) { if root == "" || len(pathDirs) == 0 { return "", "", nil } @@ -196,12 +201,12 @@ func writeEnvironmentShellHooks(root string, pathDirs []string) (zdot, bashEnv s prepend := strings.Join(pathDirs, string(os.PathListSeparator)) snippet := environmentPathHook(prepend) for _, name := range []string{".zshenv", ".zprofile", ".zshrc"} { - if err := writeManagedFile(filepath.Join(zdot, name), []byte(snippet), 0o600); err != nil { + if err := writeManagedFile(workspace, filepath.Join(zdot, name), []byte(snippet), 0o600); err != nil { return "", "", err } } bashEnv = filepath.Join(zdot, environmentBashEnvName) - if err := writeManagedFile(bashEnv, []byte(snippet), 0o600); err != nil { + if err := writeManagedFile(workspace, bashEnv, []byte(snippet), 0o600); err != nil { return "", "", err } return zdot, bashEnv, nil diff --git a/internal/runtime/workspace_preparation.go b/internal/runtime/workspace_preparation.go index 3a2c262..426b25e 100644 --- a/internal/runtime/workspace_preparation.go +++ b/internal/runtime/workspace_preparation.go @@ -41,7 +41,7 @@ type WorkspacePreparationStatus struct { func ReadWorkspacePreparationStatus(root string) WorkspacePreparationStatus { state := WorkspacePreparationStatus{Status: "unprepared", Steps: []WorkspacePreparationStep{}} - raw, err := os.ReadFile(filepath.Join(root, ".agentflow", "workspace-preparation", "state.json")) + raw, err := readWorkspaceFile(root, filepath.Join(root, ".agentflow", "workspace-preparation", "state.json")) if err == nil { _ = json.Unmarshal(raw, &state) } diff --git a/internal/runtime/workspace_terminal_sandbox.go b/internal/runtime/workspace_terminal_sandbox.go index 88e03b7..ce76e19 100644 --- a/internal/runtime/workspace_terminal_sandbox.go +++ b/internal/runtime/workspace_terminal_sandbox.go @@ -233,7 +233,24 @@ func removeTerminalHome(home string) error { defer root.Close() return root.RemoveAll(filepath.Join(".agentflow", "private-home", filepath.Base(home))) } - return os.RemoveAll(home) + // The legacy local terminal creates homes only with this prefix in TempDir. + tempRoot, err := filepath.Abs(os.TempDir()) + if err != nil { + return err + } + absolute, err := filepath.Abs(home) + if err != nil { + return err + } + if filepath.Dir(absolute) != tempRoot || !strings.HasPrefix(filepath.Base(absolute), "agentflow-terminal-home-") { + return fmt.Errorf("invalid terminal home %q", home) + } + root, err := os.OpenRoot(tempRoot) + if err != nil { + return err + } + defer root.Close() + return root.RemoveAll(filepath.Base(absolute)) } // Local tool installs may persist on disk, but background services die when diff --git a/internal/tracker/service.go b/internal/tracker/service.go index 8758772..7862ab6 100644 --- a/internal/tracker/service.go +++ b/internal/tracker/service.go @@ -66,6 +66,7 @@ type IssueStoreAdapter interface { Projects() []domain.Project Project(id string) (domain.Project, bool) PutIssue(v domain.Issue) error + PutSyncedIssue(v domain.Issue) error DeleteIssue(id string) error PutSyncConfig(cfg domain.SyncConfig) error SyncConfig() domain.SyncConfig @@ -81,12 +82,6 @@ type ProjectAuthStore interface { MCPServers() []domain.MCPServerDefinition } -// DeliveryReconcileStore backfills delivery lock fields after external sync. -type DeliveryReconcileStore interface { - ReconcileMergedMRDeliveries() error - ReconcileDeliveryMRStates() error -} - func NewService() *Service { return &Service{ cfg: &syncConfigHolder{}, @@ -349,12 +344,8 @@ func (s *Service) PullAll(store IssueStoreAdapter) error { syncCount += n pruneDuplicateExternalIssues(store) - if rs, ok := store.(DeliveryReconcileStore); ok { - if err := rs.ReconcileMergedMRDeliveries(); err != nil && lastErr == nil { - lastErr = fmt.Errorf("delivery reconcile: %w", err) - } - _ = rs.ReconcileDeliveryMRStates() - } + // Delivery transitions belong to the independent MR state sync. A tracker + // pull must not change local lifecycle via post-pull reconciliation either. cfg = s.cfg.Get() cfg.LastSyncAt = time.Now().UTC() @@ -518,7 +509,7 @@ func (s *Service) pullGongfengIssues(client *MCPClient, store IssueStoreAdapter, issue.CreatedAt = parseTime(gi.CreatedAt) } issue.UpdatedAt = time.Now().UTC() - if err := store.PutIssue(issue); err != nil { + if err := store.PutSyncedIssue(issue); err != nil { log.Printf("sync gongfeng issue %d: %v", gi.IID, err) continue } @@ -1019,8 +1010,8 @@ func (s *Service) ImportTAPDStory(store IssueStoreAdapter, projectID, rawURL str } // ResyncTAPDIssue re-pulls one TAPD story/bug/task identified by the issue's -// external reference and refreshes the local copy (title/description/status/ -// priority). Local assignee, clarification state and repository bindings are +// external reference and refreshes tracker content, priority and raw state. +// Local lifecycle, assignee, clarification state and repository bindings are // preserved by applyExistingIssue; a manual resync never re-enters the // clarification gate. func (s *Service) ResyncTAPDIssue(store IssueStoreAdapter, issue domain.Issue) (domain.Issue, error) { @@ -1213,7 +1204,7 @@ func (s *Service) upsertTAPDRecord(store IssueStoreAdapter, projectID, workspace scoreStoredRequirement(store, &issue) } issue.UpdatedAt = time.Now().UTC() - if err := store.PutIssue(issue); err != nil { + if err := store.PutSyncedIssue(issue); err != nil { return domain.Issue{}, err } stored, ok := store.Issue(issue.ID) @@ -1876,8 +1867,10 @@ func findTAPDRecord(store IssueStoreAdapter, recordID, kind, workspaceID string) } func applyExistingIssue(dst *domain.Issue, existing domain.Issue) { - previousExternalClosed := existing.TrackerClosed() - remoteClosed := dst.Status == "closed" + // Tracker state is metadata after intake. Local status belongs to the + // platform and must survive scheduled pulls, imports and manual refreshes. + dst.Status = existing.Status + dst.CloseSource = existing.CloseSource dst.ID = existing.ID dst.CreatedAt = existing.CreatedAt if existing.Number > 0 { @@ -1889,12 +1882,6 @@ func applyExistingIssue(dst *domain.Issue, existing domain.Issue) { dst.WorkflowID = existing.WorkflowID dst.LatestTaskID = existing.LatestTaskID dst.AutomationState = existing.AutomationState - if existing.ManuallyClosed() { - dst.CloseSource = "manual" - } else if previousExternalClosed && !remoteClosed && !existing.DeliveryLocked() { - // The external work item reopened; scheduled execution may resume. - dst.AutomationState = "eligible" - } dst.DeliveryState = existing.DeliveryState dst.DeliveryMRURL = existing.DeliveryMRURL dst.DeliveryMRState = existing.DeliveryMRState @@ -1929,36 +1916,6 @@ func applyExistingIssue(dst *domain.Issue, existing domain.Issue) { if existing.BaseBranch != "" { dst.BaseBranch = existing.BaseBranch } - if keepLocalExecutionStatus(existing, dst.Status) { - dst.Status = existing.Status - } else if existing.CloseSource == "external" && !remoteClosed && existing.DeliveryLocked() { - // A completed local delivery remains done after the tracker reopens. - dst.Status = "done" - } - if dst.Status != "closed" { - dst.CloseSource = "" - } -} - -func keepLocalExecutionStatus(existing domain.Issue, remote string) bool { - if existing.ManuallyClosed() { - return true - } - if remote == "closed" || existing.CloseSource == "external" { - return false - } - if existing.DeliveryLocked() { - return true - } - if strings.EqualFold(strings.TrimSpace(existing.DeliveryState), "reopened") { - return true - } - switch existing.Status { - case "in_progress", "blocked", "in_review": - return remote != "done" && remote != "cancelled" - default: - return false - } } func pruneDuplicateExternalIssues(store IssueStoreAdapter) { @@ -1982,7 +1939,7 @@ func pruneDuplicateExternalIssues(store IssueStoreAdapter) { keeper = mergeIssueKeep(keeper, extra) _ = store.DeleteIssue(extra.ID) } - _ = store.PutIssue(keeper) + _ = store.PutSyncedIssue(keeper) } } diff --git a/internal/tracker/service_test.go b/internal/tracker/service_test.go index 6442112..9030533 100644 --- a/internal/tracker/service_test.go +++ b/internal/tracker/service_test.go @@ -229,6 +229,14 @@ func (m *memIssueStore) PutIssue(v domain.Issue) error { m.issues[v.ID] = v return nil } +func (m *memIssueStore) PutSyncedIssue(v domain.Issue) error { + if old, ok := m.Issue(v.ID); ok { + v.Status = old.Status + v.CloseSource = old.CloseSource + v.AutomationState = old.AutomationState + } + return m.PutIssue(v) +} func (m *memIssueStore) DeleteIssue(id string) error { delete(m.issues, id) return nil @@ -326,21 +334,6 @@ type assertError string func (e assertError) Error() string { return string(e) } -func TestKeepLocalExecutionStatusPreservesDeliveredAndReopened(t *testing.T) { - closed := domain.Issue{Status: "closed"} - if !keepLocalExecutionStatus(closed, "in_progress") { - t.Fatal("manual close should remain authoritative during tracker sync") - } - delivered := domain.Issue{Status: "done", DeliveryState: "merged"} - if !keepLocalExecutionStatus(delivered, "in_progress") { - t.Fatal("locked delivery should keep local done") - } - reopened := domain.Issue{Status: "todo", DeliveryState: "reopened"} - if !keepLocalExecutionStatus(reopened, "done") { - t.Fatal("reopened issue should keep local todo") - } -} - func TestTrackerClosedStatusMapping(t *testing.T) { if got := gongfengStateToStatus("closed"); got != "closed" { t.Fatalf("Gongfeng closed = %q", got) @@ -362,17 +355,22 @@ func TestApplyExistingIssueExternalCloseAndReopen(t *testing.T) { existing := domain.Issue{ID: "gf-1", Source: "gongfeng", Status: "in_progress", AutomationState: "eligible", ExternalRef: &domain.ExternalRef{ExternalState: "opened"}} closed := domain.Issue{Source: "gongfeng", Status: "closed", CloseSource: "external", ExternalRef: &domain.ExternalRef{ExternalState: "closed"}} applyExistingIssue(&closed, existing) - if closed.Status != "closed" || closed.CloseSource != "external" { - t.Fatalf("external closure must override local execution: %+v", closed) + if closed.Status != existing.Status || closed.CloseSource != existing.CloseSource || !closed.TrackerClosed() { + t.Fatalf("external closure must refresh metadata and preserve local status: %+v", closed) } - closed.AutomationState = "ignored" // PutIssue marks terminal statuses ignored. reopened := domain.Issue{Status: "todo", ExternalRef: &domain.ExternalRef{ExternalState: "opened"}} applyExistingIssue(&reopened, closed) - if reopened.Status != "todo" || reopened.CloseSource != "" || reopened.AutomationState != "eligible" { - t.Fatalf("external reopen must restore local eligibility: %+v", reopened) + if reopened.Status != existing.Status || reopened.CloseSource != "" || reopened.AutomationState != "eligible" { + t.Fatalf("external reopen must preserve local lifecycle: %+v", reopened) + } + previouslyClosed := domain.Issue{Status: "closed", CloseSource: "external", AutomationState: "ignored"} + applyExistingIssue(&reopened, previouslyClosed) + if reopened.Status != "closed" || reopened.CloseSource != "external" || reopened.AutomationState != "ignored" { + t.Fatalf("external reopen must not reopen a locally closed issue: %+v", reopened) } manual := closed + manual.Status = "closed" manual.CloseSource = "manual" remoteReopened := domain.Issue{Status: "todo", ExternalRef: &domain.ExternalRef{ExternalState: "opened"}} applyExistingIssue(&remoteReopened, manual) @@ -381,6 +379,8 @@ func TestApplyExistingIssueExternalCloseAndReopen(t *testing.T) { } delivered := closed + delivered.Status = "done" + delivered.AutomationState = "ignored" delivered.DeliveryState = "merged" reopenedAfterDelivery := domain.Issue{Status: "todo", ExternalRef: &domain.ExternalRef{ExternalState: "opened"}} applyExistingIssue(&reopenedAfterDelivery, delivered) diff --git a/internal/tracker/sync_status_test.go b/internal/tracker/sync_status_test.go new file mode 100644 index 0000000..22a2e11 --- /dev/null +++ b/internal/tracker/sync_status_test.go @@ -0,0 +1,91 @@ +package tracker + +import ( + "fmt" + "testing" + + "agentflow/internal/domain" + "agentflow/internal/repo" +) + +func TestTAPDRepeatedSyncPreservesLocalStatus(t *testing.T) { + for _, kind := range []string{"requirement", "bug", "task"} { + t.Run(kind, func(t *testing.T) { + store, err := repo.OpenTest() + if err != nil { + t.Fatal(err) + } + defer store.Close() + svc := NewService() + record := tapdStory{ID: "1001", Name: "original", Status: "new"} + created, err := svc.upsertTAPDRecord(store, "platform", "10000000", record, kind, 0, false) + if err != nil { + t.Fatal(err) + } + wantInitial := "backlog" + if kind == "bug" { + wantInitial = "todo" + } + if created.Status != wantInitial { + t.Fatalf("initial status=%q, want %q", created.Status, wantInitial) + } + created.Status = "todo" + created.AutomationState = "eligible" + created.AppendOperation(domain.IssueOperation{Action: "update", Detail: "状态 待规划 → 待处理"}) + if err := store.PutIssue(created); err != nil { + t.Fatal(err) + } + for _, remote := range []string{"new", "planned", "open", "done", "closed", "new"} { + record.Status = remote + record.Name = "refreshed-" + remote + got, err := svc.upsertTAPDRecord(store, "platform", "10000000", record, kind, 0, false) + if err != nil { + t.Fatal(err) + } + if got.Status != "todo" || got.AutomationState != "eligible" || got.CloseSource != "" { + t.Fatalf("remote %q changed local lifecycle: %+v", remote, got) + } + if got.Title != record.Name || got.ExternalRef.ExternalState != remote || len(got.Operations) != 1 { + t.Fatalf("metadata or local audit trail lost: %+v", got) + } + } + }) + } +} + +func TestGongfengRepeatedSyncPreservesLocalStatus(t *testing.T) { + // The MCP transport fixture returns the same payload for both state queries; + // pullGongfengIssues deduplicates by IID just as it does for real pages. + for _, remote := range []string{"opened", "closed"} { + t.Run(remote, func(t *testing.T) { + payload := fmt.Sprintf(`{"data":[{"id":10,"iid":7,"title":"refreshed","state":%q}]}`, remote) + gateway := mockTAPDMultiGateway(t, payload, "", nil, nil) + defer gateway.Close() + store, err := repo.OpenTest() + if err != nil { + t.Fatal(err) + } + defer store.Close() + issue := domain.Issue{ + ID: "gf-sync-7", ProjectID: "platform", RepositoryID: "repo-sync", Source: "gongfeng", Status: "backlog", AutomationState: "eligible", + DeliveryState: "merged", DeliveryMRState: "closed", + ExternalRef: &domain.ExternalRef{Source: "gongfeng", ExternalIID: 7, ExternalState: "opened"}, + } + if err := store.PutIssue(issue); err != nil { + t.Fatal(err) + } + svc := NewService() + svc.Configure(domain.SyncConfig{GongfengMCPURL: gateway.URL, TAPDMCPURL: gateway.URL}) + if err := store.PutRepository(domain.Repository{ID: "repo-sync", ProjectID: "platform", Provider: "gongfeng", GongfengProjectID: "test/repo"}); err != nil { + t.Fatal(err) + } + if err := svc.PullAll(store); err != nil { + t.Fatal(err) + } + got, _ := store.Issue(issue.ID) + if got.Status != "backlog" || got.AutomationState != "eligible" || got.Title != "refreshed" || got.ExternalRef.ExternalState != remote { + t.Fatalf("sync must preserve local status and refresh tracker metadata: %+v", got) + } + }) + } +} diff --git a/internal/tracker/tapd_task_import_test.go b/internal/tracker/tapd_task_import_test.go index 4c42a82..ecc4232 100644 --- a/internal/tracker/tapd_task_import_test.go +++ b/internal/tracker/tapd_task_import_test.go @@ -90,7 +90,7 @@ func TestImportAndResyncTAPDTask(t *testing.T) { if err != nil { t.Fatal(err) } - if updated.ID != issue.ID || updated.Title != "更新的任务标题" || updated.Status != "in_progress" || updated.ExternalRef.ExternalType != "task" || updated.ExternalRef.ExternalURL != testTAPDTaskURL { + if updated.ID != issue.ID || updated.Title != "更新的任务标题" || updated.Status != "todo" || updated.ExternalRef.ExternalState != "progressing" || updated.ExternalRef.ExternalType != "task" || updated.ExternalRef.ExternalURL != testTAPDTaskURL { t.Fatalf("unexpected refreshed task: %+v", updated) } if updated.BaseBranch != issue.BaseBranch || updated.RepositoryID != issue.RepositoryID || updated.Clarification.Summary != "已澄清" || !updated.ExternalRef.Imported { diff --git a/web/app.js b/web/app.js index ce75f2b..e7edf9c 100644 --- a/web/app.js +++ b/web/app.js @@ -1183,6 +1183,23 @@ function bind(){ // --- 工作台 / 待办验收 / 可观测与分析 / Issue 详情抽屉 --- $('.platform-sidebar .brand').onclick=()=>switchView('collaboration'); + (function bindSidebarResize(){ + const KEY='agentflow.sidebarWidth',MIN=220,MAX=420,wide=matchMedia('(min-width:1001px)'),sidebar=$('.platform-sidebar'); + const handle=document.createElement('button');handle.type='button';handle.className='sidebar-resize';handle.title='拖动调整侧栏宽度,双击恢复默认';handle.setAttribute('aria-label','调整侧栏宽度');sidebar.append(handle); + const clamp=px=>Math.round(Math.min(MAX,Math.max(MIN,px))); + const apply=()=>{const saved=Number(localStorage.getItem(KEY));if(wide.matches&&saved)document.documentElement.style.setProperty('--sidebar-w',clamp(saved)+'px');else document.documentElement.style.removeProperty('--sidebar-w')}; + const save=px=>{localStorage.setItem(KEY,String(clamp(px)));apply()}; + apply();wide.addEventListener('change',apply); + handle.onpointerdown=ev=>{ + if(ev.button!==0)return;ev.preventDefault(); + const startX=ev.clientX,startW=sidebar.getBoundingClientRect().width; + handle.setPointerCapture(ev.pointerId);document.body.classList.add('sidebar-resizing'); + handle.onpointermove=move=>document.documentElement.style.setProperty('--sidebar-w',clamp(startW+move.clientX-startX)+'px'); + handle.onpointerup=handle.onpointercancel=()=>{handle.onpointermove=handle.onpointerup=handle.onpointercancel=null;document.body.classList.remove('sidebar-resizing');save(sidebar.getBoundingClientRect().width)}; + }; + handle.ondblclick=()=>{localStorage.removeItem(KEY);apply()}; + handle.onkeydown=ev=>{const cur=sidebar.getBoundingClientRect().width;if(ev.key==='ArrowRight'){ev.preventDefault();save(cur+16)}if(ev.key==='ArrowLeft'){ev.preventDefault();save(cur-16)}}; + })(); $('#dashImportTAPD')&&($('#dashImportTAPD').onclick=openImportTAPDModal); $('#dashNewRequirement').onclick=openRequirementWizard; $('#dashNewRun').onclick=()=>switchView('issues'); diff --git a/web/app/issues.js b/web/app/issues.js index 52dd397..37130a1 100644 --- a/web/app/issues.js +++ b/web/app/issues.js @@ -256,11 +256,11 @@ function issueTagTone(tag){ function issueTitleMarkup(title){ const tags=[]; let rest=String(title||''); - const re=/^\[([^\[\]]{1,24})\]\s*/; + const re=/^\s*(?:\[([^[\]【】]{1,24})\]|【([^[\]【】]{1,24})】)\s*/; let m; - while((m=rest.match(re))){tags.push(m[1]);rest=rest.slice(m[0].length)} + while((m=rest.match(re))){tags.push(m[1]||m[2]);rest=rest.slice(m[0].length)} rest=rest.trim()||title||''; - const pills=tags.map(t=>`${escapeHTML(t)}`).join(''); + const pills=tags.map(t=>`${escapeHTML(t)}`).join(''); return `${pills}${escapeHTML(rest)}`; } function issueListProgress(i,run,w){ diff --git a/web/collaboration.css b/web/collaboration.css index c3484a0..46e7986 100644 --- a/web/collaboration.css +++ b/web/collaboration.css @@ -201,6 +201,36 @@ html[data-theme="light"]{--bg:#ffffff;--bg-soft:#ffffff;--panel:#ffffff;--panel- .collab-handoff-receipt .collab-handoff-preview{white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden;color:var(--text)} .collab-loop-ribbon[role=status]{margin-bottom:12px}.collab-loop-copy{overflow-wrap:anywhere}.collab-loop-actions .button{white-space:nowrap} +.collab-group-dialog{max-height:calc(100dvh - 48px);overflow:auto;box-sizing:border-box}.collab-repository-picker{min-width:0;margin:16px 0;padding:0;border:0}.collab-repository-picker legend{font-size:12px;margin-bottom:8px}.collab-repository-choices{max-height:180px;overflow:auto;border:1px solid var(--line);border-radius:8px}.collab-repository-choices:empty{display:none}.collab-repository-choices label{flex-direction:row;align-items:center;gap:10px;margin:0;padding:9px 12px;cursor:pointer}.collab-repository-choices label+label{border-top:1px solid var(--line)}.collab-repository-choices label:hover{background:var(--hover)}.collab-repository-choices input[type=checkbox]{flex:0 0 auto;width:15px;height:15px;padding:0;accent-color:var(--blue)}.collab-repository-choices span{min-width:0;display:flex;flex-direction:column;gap:3px}.collab-repository-choices b{font-weight:500;overflow-wrap:anywhere}.collab-repository-choices small,.collab-repository-summary{font-size:11px;color:var(--muted)}.collab-repository-summary{display:block;line-height:1.6;margin-top:7px}.collab-group-settings{border-top:1px solid var(--line);padding-top:12px;font-size:12px}.collab-group-settings summary{cursor:pointer;color:var(--muted)} + +.sidebar-conversations #collabTasks{overflow-x:hidden;overflow-y:auto;scrollbar-gutter:stable;scrollbar-width:thin} +.collab-task-group{display:flex;align-items:center;gap:4px;width:100%;margin:6px 0 2px;padding:4px 6px;border:0;border-radius:6px;background:transparent;color:var(--muted);font:inherit;font-size:11px;font-weight:500;text-align:left;cursor:pointer} +.collab-task-group:first-child{margin-top:0}.collab-task-group:hover{color:var(--text)}.collab-task-group:focus-visible{outline:2px solid var(--blue);outline-offset:-2px} +.collab-task-group svg{width:12px;height:12px;flex:none;transform:rotate(90deg);transition:transform .15s}.collab-task-group[aria-expanded=false] svg{transform:none} +.collab-task-group em{margin-left:2px;font-style:normal;font-weight:400;opacity:.75;font-variant-numeric:tabular-nums} +.collab-task-group-failed{color:var(--red);font-weight:400;font-variant-numeric:tabular-nums} +.collaboration-tasks .collab-task-item{flex-direction:row;align-items:flex-start;gap:9px;min-width:0;padding:8px 8px 8px 10px} +.collab-task-dot{flex:none;width:7px;height:7px;margin-top:6px;border-radius:50%;background:#a4a8b0} +.collab-task-dot.running{background:var(--blue);animation:collab-task-pulse 1.6s ease-out infinite}.collab-task-dot.queued{background:transparent;box-shadow:inset 0 0 0 1.5px var(--blue)} +.collab-task-dot.attention{background:var(--amber)}.collab-task-dot.failed{background:var(--red)}.collab-task-dot.done{background:#22a06b;opacity:.6}.collab-task-dot.muted{background:transparent;box-shadow:inset 0 0 0 1.5px #a4a8b0} +@keyframes collab-task-pulse{0%{box-shadow:0 0 0 0 rgba(59,110,245,.45)}100%{box-shadow:0 0 0 6px rgba(59,110,245,0)}} +.collab-task-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px} +.collaboration-tasks .collab-task-item b{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;white-space:normal;overflow:hidden;overflow-wrap:anywhere;line-height:1.45;font-weight:500} +.collaboration-tasks .collab-task-item.active b{font-weight:600} +.collab-task-tag{display:inline-block;margin-right:5px;padding:0 5px;border-radius:4px;background:var(--hover);color:var(--muted);font-size:10px;font-weight:500;line-height:16px;vertical-align:1px} +.collab-task-item.active .collab-task-tag,.collab-task-item:hover .collab-task-tag{background:var(--panel)} +.collaboration-tasks .collab-task-item small{display:flex;align-items:center;gap:4px;min-width:0;white-space:nowrap;overflow:hidden} +.collab-task-state{flex:none}.collab-task-state.running,.collab-task-state.queued{color:var(--blue)}.collab-task-state.attention{color:var(--amber)}.collab-task-state.failed{color:var(--red)} +.collab-task-workflow{min-width:0;overflow:hidden;text-overflow:ellipsis} +.collab-task-time{flex:none;margin-left:auto;padding-left:6px;font-variant-numeric:tabular-nums} +@media(prefers-reduced-motion:reduce){.collab-task-dot.running{animation:none}.collab-task-group svg{transition:none}} + +:root{--sidebar-w:272px} +@media(max-width:1280px) and (min-width:1001px){:root{--sidebar-w:240px}.app-shell{grid-template-columns:var(--sidebar-w) minmax(0,1fr)!important}body.ai-assistant-open .app-shell{grid-template-columns:var(--sidebar-w) minmax(0,1fr) var(--ai-panel-w,380px)!important}.platform-sidebar{width:100%!important;max-width:var(--sidebar-w)!important}} +.sidebar-resize{position:absolute;top:0;bottom:0;right:0;z-index:30;width:6px;min-width:0;padding:0;margin:0;border:0;appearance:none;background:transparent;cursor:col-resize;touch-action:none} +.sidebar-resize:hover,.sidebar-resize:focus-visible,.sidebar-resizing .sidebar-resize{background:color-mix(in srgb,var(--blue) 45%,transparent)} +.sidebar-resizing .app-shell{transition:none!important}.sidebar-resizing{cursor:col-resize;user-select:none} +@media(max-width:1000px){.sidebar-resize{display:none}} /* Executor and model are independent choices; keep both beside the draft. */ .ai-compose-model{display:flex;align-items:center;gap:4px;position:relative;min-width:0} diff --git a/web/collaboration.js b/web/collaboration.js index 160e321..71d102c 100644 --- a/web/collaboration.js +++ b/web/collaboration.js @@ -254,7 +254,7 @@ const dialog=document.createElement('dialog');dialog.className='collab-group-dialog';const form=document.createElement('form'),heading=document.createElement('h2');heading.textContent='邀请角色加入协作';form.append(heading);const select=document.createElement('select');select.setAttribute('aria-label','选择邀请角色');for(const a of choices){const opt=document.createElement('option');opt.value=a.id;opt.textContent=a.name;select.append(opt)}form.append(select);const note=document.createElement('p');note.textContent=choices.length?'加入后可以 @ 和派发任务,邀请本身不会开始执行。':'所有可用角色都已加入。';form.append(note);const footer=document.createElement('footer'),cancel=document.createElement('button'),submit=document.createElement('button');cancel.type='button';cancel.className='button ghost';cancel.textContent='取消';cancel.onclick=()=>dialog.close();submit.type='submit';submit.className='button';submit.textContent='邀请加入';submit.disabled=!choices.length;footer.append(cancel,submit);form.append(footer);dialog.append(form);document.body.append(dialog);dismissDialog(dialog);const id=currentRun;form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;try{await api('/api/runs/'+encodeURIComponent(id)+'/group/members',{method:'POST',body:JSON.stringify({agentId:select.value})});dialog.close();await refresh()}catch(err){note.textContent=err.message;submit.disabled=false}};dialog.showModal(); } function dismissDialog(dialog){dialog.addEventListener('close',()=>dialog.remove());dialog.addEventListener('click',e=>{if(e.target===dialog){const r=dialog.getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)dialog.close()}})} - function paintModeButton(){groupPause.hidden=true;groupPause.textContent=run?.pauseRequested?'继续协作':'暂停协作';modeButton.hidden=!isGroup();modeButton.textContent='切回 SOP';modeButton.disabled=sopReturnPending();modeButton.onclick=restoreSOPDialog} + function paintModeButton(){groupPause.hidden=true;groupPause.textContent=run?.pauseRequested?'继续协作':'暂停协作';modeButton.hidden=!isGroup()||['succeeded','cancelled'].includes(run?.status);modeButton.textContent=run?.workflowSnapshot?.sopCheckpoint?.workflow?.nodes?.length===0?'标记完成':'切回 SOP';modeButton.disabled=sopReturnPending();modeButton.onclick=restoreSOPDialog} function cacheSOPHandoff(id,job){ const previous=sopReturns.get(id);if(job)sopReturns.set(id,job);else if(previous?.id||previous?.status!=='failed')sopReturns.delete(id); if(currentRun===id&&job?.status==='succeeded'&&(handoffActive(previous)||previous?.status==='failed'&&previous?.phase==='starting')){nodeId='';assistant.leaveNode()} @@ -306,10 +306,10 @@ if(Object.values(run.nodes||{}).some(n=>n.status==='running')||run.status==='running')return toast('请先停止当前角色任务,再切回 SOP'); const original=run.workflowSnapshot.sopCheckpoint?.workflow||run.workflowSnapshot,nodes=original.nodes.filter(n=>!n.requestId); const dialog=document.createElement('dialog');dialog.className='collab-group-dialog'; - const form=document.createElement('form'),heading=document.createElement('h2');heading.textContent='切回 SOP';form.append(heading); + const form=document.createElement('form'),heading=document.createElement('h2');heading.textContent=nodes.length?'切回 SOP':'完成协作任务';form.append(heading); const actionLabel=document.createElement('label');actionLabel.textContent='切回后如何处理'; const action=document.createElement('select');action.setAttribute('aria-label','SOP 恢复方式'); - for(const [value,text] of [['restart','从节点重新开始'],['complete','直接标记完成']]){const option=document.createElement('option');option.value=value;option.textContent=text;action.append(option)}actionLabel.append(action);form.append(actionLabel); + for(const [value,text] of [...(nodes.length?[['restart','从节点重新开始']]:[]),['complete','直接标记完成']]){const option=document.createElement('option');option.value=value;option.textContent=text;action.append(option)}actionLabel.append(action);actionLabel.hidden=!nodes.length;form.append(actionLabel); const label=document.createElement('label');label.textContent='从哪个节点继续';const select=document.createElement('select');select.setAttribute('aria-label','SOP 继续节点'); for(const n of nodes){const o=document.createElement('option');o.value=n.id;o.textContent=n.name||n.id;select.append(o)} const checkpoint=run.workflowSnapshot.sopCheckpoint?.nodes||run.nodes; @@ -352,7 +352,7 @@ for(const a of message.attachments||[]){const link=document.createElement('a');link.className='collab-chip';link.href='/api/assistant/attachments/'+encodeURIComponent(a.id)+'?'+new URLSearchParams({sessionId:assistant.sessionId()});link.textContent='📎 '+a.name;link.target='_blank';link.rel='noopener';if(/^image\/(png|jpeg|gif|webp)$/.test(a.mimeType)){const img=document.createElement('img');img.src=link.href;img.alt=a.name;img.loading='lazy';link.prepend(img)}bar.append(link)} if(bar.childNodes.length)bubble.append(bar); } - let taskRows=[],taskLimit=30; + let taskRows=[],taskLimit=30,taskRefreshGeneration=0; const sessionMenu=document.createElement('div');sessionMenu.className='collab-session-menu';sessionMenu.hidden=true;sessionMenu.setAttribute('role','menu');document.body.append(sessionMenu); function closeSessionMenu(){sessionMenu.hidden=true} document.addEventListener('click',e=>{if(!sessionMenu.contains(e.target))closeSessionMenu()});window.addEventListener('resize',closeSessionMenu);document.addEventListener('scroll',closeSessionMenu,true); @@ -370,35 +370,90 @@ if(!rows.length)paintSidebarEmpty(host,archived?'没有已归档会话':'开始对话后会保存在这里'); } function paintSidebarEmpty(host,text){const note=document.createElement('p');note.className='collab-sidebar-empty';note.textContent=text;host.append(note)} + const taskTones={running:'running',queued:'queued',pending:'queued',waiting:'attention',waiting_approval:'attention',waiting_retry:'attention',paused:'attention',failed:'failed',succeeded:'done',cancelled:'muted',skipped:'muted'}; + const taskGroups=[['active','进行中'],['recent','最近结束'],['earlier','更早']],collapsedTaskGroups=new Set(['earlier']); + function taskStatus(r){ + const job=sopReturns.get(r.id); + if(job&&job.status!=='succeeded'){const p=handoffPresentation(job);return {tone:p.tone==='failed'?'failed':'running',text:p.label}} + if(r.status==='waiting'&&r.pauseRequested)return {tone:'attention',text:'已暂停'}; + return {tone:taskTones[r.status]||'queued',text:label(r.status)}; + } + function taskTime(r){const ms=Date.parse(r.updatedAt||r.createdAt||'');return Number.isFinite(ms)?ms:0} + function taskClock(ms){ + if(!ms)return '';const diff=Date.now()-ms;if(diff<60e3)return '刚刚';if(diff<3600e3)return Math.floor(diff/60e3)+' 分钟前'; + const d=new Date(ms),now=new Date(),hm=String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0'); + const days=Math.round((new Date(now.getFullYear(),now.getMonth(),now.getDate())-new Date(d.getFullYear(),d.getMonth(),d.getDate()))/864e5); + return days===0?hm:days===1?'昨天 '+hm:d.getFullYear()===now.getFullYear()?(d.getMonth()+1)+'月'+d.getDate()+'日':d.getFullYear()+'/'+(d.getMonth()+1)+'/'+d.getDate(); + } + function taskElapsed(r){ + const ms=r.timing?.wallMs||Date.now()-(Date.parse(r.createdAt||'')||Date.now()),m=Math.floor(ms/60e3); + if(m<1)return '刚开始';if(m<60)return m+' 分钟';const h=Math.floor(m/60);return h<24?h+' 小时':Math.floor(h/24)+' 天'; + } + function taskGroup(r,tone){ + if(['running','queued','attention'].includes(tone))return 'active'; + return Date.now()-taskTime(r)<864e5?'recent':'earlier'; + } + function taskItem(r,st){ + const b=document.createElement('button');b.type='button';b.className='collab-list-item collab-task-item'+(currentRun===r.id?' active':''); + const workflowName=r.workflowSnapshot?.name||state.data?.workflows?.find(w=>w.id===r.workflowId)?.name||r.workflowId||''; + const issue=state.data?.issues?.find(i=>i.id===(r.issueId||r.executionContext?.issueId)); + const full=r.workflowSnapshot?.taskTitle||issue?.title||String(r.input||'').trim().split('\n')[0]||workflowName; + const tagged=full.match(/^\s*(?:\[([^[\]【】]{1,12})\]|【([^[\]【】]{1,12})】)\s*/),text=tagged&&full.length>tagged[0].length?full.slice(tagged[0].length):full; + const dot=document.createElement('span');dot.className='collab-task-dot '+st.tone;dot.setAttribute('aria-hidden','true'); + const body=document.createElement('span');body.className='collab-task-body'; + const name=document.createElement('b');if(tagged&&text!==full){const tag=document.createElement('span');tag.className='collab-task-tag';tag.textContent=tagged[1]||tagged[2];name.append(tag)}name.append(document.createTextNode(text.slice(0,120))); + const meta=document.createElement('small'),stateLabel=document.createElement('span'),flow=document.createElement('span'),time=document.createElement('time'); + stateLabel.className='collab-task-state '+st.tone;stateLabel.textContent=st.text;flow.className='collab-task-workflow';flow.textContent=workflowName?'· '+workflowName:''; + time.className='collab-task-time';const ms=taskTime(r);if(ms)time.dateTime=new Date(ms).toISOString();time.textContent=st.tone==='running'?taskElapsed(r):taskClock(ms); + meta.append(stateLabel,flow,time);body.append(name,meta);b.append(dot,body); + b.title=[full,workflowName&&'流程:'+workflowName,'状态:'+st.text,r.createdAt&&'创建:'+new Date(r.createdAt).toLocaleString(),'ID:'+r.id].filter(Boolean).join('\n'); + b.onclick=()=>selectRun(r.id);return b; + } function paintTasks(){ const host=$id('collabTasks');host.replaceChildren();const query=$id('collabTaskSearch').value.trim().toLowerCase(),filter=$id('collabTaskFilter').value; - const rows=taskRows.filter(r=>{const name=r.workflowSnapshot?.name||state.data?.workflows?.find(w=>w.id===r.workflowId)?.name||r.workflowId||'';const text=[name,r.id,r.input,label(r.status)].join(' ').toLowerCase();return (!query||text.includes(query))&&(filter==='all'||filter==='active'&&!['succeeded','failed','cancelled'].includes(r.status)||filter==='waiting'&&['waiting','waiting_approval','waiting_retry','paused'].includes(r.status)||r.status===filter)}); - for(const r of rows.slice(0,taskLimit)){const b=document.createElement('button');b.type='button';b.className='collab-list-item'+(currentRun===r.id?' active':'');const name=document.createElement('b');const workflowName=r.workflowSnapshot?.name||r.workflowId;const issue=state.data?.issues?.find(i=>i.id===(r.issueId||r.executionContext?.issueId));const title=r.workflowSnapshot?.taskTitle||issue?.title||String(r.input||'').trim().split('\n')[0];name.textContent=title?title.slice(0,70):workflowName;name.title=title||workflowName;const status=document.createElement('small');status.textContent=(sopReturns.get(r.id)&&sopReturns.get(r.id).status!=='succeeded'?handoffPresentation(sopReturns.get(r.id)).label:r.status==='waiting'&&r.pauseRequested?'已暂停':label(r.status))+' · '+workflowName+' · '+r.id.slice(-8);b.append(name,status);b.onclick=()=>selectRun(r.id);host.append(b)} + const rows=taskRows.filter(r=>{const name=r.workflowSnapshot?.name||state.data?.workflows?.find(w=>w.id===r.workflowId)?.name||r.workflowId||'';const text=[name,r.id,r.input,r.workflowSnapshot?.taskTitle,label(r.status)].join(' ').toLowerCase();return (!query||text.includes(query))&&(filter==='all'||filter==='active'&&!['succeeded','failed','cancelled'].includes(r.status)||filter==='waiting'&&['waiting','waiting_approval','waiting_retry','paused'].includes(r.status)||r.status===filter)}); + const buckets=new Map(taskGroups.map(([key])=>[key,[]])); + for(const r of rows){const st=taskStatus(r);buckets.get(taskGroup(r,st.tone)).push({r,st})} + for(const [key,list] of buckets)if(key!=='active')list.sort((a,b)=>taskTime(b.r)-taskTime(a.r)); + let budget=taskLimit; + for(const [key,title] of taskGroups){ + const list=buckets.get(key);if(!list.length||budget<=0)continue; + const collapsed=!query&&collapsedTaskGroups.has(key),head=document.createElement('button');head.type='button';head.className='collab-task-group';head.setAttribute('aria-expanded',String(!collapsed)); + head.append(icon('chevron'),document.createTextNode(title));const count=document.createElement('em');count.textContent=String(list.length);head.append(count); + const failed=list.filter(x=>x.st.tone==='failed').length;if(failed){const alert=document.createElement('span');alert.className='collab-task-group-failed';alert.textContent='· '+failed+' 失败';head.append(alert)} + head.onclick=()=>{collapsed?collapsedTaskGroups.delete(key):collapsedTaskGroups.add(key);paintTasks()};host.append(head); + if(collapsed)continue; + for(const {r,st} of list.slice(0,budget))host.append(taskItem(r,st));budget-=list.length; + } if(rows.length>taskLimit){const more=document.createElement('button');more.type='button';more.className='collab-load-more';more.textContent='加载更多('+rows.length+' 个匹配任务)';more.onclick=()=>{taskLimit+=30;paintTasks()};host.append(more)} - if(!rows.length)paintSidebarEmpty(host,query||filter!=='all'?'没有匹配的任务':project()?'还没有任务运行':'选择项目后查看任务'); + if(!rows.length)paintSidebarEmpty(host,query||filter!=='all'||$id('collabTaskResponsible').value==='me'?'没有匹配的任务':project()?'还没有任务运行':'选择项目后查看任务'); } $id('collabSessionFilter').onchange=()=>{closeSessionMenu();paintSessions()}; $id('collabTaskSearch').oninput=$id('collabTaskFilter').onchange=()=>{taskLimit=30;paintTasks()}; + $id('collabTaskResponsible').onchange=()=>{taskLimit=30;taskRows=[];paintTasks();void refresh()}; const newGroup=document.createElement('button');newGroup.type='button';newGroup.title='新建协作任务';newGroup.setAttribute('aria-label','新建协作任务');newGroup.textContent='+';newGroup.onclick=()=>project()?groupDialog(true):toast('请先选择项目');$id('collabSearchToggle').before(newGroup); $id('collabRefresh').append(icon('refresh')); $id('collabSearchToggle').append(icon('search')); function toggleTaskSearch(open){ $id('collabTaskFilters').hidden=!open;$id('collabSearchToggle').setAttribute('aria-expanded',String(open)); - if(open)$id('collabTaskSearch').focus();else{$id('collabTaskSearch').value='';$id('collabTaskFilter').value='all';taskLimit=30;paintTasks()} + if(open)$id('collabTaskSearch').focus();else{const filtered=$id('collabTaskResponsible').value==='me';$id('collabTaskSearch').value='';$id('collabTaskFilter').value='all';$id('collabTaskResponsible').value='all';taskLimit=30;if(filtered){taskRows=[];void refresh()}paintTasks()} } $id('collabSearchToggle').onclick=()=>toggleTaskSearch($id('collabTaskFilters').hidden); $id('collabTaskFilters').onkeydown=e=>{if(e.key==='Escape'){e.preventDefault();toggleTaskSearch(false);$id('collabSearchToggle').focus()}}; async function refresh(){ - const scope=project(); + const scope=project(),generation=++taskRefreshGeneration,params=new URLSearchParams({projectId:scope}); + if($id('collabTaskResponsible').value==='me')params.set('responsible','me'); + const stale=()=>scope!==project()||generation!==taskRefreshGeneration; try{ - const [history,allRuns]=await Promise.all([api('/api/assistant/sessions',{silent:true}),api('/api/runs?'+new URLSearchParams({projectId:scope}),{silent:true})]); - if(scope!==project())return; + const [history,allRuns]=await Promise.all([api('/api/assistant/sessions',{silent:true}),api('/api/runs?'+params,{silent:true})]); + if(stale())return; sessions=history.filter(s=>(s.projectId||'')===scope); - await Promise.all(sessions.filter(s=>!s.generationStatus).map(async s=>{try{const res=await fetch('/api/assistant/sessions/'+encodeURIComponent(s.id)+'/generation');if(res.ok){const g=await res.json();localSessionStates.set(s.id,g.status)}}catch{}}));if(scope!==project())return; + await Promise.all(sessions.filter(s=>!s.generationStatus).map(async s=>{try{const res=await fetch('/api/assistant/sessions/'+encodeURIComponent(s.id)+'/generation');if(res.ok){const g=await res.json();localSessionStates.set(s.id,g.status)}}catch{}}));if(stale())return; paintSessions();await assistant.refreshShared(); - taskRows=allRuns.filter(r=>(r.executionContext?.projectId||'')===scope);await refreshSOPHandoffs();if(scope!==project())return;paintTasks(); - if(currentRun){const id=currentRun;const before=JSON.stringify(run?.nodes?.[nodeId]);const detail=await api('/api/runs/'+encodeURIComponent(id),{silent:true});if(id===currentRun&&scope===project()){run=detail;renderPipeline();scheduleGroupState();if(nodeId&&before!==JSON.stringify(run?.nodes?.[nodeId]))paintNode()}} - }catch(err){if(active)error(err)} + if(stale())return; + taskRows=allRuns.filter(r=>(r.executionContext?.projectId||'')===scope);await refreshSOPHandoffs();if(stale())return;paintTasks(); + if(currentRun){const id=currentRun;const before=JSON.stringify(run?.nodes?.[nodeId]);const detail=await api('/api/runs/'+encodeURIComponent(id),{silent:true});if(id===currentRun&&!stale()){run=detail;renderPipeline();scheduleGroupState();if(nodeId&&before!==JSON.stringify(run?.nodes?.[nodeId]))paintNode()}} + }catch(err){if(active&&!stale())error(err)} } function label(status){return ({waiting:'等待处理',running:'执行中',queued:'排队中',paused:'已暂停',pending:'未开始',succeeded:'已完成',failed:'失败',cancelled:'已取消',waiting_approval:'待确认',waiting_retry:'待重试',skipped:'已跳过'})[status]||status||'未开始'} function isGroup(){return run?.workflowSnapshot?.collaborationMode==='group'} @@ -529,15 +584,31 @@ const checks=receipt.verification?.checks||[];if(checks.length){const detail=document.createElement('details');detail.className='collab-receipt-checks';const summary=document.createElement('summary');summary.textContent='查看 '+checks.length+' 项实际检查';detail.append(summary);for(const check of checks){const line=document.createElement('div');const mark=document.createElement('span');mark.textContent=check.status==='passed'?'✓':check.status==='failed'?'!':'·';line.append(mark,document.createTextNode(check.name||check.gateId||'命令检查'));if(check.historical){const old=document.createElement('small');old.textContent='历史结果';line.append(old)}detail.append(line)}card.append(detail)} } function groupDialog(create){ - const dialog=document.createElement('dialog');dialog.className='collab-group-dialog';const form=document.createElement('form');form.method='dialog';const heading=document.createElement('h2');heading.textContent=create?'新建协作任务':'派发角色任务';const intro=document.createElement('p');intro.textContent=create?'选择环境与角色模板,后续由 Main Bot 按需协调。':'角色将真实执行任务,可以与其他 Agent 并行修改共享工作区。';form.append(heading,intro); - const field=(text,control)=>{const label=document.createElement('label');label.textContent=text;label.append(control);form.append(label)}; - const select=document.createElement('select');select.required=true;const items=create?(state.data?.workflows||[]).filter(w=>w.projectId===project()||w.shared||w.generic):(state.data?.agents||[]).filter(a=>a.projectId===project()||a.shared); - for(const item of items){const opt=document.createElement('option');opt.value=item.id;opt.textContent=item.name;select.append(opt)}field(create?'角色与环境模板':'执行角色',select); + const scope=project(); + const dialog=document.createElement('dialog');dialog.className='collab-group-dialog';const form=document.createElement('form');form.method='dialog';const heading=document.createElement('h2');heading.textContent=create?'新建协作任务':'派发角色任务';const intro=document.createElement('p');intro.textContent=create?'默认自由协作。选择本次涉及的仓库,由 Main Bot 按需协调,也可以随时邀请角色加入。':'角色将真实执行任务,可以与其他 Agent 并行修改共享工作区。';form.append(heading,intro); + const field=(text,control,host=form)=>{const label=document.createElement('label');label.textContent=text;label.append(control);host.append(label);return label}; + const select=document.createElement('select');select.required=!create;select.setAttribute('aria-label',create?'角色与环境模板(可选)':'执行角色');const items=create?(state.data?.workflows||[]).filter(w=>w.projectId===scope||w.shared||w.generic):(state.data?.agents||[]).filter(a=>a.projectId===scope||a.shared); + if(create){const opt=document.createElement('option');opt.value='';opt.textContent='自由协作(不使用 SOP 模板)';select.append(opt)} + for(const item of items){const opt=document.createElement('option');opt.value=item.id;opt.textContent=item.name;select.append(opt)}if(!create)field('执行角色',select); const prompt=document.createElement('textarea');prompt.required=true;prompt.rows=5;prompt.placeholder=create?'描述这个任务的目标…':'具体需要完成什么,如何验证…';field(create?'任务目标':'工作说明',prompt); + const repositoryChecks=[],sandbox=document.createElement('select'); + if(create){ + const repositories=(state.data?.repositories||[]).filter(r=>r.projectId===scope),defaultRepository=state.data?.projects?.find(p=>p.id===scope)?.defaultRepositoryId; + const group=document.createElement('fieldset');group.className='collab-repository-picker';const legend=document.createElement('legend');legend.textContent='涉及仓库(可多选)';group.append(legend); + const choices=document.createElement('div');choices.className='collab-repository-choices'; + for(const repository of repositories){const check=document.createElement('input');check.type='checkbox';check.value=repository.id;check.checked=repository.id===defaultRepository||repositories.length===1;repositoryChecks.push(check);const row=document.createElement('label'),text=document.createElement('span'),name=document.createElement('b'),branch=document.createElement('small');name.textContent=repository.name||repository.id;branch.textContent='基于 '+(repository.defaultBranch||'默认分支');text.append(name,branch);row.append(check,text);choices.append(row)} + const summary=document.createElement('small');summary.className='collab-repository-summary';const paintSelection=()=>{summary.textContent=repositories.length?'已选择 '+repositoryChecks.filter(c=>c.checked).length+' / '+repositories.length+' 个仓库;不选仓库可进行讨论和非代码任务。':'当前项目尚未配置仓库,可先创建讨论任务。'};choices.onchange=paintSelection;paintSelection();group.append(choices,summary);form.append(group); + const settings=document.createElement('details');settings.className='collab-group-settings';const title=document.createElement('summary');title.textContent='运行环境与角色模板';settings.append(title);field('角色与环境模板(可选)',select,settings); + const environments=(state.data?.sandboxes||[]).filter(s=>s.projectId===scope||s.shared).sort((a,b)=>Number(b.projectId===scope)-Number(a.projectId===scope)); + for(const item of environments){const option=document.createElement('option');option.value=item.id;option.textContent=item.name||item.id;sandbox.append(option)} + sandbox.setAttribute('aria-label','运行环境');const sandboxLabel=field('运行环境',sandbox,settings); + const environmentNote=document.createElement('p');environmentNote.className='collab-dialog-note';settings.append(environmentNote); + const paintEnvironment=()=>{sandboxLabel.hidden=!!select.value;sandbox.disabled=!!select.value;sandbox.required=!select.value&&environments.length>0;environmentNote.textContent=select.value?'仅沿用模板的角色和运行环境,任务仍按自由协作推进。':environments.length?'使用「'+(environments.find(s=>s.id===sandbox.value)?.name||sandbox.value)+'」,可展开调整。':'当前项目没有可用运行环境,请先配置或选择模板。'};select.onchange=sandbox.onchange=paintEnvironment;paintEnvironment();if(!environments.length)settings.open=true;form.append(environmentNote,settings); + } const files=document.createElement('input');files.placeholder='例如 web/login.js, server/auth.go';if(!create)field('涉及文件(可选,用逗号分隔)',files); - const note=document.createElement('p');note.className='collab-dialog-note';note.textContent='已有变更不会回退;角色通过群消息对齐文件修改,文件范围作为分工说明。';form.append(note); + const note=document.createElement('p');note.className='collab-dialog-note';note.textContent=create?'所选仓库共用任务工作区,创建后通过群聊协作。':'已有变更不会回退;角色通过群消息对齐文件修改,文件范围作为分工说明。';form.append(note); const err=document.createElement('p');err.className='collab-dialog-error';err.setAttribute('role','alert');form.append(err);const footer=document.createElement('footer'),cancel=document.createElement('button'),submit=document.createElement('button');cancel.type='button';cancel.className='button ghost';cancel.textContent='取消';cancel.onclick=()=>dialog.close();submit.type='submit';submit.className='button';submit.textContent=create?'创建任务':'派发任务';footer.append(cancel,submit);form.append(footer);dialog.append(form);document.body.append(dialog);dialog.addEventListener('close',()=>dialog.remove());dialog.addEventListener('click',e=>{if(e.target===dialog){const r=dialog.getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)dialog.close()}}); - const requestId=crypto.randomUUID();form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;err.textContent='';try{if(create){const data=await api('/api/task-groups',{method:'POST',body:JSON.stringify({workflowId:select.value,projectId:project(),input:prompt.value})});dialog.close();await selectRun(data.id)}else{await api('/api/runs/'+encodeURIComponent(currentRun)+'/group/tasks',{method:'POST',body:JSON.stringify({agentId:select.value,prompt:prompt.value,requestId,files:files.value.split(',').map(f=>f.trim()).filter(Boolean)})});dialog.close();await returnToPM();await refresh()}}catch(error){err.textContent=error.message}finally{submit.disabled=false}};dialog.showModal();prompt.focus(); + const requestId=crypto.randomUUID();form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;err.textContent='';try{if(scope!==project())throw new Error('当前项目已切换,请重新打开任务窗口');if(create){if(!select.value&&!sandbox.value)throw new Error('请先配置可用的运行环境或选择模板');const data=await api('/api/task-groups',{method:'POST',body:JSON.stringify({workflowId:select.value,projectId:scope,input:prompt.value,repositoryIds:repositoryChecks.filter(c=>c.checked).map(c=>c.value),...(!select.value?{sandboxProfileId:sandbox.value}:{})})});dialog.close();await selectRun(data.id)}else{await api('/api/runs/'+encodeURIComponent(currentRun)+'/group/tasks',{method:'POST',body:JSON.stringify({agentId:select.value,prompt:prompt.value,requestId,files:files.value.split(',').map(f=>f.trim()).filter(Boolean)})});dialog.close();await returnToPM();await refresh()}}catch(error){err.textContent=error.message}finally{submit.disabled=false}};dialog.showModal();prompt.focus(); } async function selectRun(id){ if(busy())return toast('请先停止当前回复或等待附件上传完成'); diff --git a/web/collaboration_creation_test.go b/web/collaboration_creation_test.go new file mode 100644 index 0000000..d97c113 --- /dev/null +++ b/web/collaboration_creation_test.go @@ -0,0 +1,16 @@ +package web + +import ( + "os/exec" + "testing" +) + +func TestCollaborationResponsibleFilterRequestsAndRaces(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed") + } + if out, err := exec.Command(node, "collaboration_creation_test.js").CombinedOutput(); err != nil { + t.Fatalf("collaboration filtering: %v\n%s", err, out) + } +} diff --git a/web/collaboration_creation_test.js b/web/collaboration_creation_test.js new file mode 100644 index 0000000..d4de368 --- /dev/null +++ b/web/collaboration_creation_test.js @@ -0,0 +1,48 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync('collaboration.js', 'utf8'); +const paints = [], requests = [], filters = { + collabTaskResponsible: {value: 'all'}, collabTaskSearch: {value: 'query'}, + collabTaskFilter: {value: 'waiting'}, collabTaskFilters: {hidden: false}, + collabSearchToggle: {setAttribute() {}}, +}; +const context = { + URLSearchParams, taskRefreshGeneration: 0, taskRows: [], taskLimit: 60, + sessions: [], currentRun: '', active: true, scope: 'p', + project: () => context.scope, $id: id => filters[id], + api: path => path.startsWith('/api/runs?') ? new Promise(resolve => requests.push({path, resolve})) : Promise.resolve([]), + assistant: {refreshShared: async () => {}}, paintSessions() {}, refreshSOPHandoffs: async () => {}, + paintTasks: () => paints.push(context.taskRows.map(r => r.id)), + error: err => {throw err}, +}; +vm.createContext(context); +vm.runInContext(source.slice(source.indexOf(' async function refresh(){'), source.indexOf(' function label(')), context); +vm.runInContext(source.slice(source.indexOf(' function toggleTaskSearch('), source.indexOf(" $id('collabSearchToggle').onclick=")), context); +vm.runInContext(source.split('\n').find(line => line.includes("$id('collabTaskResponsible').onchange=")), context); +async function flush() {for (let i = 0; i < 20; i++) await Promise.resolve()} +async function main() { + const old = context.refresh(); + assert.equal(new URLSearchParams(requests[0].path.split('?')[1]).has('responsible'), false); + filters.collabTaskResponsible.value = 'me'; + filters.collabTaskResponsible.onchange(); + assert.equal(context.taskLimit, 30); + assert.equal(new URLSearchParams(requests[1].path.split('?')[1]).get('responsible'), 'me'); + requests[1].resolve([{id: 'mine', executionContext: {projectId: 'p'}}]); await flush(); + requests[0].resolve([{id: 'other', executionContext: {projectId: 'p'}}]); await old; + assert.equal(context.taskRows.length, 1); assert.equal(context.taskRows[0].id, 'mine'); + assert(!paints.flat().includes('other'), 'late unfiltered response must never overwrite my tasks'); + assert.equal(filters.collabTaskSearch.value, 'query', 'owner filter keeps keyword'); + assert.equal(filters.collabTaskFilter.value, 'waiting', 'owner filter keeps status'); + context.toggleTaskSearch(false); await flush(); + assert.equal(filters.collabTaskResponsible.value, 'all'); + assert.equal(filters.collabTaskSearch.value, ''); + assert.equal(filters.collabTaskFilter.value, 'all'); + assert.equal(new URLSearchParams(requests[2].path.split('?')[1]).has('responsible'), false); + requests[2].resolve([]); await flush(); + const previousProject = context.refresh(); + context.scope = 'other'; + requests[3].resolve([{id: 'stale-project', executionContext: {projectId: 'p'}}]); await previousProject; + assert.equal(context.taskRows.length, 0, 'project change invalidates old response'); +} +main().catch(err => {console.error(err); process.exit(1)}); diff --git a/web/index.html b/web/index.html index eb04852..d44a80f 100644 --- a/web/index.html +++ b/web/index.html @@ -9,7 +9,7 @@ - + @@ -24,7 +24,7 @@ - + @@ -53,7 +53,7 @@
@@ -1047,6 +1047,6 @@

确认操作

- + diff --git a/web/platform.css b/web/platform.css index 56fb5fa..9ea801e 100644 --- a/web/platform.css +++ b/web/platform.css @@ -316,7 +316,7 @@ html[data-theme="dark"] .issue-progress-cell.warn b{color:var(--amber)} .issue-key-num{min-width:2.25ch;text-align:left;color:var(--text);font-weight:720} .issue-title-cell{display:flex;align-items:center;gap:7px;min-width:0} .issue-title-cell b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13.5px;font-weight:640;letter-spacing:-.01em;transition:color .12s;display:block} -.issue-title-tag{flex:0 0 auto;max-width:7.5em;height:22px;padding:0 8px;display:inline-flex;align-items:center;border-radius:999px;border:1px solid transparent;font-size:10.5px;font-weight:700;letter-spacing:.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.issue-title-tag{flex:0 1 auto;min-width:3em;max-width:12em;height:22px;line-height:20px;padding:0 8px;display:inline-block;box-sizing:border-box;border-radius:999px;border:1px solid transparent;font-size:10.5px;font-weight:700;letter-spacing:.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .issue-title-tag.tone-danger{background:rgba(198,60,75,.12);color:#c63c4b} .issue-title-tag.tone-warn{background:rgba(251,191,36,.16);color:#b45309} .issue-title-tag.tone-accent{background:rgba(139,92,246,.14);color:#6d28d9}