Skip to content

Latest commit

 

History

History
1080 lines (912 loc) · 48.6 KB

File metadata and controls

1080 lines (912 loc) · 48.6 KB

Project / Workflow Inspection API

Added by the project workflow inspection PR (agentteams/AgentTeams#1169).

The controller exposes four read-only endpoints that surface TeamHarness project state (shared/projects/{id}/meta.json) as a LangGraph-aligned workflow view, plus per-team spawn subagent sessions and their conversation streams. They are the data source for human-facing views (dashboard, QwenPaw console plugin) and are consumed by agt get projects.

Scope & prerequisites

These endpoints work in any AgentTeams deployment that runs TeamHarness (projectflow/taskflow) on its workers — the storage layout is read through the controller's configured object-store client, so AGENTTEAMS_STORAGE_PREFIX and AGENTTEAMS_FS_BUCKET (including non-default values) are handled automatically; no per-deployment code or configuration is required.

Prerequisites:

  • TeamHarness MCP (plugins/teamharness) is installed on the workers that orchestrate projects. Only projects created by projectflow (create_project / create_quick_project) produce the shared/projects/{id}/meta.json that these endpoints read. Teams that manage tasks manually without projectflow have no project data — that is expected, not a bug.
  • Project writes are pushed to shared storage by _sync_project (introduced alongside this API), so the controller sees near-live state rather than a startup snapshot.

Deployment modes (embedded Docker, incluster K8s) are all supported; in the no-K8s development mode the controller skips authentication like the other endpoints, so RBAC applies only when an authenticator is configured.

Endpoints

GET /api/v1/projects

List projects across all teams (and the global shared/projects/ prefix).

Query parameters:

Param Meaning
team Return only projects whose team matches. Team leaders are already scoped to their own team(s); standalone projects (empty team) only match when no filter is set.

Response 200 OK:

{
  "projects": [
    {
      "project_id": "demo-project-001",
      "title": "Demo project",
      "status": "active",
      "plan_type": "dag",
      "team_id": "biz-team",
      "mode": "project"
    }
  ],
  "total": 1
}
  • status is the raw project status written by TeamHarness: active | paused | completed.
  • Projects are sorted by project_id. Duplicate ids across prefixes are de-duplicated (meta.json may be mirrored under both the effective team name and the CR name prefix).
  • Projects with a missing or malformed meta.json are skipped (the directory may exist while the file is mid-write upstream).

GET /api/v1/projects/{id}/workflow

Return the LangGraph-aligned workflow for one project.

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, the opaque submission_id fence, and the transition history audit trail (omitted for metas that predate it). 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 <br>, 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:

flowchart LR
    t1["Task 1: completed"]:::completed
    t2["Task 2: delegated"]:::ready
    t1 --> t2
    classDef ready fill:#d4edda,stroke:#28a745;
    ...

Each tasks_detail[] entry carries history: [] — the task's auditable state transitions recorded by the TeamHarness transition engine, oldest first. Entry shape:

{
  "ts": "2026-09-09T10:00:05Z",
  "from": "assigned",
  "to": "in_progress",
  "action": "ack_task",
  "actor": "worker:default",
  "note": ""
}

action is one of delegate_task, ack_task, submit_task, accept_task_result, cancel_task, progress; actor is role:account (MCP transitions) or the authorization actor (controller cancellations). The allowed transitions are defined by the shared contract plugins/teamharness/contracts/task-transitions.json.

Response 200 OK:

{
  "project_id": "demo-project-001",
  "title": "Demo project",
  "status": "active",
  "plan_type": "dag",
  "team_id": "biz-team",
  "mode": "project",
  "source": "dingtalk",
  "nodes": [
    {"id": "t1", "name": "Task 1", "status": "completed", "assignee": "@w1:matrix.local"},
    {"id": "t2", "name": "Task 2", "status": "delegated", "assignee": "@w2:matrix.local"}
  ],
  "edges": [
    {"source": "t1", "target": "t2", "conditional": false}
  ],
  "next": ["t2"],
  "interrupts": [
    {"id": "t3", "value": "blocked"},
    {"id": "loop", "value": "waiting for human decision"}
  ],
  "values": {
    "project_id": "demo-project-001",
    "title": "Demo project",
    "status": "active",
    "plan_type": "dag",
    "team_id": "biz-team",
    "mode": "project",
    "task_count": {"completed": 1, "delegated": 1}
  },
  "loop": null,
  "requester": "dingtalk:user:session",
  "source_room_id": "!room:matrix.local",
  "tasks_detail": [
    {
      "task_id": "t1",
      "project_id": "demo-project-001",
      "status": "completed",
      "spec_path": "shared/tasks/t1/spec.md",
      "assigned_to": "@w1:matrix.local",
      "summary": "Alpha report done",
      "result_status": "SUCCESS",
      "submission_id": "submission-123",
      "deliverables": [{"type": "file", "path": "shared/tasks/t1/output.pdf"}],
      "result_path": "shared/tasks/t1/result.md"
    }
  ]
}

tasks_detail is only present when ?includeTasks=true. It surfaces the TaskMeta fields that the project-level nodes[] summary does not carry: spec_path (task spec file), summary / result_status / result_path (submission result), deliverables (artifact list), cancel_reason, and the opaque submission_id used to fence accept/cancel decisions. TaskMeta is read from the project's owning scope only: team projects read teams/{team}/shared/tasks/{id}/meta.json, standalone projects read shared/tasks/{id}/meta.json. There is no cross-scope fallback, and a TaskMeta whose project_id names a different project is rejected — an unrelated task that happens to share the id can never mix in. Tasks without a TaskMeta file (e.g. not yet delegated) are skipped; per-task read errors are skipped so one bad task never fails the whole response.

Node statuses are normalized to a frontend-friendly enum:

API value Raw TeamHarness status
pending planned
delegated assigned
in-progress in_progress, submitted
completed completed
revision revision
blocked blocked, cancelled

Semantics (mirror upstream _ready_nodes / _ready_loop_nodes):

  • next — ready nodes: tasks whose raw status is planned/assigned and whose dependencies are all completed. Empty when the project is not active or a loop is waiting_user / blocked / completed.
  • interrupts — human-decision waiting points: a blocked task, or a loop in waiting_user / blocked state.
  • values.task_count — node counts per normalized status.

Error responses:

Code Meaning
400 Missing project id.
403 Authenticated but the role cannot read projects at all (e.g. Worker).
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.

GET /api/v1/projects/{id}/tasks/{taskId}?team=alpha-team

Response 200 OK:

{
  "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:

  • submission_id, when present in TaskMeta, is returned verbatim. Pass it as submissionId when cancelling that submission.
  • 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#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 → review → accept" loop for dashboards and the console plugin.

Optional query parameter:

Param Meaning
path The artifact path to download. Must be one of the task's declared artifacts — result_path, spec_path or an entry of deliverables (all read from TaskMeta). When omitted, the result_path (the published result) is served.

Without ?path= the artifact is the task's result_path (published result). With ?path= the requested path must be one of the task's declared artifacts — result_path, spec_path (task spec) or a deliverables entry. The path is then validated against a strict allowlist: it must be under shared/tasks/{taskId}/ or shared/projects/{projectId}/, and must not contain .. or start with /. Because the allowlist AND the declared-artifact check both apply, a compromised worker cannot craft a path that reads arbitrary MinIO objects, nor can a client download an undeclared file that happens to live in the task directory.

The file is returned with Content-Disposition: attachment (filename = basename, RFC 5987 filename*=utf-8''... for non-ASCII names so Chinese filenames download correctly) and a Content-Type inferred from the extension.

Error responses:

Code Meaning
400 Missing project id or task id.
403 Authenticated but the role cannot read projects at all (e.g. Worker).
404 Project not found / caller does not own it (existence hidden) / task not in the project graph / task has no published artifact / requested path is not a declared artifact / artifact file missing / artifact path rejected / TaskMeta exists only outside the project's scope or belongs to another project.
500 K8s or object-store failure.

GET /api/v1/projects/{id}/spawns

Aggregate the spawn subagent sessions created by the project team's workers. Spawn subagents are the primary execution vehicle of worker agents (workers schedule, spawns execute); this endpoint makes that activity visible alongside the team-level task graph.

Data source: each team worker's chats.json (agents/{worker}/.qwenpaw/workspaces/default/chats.json in object storage, mirrored by the worker's FileSync). A chat entry is a spawn session when meta.spawn == true (QwenPaw 2.1+) or its session_id carries the sub- prefix (2.0.1 fallback).

Response:

{
  "project_id": "demo-project-001",
  "workers": [
    {
      "worker": "sysdev-lead",
      "spawns": [
        {
          "session_id": "sub-3f2a9b1c",
          "name": "fix harmony build script",
          "status": "running",
          "created_at": "2026-08-13T10:00:00+00:00",
          "updated_at": "2026-08-13T10:05:00+00:00",
          "root_session_id": "matrix:!room:server",
          "spawn": true,
          "subagent_allowed_tools": ["read_file", "write_file"],
          "subagent_skills": ["pdf"]
        }
      ]
    }
  ]
}

Notes:

  • root_session_id is the normalized session key of the parent session (matrix: prefix canonicalized) — the session that called spawn_subagent. The endpoint is project-scoped, not team-scoped: a spawn is listed only when its root session is one of the project's rooms (the project source_room_id or a graph task's TaskMeta.room_id). A spawn rooted elsewhere — another project's room, or a legacy 2.0.1 spawn without a persisted root — is omitted, never attached to every project of the team.
  • A worker with a missing or unreadable chats.json is still listed, with an empty spawns array — one broken worker never 500s the whole project.
  • subagent_allowed_tools / subagent_skills are only present when the spawn was created with a tool/skill whitelist (2.1+).
  • name is the chat title assigned when the spawn session was created (not the full task prompt).
  • Standalone projects (no owning team) return an empty workers array — there is no team membership to derive the worker list from.

