Files
Agent-System/internal/controlcenter/importer/importer.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

357 lines
10 KiB
Go

// 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
}