- 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
181 lines
6.6 KiB
Go
181 lines
6.6 KiB
Go
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)
|
|
}
|