Error responses:

Code Meaning
400 Missing project id.
403 Authenticated but the role cannot read projects at all (e.g. Worker).
404 Project not found / caller does not own it (existence hidden).
500 K8s or object-store failure.

GET /api/v1/projects/{id}/spawns/{sessionId}/messages

Returns one spawn session's conversation stream — user context messages, model turns and tool results — so a client can show what the spawn is doing, its full task prompt, and its progress. The stream is read from the owning worker's history.db (QwenPaw's scroll HistoryStore, mirrored into object storage by the worker FileSync alongside chats.json).

Query parameters:

Param Type Default Meaning
limit int 20 Number of messages (most recent window). Capped at 50.

Response:

{
  "session_id": "sub-3f2a9b1c",
  "task": "Design the auth module",
  "messages": [
    {
      "seq": 1,
      "kind": "context_msg",
      "role": "user",
      "content": "Design the auth module",
      "created_at": "2026-08-13T15:00:00"
    },
    {
      "seq": 2,
      "kind": "model_turn",
      "role": "assistant",
      "name": "read_file",
      "content": "Reading the spec first",
      "headline": "read spec",
      "created_at": "2026-08-13T15:00:05"
    },
    {
      "seq": 3,
      "kind": "tool_result",
      "role": "assistant",
      "name": "read_file",
      "content": "spec.md contents",
      "tool_state": "success",
      "created_at": "2026-08-13T15:00:06"
    }
  ],
  "has_more": false
}
  • kind is one of context_msg (user message), model_turn (assistant reply — headline is the turn's retrieval headline, "what it is doing at a glance"), or tool_result (tool execution result — name is the tool and tool_state its final state).
  • task is the first user message of the session — the spawn task text.
  • messages is ascending (oldest first), covering the most recent limit rows; has_more reports whether older rows exist.
  • The database is pulled to a temp dir and opened strictly read-only (pure-Go modernc.org/sqlite), with a fallback that reads the checkpointed portion when the WAL sidecars are missing or inconsistent. The schema is stable across QwenPaw 2.0.1 and 2.1.
  • An empty session (db present, no rows) returns 200 with messages: [].

Error responses:

Code Meaning
400 Missing project id or session id.
403 Authenticated but the role cannot read projects at all (e.g. Worker).
404 Project not found / caller does not own it (existence hidden) / session not owned by any team worker / history.db missing or unreadable.
500 K8s or object-store failure.

GET /api/v1/projects/{id}/history

Returns the project's intervention timeline — the pre-intervention meta.json snapshots written by the lifecycle endpoints (see POST /pause, POST /resume, … below; each intervention snapshots the previous state into history/{unixNano}.json, retaining the most recent 50).

Query parameters:

Param Type Default Meaning
team string Optional team qualifier, same semantics as the other read endpoints.

Response:

{
  "project_id": "demo-project-001",
  "snapshots": [
    { "timestamp": "1723785123456789012" }
  ]
}
  • snapshots is newest first. timestamp is the snapshot filename (unix nanoseconds) and is kept as a string — 19-digit nanosecond values exceed the JavaScript safe-integer range, so numeric transport would silently lose precision.
  • An empty history returns 200 with "snapshots": [].

GET /api/v1/projects/{id}/history/{timestamp}

Returns one snapshot's raw meta.json content verbatim (same schema as GET /workflow's source). timestamp must be a 19-digit unixNano value; anything else is rejected 400 (this doubles as the traversal guard).

Error responses:

Code Meaning
400 Missing project id / malformed timestamp.
403 Authenticated but the role cannot read projects at all (e.g. Worker).
404 Project or snapshot not found / caller does not own it (existence hidden).
409 Ambiguous project id across teams; retry with ?team=.
500 K8s or object-store failure.

GET /api/v1/projects/{id}/events

Returns the project's task transition timeline — a read-time aggregation of every task's history array (the same audit trail ?includeTasks=true exposes per task), merged into one ascending list. No new storage: the endpoint reads the task metas on demand and does not add a write-side hook. Project-level intervention events are not part of this timeline — use GET /history for those; the two are complementary.

Query parameters:

Param Type Default Meaning
team string Optional team qualifier, same semantics as the other read endpoints.
limit int 50 Page size. Capped at 200; values < 1 are rejected 400.
cursor string Opaque cursor from a previous page's next_cursor; pass it back to continue. Encodes the page's last event identity (ts, task_id, seq), so it stays valid while new events are appended — and even while the per-task 50-entry history cap drops already-read events. For seq-less legacy events that share one second (the same identity), it additionally pins the event's position within that duplicate group, so paging always advances.

Response:

{
  "project_id": "demo-project-001",
  "events": [
    {
      "ts": "2026-09-09T10:00:00Z",
      "task_id": "t1",
      "from": "planned",
      "to": "prepared",
      "action": "delegate_task",
      "actor": "leader:default",
      "seq": 1
    },
    {
      "ts": "2026-09-09T10:00:05Z",
      "task_id": "t1",
      "from": "assigned",
      "to": "in_progress",
      "action": "ack_task",
      "actor": "worker:default",
      "note": "starting",
      "seq": 2
    }
  ],
  "next_cursor": "eyJ0cyI6IjIwMjYt..."
}
  • events is oldest first; the shared second-resolution timestamps are tie-broken by task_id, and events sharing both keep the writer's append order (seq), so paging is deterministic.
  • Every event carries seq, the writer-persisted per-task sequence number: the stable event identity. Timestamps are second-resolution and repeated progress entries are allowed, so content alone cannot identify an event.
  • next_cursor is empty when the tail was reached; an empty project returns 200 with "events": [].
  • next_cursor is an opaque, URL-safe string. The client never parses it; it anchors on the page's last event by exact identity (ts, task_id, seq), so appending new events between page requests does not invalidate it and duplicates (same second, same content) are never skipped or repeated. A cursor anchored inside a group of seq-less legacy duplicates (same second, no seq) carries the event's occurrence index within the group plus a snapshot of the list length, so paging such groups — including completed read-only histories that will never receive a seq backfill — advances exactly one event per page and can never stall.
  • cursor_expired is true (with "events": [] and no next_cursor) when the cursor's anchor event has been truncated out of the retained per-task history (50 entries, oldest dropped), the snapshot behind a legacy duplicate cursor was truncated (its position in the duplicate group may have shifted), or the cursor predates the sequence format. On that signal the client must discard the cursor and re-fetch from the start; continuing would otherwise skip unread events silently.
  • Task metas are read from the project's owning scope only — no cross-scope fallback (same rule as tasks_detail).

Error responses:

Code Meaning
400 Missing project id / invalid limit or cursor.
403 Authenticated but the role cannot read projects at all (e.g. Worker).
404 Project not found / caller does not own it (existence hidden).
409 Ambiguous project id across teams; retry with ?team=.
500 K8s or object-store failure.

Project identity & disambiguation

Project ids are only unique within a worker workspace upstream: two teams can hold the same project id. The API therefore treats (team, project_id) as the identity:

  • GET /api/v1/projects lists every distinct (team, project_id) — the same id under two teams appears twice, disambiguated by team_id (a scoped caller only sees the entries of their accessible teams).
  • The read endpoints (workflow, tasks/{taskId}/artifact, spawns, spawns/{sessionId}/messages, history, history/{timestamp}) accept an optional ?team= query parameter that narrows resolution to that team's storage prefix.
  • Without ?team=, if the same project id exists under multiple teams the endpoint returns 409 Conflict (project id is ambiguous across teams; retry with ?team=) instead of silently resolving to the first match. Callers scoped to one of those teams (team leader / L2 human) resolve their own team's project without ambiguity.

Authentication & authorization

Two bearer-token paths are accepted (composite authenticator):

  1. Kubernetes service-account token (TokenReview): admin / manager / worker. Team leaders (worker with team_leader role) see only their own team's projects.
  2. Matrix access token (L2 humans): the token is validated with GET /_matrix/client/v3/account/whoami; the owning Matrix localpart is matched to a Human CR with permissionLevel: 2 (Team). The human's accessibleTeams set is used as the multi-team scope — every team they control is aggregated into a single list/read view. Non-L2 humans (permissionLevel 1 or 3) are rejected.

Authorization matrix:

Caller List Get workflow Get spawns Get spawn messages Get history
admin / manager all teams any project any project any project any project
team-leader (SA) own team only own team only own team only own team only own team only
L2 human (Matrix) all accessibleTeams any accessible team any accessible team any accessible team any accessible team
worker denied denied denied denied denied

agt CLI

agt get projects [name] wraps both endpoints:

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 (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:

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 or AGENTTEAMS_AUTH_TOKEN_FILE) verbatim, so an L2 human can also use it by pointing either variable at their own Matrix access token — no separate CLI auth mode is needed.

Human intervention & lifecycle endpoints

The read endpoints above are complemented by write endpoints that let humans intervene in agent-orchestrated workflows. All writes are code-level authorized: the middleware rejects cross-team writes (authorizer requireSameTeam), and the handler additionally runs checkProjectAccess after resolving the owning team because the middleware cannot map a project path to a team. Every write is stamped with audit fields (updated_by / updated_at, and pause_reason when a reason is given) and applies an ETag conditional write — the object's ETag (content hash) is bound at read time and the write is a MinIO If-Match conditional write, so a worker pushing a newer meta.json between the read and the write makes the write fail with 409 instead of clobbering it.

POST /api/v1/projects

Create a project (structured, aligned with TeamHarness create_project). An admin/manager may create a standalone project without a team; a team leader or L2 human must pass a team_id they can access.

Request body:

{
  "title": "New project",
  "source": "matrix",
  "requester": "@luo:server",
  "team_id": "biz-team",
  "project_id": "optional-custom-id",
  "source_room_id": "!room:server"
}

project_id defaults to a generated value when omitted and must be a plain token matching TeamHarness _safe_id ([A-Za-z0-9][A-Za-z0-9._-]*); the generated default is a compact timestamp + nanoseconds (never an RFC3339 timestamp — its : would be rejected by TeamHarness). Response 201 Created:

{
  "project_id": "proj-20260814-160628-406426239",
  "title": "New project",
  "status": "active",
  "team_id": "biz-team",
  "plan_type": "dag"
}

Errors: 400 missing/invalid title or project id / team required for scoped callers; 409 project already exists; 403/404 cross-team (denied / existence hidden).

POST /api/v1/projects/{id}/pause

Set a project's status to paused. Pausing stops new task dispatch (ready_nodes returns empty) but does not interrupt in-flight tasks; their completion reports still arrive (documented behavior — in-flight work is not cancelled). Optional body {"reason": "..."} is recorded in pause_reason. Response 200 returns the updated workflow (buildWorkflow). Errors: 409 already paused / completed; 404 not found or not owned.

POST /api/v1/projects/{id}/resume

Set a paused project back to active. Response 200 returns the updated workflow. Errors: 409 not paused; 404 not found or not owned.

POST /api/v1/projects/{id}/replan

Replace a project's DAG plan. The request body carries the new tasks (optional tasks array):

{
  "tasks": [
    {"taskId": "t1", "title": "Step 1", "assignedTo": "@dev:server", "dependsOn": []},
    {"taskId": "t2", "title": "Step 2", "dependsOn": ["t1"]}
  ]
}

Fields are normalized like TeamHarness _normalize_task (taskId/task_id, assignedTo/assigned_to, dependsOn/depends_on, status defaults to planned, pending maps to planned); a task id that already exists keeps its previous title/assignee/status when the raw entry omits them. Validation mirrors _validate_task_graph: duplicate ids, unknown dependencies, and dependency cycles are rejected with 400. Preconditions (409): plan_type must be dag (loop replans go through record_loop_iteration), status must be active, and no task may be in_progress/submitted. Response 200 returns the updated workflow. Tasks with a persisted cancellation decision keep that decision when retained in a replan. They cannot be reopened or removed and then re-added under the same task id; create a new task id for replacement work.

POST /api/v1/projects/{id}/tasks/{taskId}/cancel

Cancel a single task:

{
  "reason": "no longer needed",
  "replacementTaskId": "replacement-01",
  "submissionId": "submission-123"
}

reason is required and replacementTaskId is optional. submissionId is conditionally required: when TaskMeta already has submission_id, callers must send that exact opaque value. Missing, invented, or stale identities are rejected before either ProjectMeta or TaskMeta is changed.

On success, the project node and TaskMeta become cancelled; TaskMeta records stable cancel_reason / replacement_task_id / cancelled_at fields and resolves an existing pending continuation as cancelled without changing its delivery_id. Repeating the same cancellation is idempotent. A different reason, replacement task, or submission identity conflicts with the committed decision. Tasks already completed, revision, or blocked cannot be cancelled. Response 200 returns the updated workflow.

The Controller writes a small cancellation decision envelope into the project node before writing TaskMeta. If the second write fails, an identical retry can finish the TaskMeta/continuation update; a retry with a different reason, replacement, or submission identity is rejected.

Errors: 400 missing reason or invalid replacement task id; 404 task not in project / task meta missing; 409 terminal task, submission fence failure, or conflicting cancellation.

POST /api/v1/projects/{id}/complete

Mark a project completed (terminal state). All tasks must be in a terminal status (completed/revision/blocked/cancelled — no in_progress/submitted/ planned), otherwise 409. Response 200 returns the updated workflow.

Notifications

After a successful write, the Controller sends an admin message (SendMessageAsAdmin) to the project's source_room_id (falling back to reply_route.target_session), so agents in that room learn about the intervention without polling. Best-effort: no notification is sent when the room is unknown or Matrix is not configured.

Authentication & authorization

Two bearer-token paths are accepted (composite authenticator):

  1. Kubernetes service-account token (TokenReview): admin / manager / worker. Team leaders (worker with team_leader role) see only their own team's projects.
  2. Matrix access token (L2 humans): the token is validated with GET /_matrix/client/v3/account/whoami; the owning Matrix localpart is matched to a Human CR with permissionLevel: 2 (Team). The human's accessibleTeams set is used as the multi-team scope — every team they control is aggregated into a single list/read view. Non-L2 humans (permissionLevel 1 or 3) are rejected.

Authorization matrix:

Caller List Get workflow Write (create/pause/resume/replan/cancel/complete)
admin / manager all teams any project any project
team-leader (SA) own team only own team only own team only
L2 human (Matrix) all accessibleTeams any accessible team any accessible team
worker denied denied denied

agt CLI

agt get projects [name] wraps both endpoints:

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 --include-tasks -o json
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:

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 or AGENTTEAMS_AUTH_TOKEN_FILE) verbatim, so an L2 human can also use it by pointing either variable at their own Matrix access token — no separate CLI auth mode is needed.

