diff --git a/agentteams-controller/cmd/agt/get.go b/agentteams-controller/cmd/agt/get.go index 925fd2147..cb2747e06 100644 --- a/agentteams-controller/cmd/agt/get.go +++ b/agentteams-controller/cmd/agt/get.go @@ -7,6 +7,8 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/workflow" ) func getCmd() *cobra.Command { @@ -123,7 +125,15 @@ func getProjectsCmd() *cobra.Command { return fmt.Errorf("get project workflow: %w", err) } if mermaid { - fmt.Println(workflowMermaid(resp)) + var snap workflow.Snapshot + buf, err := json.Marshal(resp) + if err != nil { + return fmt.Errorf("encode workflow: %w", err) + } + if err := json.Unmarshal(buf, &snap); err != nil { + return fmt.Errorf("decode workflow: %w", err) + } + fmt.Println(workflow.RenderMermaid(&snap)) return nil } if output == "json" { @@ -205,51 +215,6 @@ func listStr(v any) string { return strings.Join(parts, ", ") } -// workflowMermaid renders a workflow response as a Mermaid flowchart -// (flowchart LR), mirroring LangGraph's draw_mermaid helper. Status is -// appended to each node label; next/ready nodes are highlighted. -func workflowMermaid(resp map[string]any) string { - var b strings.Builder - b.WriteString("flowchart LR\n") - nodes, _ := resp["nodes"].([]any) - edges, _ := resp["edges"].([]any) - nextSet := map[string]bool{} - for _, n := range resp["next"].([]any) { - if id, ok := n.(string); ok { - nextSet[id] = true - } - } - for _, raw := range nodes { - m, ok := raw.(map[string]any) - if !ok { - continue - } - id := toStr(m["id"]) - name := toStr(m["name"]) - status := toStr(m["status"]) - label := name - if status != "" { - label += ": " + status - } - style := "" - if nextSet[id] { - style = ":::ready" - } - fmt.Fprintf(&b, " %s[%q]%s\n", id, label, style) - } - for _, raw := range edges { - m, ok := raw.(map[string]any) - if !ok { - continue - } - src := toStr(m["source"]) - dst := toStr(m["target"]) - fmt.Fprintf(&b, " %s --> %s\n", src, dst) - } - b.WriteString(" classDef ready fill:#d4edda,stroke:#28a745;\n") - return b.String() -} - // toStr converts a JSON-decoded value to its string form for table output. func toStr(v any) string { if v == nil { diff --git a/agentteams-controller/cmd/agt/mermaid_test.go b/agentteams-controller/cmd/agt/mermaid_test.go index b6f159d32..d0cf5c49d 100644 --- a/agentteams-controller/cmd/agt/mermaid_test.go +++ b/agentteams-controller/cmd/agt/mermaid_test.go @@ -1,48 +1,45 @@ package main import ( + "encoding/json" "strings" "testing" + + "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/workflow" ) -func TestWorkflowMermaid(t *testing.T) { - resp := map[string]any{ - "nodes": []any{ - map[string]any{"id": "t1", "name": "Task 1", "status": "completed"}, - map[string]any{"id": "t2", "name": "Task 2", "status": "delegated"}, - }, - "edges": []any{ - map[string]any{"source": "t1", "target": "t2"}, - }, - "next": []any{"t2"}, - } - out := workflowMermaid(resp) +// TestCLIMermaidPath covers the CLI integration path: a JSON-decoded +// workflow response (map[string]any, as fetched via DoJSON) is re-marshaled +// into workflow.Snapshot and rendered — the exact sequence --mermaid runs. +func TestCLIMermaidPath(t *testing.T) { + raw := []byte(`{ + "nodes": [ + {"id": "t1", "name": "Task 1", "status": "completed", "assignee": "@w1"}, + {"id": "t2", "name": "Task 2", "status": "delegated"} + ], + "edges": [{"source": "t1", "target": "t2"}], + "next": ["t2"] + }`) + var resp map[string]any + if err := json.Unmarshal(raw, &resp); err != nil { + t.Fatalf("decode: %v", err) + } + var snap workflow.Snapshot + buf, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := json.Unmarshal(buf, &snap); err != nil { + t.Fatalf("re-decode: %v", err) + } + out := workflow.RenderMermaid(&snap) if !strings.HasPrefix(out, "flowchart LR") { t.Fatalf("expected flowchart header, got %q", out) } - if !strings.Contains(out, `t1["Task 1: completed"]`) { - t.Fatalf("expected node t1 with status, got %q", out) - } - if !strings.Contains(out, "t1 --> t2") { - t.Fatalf("expected edge t1 --> t2, got %q", out) + if !strings.Contains(out, `t1["Task 1: completed"]:::completed`) { + t.Fatalf("expected node t1 with status class, got %q", out) } - // ready node t2 gets the ::ready class if !strings.Contains(out, `t2["Task 2: delegated"]:::ready`) { t.Fatalf("expected ready class on t2, got %q", out) } - if !strings.Contains(out, "classDef ready") { - t.Fatalf("expected ready classDef, got %q", out) - } -} - -func TestWorkflowMermaid_EmptyGraph(t *testing.T) { - resp := map[string]any{ - "nodes": []any{}, - "edges": []any{}, - "next": []any{}, - } - out := workflowMermaid(resp) - if !strings.HasPrefix(out, "flowchart LR") { - t.Fatalf("expected flowchart header, got %q", out) - } } diff --git a/agentteams-controller/internal/server/http.go b/agentteams-controller/internal/server/http.go index c0c986ec3..124d34066 100644 --- a/agentteams-controller/internal/server/http.go +++ b/agentteams-controller/internal/server/http.go @@ -115,6 +115,7 @@ func NewHTTPServer(addr string, deps ServerDeps) *HTTPServer { projectTaskNameFn := func(r *http.Request) string { return r.PathValue("id") + "/" + r.PathValue("taskId") } mux.Handle("GET /api/v1/projects", mw.RequireAuthz(authpkg.ActionList, "project", nil)(http.HandlerFunc(projh.ListProjects))) mux.Handle("GET /api/v1/projects/{id}/workflow", mw.RequireAuthz(authpkg.ActionGet, "project", projectNameFn)(http.HandlerFunc(projh.GetProjectWorkflow))) + mux.Handle("GET /api/v1/projects/{id}/tasks/{taskId}", mw.RequireAuthz(authpkg.ActionGet, "project", projectNameFn)(http.HandlerFunc(projh.GetTaskInspection))) mux.Handle("GET /api/v1/projects/{id}/tasks/{taskId}/artifact", mw.RequireAuthz(authpkg.ActionGet, "project", projectNameFn)(http.HandlerFunc(projh.GetTaskArtifact))) mux.Handle("GET /api/v1/projects/{id}/spawns", mw.RequireAuthz(authpkg.ActionGet, "project", projectNameFn)(http.HandlerFunc(projh.GetProjectSpawns))) mux.Handle("GET /api/v1/projects/{id}/spawns/{sessionId}/messages", mw.RequireAuthz(authpkg.ActionGet, "project", projectNameFn)(http.HandlerFunc(projh.GetProjectSpawnMessages))) diff --git a/agentteams-controller/internal/server/project_handler.go b/agentteams-controller/internal/server/project_handler.go index d583bf642..c34e4d081 100644 --- a/agentteams-controller/internal/server/project_handler.go +++ b/agentteams-controller/internal/server/project_handler.go @@ -27,6 +27,7 @@ import ( "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/httputil" "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/matrix" "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/oss" + "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/workflow" "github.com/google/uuid" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -202,6 +203,41 @@ type taskDetail struct { Deliverables []any `json:"deliverables,omitempty"` ResultPath string `json:"result_path,omitempty"` CancelReason string `json:"cancel_reason,omitempty"` + // History is the append-only transition audit for this task, maintained + // by TeamHarness taskflow (and the controller's cancel path). Each entry + // records one accepted state change; empty until the transition engine + // lands (design: agentscope-ai/AgentTeams#1223). + History []taskHistoryEntry `json:"history,omitempty"` +} + +// taskHistoryEntry is one append-only task transition record +// (task meta `history[]`, capped at 50 entries by the writer). +type taskHistoryEntry struct { + TS string `json:"ts"` + From string `json:"from"` + To string `json:"to"` + Actor string `json:"actor,omitempty"` + Action string `json:"action"` + Note string `json:"note,omitempty"` +} + +// taskInspection is the node-level inspection payload: the task's graph node +// plus its TaskMeta, transition history, and a trace hint for deep-linking +// into a tracing backend (worker entry spans are tagged with +// agentteams.project.id / agentteams.task.id). +type taskInspection struct { + taskDetail + Dependencies []string `json:"dependencies,omitempty"` + Trace taskTraceHint `json:"trace,omitempty"` +} + +// taskTraceHint is a tracing-backend filter hint: project_id / task_id are +// the values to match against the span attributes agentteams.project.id / +// agentteams.task.id, which worker entry spans already carry. No URL is +// constructed here: the tracing backend is deployment-specific. +type taskTraceHint struct { + ProjectID string `json:"project_id"` + TaskID string `json:"task_id"` } // normalizeTaskStatus maps ProjectMeta task status to the frontend-friendly @@ -602,6 +638,7 @@ func (h *ProjectHandler) GetProjectWorkflow(w http.ResponseWriter, r *http.Reque } caller := authpkg.CallerFromContext(r.Context()) includeTasks := r.URL.Query().Get("includeTasks") == "true" + format := r.URL.Query().Get("format") teamFilter := r.URL.Query().Get("team") // Single K8s List for both meta resolution and the access check (O1). @@ -634,6 +671,29 @@ func (h *ProjectHandler) GetProjectWorkflow(w http.ResponseWriter, r *http.Reque return } + if format == "mermaid" { + // Pure rendering of the same snapshot (buildWorkflow); per-task + // TaskMeta is not needed, so includeTasks is ignored. + resp := h.buildWorkflow(meta, team, false) + snap := workflow.Snapshot{Next: resp.Next} + snap.Nodes = make([]workflow.Node, len(resp.Nodes)) + for i, n := range resp.Nodes { + snap.Nodes[i] = workflow.Node{ID: n.ID, Name: n.Name, Status: n.Status, Assignee: n.Assignee} + } + snap.Edges = make([]workflow.Edge, len(resp.Edges)) + for i, e := range resp.Edges { + snap.Edges[i] = workflow.Edge{Source: e.Source, Target: e.Target, Conditional: e.Conditional} + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, workflow.RenderMermaid(&snap)) + return + } + if format != "" { + httputil.WriteError(w, http.StatusBadRequest, fmt.Sprintf("unsupported format %q: allowed: mermaid", format)) + return + } + httputil.WriteJSON(w, http.StatusOK, h.buildWorkflow(meta, team, includeTasks)) } @@ -885,6 +945,11 @@ func (h *ProjectHandler) readTasksDetail(meta *projectMeta, team string) []taskD detail.Deliverables = list } } + if raw["history"] != nil { + if list, ok := raw["history"].([]any); ok { + detail.History = parseTaskHistory(list) + } + } detailByTask[res.taskID] = detail } @@ -911,6 +976,152 @@ func keyForTaskID(key string, taskIDs []string) string { return "" } +// parseTaskHistory converts a raw task-meta `history` array into typed +// entries. Malformed entries are skipped (an audit record must never break +// the read path). +func parseTaskHistory(list []any) []taskHistoryEntry { + out := make([]taskHistoryEntry, 0, len(list)) + for _, raw := range list { + m, ok := raw.(map[string]any) + if !ok { + continue + } + entry := taskHistoryEntry{ + TS: str(m["ts"]), + From: str(m["from"]), + To: str(m["to"]), + Actor: str(m["actor"]), + Action: str(m["action"]), + Note: str(m["note"]), + } + if entry.TS == "" && entry.Action == "" { + continue + } + out = append(out, entry) + } + return out +} + +// GetTaskInspection serves node-level state for one task. +// +// GET /api/v1/projects/{id}/tasks/{taskId} +// +// Aggregates the task's graph node (status/assignee/dependencies), its +// TaskMeta (spec/summary/deliverables/result), the append-only transition +// history, and a trace hint (worker entry spans carry agentteams.project.id +// / agentteams.task.id attributes). TaskMeta is read from the project's +// owning scope only — same no-cross-scope-fallback rule as readTasksDetail. +func (h *ProjectHandler) GetTaskInspection(w http.ResponseWriter, r *http.Request) { + projectID := r.PathValue("id") + taskID := r.PathValue("taskId") + if projectID == "" || taskID == "" { + httputil.WriteError(w, http.StatusBadRequest, "project id and task id are required") + return + } + if !isSafeTaskID(taskID) { + httputil.WriteError(w, http.StatusBadRequest, "invalid task id") + return + } + caller := authpkg.CallerFromContext(r.Context()) + teamFilter := r.URL.Query().Get("team") + + // Single K8s List for both meta resolution and the access check (O1 + // pattern). Reuse the same dual-prefix layout as GetProjectWorkflow. + prefixes, crToEffective, err := h.teamProjectPrefixes(r.Context()) + if err != nil { + writeK8sError(w, "get task inspection: resolve prefixes", err) + return + } + matches, err := h.resolveProjectMeta(r.Context(), projectID, prefixes, teamFilter, caller, crToEffective) + if err != nil { + writeK8sError(w, "get task inspection", err) + return + } + meta, team, ok := h.resolveSingleProject(w, matches) + if !ok { + return + } + // W4: hide project existence from scoped callers who do not own this + // project (L2 / team leader). Same 404 semantics as GetProjectWorkflow. + if err := h.checkProjectAccess(caller, team, crToEffective); err != nil { + if _, ok := err.(*accessDeniedError); ok { + httputil.WriteError(w, http.StatusNotFound, "project not found") + return + } + httputil.WriteError(w, http.StatusForbidden, err.Error()) + return + } + + // The task must belong to this project's graph (project.tasks, or + // loop.tasks for loop plans) — same membership rule as GetTaskArtifact. + graphTasks := meta.Tasks + if meta.PlanType == "loop" && meta.Loop != nil { + graphTasks = meta.Loop.Tasks + } + var node *projectTaskMeta + for i := range graphTasks { + if graphTasks[i].TaskID == taskID { + node = &graphTasks[i] + break + } + } + if node == nil { + httputil.WriteError(w, http.StatusNotFound, "task not found") + return + } + + // TaskMeta from the owning scope only (team prefix first, then global — + // the global prefix is a fallback for standalone projects, mirroring + // readTasksDetail; a team project never leaks a global TaskMeta). + detail := taskDetail{TaskID: node.TaskID, ProjectID: meta.ProjectID, Status: normalizeTaskStatus(node.Status), AssignedTo: node.AssignedTo} + for _, key := range taskMetaKeys(taskID, team) { + data, err := h.oss.GetObject(r.Context(), key) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + httputil.WriteError(w, http.StatusInternalServerError, "read task meta: "+err.Error()) + return + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + continue // malformed TaskMeta; keep node summary only + } + // Ownership check: TaskMeta must name exactly this project's graph + // task (same rule as readTasksDetail). + if str(raw["task_id"]) != taskID || str(raw["project_id"]) != meta.ProjectID { + continue + } + detail.Status = str(raw["status"]) + detail.SpecPath = str(raw["spec_path"]) + if a := str(raw["assigned_to"]); a != "" { + detail.AssignedTo = a + } + detail.Summary = str(raw["summary"]) + detail.ResultStatus = str(raw["result_status"]) + detail.ResultPath = str(raw["result_path"]) + detail.CancelReason = str(raw["cancel_reason"]) + if raw["deliverables"] != nil { + if list, ok := raw["deliverables"].([]any); ok { + detail.Deliverables = list + } + } + if raw["history"] != nil { + if list, ok := raw["history"].([]any); ok { + detail.History = parseTaskHistory(list) + } + } + break // first non-empty match wins (team prefix takes precedence) + } + + insp := taskInspection{ + taskDetail: detail, + Dependencies: node.DependsOn, + Trace: taskTraceHint{ProjectID: meta.ProjectID, TaskID: taskID}, + } + httputil.WriteJSON(w, http.StatusOK, insp) +} + // GetTaskArtifact serves the result artifact of one task. // // GET /api/v1/projects/{id}/tasks/{taskId}/artifact diff --git a/agentteams-controller/internal/server/project_handler_test.go b/agentteams-controller/internal/server/project_handler_test.go index 3950f5169..6bef39f7e 100644 --- a/agentteams-controller/internal/server/project_handler_test.go +++ b/agentteams-controller/internal/server/project_handler_test.go @@ -4042,3 +4042,324 @@ func TestCancelTask_RetryConvergesBothObjects(t *testing.T) { t.Fatalf("task=%v, want cancelled + reason after retry", task2) } } + +// --- workflow ?format=mermaid --- + +func TestGetProjectWorkflow_MermaidFormat(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "teams/alpha-team/shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", + "title": "Alpha Project", + "status": "active", + "plan_type": "dag", + "team_id": "alpha-team", + "tasks": []map[string]any{ + {"task_id": "t1", "title": "Task 1", "assigned_to": "@w1", "depends_on": []string{}, "status": "completed"}, + {"task_id": "t2", "title": "Task 2", "assigned_to": "@w2", "depends_on": []string{"t1"}, "status": "assigned"}, + {"task_id": "t3", "title": "Task 3", "assigned_to": "@w3", "depends_on": []string{"t2"}, "status": "planned"}, + }, + }) + h := newProjectTestHandler(t, store, team("alpha-team")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/p1/workflow?format=mermaid", nil) + req.SetPathValue("id", "p1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.GetProjectWorkflow(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Fatalf("content-type=%q, want text/plain", ct) + } + out := rec.Body.String() + if !strings.HasPrefix(out, "flowchart LR") { + t.Fatalf("expected flowchart header, got %q", out) + } + if !strings.Contains(out, `t1["Task 1: completed"]:::completed`) { + t.Fatalf("expected t1 with completed class, got %q", out) + } + // t2 is the only ready node: ready class overrides its delegated class. + if !strings.Contains(out, `t2["Task 2: delegated"]:::ready`) { + t.Fatalf("expected ready class on t2, got %q", out) + } + if !strings.Contains(out, `t3["Task 3: pending"]:::pending`) { + t.Fatalf("expected t3 with pending class, got %q", out) + } + if !strings.Contains(out, "t1 --> t2") || !strings.Contains(out, "t2 --> t3") { + t.Fatalf("missing edges, got %q", out) + } + for _, cd := range []string{ + "classDef ready", "classDef pending", "classDef delegated", "classDef completed", "classDef blocked", + } { + if !strings.Contains(out, cd) { + t.Fatalf("expected %q in output, got %q", cd, out) + } + } +} + +func TestGetProjectWorkflow_MermaidFormat_Invalid(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P", "status": "active", "plan_type": "dag", + }) + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/p1/workflow?format=bogus", nil) + req.SetPathValue("id", "p1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.GetProjectWorkflow(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } +} + +// --- GET /api/v1/projects/{id}/tasks/{taskId} (node-level inspection) --- + +func TestGetTaskInspection(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "teams/alpha-team/shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", + "title": "Alpha Project", + "status": "active", + "plan_type": "dag", + "team_id": "alpha-team", + "tasks": []map[string]any{ + {"task_id": "t1", "title": "Task 1", "assigned_to": "@w1", "depends_on": []string{}, "status": "completed"}, + {"task_id": "t2", "title": "Task 2", "assigned_to": "@w2", "depends_on": []string{"t1"}, "status": "assigned"}, + }, + }) + putProject(store, "teams/alpha-team/shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", + "project_id": "p1", + "status": "completed", + "spec_path": "shared/tasks/t1/spec.md", + "assigned_to": "@w1", + "summary": "Alpha report done", + "result_status": "SUCCESS", + "result_path": "shared/tasks/t1/result.md", + "history": []any{ + map[string]any{"ts": "2026-09-05T01:00:00Z", "from": "", "to": "planned", "actor": "manager", "action": "create"}, + map[string]any{"ts": "2026-09-05T02:00:00Z", "from": "in_progress", "to": "submitted", "actor": "w1", "action": "submit_task"}, + "malformed entry must be skipped", + map[string]any{"note": "no ts and no action — skipped"}, + }, + }) + putProject(store, "teams/alpha-team/shared/tasks/t2/meta.json", map[string]any{ + "task_id": "t2", "project_id": "p1", "status": "assigned", + "spec_path": "shared/tasks/t2/spec.md", "assigned_to": "@w2", + }) + h := newProjectTestHandler(t, store, team("alpha-team")) + + // t1: full detail + parsed history (malformed entries skipped) + trace. + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/p1/tasks/t1", nil) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.GetTaskInspection(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("t1 status=%d body=%s", rec.Code, rec.Body.String()) + } + var insp taskInspection + if err := json.Unmarshal(rec.Body.Bytes(), &insp); err != nil { + t.Fatalf("decode: %v", err) + } + if insp.TaskID != "t1" || insp.ProjectID != "p1" || insp.Status != "completed" { + t.Fatalf("t1 identity wrong: %+v", insp) + } + if insp.SpecPath != "shared/tasks/t1/spec.md" || insp.Summary != "Alpha report done" || + insp.ResultStatus != "SUCCESS" || insp.ResultPath != "shared/tasks/t1/result.md" { + t.Fatalf("t1 detail wrong: %+v", insp) + } + if len(insp.History) != 2 { + t.Fatalf("t1 history=%d, want 2 (malformed entries skipped): %+v", len(insp.History), insp.History) + } + if insp.History[1].From != "in_progress" || insp.History[1].To != "submitted" || + insp.History[1].Actor != "w1" || insp.History[1].Action != "submit_task" { + t.Fatalf("t1 history entry wrong: %+v", insp.History[1]) + } + if len(insp.Dependencies) != 0 { + t.Fatalf("t1 dependencies=%v, want empty", insp.Dependencies) + } + if insp.Trace.ProjectID != "p1" || insp.Trace.TaskID != "t1" { + t.Fatalf("t1 trace hint wrong: %+v", insp.Trace) + } + + // t2: dependencies exposed from the graph node. + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/projects/p1/tasks/t2", nil) + req2.SetPathValue("id", "p1") + req2.SetPathValue("taskId", "t2") + req2 = withCaller(req2, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec2 := httptest.NewRecorder() + h.GetTaskInspection(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("t2 status=%d body=%s", rec2.Code, rec2.Body.String()) + } + var insp2 taskInspection + if err := json.Unmarshal(rec2.Body.Bytes(), &insp2); err != nil { + t.Fatalf("decode t2: %v", err) + } + if len(insp2.Dependencies) != 1 || insp2.Dependencies[0] != "t1" { + t.Fatalf("t2 dependencies=%v, want [t1]", insp2.Dependencies) + } + if insp2.Status != "assigned" || insp2.History != nil { + t.Fatalf("t2 detail wrong: %+v", insp2) + } +} + +func TestGetTaskInspection_MissingMeta(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{ + {"task_id": "t1", "title": "T1", "assigned_to": "@w1", "depends_on": []string{}, "status": "in_progress"}, + }, + }) + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/p1/tasks/t1", nil) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.GetTaskInspection(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var insp taskInspection + if err := json.Unmarshal(rec.Body.Bytes(), &insp); err != nil { + t.Fatalf("decode: %v", err) + } + // No TaskMeta: node summary only, status normalized from the graph node. + if insp.Status != "in-progress" || insp.SpecPath != "" || insp.Summary != "" { + t.Fatalf("expected node summary only, got %+v", insp) + } + if insp.Trace.TaskID != "t1" { + t.Fatalf("trace hint missing: %+v", insp.Trace) + } +} + +func TestGetTaskInspection_TaskNotInGraph(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{ + {"task_id": "t1", "title": "T1", "assigned_to": "@w1", "depends_on": []string{}, "status": "planned"}, + }, + }) + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/p1/tasks/t9", nil) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t9") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.GetTaskInspection(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d body=%s, want 404", rec.Code, rec.Body.String()) + } +} + +func TestGetTaskInspection_InvalidTaskID(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P", "status": "active", "plan_type": "dag", + }) + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/p1/tasks/bad%21id", nil) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "bad!id") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.GetTaskInspection(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } +} + +func TestGetTaskInspection_LoopTask(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", + "title": "Loop Project", + "status": "active", + "plan_type": "loop", + "tasks": []map[string]any{}, + "loop": map[string]any{ + "goal": "g", "stop_condition": "s", "current_iteration": 1, "max_iterations": 5, "status": "running", + "tasks": []map[string]any{ + {"task_id": "l1", "title": "Loop Step 1", "assigned_to": "@w1", "depends_on": []string{}, "status": "completed"}, + {"task_id": "l2", "title": "Loop Step 2", "assigned_to": "@w2", "depends_on": []string{"l1"}, "status": "assigned"}, + }, + }, + }) + putProject(store, "shared/tasks/l2/meta.json", map[string]any{ + "task_id": "l2", "project_id": "p1", "status": "assigned", + "spec_path": "shared/tasks/l2/spec.md", + }) + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/p1/tasks/l2", nil) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "l2") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.GetTaskInspection(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var insp taskInspection + if err := json.Unmarshal(rec.Body.Bytes(), &insp); err != nil { + t.Fatalf("decode: %v", err) + } + if insp.TaskID != "l2" || insp.SpecPath != "shared/tasks/l2/spec.md" { + t.Fatalf("loop task detail wrong: %+v", insp) + } + if len(insp.Dependencies) != 1 || insp.Dependencies[0] != "l1" { + t.Fatalf("loop task dependencies=%v, want [l1]", insp.Dependencies) + } +} + +func TestGetTaskInspection_CrossScopeNoFallback(t *testing.T) { + store := ossfake.NewMemory() + // Team-owned project; its task meta exists ONLY at the global prefix. + // A team project must not leak the global TaskMeta (same rule as + // readTasksDetail / includeTasks). + putProject(store, "teams/alpha-team/shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", + "title": "Alpha Project", + "status": "active", + "plan_type": "dag", + "team_id": "alpha-team", + "tasks": []map[string]any{ + {"task_id": "t1", "title": "Task 1", "assigned_to": "@w1", "depends_on": []string{}, "status": "planned"}, + }, + }) + putProject(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", "project_id": "p1", "status": "completed", + "summary": "should not leak", + }) + h := newProjectTestHandler(t, store, team("alpha-team")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/p1/tasks/t1", nil) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.GetTaskInspection(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var insp taskInspection + if err := json.Unmarshal(rec.Body.Bytes(), &insp); err != nil { + t.Fatalf("decode: %v", err) + } + if insp.Summary != "" || insp.Status != "pending" { + t.Fatalf("global TaskMeta leaked into team project: %+v", insp) + } +} diff --git a/agentteams-controller/internal/workflow/mermaid.go b/agentteams-controller/internal/workflow/mermaid.go new file mode 100644 index 000000000..4090f13bf --- /dev/null +++ b/agentteams-controller/internal/workflow/mermaid.go @@ -0,0 +1,162 @@ +// Package workflow holds workflow-snapshot presentation helpers shared by +// the controller API and the agt CLI. +package workflow + +import ( + "fmt" + "strings" +) + +// Snapshot is the minimal workflow shape needed for mermaid rendering. It +// mirrors the nodes/edges/next fields of the controller's workflow response +// (LangGraph StateSnapshot-aligned). +type Snapshot struct { + Nodes []Node `json:"nodes"` + Edges []Edge `json:"edges"` + Next []string `json:"next"` +} + +// Node is a single workflow graph node. Status is the normalized frontend +// enum (pending | delegated | in-progress | completed | revision | blocked). +type Node struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status,omitempty"` + Assignee string `json:"assignee,omitempty"` +} + +// Edge is a dependency edge (source must complete before target). +type Edge struct { + Source string `json:"source"` + Target string `json:"target"` + Conditional bool `json:"conditional,omitempty"` +} + +// mermaidStatusClass maps the normalized node status to a mermaid classDef +// name. +var mermaidStatusClass = map[string]string{ + "pending": "pending", + "delegated": "delegated", + "in-progress": "inProgress", + "completed": "completed", + "revision": "revision", + "blocked": "blocked", +} + +// mermaidClassDefs lists every classDef the renderer emits, in stable order. +var mermaidClassDefs = []string{ + "classDef ready fill:#d4edda,stroke:#28a745;", + "classDef pending fill:#e9ecef,stroke:#6c757d;", + "classDef delegated fill:#cfe2ff,stroke:#0d6efd;", + "classDef inProgress fill:#fff3cd,stroke:#ffc107;", + "classDef completed fill:#d4edda,stroke:#198754;", + "classDef revision fill:#ffe5d0,stroke:#fd7e14;", + "classDef blocked fill:#f8d7da,stroke:#dc3545;", +} + +// sanitizeNodeID maps a task id to a valid, unambiguous mermaid node id. +// Mermaid flowchart ids reliably accept [A-Za-z0-9_-]; anything else (e.g. +// dots, which the API's isSafeTaskID allows) is replaced with '_'. Colliding +// ids get a numeric suffix so two different task ids can never render as one +// node. +func sanitizeNodeID(id string, used map[string]bool) string { + var b strings.Builder + for _, r := range id { + if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' || r == '-' { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + s := b.String() + if s == "" { + s = "n" + } + if used[s] { + for i := 2; ; i++ { + cand := fmt.Sprintf("%s_%d", s, i) + if !used[cand] { + s = cand + break + } + } + } + used[s] = true + return s +} + +// sanitizeLabel makes a task title safe for use inside a quoted mermaid +// label. Titles are user-controlled (workflow input); the normalization +// guarantees a malformed or hostile title can never alter the rendered +// graph structure: +// - newlines / carriage returns -> `
` (mermaid's line-break tag) so a +// label can never spill onto another node line +// - other control characters -> space +// - double quotes -> `#quot;` (mermaid's documented entity), so a title +// can never terminate the label string +// - backslashes are dropped, since mermaid's quoted-text lexer may treat +// them as escape introducers (a trailing one could swallow the closing +// quote) +func sanitizeLabel(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r == '\n' || r == '\r': + b.WriteString("
") + case r == '"': + b.WriteString(`#quot;`) + case r == '\\': + // dropped on purpose (see doc comment) + case r < 0x20 || r == 0x7f: + b.WriteByte(' ') + default: + b.WriteRune(r) + } + } + return b.String() +} + +// RenderMermaid renders a workflow snapshot as a mermaid flowchart +// (flowchart LR), mirroring LangGraph's draw_mermaid helper. Each node label +// is "name: status" (sanitized — see sanitizeLabel); next/ready nodes are +// highlighted with the `ready` class, all other nodes get a status-specific +// class. Node ids are sanitized (see sanitizeNodeId) so the output is valid +// mermaid for any user-controlled title or task id. +func RenderMermaid(s *Snapshot) string { + var b strings.Builder + b.WriteString("flowchart LR\n") + nextSet := map[string]bool{} + for _, id := range s.Next { + nextSet[id] = true + } + idMap := map[string]string{} + used := map[string]bool{} + idFor := func(id string) string { + if v, ok := idMap[id]; ok { + return v + } + v := sanitizeNodeID(id, used) + idMap[id] = v + return v + } + for _, n := range s.Nodes { + label := n.Name + if n.Status != "" { + label += ": " + n.Status + } + style := "" + if nextSet[n.ID] { + style = ":::ready" + } else if c, ok := mermaidStatusClass[n.Status]; ok { + style = ":::" + c + } + fmt.Fprintf(&b, " %s[\"%s\"]%s\n", idFor(n.ID), sanitizeLabel(label), style) + } + for _, e := range s.Edges { + fmt.Fprintf(&b, " %s --> %s\n", idFor(e.Source), idFor(e.Target)) + } + for _, cd := range mermaidClassDefs { + b.WriteString(" " + cd + "\n") + } + return b.String() +} diff --git a/agentteams-controller/internal/workflow/mermaid_test.go b/agentteams-controller/internal/workflow/mermaid_test.go new file mode 100644 index 000000000..572c0e89e --- /dev/null +++ b/agentteams-controller/internal/workflow/mermaid_test.go @@ -0,0 +1,210 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestRenderMermaid(t *testing.T) { + s := &Snapshot{ + Nodes: []Node{ + {ID: "t1", Name: "Task 1", Status: "completed"}, + {ID: "t2", Name: "Task 2", Status: "delegated"}, + }, + Edges: []Edge{{Source: "t1", Target: "t2"}}, + Next: []string{"t2"}, + } + out := RenderMermaid(s) + if !strings.HasPrefix(out, "flowchart LR") { + t.Fatalf("expected flowchart header, got %q", out) + } + if !strings.Contains(out, `t1["Task 1: completed"]:::completed`) { + t.Fatalf("expected node t1 with status class, got %q", out) + } + if !strings.Contains(out, "t1 --> t2") { + t.Fatalf("expected edge t1 --> t2, got %q", out) + } + // ready node t2 gets the ready class (overrides the status class) + if !strings.Contains(out, `t2["Task 2: delegated"]:::ready`) { + t.Fatalf("expected ready class on t2, got %q", out) + } + for _, cd := range mermaidClassDefs { + if !strings.Contains(out, cd) { + t.Fatalf("expected classDef %q, got %q", cd, out) + } + } +} + +func TestRenderMermaid_StatusClasses(t *testing.T) { + s := &Snapshot{ + Nodes: []Node{ + {ID: "a", Name: "A", Status: "pending"}, + {ID: "b", Name: "B", Status: "in-progress"}, + {ID: "c", Name: "C", Status: "revision"}, + {ID: "d", Name: "D", Status: "blocked"}, + {ID: "e", Name: "E"}, // unknown/empty status: no class + }, + } + out := RenderMermaid(s) + for _, want := range []string{ + `a["A: pending"]:::pending`, + `b["B: in-progress"]:::inProgress`, + `c["C: revision"]:::revision`, + `d["D: blocked"]:::blocked`, + `e["E"]`, + } { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in output, got %q", want, out) + } + } +} + +func TestRenderMermaid_EmptyGraph(t *testing.T) { + out := RenderMermaid(&Snapshot{Nodes: []Node{}, Edges: []Edge{}, Next: []string{}}) + if !strings.HasPrefix(out, "flowchart LR") { + t.Fatalf("expected flowchart header, got %q", out) + } + if strings.Contains(out, "-->") { + t.Fatalf("no edges expected, got %q", out) + } +} + +func TestRenderMermaid_NilSnapshot(t *testing.T) { + // Defensive: a nil snapshot must not panic (renders header only). + out := RenderMermaid(&Snapshot{}) + if !strings.HasPrefix(out, "flowchart LR") { + t.Fatalf("expected flowchart header, got %q", out) + } +} + +// --- mermaid safety for user-controlled titles (reviewer requirement) --- + +func TestRenderMermaid_MaliciousTitles(t *testing.T) { + cases := []struct { + name string + title string + checks []string // substrings that MUST appear + absent []string // substrings that MUST NOT appear + }{ + { + name: "double quotes", + title: `say "hello" & 'world'`, + checks: []string{`#quot;hello#quot;`}, + absent: []string{`say "hello`}, + }, + { + name: "embedded newline", + title: "line1\nline2", + checks: []string{`line1
line2`}, + }, + { + name: "trailing backslash before closing quote", + title: `ends with backslash\`, + checks: []string{`ends with backslash`}, + }, + { + name: "edge-like syntax", + title: "A --> B", + checks: []string{`"A --> B: pending"`}, + }, + { + name: "brackets and parens", + title: "task [3] (final) ] [", + checks: []string{`task [3] (final) ] [`}, + }, + { + name: "control characters", + title: "tab\there\x00nul", + checks: []string{`tab here`}, + }, + { + name: "unicode preserved", + title: "任务一:设计评审", + checks: []string{`任务一:设计评审`}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := &Snapshot{ + Nodes: []Node{{ID: "t1", Name: tc.title, Status: "pending"}}, + Next: []string{}, + } + out := RenderMermaid(s) + // Structural invariant: exactly one node line, and it stays on a + // single line with balanced quoting (a label can never spill or + // terminate early). + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + var nodeLines []string + for _, l := range lines { + if strings.HasPrefix(strings.TrimSpace(l), "t1[") { + nodeLines = append(nodeLines, l) + } + } + if len(nodeLines) != 1 { + t.Fatalf("expected exactly one node line, got %d: %q", len(nodeLines), out) + } + ln := nodeLines[0] + if !strings.HasPrefix(ln, ` t1["`) { + t.Fatalf("node line must start with quoted label: %q", ln) + } + for _, want := range tc.checks { + if !strings.Contains(ln, want) { + t.Fatalf("expected %q in node line, got %q", want, ln) + } + } + for _, bad := range tc.absent { + if strings.Contains(out, bad) { + t.Fatalf("forbidden %q present in output: %q", bad, out) + } + } + }) + } +} + +func TestRenderMermaid_TitleCannotBreakStructure(t *testing.T) { + // A title containing node/edge syntax must render exactly one node and + // must not create extra edges. + s := &Snapshot{ + Nodes: []Node{ + {ID: "t1", Name: "x --> y\nz[\"quoted\"]", Status: "pending"}, + {ID: "t2", Name: "ok", Status: "completed"}, + }, + Edges: []Edge{{Source: "t1", Target: "t2"}}, + Next: []string{}, + } + out := RenderMermaid(s) + edgeLines := 0 + for _, l := range strings.Split(out, "\n") { + if strings.Contains(l, "-->") && !strings.HasPrefix(strings.TrimSpace(l), "t1[") { + edgeLines++ + } + } + if edgeLines != 1 { + t.Fatalf("expected exactly 1 edge line, got %d:\n%s", edgeLines, out) + } +} + +func TestRenderMermaid_NodeIDSanitization(t *testing.T) { + // Dots (allowed by the API's isSafeTaskID) are not reliable mermaid id + // characters: they map to '_', and colliding ids must stay distinct. + s := &Snapshot{ + Nodes: []Node{ + {ID: "t.1", Name: "dotted", Status: "pending"}, + {ID: "t_1", Name: "underscored", Status: "completed"}, + }, + Edges: []Edge{{Source: "t.1", Target: "t_1"}}, + Next: []string{"t.1"}, + } + out := RenderMermaid(s) + // t.1 -> t_1 (first), t_1 -> t_1_2 (collision suffix); both nodes and + // the edge must use the sanitized ids consistently. + if !strings.Contains(out, `t_1["dotted: pending"]:::ready`) { + t.Fatalf("dotted id not sanitized to t_1 with ready class:\n%s", out) + } + if !strings.Contains(out, `t_1_2["underscored: completed"]:::completed`) { + t.Fatalf("collision id not suffixed:\n%s", out) + } + if !strings.Contains(out, "t_1 --> t_1_2") { + t.Fatalf("edge must use sanitized ids:\n%s", out) + } +} diff --git a/docs/usage/project-workflow-api.md b/docs/usage/project-workflow-api.md index 597135085..392cceafe 100644 --- a/docs/usage/project-workflow-api.md +++ b/docs/usage/project-workflow-api.md @@ -74,11 +74,23 @@ Response `200 OK`: Return the LangGraph-aligned workflow for one project. -Optional query parameter: +Optional query parameters: | Parameter | Type | Meaning | |:--|:--|:--| | `includeTasks` | `bool` | When `true`, also read each task's TaskMeta (`shared/tasks/{id}/meta.json`) and attach a `tasks_detail` array with spec/result/deliverable fields. Default `false` keeps the response lightweight. | +| `format` | `string` | Response format. Default (absent or empty) returns the JSON snapshot above. `format=mermaid` returns the same snapshot rendered as a Mermaid flowchart (`text/plain`, no `tasks_detail` — rendering needs only nodes/edges/next). Any other value returns `400`. | + +Mermaid output (`?format=mermaid`) mirrors LangGraph's `draw_mermaid` helper: each node label is `name: status`, next/ready nodes get the `ready` highlight class, and every other node gets a status class (`pending` / `delegated` / `inProgress` / `completed` / `revision` / `blocked`). All classDefs are emitted so the graph renders standalone. Task titles and ids are user-controlled, so they are sanitized for mermaid safety: newlines become `
`, double quotes become `#quot;`, backslashes are dropped, and other control characters become spaces; a task id containing characters outside `[A-Za-z0-9_-]` is mapped to a collision-safe node id (labels keep the original text). A malformed title therefore can never alter the rendered graph structure. Example: + +```text +flowchart LR + t1["Task 1: completed"]:::completed + t2["Task 2: delegated"]:::ready + t1 --> t2 + classDef ready fill:#d4edda,stroke:#28a745; + ... +``` Response `200 OK`: @@ -172,6 +184,63 @@ Error responses: | `404` | Project not found (no meta.json under any scanned prefix) — **or** the caller is a scoped reader (team leader / L2 human) who does not own the project (existence is hidden to prevent id enumeration). | | `500` | K8s or object-store failure. | +### `GET /api/v1/projects/{id}/tasks/{taskId}` + +Node-level inspection for one task: aggregates the task's graph node (status, +assignee, dependencies), its TaskMeta (spec/summary/result/deliverables), the +append-only transition history, and a trace hint for deep-linking into a +tracing backend. + +```text +GET /api/v1/projects/{id}/tasks/{taskId}?team=alpha-team +``` + +Response `200 OK`: + +```json +{ + "task_id": "t1", + "project_id": "demo-project-001", + "status": "in-progress", + "spec_path": "shared/tasks/t1/spec.md", + "assigned_to": "@w1:matrix.local", + "summary": "Alpha report done", + "result_status": "SUCCESS", + "result_path": "shared/tasks/t1/result.md", + "deliverables": [{"type": "file", "path": "shared/tasks/t1/output.pdf"}], + "history": [ + {"ts": "2026-09-05T01:00:00Z", "from": "", "to": "planned", "actor": "manager", "action": "create"}, + {"ts": "2026-09-05T02:00:00Z", "from": "planned", "to": "in_progress", "actor": "w1", "action": "ack_task"}, + {"ts": "2026-09-05T03:00:00Z", "from": "in_progress", "to": "submitted", "actor": "w1", "action": "submit_task"} + ], + "dependencies": [], + "trace": {"project_id": "demo-project-001", "task_id": "t1"} +} +``` + +Field notes: + +- `status` is the **raw** TaskMeta status when TaskMeta exists (same + semantics as `tasks_detail` with `?includeTasks=true`); when TaskMeta is + absent it falls back to the normalized graph-node status + (`pending | delegated | in-progress | completed | revision | blocked`). +- `history` is populated by TeamHarness taskflow (and the controller's + cancel path) as an append-only audit of accepted transitions, capped at 50 + entries; it is empty until the workflow transition engine lands + (design: agentscope-ai/AgentTeams#1223). Malformed entries are skipped, + never an error. +- `trace` is a tracing-backend filter hint: its `project_id` / `task_id` are + the values to match against the span attributes `agentteams.project.id` / + `agentteams.task.id`, which worker entry spans already carry. No backend + URL is constructed here; the tracing backend is deployment-specific. +- TaskMeta is read from the project's owning scope only (team prefix first, + global prefix only for standalone projects) — the same no-cross-scope + fallback rule as `tasks_detail`. + +Errors: `400` (missing/invalid task id), `404` (project not found — +existence-hidden for scoped callers — or task not in this project's graph), +`500` (storage read failure). + ### `GET /api/v1/projects/{id}/tasks/{taskId}/artifact` Download one of a task's artifacts, completing the "deliverable → download → @@ -444,7 +513,18 @@ agt get projects # list all agt get projects --team biz-team # filter by team agt get projects demo-project-001 # workflow detail agt get projects demo-project-001 -o json -agt get projects demo-project-001 --mermaid # render DAG as mermaid +agt get projects demo-project-001 --mermaid # render DAG as mermaid (status classes) +``` + +`--mermaid` uses the same renderer as the API's `?format=mermaid`: next/ready +nodes are highlighted and every node is colored by status (see the workflow +endpoint section above). + +Node-level inspection has no dedicated CLI verb yet; use the API directly: + +```bash +curl -H "Authorization: Bearer $AGENTTEAMS_AUTH_TOKEN" \ + "$AGENTTEAMS_API_BASE/api/v1/projects/demo-project-001/tasks/t1" ``` The CLI forwards whatever bearer token is configured (`AGENTTEAMS_AUTH_TOKEN` @@ -599,7 +679,18 @@ agt get projects # list all agt get projects --team biz-team # filter by team agt get projects demo-project-001 # workflow detail agt get projects demo-project-001 -o json -agt get projects demo-project-001 --mermaid # render DAG as mermaid +agt get projects demo-project-001 --mermaid # render DAG as mermaid (status classes) +``` + +`--mermaid` uses the same renderer as the API's `?format=mermaid`: next/ready +nodes are highlighted and every node is colored by status (see the workflow +endpoint section above). + +Node-level inspection has no dedicated CLI verb yet; use the API directly: + +```bash +curl -H "Authorization: Bearer $AGENTTEAMS_AUTH_TOKEN" \ + "$AGENTTEAMS_API_BASE/api/v1/projects/demo-project-001/tasks/t1" ``` The CLI forwards whatever bearer token is configured (`AGENTTEAMS_AUTH_TOKEN` diff --git a/docs/zh-cn/usage/project-workflow-api.md b/docs/zh-cn/usage/project-workflow-api.md index 167540be8..e17ba9a08 100644 --- a/docs/zh-cn/usage/project-workflow-api.md +++ b/docs/zh-cn/usage/project-workflow-api.md @@ -63,6 +63,9 @@ Controller 提供两个只读端点,把 TeamHarness 项目状态 | 参数 | 类型 | 含义 | |:--|:--|:--| | `includeTasks` | `bool` | 为 `true` 时同时读取每个任务的 TaskMeta(`shared/tasks/{id}/meta.json`),在响应中附加 `tasks_detail` 数组(spec/result/交付物字段)。默认 `false` 保持响应轻量。 | +| `format` | `string` | 响应格式。缺省返回上方 JSON 快照;`format=mermaid` 返回同一快照渲染的 Mermaid 流程图(`text/plain`,不含 `tasks_detail`——渲染只需 nodes/edges/next)。其他值返回 `400`。 | + +Mermaid 输出(`?format=mermaid`)对齐 LangGraph 的 `draw_mermaid` 助手:每个节点标签为 `name: status`,next/ready 节点高亮 `ready`,其余节点按状态着色(`pending` / `delegated` / `inProgress` / `completed` / `revision` / `blocked`)。所有 classDef 都会输出,图可独立渲染。任务标题与 ID 为用户可控输入,渲染前做 mermaid 安全归一:换行→`
`、双引号→`#quot;`、反斜杠丢弃、其他控制字符→空格;含 `[A-Za-z0-9_-]` 之外字符的 task ID 映射为防冲突节点 ID(标签保留原文)。畸形标题因此不可能改变渲染出的图结构。 响应 `200 OK`: @@ -146,6 +149,46 @@ Controller 提供两个只读端点,把 TeamHarness 项目状态 | `404` | 项目不存在(所有扫描前缀下都无 meta.json)——**或**调用者是限定读者(团队 leader / L2 人类)且不拥有该项目(隐藏存在性以防 id 枚举)。 | | `500` | K8s 或对象存储故障。 | +### `GET /api/v1/projects/{id}/tasks/{taskId}` + +单任务节点级检视:聚合该任务的图节点(状态/负责人/依赖)、TaskMeta(spec/摘要/结果/交付物)、append-only 状态迁移历史,以及指向 tracing 后端的 trace 提示。 + +```text +GET /api/v1/projects/{id}/tasks/{taskId}?team=alpha-team +``` + +响应 `200 OK`: + +```json +{ + "task_id": "t1", + "project_id": "demo-project-001", + "status": "in-progress", + "spec_path": "shared/tasks/t1/spec.md", + "assigned_to": "@w1:matrix.local", + "summary": "Alpha report done", + "result_status": "SUCCESS", + "result_path": "shared/tasks/t1/result.md", + "deliverables": [{"type": "file", "path": "shared/tasks/t1/output.pdf"}], + "history": [ + {"ts": "2026-09-05T01:00:00Z", "from": "", "to": "planned", "actor": "manager", "action": "create"}, + {"ts": "2026-09-05T02:00:00Z", "from": "planned", "to": "in_progress", "actor": "w1", "action": "ack_task"}, + {"ts": "2026-09-05T03:00:00Z", "from": "in_progress", "to": "submitted", "actor": "w1", "action": "submit_task"} + ], + "dependencies": [], + "trace": {"project_id": "demo-project-001", "task_id": "t1"} +} +``` + +字段说明: + +- `status`:TaskMeta 存在时为**原始**状态(与 `?includeTasks=true` 的 `tasks_detail` 同语义);TaskMeta 缺失时回退到图节点归一化状态(`pending | delegated | in-progress | completed | revision | blocked`)。 +- `history`:由 TeamHarness taskflow(及 controller 的 cancel 路径)append-only 维护的已接受状态迁移审计,上限 50 条;工作流状态机落地(设计:agentscope-ai/AgentTeams#1223)前为空。畸形条目跳过,不报错。 +- `trace` 是 tracing 后端的过滤提示:其 `project_id` / `task_id` 用于匹配 span 属性 `agentteams.project.id` / `agentteams.task.id`(worker entry span 已携带这两个属性)。本端点不构造后端 URL,tracing 后端是部署特定的。 +- TaskMeta 只从项目所属 scope 读取(team 前缀优先,global 前缀仅 standalone 项目兜底)——与 `tasks_detail` 相同的禁止跨 scope 回退规则。 + +错误:`400`(task id 缺失/非法)、`404`(项目不存在——对限定读者隐藏存在性——或任务不在该项目图中)、`500`(存储读取失败)。 + ### `GET /api/v1/projects/{id}/tasks/{taskId}/artifact` 下载一个任务的一个产物,为 dashboard 和 console 插件补全「交付物 → 下载 → 审 → 接受」闭环。 @@ -169,7 +212,7 @@ Controller 提供两个只读端点,把 TeamHarness 项目状态 | `404` | 项目不存在 / 调用者不拥有它(隐藏存在性)/ 任务不在项目图中 / 任务没有已发布产物 / 请求路径不是已声明产物 / 产物文件缺失 / 产物路径被拒绝。 | | `500` | K8s 或对象存储故障。 | -## 人类干预与生命周期端点(W-PR-2) +## 人类干预与生命周期端点(写 API) 上面的只读端点之外,还有让人类干预 agent 编排工作流的写端点。所有写入都经过 **代码级授权**:中间件拒绝跨团队写入(authorizer `requireSameTeam`),handler @@ -298,14 +341,23 @@ agt get projects # 列出全部 agt get projects --team biz-team # 按团队过滤 agt get projects demo-project-001 # 工作流详情 agt get projects demo-project-001 -o json -agt get projects demo-project-001 --mermaid # 渲染 DAG 为 mermaid +agt get projects demo-project-001 --mermaid # 渲染 DAG 为 mermaid(含状态着色) +``` + +`--mermaid` 与 API 的 `?format=mermaid` 使用同一渲染器:next/ready 节点高亮,其余节点按状态着色。 + +节点级检视暂无专门 CLI 子命令,直接用 API: + +```bash +curl -H "Authorization: Bearer $AGENTTEAMS_AUTH_TOKEN" \ + "$AGENTTEAMS_API_BASE/api/v1/projects/demo-project-001/tasks/t1" ``` CLI 原样转发配置的 bearer 令牌(`AGENTTEAMS_AUTH_TOKEN` 或 `AGENTTEAMS_AUTH_TOKEN_FILE`),所以 L2 人类也可以用——把任一变量指向自己的 Matrix 访问令牌即可,无需单独的 CLI 认证模式。 -### `agt project`(W-PR-2 写命令) +### `agt project`(写命令) `agt project` 包装写端点,人类无需 raw curl 即可干预: