diff --git a/docs/plans/team-task-list-team.md b/docs/plans/team-task-list-team.md new file mode 100644 index 00000000..4ce0654a --- /dev/null +++ b/docs/plans/team-task-list-team.md @@ -0,0 +1,103 @@ +# Team task view: `team task list --team` (#1044, slice 1) + +A coordinator could not see their team's work in flight in one view. `team task list` is flat and +covers the whole App, and `team task tree` shows a single chain. Judging "open more than an hour +with no update" took a hand-written script over `--json`. This slice adds `--team`, `--tree` and +`--stale-over` to `team task list`. + +**Gate.** Xan stamped it CHECKED for slice 1, T1, in issue comment +https://github.com/hadron-memory/hadron-cli/issues/1044 (01:28Z). Patrick posted a correction from +Vera's FIT at 01:37Z. Slice 2 (the creator criterion and any cross-App rows) waits on +hadron-server#2370. + +## What the team is + +The team is the coordinator plus every worker whose roster `reportsTo` chain reaches them, within +the one App being read. The App comes from the usual `--app` → context → binding chain. + +- **Naming the coordinator.** A bare `--team` means the bound worker; `--team=worker:` names one. + - The value must be attached with `=`. A bare `--team worker:X` would otherwise leave + `worker:X` as a stray positional argument, so that is refused. + - A coordinator who is not on the roster exits 4. +- **The walk.** It goes breadth-first down from the coordinator and is cycle-safe. +- **Retired workers.** The roster lists only active workers, so a retired worker leads no team + here. Their tasks join only as a descendant, or through a current member who owns or is + assigned them. Such a party renders as `(not on roster)`. + +## Which rows + +A task joins when a team member (a WORKER party) owns it or is assigned it (`match: party`). +Every descendant of a joined task joins too (`match: descendant`). + +- **Why descendants count.** A child task's owner is its parent's assignee + (`TASK_CHILD_OWNER_MISMATCH`). A team member's seat task for another subtree's worker can + therefore have children whose parties are all outside the team. +- **Creator.** Who created a task does not count; the gate put the creator criterion in slice 2. + +## One read gate + +The view reads the App's tasks through the existing `agentTasks` read and nothing else. + +- **No server filters.** The read sends no owner/assignee/status filter: the recursion needs the + ancestors whatever their status. +- **Paging.** It pages at the 200 cap with `offset` until a short page. +- **Deduplication.** Rows are deduplicated by id. A task created mid-walk shifts the + newest-first order down by one, which produces a duplicate, never a miss. +- **A page that does not advance.** A full page that adds no new id means the server ignored the + offset. The view refuses (exit 1) and shows nothing. +- **Bound.** It also refuses after 500 pages (100k tasks). +- **Not used: `agentTaskTree`.** It isn't used, so its 1000-row bound does not apply. +- **The join is client-side.** The roster join, the descendant walk, `--status` and + `--stale-over` all run in the CLI over rows the server already let this caller read. + - The view therefore never shows a task the plain `task list` would not. + - It never shows another subtree's tasks wholesale; a test pins this. +- **Cost.** One roster read, ceil(N/200) task pages, and the alarm pages. Today's App + (~3,000 tasks) takes 15 task pages and about 20 s against production (measured 2026-10-24). + Slice 2's server creator/owner filters could cut this later. +- **Reporting lines.** These come from a dedicated `TeamReportingLines` read: `teamRoster` + narrowed to `worker` + `reportsTo`. The view therefore does not inherit the full roster's newer + fields. On 2026-10-24, production refused the full roster's `model` field, while this read worked. + +`--status` is applied after the join, so an open task under a finished team task stays in view. +The plain list's server filters and paging (`--assignee --owner --parent --tag --queued +--roots-only --limit --offset`) are refused with `--team` rather than half-honoured. + +## Idle time and `--stale-over` + +- **Age.** A task's age is measured from `updatedAt`, else `startedAt`, else `createdAt` (the gate's + fallback). JSON carries it as `ageSeconds`, with `ageFrom` naming the field used. +- **The filter.** `--stale-over ` (a Go duration, > 0) keeps PENDING/STARTED tasks whose age + exceeds `d`. +- **The note.** A stderr note says that records lag real progress such as PR pushes and receipts. +- **Unparseable timestamps.** One exits 1 rather than silently dropping the row. + +## Output + +**Text.** It always starts with two scope lines: `app: …` and `team: () — +N workers`. The second line is there because the coordinator can be ambient, the bound worker +(#470, `review:ambient-scope-must-report-its-source`). An empty view prints `no tasks` under them. + +- **Table.** Columns are ID, STATUS, IDLE, TITLE, OWNER, ASSIGNEE and ALARMS. A worker outside the + team is marked `(outside)`, and one not on the roster `(not on roster)`. +- **`--tree`.** It draws parent/child trees in server order, with the same cells per node. A row + whose parent is not shown becomes a root. Rows a corrupt parent cycle leaves unreached render at + the top afterwards, so none is dropped. +- **Open alarms.** These come from `task tree`'s join (`openAlarmsByTask`, extracted and now + shared), so the alarm facts stay the server's. + +**`--json`.** It is an envelope: `{appId, coordinator, coordinatorSource, members, +staleOverSeconds, tasks}`. Each task is the plain task DTO plus: +- `ownerTeam` and `assigneeTeam`: `team`, `outside`, `unknown`, `user`, or null when there is no + party; +- `match`; +- `ageSeconds` and `ageFrom`. + +`openAlarms` appears as in `task tree`. `--tree` changes text only. `teamTaskDTO` embeds `taskDTO` +by value, so no other command's shape moves. + +## Not in this slice + +- The creator criterion (hadron-server#2370 item 1). +- Cross-App rows. Per Vera's FIT, a worker task's parties all belong to its App, so this case + needs a ruling about people in two Apps, which is non-blocking. +- Owner/assignee team for parties outside the bound App. diff --git a/internal/api/gen/generated.go b/internal/api/gen/generated.go index eb9b206a..f6c85b02 100644 --- a/internal/api/gen/generated.go +++ b/internal/api/gen/generated.go @@ -48354,6 +48354,223 @@ type TeamMemoryAppResponse struct { // GetMemory returns TeamMemoryAppResponse.Memory, and is useful for accessing the field via an interface. func (v *TeamMemoryAppResponse) GetMemory() *TeamMemoryAppMemory { return v.Memory } +// TeamReportingLinesResponse is returned by TeamReportingLines on success. +type TeamReportingLinesResponse struct { + // #1707 — every worker's derived state in an App (plan design:agent-tasks + // §7.4), computed server-side so the CLI and portal render one answer. Plan- + // derived, not a spec (hrn:node:hadronmemory.com:specs:cor:agt:050 leaves the roster out of scope). Readers: + // the App's task audience (hrn:node:hadronmemory.com:specs:cor:agt:050:21). Retired workers are left out. + TeamRoster *TeamReportingLinesTeamRoster `json:"teamRoster"` +} + +// GetTeamRoster returns TeamReportingLinesResponse.TeamRoster, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesResponse) GetTeamRoster() *TeamReportingLinesTeamRoster { + return v.TeamRoster +} + +// TeamReportingLinesTeamRoster includes the requested fields of the GraphQL type TeamRoster. +// The GraphQL type's documentation follows. +// +// #1707 — the team roster of one App. +type TeamReportingLinesTeamRoster struct { + AppId string `json:"appId"` + Workers []*TeamReportingLinesTeamRosterWorkersTeamRosterEntry `json:"workers"` +} + +// GetAppId returns TeamReportingLinesTeamRoster.AppId, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRoster) GetAppId() string { return v.AppId } + +// GetWorkers returns TeamReportingLinesTeamRoster.Workers, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRoster) GetWorkers() []*TeamReportingLinesTeamRosterWorkersTeamRosterEntry { + return v.Workers +} + +// TeamReportingLinesTeamRosterWorkersTeamRosterEntry includes the requested fields of the GraphQL type TeamRosterEntry. +// The GraphQL type's documentation follows. +// +// #1707 — one worker on the team roster. +type TeamReportingLinesTeamRosterWorkersTeamRosterEntry struct { + Worker *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty `json:"worker"` + // #1726 — the worker it reports to; group by this for the team's structure. Read it with lastActiveAt: nothing clears a claim automatically. + ReportsTo *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty `json:"reportsTo"` +} + +// GetWorker returns TeamReportingLinesTeamRosterWorkersTeamRosterEntry.Worker, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntry) GetWorker() *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty { + return v.Worker +} + +// GetReportsTo returns TeamReportingLinesTeamRosterWorkersTeamRosterEntry.ReportsTo, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntry) GetReportsTo() *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty { + return v.ReportsTo +} + +// TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty includes the requested fields of the GraphQL type AgentTaskParty. +// The GraphQL type's documentation follows. +// +// A task's owner, assignee or audit actor, as a sanitized projection: the +// Worker's name and URN, or the person's handle. Never the User object, so a +// task never exposes another person's account fields. +type TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty struct { + AgentTaskPartyFields `json:"-"` +} + +// GetId returns TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty.Id, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty) GetId() string { + return v.AgentTaskPartyFields.Id +} + +// GetKind returns TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty.Kind, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty) GetKind() AgentTaskPartyKind { + return v.AgentTaskPartyFields.Kind +} + +// GetName returns TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty.Name, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty) GetName() *string { + return v.AgentTaskPartyFields.Name +} + +// GetUrn returns TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty.Urn, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty) GetUrn() *string { + return v.AgentTaskPartyFields.Urn +} + +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty + graphql.NoUnmarshalJSON + } + firstPass.TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.AgentTaskPartyFields) + if err != nil { + return err + } + return nil +} + +type __premarshalTeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty struct { + Id string `json:"id"` + + Kind AgentTaskPartyKind `json:"kind"` + + Name *string `json:"name"` + + Urn *string `json:"urn"` +} + +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty) __premarshalJSON() (*__premarshalTeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty, error) { + var retval __premarshalTeamReportingLinesTeamRosterWorkersTeamRosterEntryReportsToAgentTaskParty + + retval.Id = v.AgentTaskPartyFields.Id + retval.Kind = v.AgentTaskPartyFields.Kind + retval.Name = v.AgentTaskPartyFields.Name + retval.Urn = v.AgentTaskPartyFields.Urn + return &retval, nil +} + +// TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty includes the requested fields of the GraphQL type AgentTaskParty. +// The GraphQL type's documentation follows. +// +// A task's owner, assignee or audit actor, as a sanitized projection: the +// Worker's name and URN, or the person's handle. Never the User object, so a +// task never exposes another person's account fields. +type TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty struct { + AgentTaskPartyFields `json:"-"` +} + +// GetId returns TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty.Id, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty) GetId() string { + return v.AgentTaskPartyFields.Id +} + +// GetKind returns TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty.Kind, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty) GetKind() AgentTaskPartyKind { + return v.AgentTaskPartyFields.Kind +} + +// GetName returns TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty.Name, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty) GetName() *string { + return v.AgentTaskPartyFields.Name +} + +// GetUrn returns TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty.Urn, and is useful for accessing the field via an interface. +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty) GetUrn() *string { + return v.AgentTaskPartyFields.Urn +} + +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty + graphql.NoUnmarshalJSON + } + firstPass.TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.AgentTaskPartyFields) + if err != nil { + return err + } + return nil +} + +type __premarshalTeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty struct { + Id string `json:"id"` + + Kind AgentTaskPartyKind `json:"kind"` + + Name *string `json:"name"` + + Urn *string `json:"urn"` +} + +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *TeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty) __premarshalJSON() (*__premarshalTeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty, error) { + var retval __premarshalTeamReportingLinesTeamRosterWorkersTeamRosterEntryWorkerAgentTaskParty + + retval.Id = v.AgentTaskPartyFields.Id + retval.Kind = v.AgentTaskPartyFields.Kind + retval.Name = v.AgentTaskPartyFields.Name + retval.Urn = v.AgentTaskPartyFields.Urn + return &retval, nil +} + // ── Role definitions (#403 / hadron-server#960) — the pre-cast read. // // The register projection this fragment was built around (register / freeCount @@ -64583,6 +64800,14 @@ type __TeamMemoryAppInput struct { // GetRef returns __TeamMemoryAppInput.Ref, and is useful for accessing the field via an interface. func (v *__TeamMemoryAppInput) GetRef() string { return v.Ref } +// __TeamReportingLinesInput is used internally by genqlient +type __TeamReportingLinesInput struct { + AppRef string `json:"appRef"` +} + +// GetAppRef returns __TeamReportingLinesInput.AppRef, and is useful for accessing the field via an interface. +func (v *__TeamReportingLinesInput) GetAppRef() string { return v.AppRef } + // __TeamRolesInput is used internally by genqlient type __TeamRolesInput struct { AppRef string `json:"appRef"` @@ -83112,6 +83337,57 @@ func TeamMemoryApp( return data_, err_ } +// The query executed by TeamReportingLines. +const TeamReportingLines_Operation = ` +query TeamReportingLines ($appRef: ID!) { + teamRoster(appRef: $appRef) { + appId + workers { + worker { + ... AgentTaskPartyFields + } + reportsTo { + ... AgentTaskPartyFields + } + } + } +} +fragment AgentTaskPartyFields on AgentTaskParty { + id + kind + name + urn +} +` + +// The roster's reporting lines only: `task list --team` (#1044) joins on +// these and nothing else, so it does not inherit the full roster's newer +// fields (and their schema skew on an older server). +func TeamReportingLines( + ctx_ context.Context, + client_ graphql.Client, + appRef string, +) (data_ *TeamReportingLinesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "TeamReportingLines", + Query: TeamReportingLines_Operation, + Variables: &__TeamReportingLinesInput{ + AppRef: appRef, + }, + } + + data_ = &TeamReportingLinesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by TeamRoles. const TeamRoles_Operation = ` query TeamRoles ($appRef: ID!, $teamAgentRef: ID, $limit: Int, $offset: Int) { diff --git a/internal/api/queries/agenttasks.graphql b/internal/api/queries/agenttasks.graphql index 9238d0f2..4f60fbaa 100644 --- a/internal/api/queries/agenttasks.graphql +++ b/internal/api/queries/agenttasks.graphql @@ -412,3 +412,20 @@ mutation TeamStallScheduleSet($appRef: ID!, $input: AgentTaskStallScheduleInput! ...AgentTaskStallScheduleFields } } + +# The roster's reporting lines only: `task list --team` (#1044) joins on +# these and nothing else, so it does not inherit the full roster's newer +# fields (and their schema skew on an older server). +query TeamReportingLines($appRef: ID!) { + teamRoster(appRef: $appRef) { + appId + workers { + worker { + ...AgentTaskPartyFields + } + reportsTo { + ...AgentTaskPartyFields + } + } + } +} diff --git a/internal/cmd/agentic/agentic-usage.md b/internal/cmd/agentic/agentic-usage.md index c1a3e52a..b0f47be2 100644 --- a/internal/cmd/agentic/agentic-usage.md +++ b/internal/cmd/agentic/agentic-usage.md @@ -350,7 +350,7 @@ hadron team session start (--as | --launch-grant ) [-m hadron team chat post [--reply-to ] [--as-me] [--orca-host-config PATH] (uses --app or the binding; with NO binding it REFUSES, exit 4, unless --as-me — cli#842) | read [--since ] [--before ] [--limit ] [--all] [--mentions-me | --mentions ] (uses --app or the binding) | mark-read --through [--channel ] (needs the binding) hadron team attention [--since ] (uses --app or the binding) hadron team attention switchover preview | apply --proof [--yes] (uses --app or the binding) -hadron team task create [--description <d>] [--assignee <party>] [--owner <party>] [--parent <id>] [--start-by <iso>] [--tag <t>]… [--source <s>] [--status backlog|pending] [--require-role <r>] [--family-in|--family-not-in <a,b>] [--exclude-worker <w>]… [--close-session|--no-close-session] (uses --app or the binding) | assign|reassign <taskId> --assignee <party> [--close-session|--no-close-session] | start|progress|cancel <taskId> | park|ready <taskId> | finish <taskId> --outcome <word> [--summary <s>] [--evidence <url>] | get <taskId> | list [--status <s>]… [--assignee <party>] [--owner <party>] [--tag <t>] [--queued] [--roots-only] [--parent <id>] [--limit N] [--offset N] (uses --app or the binding) | mine [--limit N] [--offset N] (needs the binding: the bound worker's PENDING+STARTED tasks, fixed filters) | tree <rootTaskId> # a party is worker:<id|urn|name> or user:<id|handle>; close-when-done defaults to NO at create (the assigner opts in with --close-session; hadron-server#2249), while assign|reassign with neither flag keep the stored value; writes are the bound worker's when a session is bound; create|assign|reassign|finish may succeed WITH server warnings (cli#962, server#2198): text mode adds stderr lines `warning: [<code>] <text>` (+ ` → <taskUrn>` when the register maps it, ` (no task mapped)` when unmapped) after the unchanged stdout receipt; --json adds `warnings: [{code, text, register: {state, taskLoc, taskUrn} | null}]`, always present, [] when none; codes and text are the server's verbatim (unknown codes included); exit code unchanged, and a failed call prints no warnings; the create|assign|reassign receipts and `get` print `close session when done: yes|no` from the server-returned value (the same boolean as --json `closeSessionWhenDone`, cor:agt:050:10) — yes adds that finishing or cancelling asks the assignee to end its worker session and the platform may end it after a grace period (through a live host), no is the bare line; the command itself never ends one, and declining the request does not keep the session, so pass --no-close-session at create (or on the assign that first names a queued task's assignee) for a long-lived assignee (cli#958); the platform may end a flagged session through the worker's live host after a grace period (10 minutes by default, set per App), the choice has no in-place setter, and reassigning a task to its current assignee is refused (TASK_INVALID_TRANSITION, exit 5) with the choice unchanged (cli#959) +hadron team task create <title> [--description <d>] [--assignee <party>] [--owner <party>] [--parent <id>] [--start-by <iso>] [--tag <t>]… [--source <s>] [--status backlog|pending] [--require-role <r>] [--family-in|--family-not-in <a,b>] [--exclude-worker <w>]… [--close-session|--no-close-session] (uses --app or the binding) | assign|reassign <taskId> --assignee <party> [--close-session|--no-close-session] | start|progress|cancel <taskId> | park|ready <taskId> | finish <taskId> --outcome <word> [--summary <s>] [--evidence <url>] | get <taskId> | list [--status <s>]… [--assignee <party>] [--owner <party>] [--tag <t>] [--queued] [--roots-only] [--parent <id>] [--limit N] [--offset N] (uses --app or the binding) | list --team[=worker:<ref>] [--status <s>]… [--tree] [--stale-over <duration>] (cli#1044: a coordinator's work in flight. Team = the coordinator (bare --team = the bound worker; the value must be attached with =) plus every worker whose roster reportsTo chain reaches them, in this App. Rows = the App's tasks a team member owns or is assigned, plus all their descendants. It reads every page, never truncates, and refuses a non-advancing page (exit 1). --status applies after the join. --stale-over keeps PENDING/STARTED tasks idle longer than the duration, measured from updatedAt, else startedAt, else createdAt, and adds a stderr note that records lag. An unknown coordinator exits 4. The creator does not count, and no other App is read (server#2370). The only flags it combines with are --status/--tree/--stale-over, and the others are refused (exit 2). --json = {appId, coordinator, coordinatorSource, members, staleOverSeconds, tasks:[task + ownerTeam/assigneeTeam team|outside|unknown|user|null, match party|descendant, ageSeconds, ageFrom]}; --tree changes text only) | mine [--limit N] [--offset N] (needs the binding: the bound worker's PENDING+STARTED tasks, fixed filters) | tree <rootTaskId> # a party is worker:<id|urn|name> or user:<id|handle>; close-when-done defaults to NO at create (the assigner opts in with --close-session; hadron-server#2249), while assign|reassign with neither flag keep the stored value; writes are the bound worker's when a session is bound; create|assign|reassign|finish may succeed WITH server warnings (cli#962, server#2198): text mode adds stderr lines `warning: [<code>] <text>` (+ ` → <taskUrn>` when the register maps it, ` (no task mapped)` when unmapped) after the unchanged stdout receipt; --json adds `warnings: [{code, text, register: {state, taskLoc, taskUrn} | null}]`, always present, [] when none; codes and text are the server's verbatim (unknown codes included); exit code unchanged, and a failed call prints no warnings; the create|assign|reassign receipts and `get` print `close session when done: yes|no` from the server-returned value (the same boolean as --json `closeSessionWhenDone`, cor:agt:050:10) — yes adds that finishing or cancelling asks the assignee to end its worker session and the platform may end it after a grace period (through a live host), no is the bare line; the command itself never ends one, and declining the request does not keep the session, so pass --no-close-session at create (or on the assign that first names a queued task's assignee) for a long-lived assignee (cli#958); the platform may end a flagged session through the worker's live host after a grace period (10 minutes by default, set per App), the choice has no in-place setter, and reassigning a task to its current assignee is refused (TASK_INVALID_TRANSITION, exit 5) with the choice unchanged (cli#959) hadron team roster (uses --app or the binding) # states are plan-derived, rendered as the server derives them; MODEL (self-reported) and TIER (server high/mid/low, null = unbound/unreported/unmapped) sit beside FAMILY, advisory only and never authorization; the table ends with a 'free for work by tier' line tallying the rows the server marked freeForWork (NOT state AVAILABLE), unrecognised/null tiers under unknown (table only; --json adds model, modelReportedAt, tier and keeps every earlier key). `team worker list` gains the same MODEL/TIER columns and --json keys. Needs a server that serves these fields (older servers reject the query, no fallback) ; HELD BY (name, else @handle) + SINCE show the holder of the worker name (heldBy {id,handle,urn,name}|null + heldAt in --json, additive; a name the server withheld is null and renders like an unset one, cli#1032) # Deleted-node recovery (cli#985; writers only): `node recovery list|get|graph` and `node restore` are the ONLY way to read or bring back a soft-deleted node — `node get|list|rm` stay live-only. A ref goes to the server unresolved (an ordinary resolve omits deleted nodes). `recovery list|graph` page 20 by default with an explicit `--limit` (1-200); when more exist, STDERR says `showing a-b of N; more: --offset b` (stdout/--json unchanged) and `--json` carries `{items,total,limit,offset,hasMore}`; `--all` reads every page and fails as a whole if any page fails (never a partial list; pages are not a snapshot, so concurrent restores can move rows). `recovery get` never reads the body unless `--content`, which writes the raw stored body byte for byte (text) or `content` + `contentIncluded:true` (--json); an unreadable body is an error, never an empty one. A `recovery get` miss is exit 4 with one fixed sentence whatever the cause (live, absent, not a writer). `node restore` restores ONE node (and the comment threads its own deletion removed), sends one request and never retries: refusals keep the server's message (`NODE_PARENT_DELETED` exit 5 names the ancestor to restore first; governed/comment/compiled-boot-prompt/protected exit 2; `ROLE_WRITER_LEVEL_REQUIRED`/`ROLE_WRITERS_UNSUPPORTED` exit 8); a lost response is exit 7 and means VERIFY with `node recovery get` before retrying. The receipt is the plain node (id, urn, memoryId, loc, name, nodeType, updatedAt, deletedAt) with no changed/idempotent flag. # Paging (cli#967): `agent list`, `node list` and `team session list` list EVERYTHING unless you pass --limit/--offset (one page). `team task list|mine`, `team events`, `team host ops`, `comment list`, `node revision list` and a bounded `team chat read` default to 20 per page; a full default page prints "more may exist" on STDERR (stdout and --json shapes are unchanged), and `team chat read --all` walks every page. A list with no total (`node revision list`, `team task list`) cannot prove it ended, so treat a full default page as "there may be more". A scan that drains a list (`chat read`, `memory export`, `spec` scans, `node import` overwrite probes, …) stops only on an empty page; a server that keeps returning a page already read makes it FAIL with exit 7 (`Unavailable`: "the server returned a page already read … the list is not advancing"), never succeed with a partial list. diff --git a/internal/cmd/team/taskread.go b/internal/cmd/team/taskread.go index 9bbb3994..91d43746 100644 --- a/internal/cmd/team/taskread.go +++ b/internal/cmd/team/taskread.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/Khan/genqlient/graphql" "github.com/spf13/cobra" "github.com/hadron-memory/hadron-cli/internal/api" @@ -204,8 +205,8 @@ func strOrList(list []string) string { func newCmdTeamTaskList(f *cmdutil.Factory) *cobra.Command { var statuses []string - var assignee, owner, parent, tag string - var queued, rootsOnly bool + var assignee, owner, parent, tag, team, staleOver string + var queued, rootsOnly, tree bool var limit, offset int cmd := &cobra.Command{ Use: "list", @@ -215,17 +216,61 @@ func newCmdTeamTaskList(f *cmdutil.Factory) *cobra.Command { combine with AND: --status (repeatable, ` + taskStatusDoc + `), --assignee and --owner name a party, --queued keeps only pending tasks with no assignee, --roots-only drops children, --parent lists one task's children, --tag -filters by tag. --limit/--offset page (default 20, cap 200); a full default page says more may exist.`, +filters by tag. --limit/--offset page (default 20, cap 200); a full default page says more may exist. + +--team is a coordinator's view of work in flight (#1044). The team is the +coordinator — the bound worker for a bare --team, or --team=worker:<id, URN +or name> (attached with =) — plus every worker whose roster reportsTo chain +reaches them, in this App. It lists the App's tasks a team member OWNS or is +ASSIGNED, plus every descendant of those, even where the descendant's parties +sit outside the team; owner/assignee cells say "(outside)" for a worker in +another reporting subtree and "(not on roster)" for one the roster does not +list (a retired worker: the roster omits them, so their tasks join only as a +descendant or through a current team member). Who CREATED a task does not +count yet, and other Apps are never read (hadron-server#2370). It reads every +page of the App's tasks (no truncation; a page that does not advance is +refused, exit 1), then applies --status after the join, so an open task under +a finished team task stays in view. It combines only with --status, --tree +and --stale-over. + --tree draws parent/child trees with status, idle time, owner, assignee, + title and open alarms; a row whose parent is not shown is a root. + --stale-over <duration> (e.g. 1h) keeps PENDING/STARTED tasks idle longer + than that, where idle runs from the record's updatedAt, else startedAt, + else createdAt. Records lag real progress (PR pushes, receipts); a stderr + note says so. +--json is an object: {appId, coordinator, coordinatorSource, members, +staleOverSeconds, tasks}, each task the plain task plus ownerTeam/assigneeTeam +(team|outside|unknown|user|null), match (party|descendant), ageSeconds and +ageFrom; --tree changes text only.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - if err := checkTaskPage(limit, offset); err != nil { - return err - } for _, s := range statuses { if !taskStatuses[strings.ToUpper(s)] { return exitcode.Newf(exitcode.Usage, "--status takes %s, got %q", taskStatusDoc, s) } } + stale, err := checkTeamListFlags(cmd, args, team, staleOver) + if err != nil { + return err + } + if cmd.Flags().Changed("team") { + b, err := readBindingOrNilWithApp(ctx, f) + if err != nil { + return err + } + scope, err := resolveTeamAppScope(ctx, f, b) + if err != nil { + return err + } + wanted := map[string]bool{} + for _, s := range statuses { + wanted[strings.ToUpper(s)] = true + } + return listTeamTasks(ctx, f, scope, b, teamTaskQuery{team: team, status: wanted, staleOver: stale, tree: tree}) + } + if err := checkTaskPage(limit, offset); err != nil { + return err + } var status []gen.AgentTaskStatus for _, s := range statuses { status = append(status, gen.AgentTaskStatus(strings.ToUpper(s))) @@ -269,6 +314,10 @@ filters by tag. --limit/--offset page (default 20, cap 200); a full default page cmd.Flags().BoolVar(&rootsOnly, "roots-only", false, "drop child tasks") cmd.Flags().IntVar(&limit, "limit", taskListDefaultLimit, "page size (default 20, server cap 200)") cmd.Flags().IntVar(&offset, "offset", 0, "page offset") + cmd.Flags().StringVar(&team, "team", "", "a coordinator's team view: bare for the bound worker, or --team=worker:<ref>") + cmd.Flags().Lookup("team").NoOptDefVal = teamFlagBound + cmd.Flags().BoolVar(&tree, "tree", false, "with --team: render parent/child trees") + cmd.Flags().StringVar(&staleOver, "stale-over", "", "with --team: keep PENDING/STARTED tasks idle longer than this duration (e.g. 1h)") return cmd } @@ -450,12 +499,6 @@ alarm facts are the server's.`, if len(resp.AgentTaskTree) == 0 { return exitcode.Newf(exitcode.NotFound, "task %s not found", rootId) } - // Open alarms: the App's alarm events not yet acknowledged, - // keyed by task. Paged to exhaustion with the after cursor — - // one bounded read stopped at the first page and silently - // missed every later alarm, open ones included (cycle 1, - // Diego P2-1; a team with 205 alarms rendered 49 open). - alarmsByTask := map[string]int{} appID := "" for _, t := range resp.AgentTaskTree { if t != nil { @@ -463,42 +506,9 @@ alarm facts are the server's.`, break } } - if appID != "" { - var after *string - alarmPageSize := teamEventPageCap - // Non-advancement safeguard (cycle 2, Xylon P3-4): a server - // that repeats the same full page for every cursor would loop - // forever. A repeated last id, or an absurd page count, stops - // the walk with a stderr note — the counts render possibly - // incomplete rather than never arriving. - prevLast := "" - for page := 0; ; page++ { - events, err := gen.AgentTeamEvents(ctx, client, appID, after, boolPtr(true), &alarmPageSize, nil, nil, nil) - if err != nil { - return api.MapError(err) - } - pageSize := len(events.AgentTeamEvents) - last := "" - for _, e := range events.AgentTeamEvents { - if e == nil { - continue - } - last = e.Id - if e.Severity != nil && e.AcknowledgedAt == nil && e.TaskId != nil { - alarmsByTask[*e.TaskId]++ - } - } - if pageSize < alarmPageSize || last == "" { - break - } - if last == prevLast || page >= alarmPageLimit { - fmt.Fprintf(f.IOStreams.ErrOut, - "note: alarm paging stopped at %s without exhausting the stream; open-alarm counts may be incomplete\n", last) - break - } - prevLast = last - after = &last - } + alarmsByTask, err := openAlarmsByTask(ctx, client, f.IOStreams.ErrOut, appID) + if err != nil { + return err } tasks := []taskDTO{} for _, t := range resp.AgentTaskTree { @@ -570,6 +580,53 @@ alarm facts are the server's.`, return cmd } +// openAlarmsByTask counts the App's alarm events not yet acknowledged, keyed +// by task — `task tree`'s alarm join, shared with `task list --team`. Paged +// to exhaustion with the after cursor: one bounded read stopped at the first +// page and silently missed every later alarm, open ones included (cycle 1, +// Diego P2-1; a team with 205 alarms rendered 49 open). An empty appID reads +// nothing. +func openAlarmsByTask(ctx context.Context, client graphql.Client, errOut io.Writer, appID string) (map[string]int, error) { + alarmsByTask := map[string]int{} + if appID == "" { + return alarmsByTask, nil + } + var after *string + alarmPageSize := teamEventPageCap + // Non-advancement safeguard (cycle 2, Xylon P3-4): a server that repeats + // the same full page for every cursor would loop forever. A repeated last + // id, or an absurd page count, stops the walk with a stderr note — the + // counts render possibly incomplete rather than never arriving. + prevLast := "" + for page := 0; ; page++ { + events, err := gen.AgentTeamEvents(ctx, client, appID, after, boolPtr(true), &alarmPageSize, nil, nil, nil) + if err != nil { + return nil, api.MapError(err) + } + pageSize := len(events.AgentTeamEvents) + last := "" + for _, e := range events.AgentTeamEvents { + if e == nil { + continue + } + last = e.Id + if e.Severity != nil && e.AcknowledgedAt == nil && e.TaskId != nil { + alarmsByTask[*e.TaskId]++ + } + } + if pageSize < alarmPageSize || last == "" { + return alarmsByTask, nil + } + if last == prevLast || page >= alarmPageLimit { + fmt.Fprintf(errOut, + "note: alarm paging stopped at %s without exhausting the stream; open-alarm counts may be incomplete\n", last) + return alarmsByTask, nil + } + prevLast = last + after = &last + } +} + func findByID(tasks []taskDTO, id string) (*taskDTO, bool) { for i := range tasks { if tasks[i].ID == id { diff --git a/internal/cmd/team/taskteam.go b/internal/cmd/team/taskteam.go new file mode 100644 index 00000000..1480afee --- /dev/null +++ b/internal/cmd/team/taskteam.go @@ -0,0 +1,457 @@ +package team + +// `team task list --team [--tree] [--stale-over <d>]` — #1044 slice 1: a +// coordinator's work in flight. The team is the coordinator plus every worker +// whose roster reportsTo chain reaches them, within the one App read. The +// rows are that App's tasks a team member owns or is assigned, plus every +// descendant of those (a child's owner is its parent's assignee, so a seat +// task's own children can sit wholly outside the team). One read gate: the +// existing agentTasks read, paged to the end, joined to the roster +// client-side. The creator criterion and cross-App rows are slice 2 +// (hadron-server#2370). + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/hadron-memory/hadron-cli/internal/api" + "github.com/hadron-memory/hadron-cli/internal/api/gen" + "github.com/hadron-memory/hadron-cli/internal/cmdutil" + "github.com/hadron-memory/hadron-cli/internal/exitcode" + "github.com/hadron-memory/hadron-cli/internal/output" +) + +// teamFlagBound is --team's value when it is given bare: the bound worker. +const teamFlagBound = "@bound" + +// teamTaskPageLimit bounds the whole-App read: 500 pages at the 200 cap is a +// hundred thousand tasks, past which the view refuses rather than spin. +const teamTaskPageLimit = 500 + +// teamListFlagConflicts are the plain list's server filters and paging. The +// team view reads the whole App and selects by the roster join, so each of +// these would either narrow the read below the recursion or page a view that +// is not a page; refused rather than half-honoured. +var teamListFlagConflicts = []string{"assignee", "owner", "parent", "tag", "queued", "roots-only", "limit", "offset"} + +// teamTaskDTO is one row of the team view: the plain task plus how it joined. +// OwnerTeam / AssigneeTeam: "team" (a worker in the requested team), "outside" +// (a worker on this App's roster outside it), "unknown" (a worker the roster +// does not list — retired), "user" (a person), null for no party. Match: +// "party" (a team member owns or is assigned it) or "descendant" (under such +// a task). AgeSeconds counts from AgeFrom: updatedAt, else startedAt, else +// createdAt. +type teamTaskDTO struct { + taskDTO + OwnerTeam *string `json:"ownerTeam"` + AssigneeTeam *string `json:"assigneeTeam"` + Match string `json:"match"` + AgeSeconds int64 `json:"ageSeconds"` + AgeFrom string `json:"ageFrom"` +} + +type teamTaskListDTO struct { + AppID string `json:"appId"` + Coordinator taskPartyDTO `json:"coordinator"` + CoordinatorSource string `json:"coordinatorSource"` + Members []taskPartyDTO `json:"members"` + StaleOverSeconds *int64 `json:"staleOverSeconds"` + Tasks []teamTaskDTO `json:"tasks"` +} + +type teamTaskQuery struct { + team string + status map[string]bool + staleOver time.Duration + tree bool +} + +// checkTeamListFlags validates the team view's flags before any read. +func checkTeamListFlags(cmd *cobra.Command, args []string, team, staleOver string) (time.Duration, error) { + if !cmd.Flags().Changed("team") { + for _, name := range []string{"tree", "stale-over"} { + if cmd.Flags().Changed(name) { + return 0, exitcode.Newf(exitcode.Usage, "--%s works on the team view: add --team (the bound worker's team) or --team=worker:<ref>", name) + } + } + return 0, nil + } + if len(args) > 0 { + return 0, exitcode.Newf(exitcode.Usage, "unexpected argument %q: --team takes its worker attached, as --team=worker:<ref>", args[0]) + } + for _, name := range teamListFlagConflicts { + if cmd.Flags().Changed(name) { + return 0, exitcode.Newf(exitcode.Usage, "--%s does not combine with --team: the team view reads the whole App and selects by the roster (combine --status, --tree, --stale-over)", name) + } + } + if team != teamFlagBound { + p, err := parseTaskPartyFlag("--team", team) + if err != nil { + return 0, err + } + if p.Worker == nil { + return 0, exitcode.Newf(exitcode.Usage, "--team names a worker (worker:<id, URN or name>), got %q", team) + } + } + if !cmd.Flags().Changed("stale-over") { + return 0, nil + } + d, err := time.ParseDuration(staleOver) + if err != nil || d <= 0 { + return 0, exitcode.Newf(exitcode.Usage, "--stale-over takes a positive duration such as 30m or 1h, got %q", staleOver) + } + return d, nil +} + +// rosterWorker is the roster's view of one worker, for the team join. +type rosterWorker struct { + party taskPartyDTO + reportsTo string +} + +// resolveTeam finds the coordinator on the roster and walks reportsTo down +// from them. Members come back coordinator first, then breadth-first in +// roster order; a reportsTo cycle cannot loop the walk. +func resolveTeam(workers []rosterWorker, coordinatorRef string) (taskPartyDTO, []taskPartyDTO, bool) { + var coordinator *rosterWorker + for i := range workers { + w := &workers[i] + if w.party.ID == coordinatorRef || + (w.party.Urn != nil && *w.party.Urn == coordinatorRef) || + (w.party.Name != nil && strings.EqualFold(*w.party.Name, coordinatorRef)) { + coordinator = w + break + } + } + if coordinator == nil { + return taskPartyDTO{}, nil, false + } + members := []taskPartyDTO{coordinator.party} + seen := map[string]bool{coordinator.party.ID: true} + for queue := []string{coordinator.party.ID}; len(queue) > 0; queue = queue[1:] { + for _, w := range workers { + if w.reportsTo == queue[0] && !seen[w.party.ID] { + seen[w.party.ID] = true + members = append(members, w.party) + queue = append(queue, w.party.ID) + } + } + } + return coordinator.party, members, true +} + +// readAllTasks pages the App's task read to the end. A page that adds no new +// task is a server ignoring the offset: refused, never silently repeated. +func readAllTasks(ctx context.Context, f *cmdutil.Factory, appRef string) ([]taskDTO, error) { + client, err := f.GraphQLClient() + if err != nil { + return nil, err + } + tasks := []taskDTO{} + seen := map[string]bool{} + limit := teamEventPageCap + for page := 0; ; page++ { + if page >= teamTaskPageLimit { + return nil, exitcode.Newf(exitcode.Error, "the App's task read did not end after %d pages of %d; nothing shown", page, limit) + } + offset := page * limit + resp, err := gen.AgentTaskList(ctx, client, appRef, nil, &limit, &offset, nil, nil, nil, nil, nil, nil) + if err != nil { + return nil, api.MapError(err) + } + added := 0 + for _, t := range resp.AgentTasks { + if t == nil || seen[t.Id] { + continue + } + seen[t.Id] = true + added++ + tasks = append(tasks, taskDTOFrom(t.AgentTaskFields)) + } + if len(resp.AgentTasks) < limit { + return tasks, nil + } + if added == 0 { + return nil, exitcode.Newf(exitcode.Error, "the App's task read did not advance at offset %d (a full page of tasks already read); nothing shown", offset) + } + } +} + +// taskAge is the task record's idle time: from updatedAt, else startedAt, +// else createdAt. +func taskAge(t taskDTO, now time.Time) (time.Duration, string, error) { + at, from := t.CreatedAt, "createdAt" + if t.UpdatedAt != nil { + at, from = *t.UpdatedAt, "updatedAt" + } else if t.StartedAt != nil { + at, from = *t.StartedAt, "startedAt" + } + parsed, err := time.Parse(time.RFC3339Nano, at) + if err != nil { + return 0, "", exitcode.Newf(exitcode.Error, "task %s has an unreadable %s %q", t.ID, from, at) + } + return now.Sub(parsed), from, nil +} + +func listTeamTasks(ctx context.Context, f *cmdutil.Factory, scope appScope, b *binding, q teamTaskQuery) error { + // checkTeamListFlags has already refused anything but worker:<ref>. + _, coordinatorRef, _ := strings.Cut(q.team, ":") + source := "from --team" + if q.team == teamFlagBound { + if b == nil || b.WorkerID == "" { + return exitcode.Newf(exitcode.Usage, "a bare --team means the bound worker's team, and this worktree has no worker session — name one with --team=worker:<ref>") + } + coordinatorRef, source = b.WorkerID, "the bound worker" + } + appLabel := lazyAppLabel(ctx, f, scope) + client, err := f.GraphQLClient() + if err != nil { + return err + } + roster, err := gen.TeamReportingLines(ctx, client, scope.Ref) + if err != nil { + return api.MapError(err) + } + workers := []rosterWorker{} + for _, w := range roster.TeamRoster.Workers { + if w == nil || w.Worker == nil { + continue + } + rw := rosterWorker{party: *partyDTO(w.Worker)} + if w.ReportsTo != nil { + rw.reportsTo = w.ReportsTo.Id + } + workers = append(workers, rw) + } + coordinator, members, ok := resolveTeam(workers, coordinatorRef) + if !ok { + return exitcode.Newf(exitcode.NotFound, "worker %s is not on the roster of %s (the roster lists active workers; a retired worker leads no team here)", coordinatorRef, appLabel()) + } + all, err := readAllTasks(ctx, f, scope.Ref) + if err != nil { + return err + } + alarms, err := openAlarmsByTask(ctx, client, f.IOStreams.ErrOut, roster.TeamRoster.AppId) + if err != nil { + return err + } + + inTeam := map[string]bool{} + for _, m := range members { + inTeam[m.ID] = true + } + onRoster := map[string]bool{} + for _, w := range workers { + onRoster[w.party.ID] = true + } + label := func(p *taskPartyDTO) *string { + if p == nil { + return nil + } + l := "user" + if p.Kind == string(gen.AgentTaskPartyKindWorker) { + switch { + case inTeam[p.ID]: + l = "team" + case onRoster[p.ID]: + l = "outside" + default: + l = "unknown" + } + } + return &l + } + + // Select: a team party, then every descendant of a selected task. + match := map[string]string{} + children := map[string][]string{} + for _, t := range all { + if t.ParentTaskID != nil { + children[*t.ParentTaskID] = append(children[*t.ParentTaskID], t.ID) + } + if (t.Owner != nil && inTeam[t.Owner.ID] && t.Owner.Kind == string(gen.AgentTaskPartyKindWorker)) || + (t.Assignee != nil && inTeam[t.Assignee.ID] && t.Assignee.Kind == string(gen.AgentTaskPartyKindWorker)) { + match[t.ID] = "party" + } + } + queue := []string{} + for id := range match { + queue = append(queue, id) + } + for ; len(queue) > 0; queue = queue[1:] { + for _, child := range children[queue[0]] { + if _, ok := match[child]; !ok { + match[child] = "descendant" + queue = append(queue, child) + } + } + } + + now := time.Now() + dto := teamTaskListDTO{ + AppID: roster.TeamRoster.AppId, + Coordinator: coordinator, + CoordinatorSource: source, + Members: members, + Tasks: []teamTaskDTO{}, + } + if q.staleOver > 0 { + s := int64(q.staleOver / time.Second) + dto.StaleOverSeconds = &s + } + for _, t := range all { + how, ok := match[t.ID] + if !ok { + continue + } + if len(q.status) > 0 && !q.status[t.Status] { + continue + } + age, from, err := taskAge(t, now) + if err != nil { + return err + } + open := t.Status == "STARTED" || t.Status == "PENDING" + if q.staleOver > 0 && (!open || age <= q.staleOver) { + continue + } + if n, ok := alarms[t.ID]; ok { + open := n + t.OpenAlarms = &open + } + dto.Tasks = append(dto.Tasks, teamTaskDTO{ + taskDTO: t, + OwnerTeam: label(t.Owner), + AssigneeTeam: label(t.Assignee), + Match: how, + AgeSeconds: int64(age / time.Second), + AgeFrom: from, + }) + } + if q.staleOver > 0 { + _, _ = fmt.Fprintln(f.IOStreams.ErrOut, "note: age is the time since the task record changed (updatedAt, else startedAt, else createdAt); records lag real progress such as PR pushes and receipts") + } + + return output.Write(f.IOStreams, f.JSON, dto, func(w io.Writer) error { + // Both scope lines unconditionally and first (#470): the App and the + // team are each resolved from a fallback, and an empty view must still + // say what it read. + if _, err := fmt.Fprintf(w, "app: %s\n", appLabel()); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "team: %s (%s) — %d worker%s\n", partyCell(&dto.Coordinator), source, len(members), plural(len(members))); err != nil { + return err + } + if len(dto.Tasks) == 0 { + _, err := fmt.Fprintln(w, "no tasks") + return err + } + if q.tree { + return renderTeamTree(w, dto.Tasks) + } + t := output.NewTable(w, "ID", "STATUS", "IDLE", "TITLE", "OWNER", "ASSIGNEE", "ALARMS") + for _, task := range dto.Tasks { + t.Row(task.ID, task.Status, idleCell(task.AgeSeconds), task.Title, + teamPartyCell(task.Owner, task.OwnerTeam), teamAssigneeCell(task), alarmCell(task.OpenAlarms)) + } + return t.Flush() + }) +} + +// teamPartyCell is partyCell plus the join's verdict when it is not "team". +func teamPartyCell(p *taskPartyDTO, team *string) string { + cell := partyCell(p) + if team != nil && (*team == "outside" || *team == "unknown") { + cell += " (" + map[string]string{"outside": "outside", "unknown": "not on roster"}[*team] + ")" + } + return cell +} + +func teamAssigneeCell(task teamTaskDTO) string { + if task.Queued { + return "(queued)" + } + return teamPartyCell(task.Assignee, task.AssigneeTeam) +} + +func alarmCell(n *int) string { + if n == nil || *n == 0 { + return "—" + } + return fmt.Sprint(*n) +} + +// idleCell renders an age in whole minutes, hours and days. +func idleCell(seconds int64) string { + d := time.Duration(seconds) * time.Second + switch { + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 48*time.Hour: + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +// renderTeamTree draws the selected rows as parent/child trees, in the +// server's order. A row whose parent was not selected (filtered by status or +// staleness, or outside the view) is a root; a cycle in corrupt parent links +// cannot hide a row, because anything the walk did not reach renders at the +// top afterwards. +func renderTeamTree(w io.Writer, tasks []teamTaskDTO) error { + byID := map[string]int{} + for i, t := range tasks { + byID[t.ID] = i + } + children := map[string][]int{} + roots := []int{} + for i, t := range tasks { + if t.ParentTaskID != nil { + if _, ok := byID[*t.ParentTaskID]; ok { + children[*t.ParentTaskID] = append(children[*t.ParentTaskID], i) + continue + } + } + roots = append(roots, i) + } + visited := map[int]bool{} + var draw func(int, int) error + draw = func(i, depth int) error { + if visited[i] { + return nil + } + visited[i] = true + t := tasks[i] + alarm := "" + if t.OpenAlarms != nil && *t.OpenAlarms > 0 { + alarm = fmt.Sprintf(" ⏰ %d open alarm%s", *t.OpenAlarms, plural(*t.OpenAlarms)) + } + if _, err := fmt.Fprintf(w, "%s• %s [%s] idle %s %s → %s %s%s\n", strings.Repeat(" ", depth), t.ID, t.Status, + idleCell(t.AgeSeconds), teamPartyCell(t.Owner, t.OwnerTeam), teamAssigneeCell(t), t.Title, alarm); err != nil { + return err + } + for _, c := range children[t.ID] { + if err := draw(c, depth+1); err != nil { + return err + } + } + return nil + } + for _, r := range roots { + if err := draw(r, 0); err != nil { + return err + } + } + for i := range tasks { + if err := draw(i, 0); err != nil { + return err + } + } + return nil +} diff --git a/internal/cmd/team_task_team_1044_test.go b/internal/cmd/team_task_team_1044_test.go new file mode 100644 index 00000000..3616e0a7 --- /dev/null +++ b/internal/cmd/team_task_team_1044_test.go @@ -0,0 +1,444 @@ +package cmd + +// hadron-cli#1044 slice 1: `team task list --team [worker:<ref>]` with +// --tree and --stale-over. The team is the coordinator plus every worker +// whose roster reportsTo chain reaches them, within the bound App; the rows +// are the App's tasks a team member owns or is assigned, plus every +// descendant of those, read through the one existing task read (agentTasks) +// paged to the end. + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "github.com/hadron-memory/hadron-cli/internal/exitcode" +) + +// ---- fixtures --------------------------------------------------------------- + +func worker1044(name string) string { + return fmt.Sprintf(`{"id":"w-%s","kind":"WORKER","name":"%s","urn":null}`, name, name) +} + +const user1044 = `{"id":"u1","kind":"USER","name":"holger","urn":null}` + +// rosterRow1044 is a roster row whose worker reports to boss ("" for none). +func rosterRow1044(name, boss string) string { + row := rosterRow993(name, "AVAILABLE", true, "null", "null") + if boss == "" { + return row + } + return strings.Replace(row, `"reportsTo":null`, `"reportsTo":`+worker1044(boss), 1) +} + +// The App: Marco leads Jonas, who leads Cedar (a nested chain); Ada leads +// Cameron, a separate reporting subtree of the same App. +func roster1044() []string { + return []string{ + rosterRow1044("Marco", ""), + rosterRow1044("Jonas", "Marco"), + rosterRow1044("Cedar", "Jonas"), + rosterRow1044("Ada", ""), + rosterRow1044("Cameron", "Ada"), + } +} + +type task1044 struct { + id, status, owner, assignee, parent string + createdAt, updatedAt, startedAt string // "" renders null (createdAt is required) +} + +func ts1044(s string) string { + if s == "" { + return "null" + } + return strconv.Quote(s) +} + +func (t task1044) json() string { + assignee := "null" + if t.assignee != "" { + assignee = t.assignee + } + parent := "null" + if t.parent != "" { + parent = strconv.Quote(t.parent) + } + created := t.createdAt + if created == "" { + created = "2026-10-20T00:00:00Z" + } + status := t.status + if status == "" { + status = "STARTED" + } + return fmt.Sprintf(`{"id":%q,"appId":"capp100000000000000000000","title":"title of %s","description":null, + "status":%q,"queued":false,"waitingOnChildren":false,"owner":%s,"assignee":%s,"assignedBy":null, + "createdBy":%s,"parentTaskId":%s,"tags":[],"source":null,"startBy":null, + "createdAt":%q,"updatedAt":%s,"startedAt":%s,"finishedAt":null,"cancelledAt":null, + "closeSessionWhenDone":false,"requirements":null,"result":null}`, + t.id, t.id, status, t.owner, assignee, t.owner, parent, created, ts1044(t.updatedAt), ts1044(t.startedAt)) +} + +func taskPage1044(tasks []task1044) string { + rows := make([]string, 0, len(tasks)) + for _, t := range tasks { + rows = append(rows, t.json()) + } + return `{"data":{"agentTasks":[` + strings.Join(rows, ",") + `]}}` +} + +// The App's tasks, newest first as the server orders them. +func tasks1044() []task1044 { + return []task1044{ + {id: "t1", owner: worker1044("Marco"), assignee: worker1044("Jonas")}, + // Cross-team: a team member's seat task for a worker in Ada's subtree. + {id: "t2", owner: worker1044("Jonas"), assignee: worker1044("Cameron"), parent: "t1"}, + // Cameron's own child under it: no team party, included as a descendant. + {id: "t3", owner: worker1044("Cameron"), assignee: worker1044("Cameron"), parent: "t2"}, + // Ada's subtree only: never in Marco's view. + {id: "t4", owner: worker1044("Ada"), assignee: worker1044("Cameron")}, + // Nested member (Cedar reports to Jonas reports to Marco). + {id: "t5", owner: worker1044("Cedar")}, + // A person owns it; the team member is the assignee. + {id: "t6", owner: user1044, assignee: worker1044("Cedar")}, + // A person's task for a worker outside the team: excluded. + {id: "t7", owner: user1044, assignee: worker1044("Cameron")}, + } +} + +// server1044 answers AgentTaskList page by page (pages[i] for the i-th call; +// an empty page past the end) and the roster. +func run1044(t *testing.T, roster []string, pages [][]task1044, argv ...string) (*[]attnCall, string, string, error) { + t.Helper() + writeTeamBinding(t) + responses := rosterResponses993(roster...) + responses["TeamReportingLines"] = responses["TeamRoster"] + responses["AgentTeamEvents"] = `{"data":{"agentTeamEvents":[]}}` + n := 0 + setPage := func() { + if n < len(pages) { + responses["AgentTaskList"] = taskPage1044(pages[n]) + } else { + responses["AgentTaskList"] = `{"data":{"agentTasks":[]}}` + } + } + setPage() + srv, calls := attnServerHook(t, responses, func(op string) { + if op == "AgentTaskList" { + setPage() + n++ + } + }) + setTeamBindingServer(t, srv.URL) + f, out := testFactory(t) + root := NewRootCmd(f) + root.SetArgs(append([]string{"--server", srv.URL}, argv...)) + err := root.Execute() + return calls, out.String(), f.IOStreams.ErrOut.(*strings.Builder).String(), err +} + +type team1044JSON struct { + AppID string `json:"appId"` + Coordinator struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"coordinator"` + CoordinatorSource string `json:"coordinatorSource"` + Members []struct { + ID string `json:"id"` + } `json:"members"` + Tasks []map[string]any `json:"tasks"` +} + +func decode1044(t *testing.T, out string) team1044JSON { + t.Helper() + var got team1044JSON + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("not the --team JSON envelope: %v\n%s", err, out) + } + return got +} + +func taskIDs1044(tasks []map[string]any) []string { + ids := []string{} + for _, task := range tasks { + ids = append(ids, fmt.Sprint(task["id"])) + } + return ids +} + +// ---- team resolution, recursion, cross-team ---------------------------------- + +func TestTeamTaskListTeamResolvesNestedChainAndRecursion(t *testing.T) { + _, out, _, err := run1044(t, roster1044(), [][]task1044{tasks1044()}, + "team", "task", "list", "--team=worker:Marco", "--json") + if err != nil { + t.Fatal(err) + } + got := decode1044(t, out) + if got.Coordinator.ID != "w-Marco" || got.CoordinatorSource != "from --team" { + t.Fatalf("coordinator %+v source %q", got.Coordinator, got.CoordinatorSource) + } + members := []string{} + for _, m := range got.Members { + members = append(members, m.ID) + } + if strings.Join(members, ",") != "w-Marco,w-Jonas,w-Cedar" { + t.Fatalf("members %v, want Marco + Jonas + nested Cedar", members) + } + if ids := strings.Join(taskIDs1044(got.Tasks), ","); ids != "t1,t2,t3,t5,t6" { + t.Fatalf("tasks %s, want t1,t2,t3,t5,t6 (server order; t4/t7 belong to another subtree)", ids) + } + byID := map[string]map[string]any{} + for _, task := range got.Tasks { + byID[fmt.Sprint(task["id"])] = task + } + for id, want := range map[string][3]string{ + "t1": {"team", "team", "party"}, + "t2": {"team", "outside", "party"}, + "t3": {"outside", "outside", "descendant"}, + "t6": {"user", "team", "party"}, + } { + task := byID[id] + if task["ownerTeam"] != want[0] || task["assigneeTeam"] != want[1] || task["match"] != want[2] { + t.Errorf("%s: ownerTeam=%v assigneeTeam=%v match=%v, want %v", id, task["ownerTeam"], task["assigneeTeam"], task["match"], want) + } + } + if byID["t3"]["parentTaskId"] != "t2" { + t.Errorf("t3 parentTaskId %v", byID["t3"]["parentTaskId"]) + } + if byID["t5"]["assigneeTeam"] != nil { + t.Errorf("no assignee must be null, got %v", byID["t5"]["assigneeTeam"]) + } +} + +// No wholesale visibility: the view is the bound App's one task read, joined +// to the roster — another subtree's tasks never appear, and the read sends +// no filter that would widen or narrow it server-side. +func TestTeamTaskListTeamNeverShowsAnotherSubtreeWholesale(t *testing.T) { + calls, out, _, err := run1044(t, roster1044(), [][]task1044{tasks1044()}, + "team", "task", "list", "--team=worker:Ada", "--json") + if err != nil { + t.Fatal(err) + } + got := decode1044(t, out) + // Ada's view: her own t4, Cameron's t2/t3/t7 rows; never Marco's t1/t5/t6. + if ids := strings.Join(taskIDs1044(got.Tasks), ","); ids != "t2,t3,t4,t7" { + t.Fatalf("Ada's tasks %s, want t2,t3,t4,t7", ids) + } + for _, c := range *calls { + if c.Op != "AgentTaskList" { + continue + } + if c.Vars["appRef"] != "capp100000000000000000000" { + t.Fatalf("read another App: %v", c.Vars["appRef"]) + } + for _, k := range []string{"owner", "assignee", "parentTaskId", "status", "tag", "queued", "rootsOnly"} { + if _, ok := c.Vars[k]; ok { + t.Fatalf("--team read sent filter %s=%v", k, c.Vars[k]) + } + } + } +} + +// A bare --team is the bound worker, and the output says so (an ambient +// scope reports its source). +func TestTeamTaskListBareTeamIsTheBoundWorker(t *testing.T) { + roster := append(roster1044(), strings.Replace(rosterRow1044("Rufus", "Marco"), `"id":"w-Rufus"`, `"id":"wkr1"`, 1)) + _, out, _, err := run1044(t, roster, [][]task1044{tasks1044()}, "team", "task", "list", "--team") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "team: Rufus (the bound worker)") { + t.Fatalf("bare --team must name the bound worker and say so:\n%s", out) + } +} + +func TestTeamTaskListTeamUnknownWorkerIsNotFound(t *testing.T) { + _, _, _, err := run1044(t, roster1044(), [][]task1044{tasks1044()}, + "team", "task", "list", "--team=worker:Nobody") + if exitOf(err) != exitcode.NotFound || !strings.Contains(fmt.Sprint(err), "Nobody") { + t.Fatalf("unknown coordinator: exit %d %v", exitOf(err), err) + } +} + +// ---- paging ------------------------------------------------------------------ + +func TestTeamTaskListTeamPagesToTheEnd(t *testing.T) { + mk := func(from, n int) []task1044 { + out := []task1044{} + for i := from; i < from+n; i++ { + out = append(out, task1044{id: fmt.Sprintf("p%03d", i), owner: worker1044("Jonas")}) + } + return out + } + calls, out, _, err := run1044(t, roster1044(), [][]task1044{mk(0, 200), mk(200, 200), mk(400, 50)}, + "team", "task", "list", "--team=worker:Marco", "--json") + if err != nil { + t.Fatal(err) + } + if n := len(decode1044(t, out).Tasks); n != 450 { + t.Fatalf("got %d tasks, want all 450 across three pages", n) + } + offsets := []string{} + for _, c := range *calls { + if c.Op == "AgentTaskList" { + offsets = append(offsets, fmt.Sprint(c.Vars["offset"])) + if c.Vars["limit"] != float64(200) { + t.Fatalf("page size %v, want the 200 cap", c.Vars["limit"]) + } + } + } + if strings.Join(offsets, ",") != "0,200,400" { + t.Fatalf("offsets %v", offsets) + } +} + +// A server that answers every offset with the same full page would loop +// forever or silently repeat: refuse, show nothing. +func TestTeamTaskListTeamRefusesANonAdvancingPage(t *testing.T) { + page := []task1044{} + for i := 0; i < 200; i++ { + page = append(page, task1044{id: fmt.Sprintf("r%03d", i), owner: worker1044("Jonas")}) + } + _, out, _, err := run1044(t, roster1044(), [][]task1044{page, page, page}, + "team", "task", "list", "--team=worker:Marco") + if exitOf(err) != exitcode.Error || !strings.Contains(fmt.Sprint(err), "did not advance") { + t.Fatalf("non-advancing paging: exit %d %v", exitOf(err), err) + } + if strings.Contains(out, "r000") { + t.Fatalf("a refused read printed rows:\n%s", out) + } +} + +// ---- stale filter -------------------------------------------------------------- + +func TestTeamTaskListStaleOverKeepsExactlyTheIdleOpenTasks(t *testing.T) { + now := time.Now().UTC() + ago := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339Nano) } + tasks := []task1044{ + {id: "idle-started", owner: worker1044("Jonas"), updatedAt: ago(2 * time.Hour)}, + {id: "fresh-started", owner: worker1044("Jonas"), updatedAt: ago(10 * time.Minute)}, + {id: "old-finished", status: "FINISHED", owner: worker1044("Jonas"), updatedAt: ago(3 * time.Hour)}, + // updatedAt null: falls back to startedAt, then createdAt. + {id: "idle-by-started", owner: worker1044("Jonas"), startedAt: ago(90 * time.Minute), createdAt: ago(5 * time.Minute)}, + {id: "idle-pending-by-created", status: "PENDING", owner: worker1044("Jonas"), createdAt: ago(2 * time.Hour)}, + {id: "fresh-by-started", owner: worker1044("Jonas"), startedAt: ago(5 * time.Minute), createdAt: ago(5 * time.Hour)}, + {id: "idle-backlog", status: "BACKLOG", owner: worker1044("Jonas"), updatedAt: ago(9 * time.Hour)}, + } + _, out, errOut, err := run1044(t, roster1044(), [][]task1044{tasks}, + "team", "task", "list", "--team=worker:Marco", "--stale-over", "1h", "--json") + if err != nil { + t.Fatal(err) + } + got := decode1044(t, out) + if ids := strings.Join(taskIDs1044(got.Tasks), ","); ids != "idle-started,idle-by-started,idle-pending-by-created" { + t.Fatalf("stale tasks %s", ids) + } + from := map[string]any{} + for _, task := range got.Tasks { + from[fmt.Sprint(task["id"])] = task["ageFrom"] + } + if from["idle-started"] != "updatedAt" || from["idle-by-started"] != "startedAt" || from["idle-pending-by-created"] != "createdAt" { + t.Fatalf("ageFrom %v", from) + } + if !strings.Contains(errOut, "records lag") { + t.Fatalf("--stale-over must say the record lags real progress, stderr %q", errOut) + } +} + +// --status filters the team view client-side, AFTER the recursion: a +// descendant whose parent is finished still belongs to the team. +func TestTeamTaskListTeamStatusAppliesAfterRecursion(t *testing.T) { + tasks := []task1044{ + {id: "parent", status: "FINISHED", owner: worker1044("Jonas"), assignee: worker1044("Cameron")}, + {id: "child", owner: worker1044("Cameron"), assignee: worker1044("Cameron"), parent: "parent"}, + } + _, out, _, err := run1044(t, roster1044(), [][]task1044{tasks}, + "team", "task", "list", "--team=worker:Marco", "--status", "STARTED", "--json") + if err != nil { + t.Fatal(err) + } + if ids := strings.Join(taskIDs1044(decode1044(t, out).Tasks), ","); ids != "child" { + t.Fatalf("tasks %s, want the open descendant of the finished team task", ids) + } +} + +// ---- tree render ---------------------------------------------------------------- + +func TestTeamTaskListTeamTreeNestsAndMarksCrossTeam(t *testing.T) { + _, out, _, err := run1044(t, roster1044(), [][]task1044{tasks1044()}, + "team", "task", "list", "--team=worker:Marco", "--tree") + if err != nil { + t.Fatal(err) + } + lines := strings.Split(out, "\n") + indent := map[string]int{} + for _, l := range lines { + trimmed := strings.TrimLeft(l, " ") + if !strings.HasPrefix(trimmed, "• ") { + continue + } + id := strings.Fields(trimmed)[1] + indent[id] = len(l) - len(trimmed) + } + if indent["t1"] != 0 || indent["t2"] != 2 || indent["t3"] != 4 || indent["t5"] != 0 { + t.Fatalf("tree indents %v:\n%s", indent, out) + } + if _, ok := indent["t4"]; ok { + t.Fatalf("another subtree's task rendered:\n%s", out) + } + if !strings.Contains(out, "Cameron (outside)") { + t.Fatalf("a cross-team party must say so:\n%s", out) + } + if !strings.HasPrefix(out, "app: ") || !strings.Contains(out, "team: Marco (from --team)") { + t.Fatalf("scope lines missing:\n%s", out) + } +} + +// A view that selects nothing still says which App and team it read. +func TestTeamTaskListTeamEmptyStillNamesItsScope(t *testing.T) { + _, out, _, err := run1044(t, roster1044(), [][]task1044{{}}, + "team", "task", "list", "--team=worker:Marco", "--tree") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"app: ", "team: Marco", "no tasks"} { + if !strings.Contains(out, want) { + t.Errorf("empty view missing %q:\n%s", want, out) + } + } +} + +// ---- usage refusals --------------------------------------------------------------- + +func TestTeamTaskListTeamUsageRefusals(t *testing.T) { + for _, argv := range [][]string{ + {"team", "task", "list", "--tree"}, + {"team", "task", "list", "--stale-over", "1h"}, + {"team", "task", "list", "--team", "--owner", "worker:Jonas"}, + {"team", "task", "list", "--team", "--limit", "5"}, + {"team", "task", "list", "--team", "--roots-only"}, + {"team", "task", "list", "--team", "--stale-over", "0s"}, + {"team", "task", "list", "--team", "--stale-over", "soon"}, + {"team", "task", "list", "--team=Marco"}, + {"team", "task", "list", "--team", "worker:Marco"}, + } { + t.Run(strings.Join(argv[3:], " "), func(t *testing.T) { + calls, _, _, err := run1044(t, roster1044(), [][]task1044{tasks1044()}, argv...) + if exitOf(err) != exitcode.Usage { + t.Fatalf("exit %d (%v), want usage", exitOf(err), err) + } + for _, c := range *calls { + if c.Op == "AgentTaskList" || c.Op == "TeamRoster" || c.Op == "TeamReportingLines" { + t.Fatalf("a usage error sent %s", c.Op) + } + } + }) + } +}