--include-tasks requires -o json; the default detail view does not render raw TaskMeta fields.

agt project (the lifecycle write API write commands)

agt project wraps the write endpoints so a human can intervene without raw curl:

agt project create --title "New project" --team biz-team --source matrix
agt project pause demo-project-001 --reason "customer review"
agt project resume demo-project-001
agt project replan demo-project-001 --tasks tasks.json   # JSON array file
agt project cancel demo-project-001 demo-project-001-01 \
  --reason "no longer needed" --submission-id submission-123 --team biz-team
agt project complete demo-project-001

The same bearer-token forwarding applies (Matrix token for L2 humans).

Worker checkpoint endpoints

The Controller proxies two read-only endpoints of each worker's QwenPaw app (checkpoint system, QwenPaw ≥ 2.1) so humans and frontends can inspect a worker's execution timeline — auto snapshots after every response round plus manual /checkpoint snapshot, stored in the worker's checkpoints/shadow.git.

Endpoint Meaning
GET /api/v1/workers/{name}/checkpoints/graph Checkpoint graph (nodes with kind/timestamp/query preview, sessions, summary). Optional ?limit= (1..1000).
GET /api/v1/workers/{name}/checkpoints/status auto_enabled, has_checkpoints, workspace_dir.
  • Scope: same worker read authorization as GET /api/v1/workers/{name} — team leaders / L2 humans only see workers in their teams; unknown or out-of-scope workers are hidden as 404.
  • Embedded mode only: the endpoints proxy the worker's qwenpaw app inside the shared docker network. The upstream address is resolved from the effective container prefix (AGENTTEAMS_PROXY_CONTAINER_PREFIX, or derived from AGENTTEAMS_RESOURCE_PREFIX when auto-prefixing is enabled; empty when disabled — the same value the docker backend uses for container naming) and the effective console port resolved through the same system-wins env chain used at container creation (the system env always defines AGENTTEAMS_CONSOLE_PORT, so a conflicting Worker.spec.env value is discarded and the container always listens on 8088). In kube mode they return 503.
  • Degradation: a worker running QwenPaw < 2.1 has no checkpoint router, so the upstream 404 is translated to 502 with checkpoint API unavailable (requires QwenPaw 2.1).
  • Forwarding is fixed-path (graph / status) with a strict query whitelist — not a generic reverse proxy.

