4 Commits

Author SHA1 Message Date
e4803a5541 Merge feat/agent-status-of: read another agent's runtime status 2026-06-10 21:32:31 +01:00
782f62cc78 feat(agent): hf agent status-of <agent-id> — read another agent's status
The backend has GET /calendar/agent/status?agent_id=<id> (read an agent's
runtime status, no side effects) but the CLI only exposed `hf agent status
--set` (sets the CALLER's own status). So delegate-task / on-call-handoff
status gates — which need to check whether the RECEIVER/incoming agent is
idle before pinging/handing off — had no CLI path; agents had to guess from
is_active.

Add `hf agent status-of <agent-id>` wrapping the GET endpoint
(X-Claw-Identifier header, no token). Prints {agent_id, status}; surfaces the
backend's 404 for unknown agents so callers can fail-open/closed.

Verified on sim: `hf agent status-of plxrec2` → idle; --json → {"agent_id",
"status"}; unknown agent → 404 "Agent not found".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:32:31 +01:00
0d656a6678 Merge branch 'fix/milestone-nested-routes' 2026-06-10 13:01:43 +01:00
fdf1ba1b17 fix(milestone): use nested /projects/{project}/milestones routes + datetime due
Two contract bugs broke `hf milestone *` against the backend:

- The backend mounts milestones at prefix /projects/{project_id}/milestones
  (nested), but the CLI used flat /milestones, /milestones/<code>, etc. →
  every milestone create/get/update/delete/progress/list 404'd. Switch to
  the nested routes: list/create take --project; get/update/delete/progress
  derive the project from the milestone code (PFIXTU:00001 → PFIXTU) via a
  new milestoneProject() helper. list now requires --project.
- due_date is a REQUIRED datetime on the backend, but --due <yyyy-mm-dd>
  was sent date-only → 422 datetime_parsing. Anchor a bare date to
  start-of-day (toMilestoneDateTime), same as the worklog logged_date fix.

Verified on sim: milestone create/list/get/progress all succeed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:56:58 +01:00
3 changed files with 109 additions and 11 deletions

View File

@@ -60,11 +60,19 @@ func main() {
// `POST /calendar/agent/status`, identifies caller via // `POST /calendar/agent/status`, identifies caller via
// AGENT_ID/CLAW_IDENTIFIER env, no token needed). // AGENT_ID/CLAW_IDENTIFIER env, no token needed).
if len(args) < 2 { if len(args) < 2 {
output.Error("usage: hf agent status --set <idle|busy|on_call|exhausted|offline>") output.Error("usage: hf agent status --set <idle|busy|on_call|exhausted|offline> | hf agent status-of <agent-id>")
} }
switch args[1] { switch args[1] {
case "status": case "status":
commands.RunAgentStatus(args[2:]) commands.RunAgentStatus(args[2:])
case "status-of":
// `hf agent status-of <agent-id>` — read ANOTHER agent's runtime
// status (no side effects), wrapping GET /calendar/agent/status.
// Used by delegate-task / on-call-handoff status gates.
if len(args) < 3 {
output.Error("usage: hf agent status-of <agent-id>")
}
commands.RunAgentStatusOf(args[2])
default: default:
output.Errorf("unknown agent subcommand: %s", args[1]) output.Errorf("unknown agent subcommand: %s", args[1])
} }

View File

@@ -16,6 +16,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
neturl "net/url"
"os" "os"
"strings" "strings"
"time" "time"
@@ -138,3 +139,69 @@ func RunAgentStatus(args []string) {
"ok", "true", "ok", "true",
) )
} }
// RunAgentStatusOf implements `hf agent status-of <agent-id>` — read ANOTHER
// agent's current runtime status without side effects, wrapping
// `GET /calendar/agent/status?agent_id=<id>`. Used by the delegate-task and
// on-call-handoff status gates (which previously referenced this endpoint with
// no CLI to reach it). Like RunAgentStatus this is header-identified, not
// token-authed; the X-Claw-Identifier comes from CLAW_IDENTIFIER env or
// hostname. Prints `{agent_id, status}`; backend returns 404 for unknown
// agents so callers can fail-open or fail-closed.
func RunAgentStatusOf(targetAgentID string) {
targetAgentID = strings.TrimSpace(targetAgentID)
if targetAgentID == "" {
output.Error("usage: hf agent status-of <agent-id>")
}
clawID := strings.TrimSpace(os.Getenv("CLAW_IDENTIFIER"))
if clawID == "" {
if h, err := os.Hostname(); err == nil {
clawID = h
}
}
if clawID == "" {
output.Error("CLAW_IDENTIFIER env not set and hostname() failed — set CLAW_IDENTIFIER explicitly")
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
url := strings.TrimRight(cfg.BaseURL, "/") + "/calendar/agent/status?agent_id=" + neturl.QueryEscape(targetAgentID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
output.Errorf("cannot build request: %v", err)
}
req.Header.Set("X-Claw-Identifier", clawID)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
output.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
if resp.StatusCode/100 != 2 {
output.Errorf("backend returned %d: %s", resp.StatusCode, buf.String())
}
if output.JSONMode {
fmt.Println(buf.String())
return
}
var parsed struct {
AgentID string `json:"agent_id"`
Status string `json:"status"`
}
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
output.Errorf("cannot parse response: %v", err)
}
output.PrintKeyValue(
"agent_id", parsed.AgentID,
"status", parsed.Status,
)
}

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
@@ -32,11 +33,30 @@ type milestoneProgressResponse struct {
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
} }
// milestoneProject extracts the project code from a milestone code
// (e.g. "PFIXTU:00001" -> "PFIXTU"); milestones are nested under their
// project in the API (/projects/{project}/milestones/{code}).
func milestoneProject(code string) string {
if i := strings.IndexByte(code, ':'); i >= 0 {
return code[:i]
}
return code
}
// toMilestoneDateTime anchors a bare YYYY-MM-DD due date to a datetime, since
// the backend's due_date field requires a full datetime.
func toMilestoneDateTime(d string) string {
if len(d) == 10 {
return d + "T00:00:00Z"
}
return d
}
// RunMilestoneList implements `hf milestone list --project <project-code>`. // RunMilestoneList implements `hf milestone list --project <project-code>`.
func RunMilestoneList(args []string, tokenFlag string) { func RunMilestoneList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag) token := ResolveToken(tokenFlag)
query := "" query, project := "", ""
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
switch args[i] { switch args[i] {
case "--project": case "--project":
@@ -44,7 +64,7 @@ func RunMilestoneList(args []string, tokenFlag string) {
output.Error("--project requires a value") output.Error("--project requires a value")
} }
i++ i++
query = appendQuery(query, "project_code", args[i]) project = args[i]
case "--status": case "--status":
if i+1 >= len(args) { if i+1 >= len(args) {
output.Error("--status requires a value") output.Error("--status requires a value")
@@ -66,8 +86,11 @@ func RunMilestoneList(args []string, tokenFlag string) {
if err != nil { if err != nil {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
if project == "" {
output.Error("--project is required (milestones are listed per project)")
}
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
path := "/milestones" path := "/projects/" + project + "/milestones"
if query != "" { if query != "" {
path += "?" + query path += "?" + query
} }
@@ -110,7 +133,7 @@ func RunMilestoneGet(milestoneCode, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
data, err := c.Get("/milestones/" + milestoneCode) data, err := c.Get("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode)
if err != nil { if err != nil {
output.Errorf("failed to get milestone: %v", err) output.Errorf("failed to get milestone: %v", err)
} }
@@ -196,7 +219,7 @@ func RunMilestoneCreate(args []string, tokenFlag string) {
payload["description"] = desc payload["description"] = desc
} }
if due != "" { if due != "" {
payload["due_date"] = due payload["due_date"] = toMilestoneDateTime(due)
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
@@ -209,7 +232,7 @@ func RunMilestoneCreate(args []string, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
data, err := c.Post("/milestones", bytes.NewReader(body)) data, err := c.Post("/projects/"+project+"/milestones", bytes.NewReader(body))
if err != nil { if err != nil {
output.Errorf("failed to create milestone: %v", err) output.Errorf("failed to create milestone: %v", err)
} }
@@ -261,7 +284,7 @@ func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) {
output.Error("--due requires a value") output.Error("--due requires a value")
} }
i++ i++
payload["due_date"] = args[i] payload["due_date"] = toMilestoneDateTime(args[i])
default: default:
output.Errorf("unknown flag: %s", args[i]) output.Errorf("unknown flag: %s", args[i])
} }
@@ -281,7 +304,7 @@ func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/milestones/"+milestoneCode, bytes.NewReader(body)) _, err = c.Patch("/projects/"+milestoneProject(milestoneCode)+"/milestones/"+milestoneCode, bytes.NewReader(body))
if err != nil { if err != nil {
output.Errorf("failed to update milestone: %v", err) output.Errorf("failed to update milestone: %v", err)
} }
@@ -297,7 +320,7 @@ func RunMilestoneDelete(milestoneCode, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/milestones/" + milestoneCode) _, err = c.Delete("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode)
if err != nil { if err != nil {
output.Errorf("failed to delete milestone: %v", err) output.Errorf("failed to delete milestone: %v", err)
} }
@@ -312,7 +335,7 @@ func RunMilestoneProgress(milestoneCode, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
data, err := c.Get("/milestones/" + milestoneCode + "/progress") data, err := c.Get("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode + "/progress")
if err != nil { if err != nil {
output.Errorf("failed to get milestone progress: %v", err) output.Errorf("failed to get milestone progress: %v", err)
} }