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
This commit is contained in:
@@ -253,6 +253,9 @@ imports:
|
||||
controlcenter/docs:
|
||||
- controlcenter/embed
|
||||
- controlcenter/store
|
||||
controlcenter/importer:
|
||||
- controlcenter/pipeline
|
||||
- controlcenter/store
|
||||
controlcenter/pipeline: []
|
||||
controlcenter/store: []
|
||||
controlcenter/tasks:
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
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
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Control Center: personal project control plane, independent of the product
|
||||
// system. Subcommands: serve, migrate, register-agent.
|
||||
// system. Subcommands: serve, migrate, register-agent, import-docs, plus the
|
||||
// agent CLI (next / instruction / claim / heartbeat / release / event /
|
||||
// artifact / advance-request / decision / report).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
"chat-agent-system/internal/controlcenter/config"
|
||||
"chat-agent-system/internal/controlcenter/docs"
|
||||
"chat-agent-system/internal/controlcenter/embed"
|
||||
"chat-agent-system/internal/controlcenter/importer"
|
||||
"chat-agent-system/internal/controlcenter/server"
|
||||
"chat-agent-system/internal/controlcenter/store"
|
||||
)
|
||||
@@ -35,6 +38,28 @@ func main() {
|
||||
err = cmdMigrate(os.Args[2:])
|
||||
case "register-agent":
|
||||
err = cmdRegisterAgent(os.Args[2:])
|
||||
case "import-docs":
|
||||
err = cmdImportDocs(os.Args[2:])
|
||||
case "next":
|
||||
err = cmdAgentNext(os.Args[2:])
|
||||
case "instruction":
|
||||
err = cmdAgentInstruction(os.Args[2:])
|
||||
case "claim":
|
||||
err = simpleTaskAction("claim")(os.Args[2:])
|
||||
case "heartbeat":
|
||||
err = simpleTaskAction("heartbeat")(os.Args[2:])
|
||||
case "release":
|
||||
err = simpleTaskAction("release")(os.Args[2:])
|
||||
case "event":
|
||||
err = cmdAgentEvent(os.Args[2:])
|
||||
case "artifact":
|
||||
err = cmdAgentArtifact(os.Args[2:])
|
||||
case "advance-request":
|
||||
err = cmdAgentAdvanceRequest(os.Args[2:])
|
||||
case "decision":
|
||||
err = cmdAgentDecision(os.Args[2:])
|
||||
case "report":
|
||||
err = cmdAgentReport(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
@@ -51,7 +76,19 @@ func usage() {
|
||||
usage:
|
||||
control-center serve run HTTP server (CC_HTTP_ADDR, default 127.0.0.1:8790)
|
||||
control-center migrate apply goose migrations only
|
||||
control-center register-agent -name X [-kind K] [-token T] create/rotate an agent token`)
|
||||
control-center register-agent -name X [-kind K] [-token T] create/rotate an agent token
|
||||
control-center import-docs import .doc task docs into cc_tasks (also runs periodically in serve)
|
||||
|
||||
agent CLI (common flags: -server, default $CC_SERVER or http://127.0.0.1:8790;
|
||||
-token, default $CC_AGENT_TOKEN; -task takes a task uuid — use 'next' to discover ids):
|
||||
next print next claimable task (slug/title/id/stage)
|
||||
instruction -task ID [-md] print the instruction package (JSON, or readable with -md)
|
||||
claim|heartbeat|release -task ID lease lifecycle
|
||||
event -task ID -phase P -message M [-payload '<json>']
|
||||
artifact -task ID -kind K -title T [-file F | -content MD]
|
||||
advance-request -task ID -summary S [-artifacts id1,id2]
|
||||
decision -task ID -question Q -context MD -suggestion "选项||理由||影响" [-suggestion ...]
|
||||
report -task ID -kind result|test-report|handoff [-file F | -content MD]`)
|
||||
}
|
||||
|
||||
func cmdServe(args []string) error {
|
||||
@@ -95,6 +132,7 @@ func cmdServe(args []string) error {
|
||||
syncCtx, stopSync := context.WithCancel(ctx)
|
||||
defer stopSync()
|
||||
syncer.StartPeriodic(syncCtx, time.Duration(cfg.DocSyncSecond)*time.Second)
|
||||
importer.New(st, cfg.RepoRoot).StartPeriodic(syncCtx, time.Duration(cfg.DocSyncSecond)*time.Second)
|
||||
|
||||
reapCtx, stopReaper := context.WithCancel(ctx)
|
||||
defer stopReaper()
|
||||
@@ -131,6 +169,29 @@ func cmdMigrate(args []string) error {
|
||||
return store.Migrate(context.Background(), cfg.DBDSN, cfg.MigrationsDir)
|
||||
}
|
||||
|
||||
func cmdImportDocs(args []string) error {
|
||||
fs := flag.NewFlagSet("import-docs", flag.ContinueOnError)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
st, err := store.Connect(ctx, cfg.DBDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
stats, err := importer.New(st, cfg.RepoRoot).Import(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("import-docs: added=%d updated=%d skipped=%d\n", stats.Added, stats.Updated, stats.Skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdRegisterAgent(args []string) error {
|
||||
fs := flag.NewFlagSet("register-agent", flag.ContinueOnError)
|
||||
name := fs.String("name", "", "agent name (required)")
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// Package importer ingests existing .doc task documents (ops/short-term,
|
||||
// ops/long-term, backlog) into cc_tasks, keyed by slug (file name without
|
||||
// .md). Re-imports are idempotent: unchanged documents are skipped, and
|
||||
// fields a human edited in the UI (priority, stage, assignment, terminal
|
||||
// status) are never overwritten.
|
||||
package importer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"chat-agent-system/internal/controlcenter/pipeline"
|
||||
"chat-agent-system/internal/controlcenter/store"
|
||||
)
|
||||
|
||||
// planDirs maps a .doc task-doc directory to its plan slug and horizon.
|
||||
var planDirs = []struct {
|
||||
dir string // under .doc/
|
||||
slug string
|
||||
title string
|
||||
horizon string
|
||||
}{
|
||||
{"ops/short-term", "short-term", "短期任务", "short"},
|
||||
{"ops/long-term", "long-term", "长期任务", "long"},
|
||||
{"backlog", "backlog", "Backlog", "backlog"},
|
||||
}
|
||||
|
||||
type Importer struct {
|
||||
st *store.Store
|
||||
repoRoot string
|
||||
}
|
||||
|
||||
func New(st *store.Store, repoRoot string) *Importer {
|
||||
return &Importer{st: st, repoRoot: repoRoot}
|
||||
}
|
||||
|
||||
// Stats summarizes one Import run.
|
||||
type Stats struct {
|
||||
Added int `json:"added"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// docTask is one parsed task document.
|
||||
type docTask struct {
|
||||
slug string
|
||||
title string
|
||||
status string // cc_tasks status
|
||||
stage string
|
||||
stageState string
|
||||
owner string
|
||||
branch string
|
||||
worktree string
|
||||
mutex string
|
||||
raw string // full file content, stored as instruction_md
|
||||
docPath string // repo-relative slash path
|
||||
planSlug string
|
||||
}
|
||||
|
||||
// Import scans the task-doc directories and upserts one cc_tasks row per
|
||||
// document. See package doc for the overwrite rules.
|
||||
func (im *Importer) Import(ctx context.Context) (Stats, error) {
|
||||
var stats Stats
|
||||
planIDs := map[string]string{}
|
||||
for _, pd := range planDirs {
|
||||
id, err := im.ensurePlan(ctx, pd.slug, pd.title, pd.horizon)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("ensure plan %s: %w", pd.slug, err)
|
||||
}
|
||||
planIDs[pd.slug] = id
|
||||
}
|
||||
|
||||
// The same file name may live in more than one task directory (e.g. a
|
||||
// doc copied from short-term to long-term). Directory order in planDirs
|
||||
// decides the winner; later duplicates are skipped so re-runs stay
|
||||
// idempotent instead of ping-ponging the row.
|
||||
seen := map[string]string{}
|
||||
for _, pd := range planDirs {
|
||||
root := filepath.Join(im.repoRoot, ".doc", filepath.FromSlash(pd.dir))
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
dt, err := im.parseDoc(path, pd.slug)
|
||||
if err != nil {
|
||||
slog.Warn("doc task import: skip unparseable doc", "path", path, "err", err)
|
||||
stats.Skipped++
|
||||
return nil
|
||||
}
|
||||
if prev, dup := seen[dt.slug]; dup {
|
||||
slog.Warn("doc task import: duplicate slug, keeping first",
|
||||
"slug", dt.slug, "kept", prev, "skipped", dt.docPath)
|
||||
stats.Skipped++
|
||||
return nil
|
||||
}
|
||||
seen[dt.slug] = dt.docPath
|
||||
res, err := im.upsert(ctx, dt, planIDs[pd.slug])
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert %s: %w", dt.docPath, err)
|
||||
}
|
||||
switch res {
|
||||
case "added":
|
||||
stats.Added++
|
||||
case "updated":
|
||||
stats.Updated++
|
||||
default:
|
||||
stats.Skipped++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return stats, err
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// StartPeriodic runs Import immediately, then every interval until ctx is
|
||||
// cancelled (same ticker pattern as docs.Syncer.StartPeriodic).
|
||||
func (im *Importer) StartPeriodic(ctx context.Context, interval time.Duration) {
|
||||
go func() {
|
||||
im.runOnce(ctx)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
im.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (im *Importer) runOnce(ctx context.Context) {
|
||||
stats, err := im.Import(ctx)
|
||||
if err != nil {
|
||||
slog.Error("doc task import", "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("doc task import done",
|
||||
"added", stats.Added, "updated", stats.Updated, "skipped", stats.Skipped)
|
||||
}
|
||||
|
||||
func (im *Importer) ensurePlan(ctx context.Context, slug, title, horizon string) (string, error) {
|
||||
var id string
|
||||
err := im.st.Pool.QueryRow(ctx,
|
||||
`INSERT INTO cc_plans (slug, title, horizon) VALUES ($1,$2,$3)
|
||||
ON CONFLICT (slug) DO NOTHING RETURNING id`, slug, title, horizon).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = im.st.Pool.QueryRow(ctx, `SELECT id FROM cc_plans WHERE slug = $1`, slug).Scan(&id)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// parseDoc reads one markdown file into a docTask.
|
||||
func (im *Importer) parseDoc(path, planSlug string) (*docTask, error) {
|
||||
rawBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := string(rawBytes)
|
||||
fm, body := splitFrontMatter(raw)
|
||||
|
||||
rel, err := filepath.Rel(im.repoRoot, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
slug := strings.TrimSuffix(base, ".md")
|
||||
|
||||
status := mapStatus(fmString(fm, "status"))
|
||||
stage, stageState := inferStage(status)
|
||||
return &docTask{
|
||||
slug: slug,
|
||||
title: docTitle(fm, body, slug),
|
||||
status: status,
|
||||
stage: stage,
|
||||
stageState: stageState,
|
||||
owner: fmString(fm, "owner"),
|
||||
branch: fmString(fm, "branch"),
|
||||
worktree: fmString(fm, "worktree"),
|
||||
mutex: fmString(fm, "mutex"),
|
||||
raw: raw,
|
||||
docPath: filepath.ToSlash(rel),
|
||||
planSlug: planSlug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mapStatus maps a doc front-matter status onto the cc_tasks status space.
|
||||
// Unknown or missing statuses fall back to planned.
|
||||
func mapStatus(s string) string {
|
||||
switch strings.TrimSpace(s) {
|
||||
case pipeline.StatusPlanned:
|
||||
return pipeline.StatusPlanned
|
||||
case pipeline.StatusInProgress:
|
||||
return pipeline.StatusInProgress
|
||||
case "blocked":
|
||||
return pipeline.StatusBlockedDecision
|
||||
case pipeline.StatusDone:
|
||||
return pipeline.StatusDone
|
||||
case pipeline.StatusCancelled:
|
||||
return pipeline.StatusCancelled
|
||||
default:
|
||||
return pipeline.StatusPlanned
|
||||
}
|
||||
}
|
||||
|
||||
// inferStage derives the initial stage/stage_state for a newly imported
|
||||
// task. Stage is only set on insert; later syncs never move a task's stage.
|
||||
func inferStage(status string) (stage, stageState string) {
|
||||
switch status {
|
||||
case pipeline.StatusDone:
|
||||
return pipeline.StageConfirmation, pipeline.StateApproved
|
||||
case pipeline.StatusInProgress:
|
||||
return pipeline.StageImplementation, pipeline.StateInProgress
|
||||
case pipeline.StatusBlockedDecision:
|
||||
return pipeline.StageImplementation, pipeline.StateChangesRequested
|
||||
default: // planned, cancelled
|
||||
return pipeline.StageProposal, pipeline.StateIdle
|
||||
}
|
||||
}
|
||||
|
||||
// upsert inserts the task or updates the doc-owned fields of an existing
|
||||
// one. Returns "added", "updated" or "skipped".
|
||||
//
|
||||
// Overwrite rules for existing tasks:
|
||||
// - title/owner/branch/worktree/mutex/instruction_md/doc_path follow the doc
|
||||
// - plan_id is backfilled only when the task has none
|
||||
// - status follows the doc only while the task is planned or in_progress;
|
||||
// done/cancelled/blocked_pending_decision are never moved by the importer
|
||||
// - stage/stage_state/priority/assignment are never touched after insert
|
||||
func (im *Importer) upsert(ctx context.Context, dt *docTask, planID string) (string, error) {
|
||||
var cur struct {
|
||||
id string
|
||||
title string
|
||||
owner string
|
||||
branch string
|
||||
worktree string
|
||||
mutex string
|
||||
instr string
|
||||
docPath string
|
||||
status string
|
||||
planID *string
|
||||
}
|
||||
err := im.st.Pool.QueryRow(ctx,
|
||||
`SELECT id, title, owner, branch, worktree, mutex, instruction_md, doc_path, status, plan_id
|
||||
FROM cc_tasks WHERE slug = $1`, dt.slug).
|
||||
Scan(&cur.id, &cur.title, &cur.owner, &cur.branch, &cur.worktree, &cur.mutex,
|
||||
&cur.instr, &cur.docPath, &cur.status, &cur.planID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
_, err := im.st.Pool.Exec(ctx,
|
||||
`INSERT INTO cc_tasks (slug, plan_id, title, status, stage, stage_state,
|
||||
owner, branch, worktree, mutex, instruction_md, doc_path)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||
dt.slug, planID, dt.title, dt.status, dt.stage, dt.stageState,
|
||||
dt.owner, dt.branch, dt.worktree, dt.mutex, dt.raw, dt.docPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "added", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
followStatus := cur.status == pipeline.StatusPlanned || cur.status == pipeline.StatusInProgress
|
||||
newStatus := cur.status
|
||||
if followStatus {
|
||||
newStatus = dt.status
|
||||
}
|
||||
changed := cur.title != dt.title || cur.owner != dt.owner || cur.branch != dt.branch ||
|
||||
cur.worktree != dt.worktree || cur.mutex != dt.mutex || cur.instr != dt.raw ||
|
||||
cur.docPath != dt.docPath || cur.status != newStatus || cur.planID == nil
|
||||
if !changed {
|
||||
return "skipped", nil
|
||||
}
|
||||
newPlanID := cur.planID
|
||||
if newPlanID == nil {
|
||||
newPlanID = &planID
|
||||
}
|
||||
_, err = im.st.Pool.Exec(ctx,
|
||||
`UPDATE cc_tasks SET title=$2, owner=$3, branch=$4, worktree=$5, mutex=$6,
|
||||
instruction_md=$7, doc_path=$8, status=$9, plan_id=$10, updated_at=now()
|
||||
WHERE id = $1`,
|
||||
cur.id, dt.title, dt.owner, dt.branch, dt.worktree, dt.mutex,
|
||||
dt.raw, dt.docPath, newStatus, newPlanID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "updated", nil
|
||||
}
|
||||
|
||||
// splitFrontMatter extracts a leading --- yaml --- block; returns the parsed
|
||||
// map (possibly empty) and the remaining body.
|
||||
func splitFrontMatter(md string) (map[string]any, string) {
|
||||
fm := map[string]any{}
|
||||
body := md
|
||||
if rest, ok := strings.CutPrefix(md, "---\n"); ok {
|
||||
if end := strings.Index(rest, "\n---"); end >= 0 {
|
||||
rawFM := rest[:end]
|
||||
after := rest[end+len("\n---"):]
|
||||
if after == "" || after[0] == '\n' || after[0] == '\r' {
|
||||
if err := yaml.Unmarshal([]byte(rawFM), &fm); err != nil {
|
||||
fm = map[string]any{}
|
||||
} else {
|
||||
body = strings.TrimLeft(after, "\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if fm == nil {
|
||||
fm = map[string]any{}
|
||||
}
|
||||
return fm, body
|
||||
}
|
||||
|
||||
func fmString(fm map[string]any, key string) string {
|
||||
if v, ok := fm[key].(string); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// docTitle prefers front matter title, then the first level-1 heading, then
|
||||
// the slug.
|
||||
func docTitle(fm map[string]any, body, slug string) string {
|
||||
if t := fmString(fm, "title"); t != "" {
|
||||
return t
|
||||
}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if h, ok := strings.CutPrefix(strings.TrimSpace(line), "# "); ok {
|
||||
if t := strings.TrimSpace(h); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return slug
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"chat-agent-system/internal/controlcenter/pipeline"
|
||||
"chat-agent-system/internal/controlcenter/store"
|
||||
)
|
||||
|
||||
func TestMapStatus(t *testing.T) {
|
||||
assert.Equal(t, pipeline.StatusPlanned, mapStatus("planned"))
|
||||
assert.Equal(t, pipeline.StatusInProgress, mapStatus("in_progress"))
|
||||
assert.Equal(t, pipeline.StatusBlockedDecision, mapStatus("blocked"))
|
||||
assert.Equal(t, pipeline.StatusDone, mapStatus("done"))
|
||||
assert.Equal(t, pipeline.StatusCancelled, mapStatus("cancelled"))
|
||||
assert.Equal(t, pipeline.StatusPlanned, mapStatus(""))
|
||||
assert.Equal(t, pipeline.StatusPlanned, mapStatus("weird"))
|
||||
}
|
||||
|
||||
func TestInferStage(t *testing.T) {
|
||||
st, ss := inferStage(pipeline.StatusDone)
|
||||
assert.Equal(t, pipeline.StageConfirmation, st)
|
||||
assert.Equal(t, pipeline.StateApproved, ss)
|
||||
|
||||
st, ss = inferStage(pipeline.StatusInProgress)
|
||||
assert.Equal(t, pipeline.StageImplementation, st)
|
||||
assert.Equal(t, pipeline.StateInProgress, ss)
|
||||
|
||||
st, ss = inferStage(pipeline.StatusBlockedDecision)
|
||||
assert.Equal(t, pipeline.StageImplementation, st)
|
||||
assert.Equal(t, pipeline.StateChangesRequested, ss)
|
||||
|
||||
for _, s := range []string{pipeline.StatusPlanned, pipeline.StatusCancelled} {
|
||||
st, ss = inferStage(s)
|
||||
assert.Equal(t, pipeline.StageProposal, st)
|
||||
assert.Equal(t, pipeline.StateIdle, ss)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDoc(t *testing.T, repoRoot, rel, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(repoRoot, filepath.FromSlash(rel))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func TestParseDoc(t *testing.T) {
|
||||
repoRoot := t.TempDir()
|
||||
writeDoc(t, repoRoot, ".doc/ops/short-term/2026-09-17-demo-task.md", `---
|
||||
title: "示例任务"
|
||||
status: blocked
|
||||
owner: kimi
|
||||
branch: feature/demo
|
||||
worktree: "-"
|
||||
mutex: docgov
|
||||
---
|
||||
|
||||
# 示例任务
|
||||
|
||||
## 目标
|
||||
|
||||
做点什么。
|
||||
`)
|
||||
im := New(nil, repoRoot)
|
||||
dt, err := im.parseDoc(filepath.Join(repoRoot, ".doc", "ops", "short-term", "2026-09-17-demo-task.md"), "short-term")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2026-09-17-demo-task", dt.slug)
|
||||
assert.Equal(t, "示例任务", dt.title)
|
||||
assert.Equal(t, pipeline.StatusBlockedDecision, dt.status)
|
||||
assert.Equal(t, "kimi", dt.owner)
|
||||
assert.Equal(t, "feature/demo", dt.branch)
|
||||
assert.Equal(t, "-", dt.worktree)
|
||||
assert.Equal(t, "docgov", dt.mutex)
|
||||
assert.Equal(t, ".doc/ops/short-term/2026-09-17-demo-task.md", dt.docPath)
|
||||
assert.Contains(t, dt.raw, "## 目标")
|
||||
}
|
||||
|
||||
func TestParseDocTitleFallback(t *testing.T) {
|
||||
repoRoot := t.TempDir()
|
||||
writeDoc(t, repoRoot, ".doc/backlog/no-frontmatter.md", "# 标题来自正文\n\n内容。\n")
|
||||
im := New(nil, repoRoot)
|
||||
dt, err := im.parseDoc(filepath.Join(repoRoot, ".doc", "backlog", "no-frontmatter.md"), "backlog")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "标题来自正文", dt.title)
|
||||
assert.Equal(t, pipeline.StatusPlanned, dt.status)
|
||||
assert.Equal(t, pipeline.StageProposal, dt.stage)
|
||||
|
||||
writeDoc(t, repoRoot, ".doc/backlog/plain.md", "没有任何标题。\n")
|
||||
dt, err = im.parseDoc(filepath.Join(repoRoot, ".doc", "backlog", "plain.md"), "backlog")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "plain", dt.title)
|
||||
}
|
||||
|
||||
// --- DB integration (TEST_CC_DB_DSN gated) ---
|
||||
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_CC_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_CC_DB_DSN not set")
|
||||
}
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
|
||||
ctx := t.Context()
|
||||
require.NoError(t, store.Migrate(ctx, dsn, migrationsDir))
|
||||
st, err := store.Connect(ctx, dsn)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(st.Close)
|
||||
return st
|
||||
}
|
||||
|
||||
func TestImportIntegration(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
tag := fmt.Sprintf("imp%d", time.Now().UnixNano())
|
||||
repoRoot := t.TempDir()
|
||||
slugDone := tag + "-done"
|
||||
slugPlanned := tag + "-planned"
|
||||
|
||||
writeDoc(t, repoRoot, ".doc/ops/short-term/"+slugDone+".md",
|
||||
"---\ntitle: \"已完成任务 "+tag+"\"\nstatus: done\nowner: cursor\nbranch: fix/x\nworktree: \"-\"\nmutex: docgov\n---\n\n# 已完成任务\n\n正文。\n")
|
||||
writeDoc(t, repoRoot, ".doc/ops/long-term/"+slugPlanned+".md",
|
||||
"---\ntitle: \"计划中任务 "+tag+"\"\nstatus: planned\n---\n\n# 计划中任务\n\n正文。\n")
|
||||
|
||||
im := New(st, repoRoot)
|
||||
stats, err := im.Import(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, stats.Added)
|
||||
assert.Equal(t, 0, stats.Updated)
|
||||
|
||||
var stage, stageState, status, planSlug, owner string
|
||||
require.NoError(t, st.Pool.QueryRow(t.Context(),
|
||||
`SELECT t.stage, t.stage_state, t.status, p.slug, t.owner
|
||||
FROM cc_tasks t JOIN cc_plans p ON p.id = t.plan_id WHERE t.slug = $1`, slugDone).
|
||||
Scan(&stage, &stageState, &status, &planSlug, &owner))
|
||||
assert.Equal(t, pipeline.StageConfirmation, stage)
|
||||
assert.Equal(t, pipeline.StateApproved, stageState)
|
||||
assert.Equal(t, pipeline.StatusDone, status)
|
||||
assert.Equal(t, "short-term", planSlug)
|
||||
assert.Equal(t, "cursor", owner)
|
||||
|
||||
require.NoError(t, st.Pool.QueryRow(t.Context(),
|
||||
`SELECT p.slug FROM cc_tasks t JOIN cc_plans p ON p.id = t.plan_id WHERE t.slug = $1`, slugPlanned).
|
||||
Scan(&planSlug))
|
||||
assert.Equal(t, "long-term", planSlug)
|
||||
|
||||
// Idempotent: second run skips everything.
|
||||
stats, err = im.Import(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, stats.Added+stats.Updated, "unchanged docs must be skipped")
|
||||
assert.Equal(t, 2, stats.Skipped)
|
||||
|
||||
// Doc moves planned -> in_progress: status follows while task is planned.
|
||||
writeDoc(t, repoRoot, ".doc/ops/long-term/"+slugPlanned+".md",
|
||||
"---\ntitle: \"计划中任务 "+tag+"\"\nstatus: in_progress\n---\n\n# 计划中任务\n\n正文改。\n")
|
||||
stats, err = im.Import(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, stats.Updated)
|
||||
require.NoError(t, st.Pool.QueryRow(t.Context(),
|
||||
`SELECT status, stage FROM cc_tasks WHERE slug = $1`, slugPlanned).Scan(&status, &stage))
|
||||
assert.Equal(t, pipeline.StatusInProgress, status)
|
||||
assert.Equal(t, pipeline.StageProposal, stage, "stage must not move after insert")
|
||||
|
||||
// Terminal status never regresses: doc edited to in_progress, task stays done.
|
||||
writeDoc(t, repoRoot, ".doc/ops/short-term/"+slugDone+".md",
|
||||
"---\ntitle: \"已完成任务 "+tag+"\"\nstatus: in_progress\nowner: cursor\nbranch: fix/x\nworktree: \"-\"\nmutex: docgov\n---\n\n# 已完成任务\n\n正文改。\n")
|
||||
stats, err = im.Import(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, stats.Updated, "content changed but status must not regress")
|
||||
require.NoError(t, st.Pool.QueryRow(t.Context(),
|
||||
`SELECT status FROM cc_tasks WHERE slug = $1`, slugDone).Scan(&status))
|
||||
assert.Equal(t, pipeline.StatusDone, status)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/controlcenter/pipeline"
|
||||
"chat-agent-system/internal/controlcenter/tasks"
|
||||
)
|
||||
|
||||
// handleTaskExportDoc renders a task as a .doc short-term task document and
|
||||
// writes it to <RepoRoot>/.doc/ops/short-term/YYYY-MM-DD-<slug>.md. The
|
||||
// periodic docs sync then indexes it like any other .doc file. An existing
|
||||
// file is never overwritten (409).
|
||||
func (s *Server) handleTaskExportDoc(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
t, err := tasks.GetTask(r.Context(), s.store, id)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
if !tasks.ValidDocSlug(t.Slug) {
|
||||
writeError(w, http.StatusBadRequest, "task slug is not exportable: must match [a-z0-9-]+")
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
rel := filepath.Join(".doc", "ops", "short-term", today+"-"+t.Slug+".md")
|
||||
full := filepath.Join(s.cfg.RepoRoot, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(full, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
writeError(w, http.StatusConflict, "doc file already exists: "+filepath.ToSlash(rel))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := f.WriteString(tasks.RenderTaskDoc(t, today)); err != nil {
|
||||
f.Close()
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
t, err = tasks.PatchTask(r.Context(), s.store, id, tasks.Patch{DocPath: &relSlash})
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
s.appendEvent(r.Context(), id, "", pipeline.PhaseSystem, "exported doc "+relSlash, nil)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"doc_path": relSlash, "task": t})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"chat-agent-system/internal/controlcenter/config"
|
||||
"chat-agent-system/internal/controlcenter/store"
|
||||
)
|
||||
|
||||
func newExportTestServer(t *testing.T, repoRoot string) *httptest.Server {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_CC_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_CC_DB_DSN not set")
|
||||
}
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
|
||||
ctx := t.Context()
|
||||
require.NoError(t, store.Migrate(ctx, dsn, migrationsDir))
|
||||
st, err := store.Connect(ctx, dsn)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(st.Close)
|
||||
|
||||
srv := New(config.Config{ConsoleToken: testConsoleToken, RepoRoot: repoRoot}, st)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestTaskExportDoc(t *testing.T) {
|
||||
repoRoot := t.TempDir()
|
||||
ts := newExportTestServer(t, repoRoot)
|
||||
|
||||
taskSlug := slug("export")
|
||||
code, raw := doReq(t, "POST", ts.URL+"/api/v1/tasks", testConsoleToken, map[string]any{
|
||||
"slug": taskSlug, "title": "导出任务", "instruction_md": "## 目标\n\n验证导出。\n",
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, code, string(raw))
|
||||
id := decodeBody(t, raw)["id"].(string)
|
||||
|
||||
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id+"/export-doc", testConsoleToken, nil)
|
||||
require.Equal(t, http.StatusCreated, code, string(raw))
|
||||
docPath := decodeBody(t, raw)["doc_path"].(string)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
assert.Equal(t, ".doc/ops/short-term/"+today+"-"+taskSlug+".md", docPath)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(docPath)))
|
||||
require.NoError(t, err)
|
||||
md := string(content)
|
||||
assert.True(t, strings.HasPrefix(md, "---\n"), "front matter first")
|
||||
assert.Contains(t, md, `title: "导出任务"`)
|
||||
assert.Contains(t, md, "status: planned\n")
|
||||
assert.Contains(t, md, "created: "+today+"\n")
|
||||
assert.Contains(t, md, "# 导出任务")
|
||||
assert.Contains(t, md, "## 目标")
|
||||
|
||||
// task doc_path updated
|
||||
code, raw = doReq(t, "GET", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, nil)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, docPath, decodeBody(t, raw)["doc_path"])
|
||||
|
||||
// second export conflicts, file untouched
|
||||
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id+"/export-doc", testConsoleToken, nil)
|
||||
assert.Equal(t, http.StatusConflict, code, string(raw))
|
||||
assert.Contains(t, string(raw), "already exists")
|
||||
|
||||
// unsafe slug rejected
|
||||
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks", testConsoleToken, map[string]any{
|
||||
"slug": slug("Bad_Slug"), "title": "不可导出",
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, code, string(raw))
|
||||
badID := decodeBody(t, raw)["id"].(string)
|
||||
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+badID+"/export-doc", testConsoleToken, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, code, string(raw))
|
||||
}
|
||||
@@ -84,6 +84,7 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/send-back", s.handleTaskSendBack)
|
||||
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/{stage}/external-submit", s.handleTaskExternalSubmit)
|
||||
s.mux.HandleFunc("GET /api/v1/tasks/{id}/pipeline", s.handleTaskPipeline)
|
||||
s.mux.HandleFunc("POST /api/v1/tasks/{id}/export-doc", s.handleTaskExportDoc)
|
||||
s.mux.HandleFunc("GET /api/v1/decisions", s.handleDecisionList)
|
||||
s.mux.HandleFunc("POST /api/v1/decisions/{id}/resolve", s.handleDecisionResolve)
|
||||
s.mux.HandleFunc("GET /api/v1/stage-settings", s.handleStageSettingsGet)
|
||||
|
||||
@@ -108,7 +108,10 @@ func TestAgentHasNoAdvanceRoute(t *testing.T) {
|
||||
claimTask(t, ts, id)
|
||||
|
||||
code, _ := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/stages/advance", testAgentToken, map[string]any{})
|
||||
assert.Equal(t, http.StatusNotFound, code, "agents must not have any stage-advance route")
|
||||
// The SPA catch-all "GET /" makes the mux answer 405 (Allow: GET) for
|
||||
// unmatched POST paths; either way the agent must have no advance route.
|
||||
assert.Contains(t, []int{http.StatusNotFound, http.StatusMethodNotAllowed}, code,
|
||||
"agents must not have any stage-advance route")
|
||||
|
||||
// And the task is untouched.
|
||||
code, raw := doReq(t, "GET", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, nil)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/controlcenter/pipeline"
|
||||
)
|
||||
|
||||
// docSlugRe constrains task slugs that may be exported to .doc files; it
|
||||
// also makes path traversal impossible by construction.
|
||||
var docSlugRe = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
|
||||
// ValidDocSlug reports whether slug is safe to use in a .doc file name.
|
||||
func ValidDocSlug(slug string) bool { return docSlugRe.MatchString(slug) }
|
||||
|
||||
// DocStatus maps a cc_tasks status back onto the .doc front-matter space.
|
||||
func DocStatus(status string) string {
|
||||
if status == pipeline.StatusBlockedDecision {
|
||||
return "blocked"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// RenderTaskDoc renders a task as a .doc short-term task document following
|
||||
// .doc/ops/templates/short-term-task.md. today is the YYYY-MM-DD date used
|
||||
// for created/updated. The task's instruction_md becomes the body; when
|
||||
// empty the template skeleton is used.
|
||||
func RenderTaskDoc(t *Task, today string) string {
|
||||
orDash := func(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
b.WriteString("title: " + strconv.Quote(t.Title) + "\n")
|
||||
b.WriteString("status: " + DocStatus(t.Status) + "\n")
|
||||
b.WriteString("created: " + today + "\n")
|
||||
b.WriteString("updated: " + today + "\n")
|
||||
b.WriteString("owner: " + orDash(t.Owner) + "\n")
|
||||
b.WriteString("branch: " + orDash(t.Branch) + "\n")
|
||||
b.WriteString("worktree: " + strconv.Quote(orDash(t.Worktree)) + "\n")
|
||||
b.WriteString("mutex: " + orDash(t.Mutex) + "\n")
|
||||
b.WriteString("---\n\n")
|
||||
|
||||
body := strings.TrimSpace(t.InstructionMD)
|
||||
if body == "" {
|
||||
body = "## 目标\n\n(一句话可验证目标)\n\n## 范围\n\n- \n\n## 非目标\n\n- \n\n" +
|
||||
"## 步骤\n\n1. [ ] \n\n## 验收标准\n\n- [ ] \n\n## 相关链接\n\n" +
|
||||
"- Checklist: \n- Handoff: \n- Test report: \n\n## 证据 / 备注\n\n(命令输出、commit、文件路径)"
|
||||
}
|
||||
b.WriteString("# " + t.Title + "\n\n")
|
||||
b.WriteString(body + "\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -172,6 +172,7 @@ type Patch struct {
|
||||
Branch *string
|
||||
Worktree *string
|
||||
PlanID *string
|
||||
DocPath *string
|
||||
}
|
||||
|
||||
func PatchTask(ctx context.Context, st *store.Store, id string, p Patch) (*Task, error) {
|
||||
@@ -208,6 +209,9 @@ func PatchTask(ctx context.Context, st *store.Store, id string, p Patch) (*Task,
|
||||
if p.PlanID != nil {
|
||||
add("plan_id", *p.PlanID)
|
||||
}
|
||||
if p.DocPath != nil {
|
||||
add("doc_path", *p.DocPath)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return GetTask(ctx, st, id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user