Error responses:

Code Meaning
400 Invalid worker name / unsupported subpath or query parameter / invalid limit.
404 Worker not found / caller does not own it (existence hidden).
502 Worker app unreachable, pre-2.1 checkpoint API, or upstream error.
503 Kube mode (no stable worker pod DNS to proxy).

Worker knowledge base (workspace files) endpoints

The Controller proxies four endpoints of each worker's QwenPaw app (QwenPaw ≥ 2.1) so L2 humans and frontends can inspect — and, where the caller's Human CR permits, update — a worker's knowledge base: the long-term memory file MEMORY.md, the daily note tree memory/, and the distilled knowledge tree digest/.

Endpoint Meaning
GET /api/v1/workers/{name}/workspace-files/tree Paginated listing of one knowledge directory: ?path= (required, memory / digest or any subpath of them), optional ?cursor= (opaque) and ?limit= (1..500). Returns {directory, entries[], has_more, next_cursor}.
GET /api/v1/workers/{name}/workspace-files/file-metadata ?path= (required, an allowed knowledge file): {etag, modified_at, path, preview_kind, size}.
GET /api/v1/workers/{name}/workspace-files/file-content ?path= (required) plus optional ?offset= (≥0) and ?limit= (1..1048576): a bounded UTF-8 chunk {content, eof, next_offset, truncated, etag, ...} — continue with offset=next_offset while truncated is true.
PUT /api/v1/workers/{name}/workspace-files/file-content Save one knowledge file: ?path= (required), body {"content": "<text>"} (1 MiB cap, non-empty) and the If-Match header (see the concurrency rule below). Returns the new {etag, path, size}.
GET /api/v1/workers/{name}/workspace-files/file-download Stream one knowledge file as an attachment: ?path= (required). Forwards the upstream Content-Disposition / Content-Length / ETag headers.
  • Scope: same worker read authorization as GET /api/v1/workers/{name} and the checkpoint endpoints — team leaders / L2 humans only see workers in their accessible teams; unknown or out-of-scope workers are hidden as 404.
  • Write scope: PUT file-content is allowed for admin/manager (any team); an L2 human may write only workers in their own teams while Human.spec.workspaceFileAccess is explicitly "readwrite" — the default is read (an empty or unset value means read-only), so a controller upgrade cannot silently grant pre-existing humans write access; L1 grants a user write access by setting the field to readwrite. Team leaders stay read-only on this API. Cross-team writes hide the worker as 404 (existence must not be probeable); an in-scope caller without write permission gets an explicit 403.
  • Concurrency (ETag): before a write the proxy probes file-metadata. For an existing file the If-Match header is mandatory (a worker auto-appends to its memory files, so an unconditional overwrite would be a lost update) and for a new file it must be absent. An upstream ETag mismatch passes through as 409 — reload the file and retry.
  • Write limits: the body is capped at 1 MiB (the read chunk cap), content must be a non-empty string, and every successful write is audit-logged by the controller (worker, path, caller, byte count).
  • workspaceFileAccess (Human CRD field): read | readwrite (default is read when empty — write is an explicit opt-in) — the per-user read/write permission L1 can set on a human's access to team knowledge base files. Settable at human creation (agt apply) and through PUT /api/v1/humans/{name} (that update API carries the field in its updatable set, shipped with the humans-update PR).
  • Path allowlist (knowledge boundary): only MEMORY.md, memory/** and digest/** are addressable — for reads and writes alike. Every other workspace location — SOUL.md, PROFILE.md, TODO.md, checkpoints/, skills/, and all dot directories (.copaw/agent.json carries the worker's credentials) — is rejected with 400 before the request reaches the worker. Roots match on the exact first segment, so memories/ and memoryX/ are not prefixes of memory/. File roots are single top-level files: MEMORY.md is addressable, but MEMORY.md/foo is rejected (MEMORY.md is one file, not a directory — nested files must live under memory/ or digest/).
  • Root pinned: the QwenPaw root=workspace parameter (the agent's own storage root, as opposed to root=project, the primary bound project directory) is fixed server-side and is not part of the client query surface.
  • Embedded mode only: the endpoints proxy the worker's qwenpaw app inside the shared docker network, using the effective container prefix and the system-wins console port — the same address resolution as the checkpoint endpoints. In kube mode they return 503.
  • Version gate (passthrough 404): a worker running QwenPaw < 2.1 has no workspace file router, so every request is an upstream 404 passed through verbatim. To distinguish "worker too old" from "file missing", probe file-metadata?path=MEMORY.md — that file exists in every initialized QwenPaw workspace, so a 404 there means the worker is pre-2.1 (or its workspace is not initialized) while other 404s are plain missing files.
  • Runtime scope: the endpoints target workers running the QwenPaw app (qwenpaw runtime). Workers on other runtimes have no QwenPaw workspace API: when that runtime's app serves the console port the proxy passes its response through verbatim (typically 404); when nothing listens it returns 502. The MEMORY.md probe is therefore only meaningful for QwenPaw workers.
  • Forwarding is fixed-path (tree / file-metadata / file-content GET+PUT / file-download) with a strict query whitelist — not a generic reverse proxy. The multipart file-upload endpoint and the rest of the workspace API surface remain unreachable.

Worker tool-approval endpoints

Each QwenPaw worker's agent profile carries a tool-execution security level (approval_level in the worker's agent.json) that decides which tool calls run automatically and which pause for a human approval. The Controller proxies a minimal read/write surface of the worker's /workspace/running-config API so L2 humans can manage this level for workers in their own teams:

Endpoint Meaning
GET /api/v1/workers/{name}/approval Current level: {"approval_level": "AUTO"}.
PUT /api/v1/workers/{name}/approval Set the level. Body: {"approval_level": "STRICT"}.
  • Levels: STRICT (every tool call needs approval) / SMART (low-risk tools auto-allowed) / AUTO (only guarded tools — the upstream default) / OFF (guard disabled). Any other value is rejected with 400 before the worker is touched (the upstream model does not validate the value — the proxy is the validation boundary).
  • OFF is elevated: approval_level=OFF disables Tool Guard entirely, so it is a security-policy operation rather than ordinary configuration. Default L2 humans get 403 on OFF — they may switch among the guarded levels (STRICT/SMART/AUTO) only. OFF requires the elevated tool-approval capability that the L2 permission design (#1220) defines and admins grant explicitly; admin/manager keep the full range until that capability model lands.
  • Write scope: PUT is allowed for admin/manager (any team); an L2 human may set only workers in their own teams — cross-team workers hide as 404 (existence is not probeable). Team leaders stay read-only (403 on PUT, the same boundary as the knowledge base write API).
  • Safe write: the upstream PUT /workspace/running-config persists the full running-config object, so the proxy performs GET → change only approval_level → PUT the whole object back; every other field round trips verbatim. An upstream 409 (concurrent config change) is passed through so clients retry with a fresh GET.
  • Embedded mode only: same worker addressing as the checkpoint proxy (effective container prefix + system-wins console port). Kube mode returns 503.
  • Degradation: a worker on a QwenPaw version without the running-config router surfaces the upstream 404 verbatim (version gate).
  • Every successful change is audit-logged (worker, new level, caller, role).

Error responses:

Code Meaning
400 Invalid worker name / unsupported subpath or query parameter / path outside the knowledge allowlist / out-of-bounds limit or offset / (write) missing or misplaced If-Match, over-size or empty body.
403 (write only) in-scope caller without write permission — read-only human or team leader.
404 Worker not found or not in the caller's teams (existence hidden); or (passthrough) the file does not exist — see the version-gate probe above.
409 / 416 (passthrough) the file changed while being read / while waiting for the write (ETag mismatch — reload and retry) / offset beyond end of file.
502 Worker app unreachable, or an upstream error (status echoed in the body).

| 400 | Invalid worker name / invalid request body / approval_level not in the fixed set. | | 403 | Team leader attempting PUT (read-only) / L2 human setting OFF (elevated capability required, #1220). | | 404 | Worker not found / caller does not own it (existence hidden) / pre-2.x worker (version gate, passthrough). | | 409 | Concurrent upstream config change (retry with a fresh GET). | | 502 | Worker app unreachable or upstream error. | | 503 | Kube mode (no stable worker pod DNS to proxy). |

Worker tool-settings endpoints

Each QwenPaw worker's agent profile carries a per-tool table (tools.builtin_tools): which built-in tools are enabled and which execute asynchronously. The Controller proxies a minimal read/write surface of the worker's /api/tools API so L2 humans can manage the tools of workers in their own teams — no docker exec required.

Endpoint Meaning
GET /api/v1/workers/{name}/tools Tool list: {"tools": [ {name, enabled, description, asyncExecution, icon, requiresConfig}, ... ], "total": N}.
PATCH /api/v1/workers/{name}/tools/{tool} Declarative update. Body: one or both of {"enabled": bool, "asyncExecution": bool}.
  • Declarative, retry-safe: the proxy reads the current table first and issues the worker-local mutation only for each field whose requested value differs from the current one (the worker-local toggle endpoint flips state without a body, so a bare forward would double-flip on a retry). A PATCH that changes nothing is a 200 no-op with zero upstream writes.
  • Concurrent-safe: because the worker-local enabled mutation is a blind toggle, the read-decide-mutate sequence runs under a per-(worker, tool) lock and the mutation response is verified to carry the requested enabled value. Two overlapping PATCH {"enabled":true} requests both return 200 and leave the tool enabled (the second observes the first's write and no-ops); a verification mismatch is a 502, never a false 200.
  • State only, never configuration: the entry exposes requiresConfig (a flag) but never the tool's configuration values — they can hold credentials.
  • Write scope: PATCH is allowed for admin/manager (any worker); an L2 human may patch only workers in their own teams — cross-team workers hide as 404 (existence is not probeable). Team leaders stay read-only (403 on PATCH, the same boundary as the approval endpoint).
  • Unknown field names are rejected with 400 (fail-closed); the controller-facing names are camelCase (asyncExecution, requiresConfig).
  • Runtime-aware: the tool-settings model is QwenPaw-specific; workers on another runtime surface 400. Embedded mode only (kube mode 503).
  • Failure semantics: unknown worker/tool 404; upstream error 502 (a QwenPaw build without the /api/tools router is a 502 "API unavailable"); a malformed upstream list fails closed with 502 rather than a partial list.
  • Live effect: the worker-local mutation saves the agent profile and hot-reloads the agent; the worker's sync loop persists the profile to the shared store.
  • Every successful change is audit-logged (worker, tool, changed fields with old/new values, caller, role).