Files
Agent-System/cmd/control-center/agentcli.go
T
weichen f79956c498 feat(control-center): P4 .doc importer, agent CLI, task doc export
- internal/controlcenter/importer: scan .doc/ops/{short-term,long-term}
  and .doc/backlog for task docs, upsert cc_tasks keyed by slug; maps doc
  status/stage, auto-creates the three plans, follows doc status only
  while a task is planned/in_progress (terminal statuses never regress),
  skips unchanged docs, and resolves cross-directory slug collisions by
  fixed directory precedence with a warning
- cmd/control-center: import-docs subcommand (also runs at serve startup
  and on the doc-sync ticker) plus the agent CLI: next / instruction /
  claim / heartbeat / release / event / artifact / advance-request /
  decision / report, thin net/http client, compact JSON output
- console API POST /api/v1/tasks/{id}/export-doc renders a task per
  .doc/ops/templates/short-term-task.md into .doc/ops/short-term/, refuses
  overwrite (409) and unsafe slugs, then backfills doc_path
- fix pre-existing TestAgentHasNoAdvanceRoute: SPA catch-all GET / makes
  the mux answer 405 for unmatched POST paths; accept 404 or 405
- archtest allowlist: controlcenter/importer -> pipeline, store
2026-09-17 11:57:47 +00:00

402 lines
10 KiB
Go

package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// Thin agent-side HTTP client over the Control Center agent API. Output is
// compact JSON on stdout; failures print the server's error field to stderr
// and exit non-zero.
type agentClient struct {
base string
token string
hc *http.Client
}
func newAgentClient(server, token string) (*agentClient, error) {
if token == "" {
return nil, fmt.Errorf("agent token required: pass -token or set CC_AGENT_TOKEN")
}
return &agentClient{
base: strings.TrimRight(server, "/"),
token: token,
hc: &http.Client{Timeout: 30 * time.Second},
}, nil
}
// do performs one request and returns the decoded body. Server error
// responses are unwrapped into an error carrying the server's error field.
func (c *agentClient) do(method, path string, body any) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, c.base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
if resp.StatusCode >= 400 {
var e struct {
Error string `json:"error"`
}
if json.Unmarshal(raw, &e) == nil && e.Error != "" {
return nil, fmt.Errorf("%s %s: %s (%d)", method, path, e.Error, resp.StatusCode)
}
return nil, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
}
return json.RawMessage(raw), nil
}
func printJSON(raw json.RawMessage) {
if len(raw) == 0 {
fmt.Println("null")
return
}
// Re-encode compactly so output is stable regardless of server framing.
var v any
if err := json.Unmarshal(raw, &v); err != nil {
fmt.Println(string(raw))
return
}
out, _ := json.Marshal(v)
fmt.Println(string(out))
}
func envDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// commonFlags registers -server and -token on fs and returns a client
// factory.
func commonFlags(fs *flag.FlagSet) func() (*agentClient, error) {
server := fs.String("server", envDefault("CC_SERVER", "http://127.0.0.1:8790"), "control-center base URL")
token := fs.String("token", envDefault("CC_AGENT_TOKEN", ""), "agent token (or CC_AGENT_TOKEN)")
return func() (*agentClient, error) { return newAgentClient(*server, *token) }
}
func readContent(file, content string) (string, error) {
if file != "" {
raw, err := os.ReadFile(file)
if err != nil {
return "", err
}
return string(raw), nil
}
if content == "" {
return "", fmt.Errorf("one of -file or -content is required")
}
return content, nil
}
func cmdAgentNext(args []string) error {
fs := flag.NewFlagSet("next", flag.ContinueOnError)
mk := commonFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("GET", "/api/v1/agent/tasks/next", nil)
if err != nil {
return err
}
if len(raw) == 0 {
fmt.Println(`{"task":null}`)
return nil
}
var t struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Stage string `json:"stage"`
}
if err := json.Unmarshal(raw, &t); err != nil {
return err
}
out, _ := json.Marshal(map[string]string{
"id": t.ID, "slug": t.Slug, "title": t.Title, "stage": t.Stage,
})
fmt.Println(string(out))
return nil
}
func cmdAgentInstruction(args []string) error {
fs := flag.NewFlagSet("instruction", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid; use `next` to discover)")
asMD := fs.Bool("md", false, "render a readable summary instead of raw JSON")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" {
return fmt.Errorf("-task is required")
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("GET", "/api/v1/agent/tasks/"+*task+"/instruction", nil)
if err != nil {
return err
}
if !*asMD {
printJSON(raw)
return nil
}
var pkg struct {
Task struct {
Slug string `json:"slug"`
Title string `json:"title"`
InstructionMD string `json:"instruction_md"`
} `json:"task"`
Stage string `json:"stage"`
StageState string `json:"stage_state"`
Deliverable string `json:"deliverable"`
DecisionRules string `json:"decision_rules"`
}
if err := json.Unmarshal(raw, &pkg); err != nil {
return err
}
fmt.Printf("# %s (%s)\nstage: %s [%s]\n\n## 交付要求\n%s\n\n## 决策规则\n%s\n\n## 任务说明\n%s\n",
pkg.Task.Title, pkg.Task.Slug, pkg.Stage, pkg.StageState,
pkg.Deliverable, pkg.DecisionRules, pkg.Task.InstructionMD)
return nil
}
// simpleTaskAction covers claim / heartbeat / release: POST with no body.
func simpleTaskAction(name string) func([]string) error {
return func(args []string) error {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" {
return fmt.Errorf("-task is required")
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/"+name, nil)
if err != nil {
return err
}
printJSON(raw)
return nil
}
}
func cmdAgentEvent(args []string) error {
fs := flag.NewFlagSet("event", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
phase := fs.String("phase", "", "event phase: start|plan|progress|blocked|report|done|advance-request|decision|system")
message := fs.String("message", "", "event message")
payload := fs.String("payload", "", "optional JSON payload")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" || *phase == "" {
return fmt.Errorf("-task and -phase are required")
}
body := map[string]any{"phase": *phase, "message": *message}
if *payload != "" {
var p any
if err := json.Unmarshal([]byte(*payload), &p); err != nil {
return fmt.Errorf("-payload must be valid JSON: %w", err)
}
body["payload"] = json.RawMessage(*payload)
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/events", body)
if err != nil {
return err
}
printJSON(raw)
return nil
}
func cmdAgentArtifact(args []string) error {
fs := flag.NewFlagSet("artifact", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
kind := fs.String("kind", "note", "artifact kind, e.g. proposal|analysis|design|implementation|test-report|evaluation")
title := fs.String("title", "", "artifact title")
file := fs.String("file", "", "read content_md from file")
content := fs.String("content", "", "inline content_md")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" {
return fmt.Errorf("-task is required")
}
md, err := readContent(*file, *content)
if err != nil {
return err
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/artifacts", map[string]string{
"kind": *kind, "title": *title, "content_md": md,
})
if err != nil {
return err
}
printJSON(raw)
return nil
}
func cmdAgentAdvanceRequest(args []string) error {
fs := flag.NewFlagSet("advance-request", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
summary := fs.String("summary", "", "handoff summary for human review")
artifacts := fs.String("artifacts", "", "comma-separated artifact ids")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" {
return fmt.Errorf("-task is required")
}
ids := []string{}
if *artifacts != "" {
for _, id := range strings.Split(*artifacts, ",") {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/advance-request", map[string]any{
"summary": *summary, "artifact_ids": ids,
})
if err != nil {
return err
}
printJSON(raw)
return nil
}
// suggestions collects repeated -suggestion "选项||理由||影响" flags.
type suggestions []map[string]string
func (s *suggestions) String() string { return fmt.Sprint([]map[string]string(*s)) }
func (s *suggestions) Set(v string) error {
parts := strings.SplitN(v, "||", 3)
if len(parts) != 3 {
return fmt.Errorf("suggestion must be \"选项||理由||影响\"")
}
*s = append(*s, map[string]string{
"option": strings.TrimSpace(parts[0]),
"rationale": strings.TrimSpace(parts[1]),
"impact": strings.TrimSpace(parts[2]),
})
return nil
}
func cmdAgentDecision(args []string) error {
fs := flag.NewFlagSet("decision", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
question := fs.String("question", "", "question for the human (required)")
contextMD := fs.String("context", "", "markdown context")
var suggs suggestions
fs.Var(&suggs, "suggestion", `"选项||理由||影响"; repeatable, at least one required`)
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" || *question == "" {
return fmt.Errorf("-task and -question are required")
}
if len(suggs) == 0 {
return fmt.Errorf("at least one -suggestion is required")
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/decisions", map[string]any{
"question": *question, "context_md": *contextMD, "suggestions": suggs,
})
if err != nil {
return err
}
printJSON(raw)
return nil
}
func cmdAgentReport(args []string) error {
fs := flag.NewFlagSet("report", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
kind := fs.String("kind", "", "result|test-report|handoff")
file := fs.String("file", "", "read content_md from file")
content := fs.String("content", "", "inline content_md")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" || *kind == "" {
return fmt.Errorf("-task and -kind are required")
}
md, err := readContent(*file, *content)
if err != nil {
return err
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/reports", map[string]string{
"task_id": *task, "kind": *kind, "content_md": md,
})
if err != nil {
return err
}
printJSON(raw)
return nil
}