- 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
402 lines
10 KiB
Go
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
|
|
}
|