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 即可干预: