feat(control-center): P5 scheduled repo audits — deterministic checks, LLM review, findings UI

This commit is contained in:
weichen
2026-09-17 12:39:46 +00:00
parent f79956c498
commit 07a8e126fc
21 changed files with 2105 additions and 85 deletions
@@ -250,6 +250,10 @@ imports:
- config
controlcenter/config: []
controlcenter/embed: []
controlcenter/llm: []
controlcenter/audit:
- controlcenter/llm
- controlcenter/store
controlcenter/docs:
- controlcenter/embed
- controlcenter/store
@@ -262,6 +266,7 @@ imports:
- controlcenter/pipeline
- controlcenter/store
controlcenter/server:
- controlcenter/audit
- controlcenter/config
- controlcenter/docs
- controlcenter/embed
+12
View File
@@ -17,10 +17,12 @@ import (
"syscall"
"time"
"chat-agent-system/internal/controlcenter/audit"
"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/llm"
"chat-agent-system/internal/controlcenter/server"
"chat-agent-system/internal/controlcenter/store"
)
@@ -129,10 +131,20 @@ func cmdServe(args []string) error {
}
srv.SetDocs(embIface, syncer)
chat := llm.NewHTTP(cfg.LLMEndpoint, cfg.LLMAPIKey, cfg.LLMModel)
if !chat.Configured() {
slog.Warn("LLM endpoint/model not configured; audits run deterministic checks only")
}
auditRunner := audit.New(st, cfg.RepoRoot, chat)
srv.SetAudit(auditRunner)
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)
if cfg.AuditEnabled {
auditRunner.StartScheduler(syncCtx, cfg.AuditDailyAt)
}
reapCtx, stopReaper := context.WithCancel(ctx)
defer stopReaper()
+18
View File
@@ -132,6 +132,24 @@ textarea { width: 100%; resize: vertical; }
.badge.status-done { color: var(--green); border-color: var(--green); }
.badge.status-in_progress { color: var(--accent); border-color: var(--accent); }
.badge.status-cancelled { color: var(--fg-dim); }
.badge.sev-low { color: var(--fg-dim); }
.badge.sev-medium { color: var(--accent); border-color: var(--accent); }
.badge.sev-high { color: var(--yellow); border-color: var(--yellow); }
.badge.sev-critical { color: var(--red); border-color: var(--red); }
/* audit */
.finding-card { border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; margin-bottom: 10px; background: var(--bg-panel); }
.finding-card.sev-high, .finding-card.sev-critical { border-left: 3px solid var(--yellow); }
.finding-card.sev-critical { border-left-color: var(--red); }
.finding-card .f-head { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; cursor: pointer; }
.finding-card .f-title { font-weight: 600; }
.finding-card .f-body { margin-top: 8px; border-top: 1px solid var(--border); padding-top: 8px; }
.finding-card .f-refs { font-size: 12px; color: var(--fg-dim); margin-top: 6px; }
.finding-card .f-actions { display: flex; gap: 8px; margin-top: 8px; }
.runs-table { border-collapse: collapse; width: 100%; }
.runs-table th, .runs-table td { border: 1px solid var(--border); padding: 5px 8px; text-align: left; font-size: 12px; }
.runs-table th { color: var(--fg-dim); font-weight: 500; background: var(--bg-hover); }
.runs-table td .summary { color: var(--fg-dim); }
/* overview */
.stat-cards { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
+207 -4
View File
@@ -1,15 +1,218 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api'
import { nav } from '../router'
import { Markdown } from '../markdown'
import { fmtTime } from '../util'
import type { AuditFinding, AuditRun } from '../types'
type FindingStatus = 'open' | 'converted' | 'dismissed'
function runDuration(r: AuditRun): string {
if (!r.finished_at) return '-'
const ms = new Date(r.finished_at).getTime() - new Date(r.started_at).getTime()
if (Number.isNaN(ms)) return '-'
return `${(ms / 1000).toFixed(1)}s`
}
function FindingCard({ f, onChanged }: { f: AuditFinding; onChanged: () => void }) {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const act = async (action: 'convert' | 'dismiss') => {
setBusy(true)
setErr('')
try {
await api.post(`/audits/findings/${f.id}/${action}`)
onChanged()
} catch (ex) {
setErr((ex as Error).message)
setBusy(false)
}
}
return (
<div className={`finding-card sev-${f.severity}`}>
<div className="f-head" onClick={() => setOpen(!open)}>
<span className={`badge sev-${f.severity}`}>{f.severity}</span>
<span className="badge">{f.kind}</span>
<span className="f-title">{f.title}</span>
<span className="dim mono-sm">{fmtTime(f.created_at)}</span>
<span className="dim mono-sm">{open ? '▾' : '▸'}</span>
</div>
{open && (
<div className="f-body">
<Markdown text={f.detail_md} />
{f.refs.length > 0 && (
<div className="f-refs">refs: {f.refs.join(' · ')}</div>
)}
<div className="f-refs">
run #{f.run_id} · {fmtTime(f.run_started_at)}
{f.task_id && (
<>
{' · 已转任务 '}
<a
href={`#/tasks/${f.task_id}`}
onClick={(e) => {
e.preventDefault()
nav(`/tasks/${f.task_id}`)
}}
>
{f.task_id.slice(0, 8)}
</a>
</>
)}
</div>
{err && <div className="error-box">{err}</div>}
{f.status === 'open' && (
<div className="f-actions" onClick={(e) => e.stopPropagation()}>
<button className="primary" disabled={busy} onClick={() => act('convert')}>
转任务
</button>
<button disabled={busy} onClick={() => act('dismiss')}>
忽略
</button>
</div>
)}
</div>
)}
</div>
)
}
export default function Audit() {
const [runs, setRuns] = useState<AuditRun[]>([])
const [findings, setFindings] = useState<AuditFinding[]>([])
const [tab, setTab] = useState<FindingStatus>('open')
const [severity, setSeverity] = useState('')
const [err, setErr] = useState('')
const [running, setRunning] = useState(false)
const loadRuns = useCallback(() => {
api
.get<AuditRun[]>('/audits/runs?limit=20')
.then(setRuns)
.catch(() => {})
}, [])
const loadFindings = useCallback(() => {
api
.get<AuditFinding[]>(`/audits/findings?status=${tab}${severity ? `&severity=${severity}` : ''}&limit=100`)
.then((f) => {
setFindings(f)
setErr('')
})
.catch((e: Error) => {
if (e.message !== 'unauthorized') setErr(e.message)
})
}, [tab, severity])
const refresh = useCallback(() => {
loadRuns()
loadFindings()
}, [loadRuns, loadFindings])
useEffect(() => {
refresh()
}, [refresh])
// While a run is in flight, poll until it finishes.
useEffect(() => {
if (!runs.some((r) => r.status === 'running')) return
const timer = setInterval(refresh, 3000)
return () => clearInterval(timer)
}, [runs, refresh])
const triggerRun = async () => {
setRunning(true)
setErr('')
try {
await api.post('/audits/run')
loadRuns()
} catch (ex) {
setErr((ex as Error).message)
} finally {
setRunning(false)
}
}
const isRunning = runs.some((r) => r.status === 'running')
return (
<div>
<div className="page-head">
<h2>审计</h2>
<span className="badge">{findings.length} {tab}</span>
<div className="spacer" />
<button className="primary" disabled={running || isRunning} onClick={triggerRun}>
{isRunning ? '审查进行中…' : '立即运行'}
</button>
<button onClick={refresh}>刷新</button>
</div>
{err && <div className="error-box">{err}</div>}
<div className="panel">
<h3>仓库审查 Findings</h3>
<div className="dim">
每日定时仓库审查(确定性检查 + LLM 复审)将在 <strong>P5</strong> 上线。
届时本页展示 cc_audit_findings 的 open / resolved 列表与处理入口。
<h3>运行历史</h3>
{runs.length === 0 ? (
<div className="dim mono-sm">暂无审查记录。点击「立即运行」触发一次审查。</div>
) : (
<table className="runs-table">
<thead>
<tr>
<th>#</th>
<th>kind</th>
<th>状态</th>
<th>开始</th>
<th>耗时</th>
<th>findings</th>
<th>summary</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.id}>
<td>{r.id}</td>
<td>{r.kind}</td>
<td>
<span className={`badge ${r.status === 'failed' ? 'sev-critical' : r.status === 'running' ? 'st-in_progress' : 'status-done'}`}>
{r.status}
</span>
</td>
<td>{fmtTime(r.started_at)}</td>
<td>{runDuration(r)}</td>
<td>{r.findings}</td>
<td>
<span className="summary">{r.error || r.summary}</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="panel">
<h3>Findings</h3>
<div className="filters" style={{ marginBottom: 10 }}>
{(['open', 'converted', 'dismissed'] as FindingStatus[]).map((s) => (
<button key={s} className={tab === s ? 'primary' : ''} onClick={() => setTab(s)}>
{s}
</button>
))}
<span className="dim mono-sm">severity:</span>
<select value={severity} onChange={(e) => setSeverity(e.target.value)}>
<option value="">全部</option>
<option value="critical">critical</option>
<option value="high">high</option>
<option value="medium">medium</option>
<option value="low">low</option>
</select>
</div>
{findings.length === 0 ? (
<div className="dim mono-sm">当前过滤条件下没有 findings。</div>
) : (
findings.map((f) => <FindingCard key={f.id} f={f} onChanged={refresh} />)
)}
</div>
</div>
)
+25
View File
@@ -204,3 +204,28 @@ export interface SearchResponse {
count: number
results: SearchResult[]
}
export interface AuditRun {
id: number
kind: string
status: string
summary: string
error: string
started_at: string
finished_at?: string
findings: number
}
export interface AuditFinding {
id: string
run_id: number
severity: string
kind: string
title: string
detail_md: string
refs: string[]
status: string
task_id?: string
created_at: string
run_started_at: string
}
+250
View File
@@ -0,0 +1,250 @@
// Package audit runs the daily repository review: deterministic checks over
// cc_tasks (pure SQL + git), plus an optional LLM review, writing runs into
// cc_audit_runs and findings into cc_audit_findings. It also owns the
// findings lifecycle (convert to task, dismiss).
package audit
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"chat-agent-system/internal/controlcenter/llm"
"chat-agent-system/internal/controlcenter/store"
)
var (
ErrAlreadyRunning = errors.New("audit: a run is already in progress")
ErrNotFound = errors.New("audit: not found")
ErrBadState = errors.New("audit: bad state")
)
// advisoryLockKey serializes run creation across processes.
const advisoryLockKey int64 = 0x6363_6175_6469_74 // "ccaudit"
// Runner executes audit runs against the store and the local git repo.
// Chat may be nil: the run then covers deterministic checks only.
type Runner struct {
st *store.Store
repoRoot string
chat llm.ChatClient
}
func New(st *store.Store, repoRoot string, chat llm.ChatClient) *Runner {
return &Runner{st: st, repoRoot: repoRoot, chat: chat}
}
type Finding struct {
ID string `json:"id"`
RunID int64 `json:"run_id"`
Severity string `json:"severity"`
Kind string `json:"kind"`
Title string `json:"title"`
DetailMD string `json:"detail_md"`
Refs []string `json:"refs"`
Status string `json:"status"`
TaskID *string `json:"task_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
var validSeverity = map[string]bool{"low": true, "medium": true, "high": true, "critical": true}
var validKind = map[string]bool{"bug": true, "gap": true, "unfinished": true, "doc-drift": true}
// Run executes one audit synchronously: inserts a running cc_audit_runs row
// (ErrAlreadyRunning when one exists), then the deterministic checks, then the
// LLM review when configured. The run row always ends completed or failed;
// an LLM failure degrades into the summary instead of failing the run.
func (r *Runner) Run(ctx context.Context, kind string) (int64, error) {
runID, err := r.startRun(ctx, kind)
if err != nil {
return 0, err
}
return runID, r.executeRun(ctx, runID, kind)
}
// StartManual reserves a run row and executes it in a background goroutine,
// returning the run id immediately. A second call while a run is in flight
// gets ErrAlreadyRunning.
func StartManual(ctx context.Context, r *Runner) (int64, error) {
runID, err := r.startRun(ctx, "manual")
if err != nil {
return 0, err
}
go func() {
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if err := r.executeRun(runCtx, runID, "manual"); err != nil {
slog.Error("manual audit run", "run", runID, "err", err)
}
}()
return runID, nil
}
// executeRun performs the checks and finalizes the run row.
func (r *Runner) executeRun(ctx context.Context, runID int64, kind string) (err error) {
slog.Info("audit run started", "run", runID, "kind", kind)
var summary string
defer func() {
finCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err != nil {
_, _ = r.st.Pool.Exec(finCtx,
`UPDATE cc_audit_runs SET status='failed', error=$2, summary=$3, finished_at=now() WHERE id=$1`,
runID, err.Error(), summary)
return
}
_, _ = r.st.Pool.Exec(finCtx,
`UPDATE cc_audit_runs SET status='completed', summary=$2, finished_at=now() WHERE id=$1`,
runID, summary)
}()
var detCount, llmCount int
var notes []string
findings, ferr := r.deterministicChecks(ctx)
if ferr != nil {
return fmt.Errorf("deterministic checks: %w", ferr)
}
for _, f := range findings {
inserted, ierr := r.insertFinding(ctx, runID, f)
if ierr != nil {
return fmt.Errorf("insert finding %q: %w", f.Title, ierr)
}
if inserted {
detCount++
}
}
if r.chat == nil {
notes = append(notes, "LLM 未接入,仅确定性检查")
} else {
llmFindings, lerr := r.llmReview(ctx, runID)
switch {
case errors.Is(lerr, llm.ErrNotConfigured):
notes = append(notes, "LLM 未配置,仅确定性检查")
case lerr != nil:
notes = append(notes, "LLM 审查降级: "+lerr.Error())
default:
for _, f := range llmFindings {
inserted, ierr := r.insertFinding(ctx, runID, f)
if ierr != nil {
notes = append(notes, fmt.Sprintf("LLM finding %q 写入失败: %v", f.Title, ierr))
continue
}
if inserted {
llmCount++
}
}
}
}
summary = fmt.Sprintf("确定性 findings: %d;LLM findings: %d", detCount, llmCount)
for _, n := range notes {
summary += ";" + n
}
slog.Info("audit run finished", "run", runID, "summary", summary)
return nil
}
// startRun inserts the running row; an advisory lock serializes the
// check-then-insert so concurrent triggers cannot both start a run.
func (r *Runner) startRun(ctx context.Context, kind string) (int64, error) {
if kind == "" {
kind = "daily"
}
tx, err := r.st.Pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, advisoryLockKey); err != nil {
return 0, err
}
var busy int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM cc_audit_runs WHERE status='running'`).Scan(&busy); err != nil {
return 0, err
}
if busy > 0 {
return 0, ErrAlreadyRunning
}
var id int64
if err := tx.QueryRow(ctx,
`INSERT INTO cc_audit_runs (kind) VALUES ($1) RETURNING id`, kind).Scan(&id); err != nil {
return 0, err
}
return id, tx.Commit(ctx)
}
// insertFinding inserts a finding, skipping duplicates: an open finding with
// the same title (from any earlier run) suppresses the insert.
func (r *Runner) insertFinding(ctx context.Context, runID int64, f Finding) (bool, error) {
refs, err := json.Marshal(f.Refs)
if err != nil {
return false, err
}
var id string
err = r.st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_findings (run_id, severity, kind, title, detail_md, refs)
SELECT $1,$2,$3,$4,$5,$6
WHERE NOT EXISTS (SELECT 1 FROM cc_audit_findings WHERE status='open' AND title=$4)
RETURNING id`,
runID, f.Severity, f.Kind, f.Title, f.DetailMD, refs).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// NextFire computes the next local wall-clock trigger for dailyAt ("HH:MM"),
// strictly after now. An unparsable dailyAt falls back to 03:30.
func NextFire(now time.Time, dailyAt string) time.Time {
hh, mm := 3, 30
if _, err := fmt.Sscanf(dailyAt, "%d:%d", &hh, &mm); err != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 {
hh, mm = 3, 30
}
loc := now.Location()
next := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc)
if !next.After(now) {
next = next.AddDate(0, 0, 1)
}
return next
}
// StartScheduler fires a daily run at dailyAt (local time), checking once per
// minute, until ctx is cancelled. Returns immediately.
func (r *Runner) StartScheduler(ctx context.Context, dailyAt string) {
go func() {
next := NextFire(time.Now(), dailyAt)
slog.Info("audit scheduler armed", "daily_at", dailyAt, "next", next.Format(time.RFC3339))
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
if now.Before(next) {
continue
}
next = NextFire(now, dailyAt)
go func() {
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if _, err := r.Run(runCtx, "daily"); err != nil && !errors.Is(err, ErrAlreadyRunning) {
slog.Error("scheduled audit", "err", err)
}
}()
}
}
}()
}
@@ -0,0 +1,90 @@
package audit
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseFindingsJSON(t *testing.T) {
t.Run("fenced block with surrounding prose", func(t *testing.T) {
raw := "以下是审查结果:\n```json\n[{\"severity\":\"high\",\"kind\":\"bug\",\"title\":\"空指针\",\"detail\":\"d\",\"refs\":[\"a.go\"]}]\n```\n以上。"
fs, err := parseFindingsJSON(raw)
require.NoError(t, err)
require.Len(t, fs, 1)
assert.Equal(t, "high", fs[0].Severity)
assert.Equal(t, "bug", fs[0].Kind)
assert.Equal(t, "空指针", fs[0].Title)
assert.Equal(t, []string{"a.go"}, fs[0].Refs)
})
t.Run("bare array", func(t *testing.T) {
fs, err := parseFindingsJSON(`[{"title":"t1"}]`)
require.NoError(t, err)
require.Len(t, fs, 1)
assert.Equal(t, "medium", fs[0].Severity, "invalid severity defaults to medium")
assert.Equal(t, "gap", fs[0].Kind, "invalid kind defaults to gap")
assert.Equal(t, []string{}, fs[0].Refs)
})
t.Run("empty array", func(t *testing.T) {
fs, err := parseFindingsJSON("没有问题。\n[]")
require.NoError(t, err)
assert.Empty(t, fs)
})
t.Run("entries without title dropped", func(t *testing.T) {
fs, err := parseFindingsJSON(`[{"severity":"low","kind":"gap","detail":"no title"},{"title":"ok"}]`)
require.NoError(t, err)
require.Len(t, fs, 1)
assert.Equal(t, "ok", fs[0].Title)
})
t.Run("bad json", func(t *testing.T) {
_, err := parseFindingsJSON("模型没有输出数组")
require.Error(t, err)
_, err = parseFindingsJSON("[{\"title\": 不合法}")
require.Error(t, err)
})
t.Run("severity/kind case normalized", func(t *testing.T) {
fs, err := parseFindingsJSON(`[{"severity":"HIGH","kind":"Doc-Drift","title":"x"}]`)
require.NoError(t, err)
assert.Equal(t, "high", fs[0].Severity)
assert.Equal(t, "doc-drift", fs[0].Kind)
})
}
func TestNextFire(t *testing.T) {
loc := time.Local
at := func(h, m int) time.Time { return time.Date(2026, 9, 17, h, m, 0, 0, loc) }
t.Run("before daily time fires today", func(t *testing.T) {
next := NextFire(at(2, 0), "03:30")
assert.Equal(t, at(3, 30), next)
})
t.Run("after daily time fires tomorrow", func(t *testing.T) {
next := NextFire(at(10, 0), "03:30")
assert.Equal(t, at(3, 30).AddDate(0, 0, 1), next)
})
t.Run("exactly at daily time fires tomorrow (strictly after)", func(t *testing.T) {
next := NextFire(at(3, 30), "03:30")
assert.Equal(t, at(3, 30).AddDate(0, 0, 1), next)
})
t.Run("invalid value falls back to 03:30", func(t *testing.T) {
next := NextFire(at(10, 0), "junk")
assert.Equal(t, at(3, 30).AddDate(0, 0, 1), next)
})
t.Run("out of range falls back to 03:30", func(t *testing.T) {
next := NextFire(at(1, 0), "25:99")
assert.Equal(t, at(3, 30), next)
})
t.Run("non-zero seconds still fire same day", func(t *testing.T) {
now := time.Date(2026, 9, 17, 3, 29, 59, 0, loc)
next := NextFire(now, "03:30")
assert.Equal(t, at(3, 30), next)
})
}
+172
View File
@@ -0,0 +1,172 @@
package audit
import (
"context"
"fmt"
"os/exec"
"sort"
"strings"
"time"
)
type staleTask struct {
ID string
Slug string
Title string
Status string
Stage string
Branch string
UpdatedAt time.Time
}
// deterministicChecks runs the SQL + git checks that must always execute,
// regardless of LLM availability.
func (r *Runner) deterministicChecks(ctx context.Context) ([]Finding, error) {
var out []Finding
query := func(sql, titleFn, kind, severity, detailFmt string) error {
rows, err := r.st.Pool.Query(ctx, sql)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t staleTask
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Status, &t.Stage, &t.UpdatedAt); err != nil {
return err
}
out = append(out, Finding{
Severity: severity,
Kind: kind,
Title: fmt.Sprintf(titleFn, t.Slug),
DetailMD: fmt.Sprintf(detailFmt, t.Slug, t.Title, t.Status, t.Stage, t.UpdatedAt.Format(time.RFC3339)),
Refs: []string{"cc_tasks/" + t.Slug},
})
}
return rows.Err()
}
const cols = `SELECT id, slug, title, status, stage, updated_at FROM cc_tasks `
const detail = "任务 `%s`(%s)\n\n- status: `%s`\n- stage: `%s`\n- updated_at: %s\n"
// 1. awaiting_review for more than 24h.
if err := query(
cols+`WHERE stage_state='awaiting_review' AND status NOT IN ('done','cancelled')
AND updated_at < now() - interval '24 hours'`,
"任务 %s 待审(awaiting_review)超过 24h", "unfinished", "medium", detail); err != nil {
return nil, err
}
// 3. blocked_pending_decision for more than 48h.
if err := query(
cols+`WHERE status='blocked_pending_decision' AND updated_at < now() - interval '48 hours'`,
"任务 %s 决策阻塞超过 48h", "gap", "high", detail); err != nil {
return nil, err
}
// 4. planned for more than 7 days without movement.
if err := query(
cols+`WHERE status='planned' AND updated_at < now() - interval '7 days'`,
"任务 %s planned 超过 7 天未推进", "gap", "low", detail); err != nil {
return nil, err
}
// 2. in_progress with a stale heartbeat (>30min) or an expired lease.
rows, err := r.st.Pool.Query(ctx,
cols+`WHERE stage_state='in_progress' AND status NOT IN ('done','cancelled')
AND (heartbeat_at IS NULL OR heartbeat_at < now() - interval '30 minutes'
OR (lease_expires_at IS NOT NULL AND lease_expires_at < now()))`)
if err != nil {
return nil, err
}
for rows.Next() {
var t staleTask
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Status, &t.Stage, &t.UpdatedAt); err != nil {
rows.Close()
return nil, err
}
out = append(out, Finding{
Severity: "high",
Kind: "unfinished",
Title: fmt.Sprintf("任务 %s in_progress 心跳过期或租约过期", t.Slug),
DetailMD: fmt.Sprintf(detail, t.Slug, t.Title, t.Status, t.Stage, t.UpdatedAt.Format(time.RFC3339)),
Refs: []string{"cc_tasks/" + t.Slug},
})
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// 5. Declared branch missing from the local repo.
branches, err := r.missingBranches(ctx)
if err != nil {
return nil, err
}
out = append(out, branches...)
sortFindings(out)
return out, nil
}
func (r *Runner) missingBranches(ctx context.Context) ([]Finding, error) {
rows, err := r.st.Pool.Query(ctx,
`SELECT slug, title, branch FROM cc_tasks
WHERE branch <> '' AND status NOT IN ('done','cancelled')
ORDER BY slug`)
if err != nil {
return nil, err
}
defer rows.Close()
type row struct{ slug, title, branch string }
var declared []row
uniq := map[string]bool{}
for rows.Next() {
var t row
if err := rows.Scan(&t.slug, &t.title, &t.branch); err != nil {
return nil, err
}
if uniq[t.branch] {
continue
}
uniq[t.branch] = true
declared = append(declared, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
var out []Finding
for _, t := range declared {
if strings.Contains(t.branch, "..") || strings.HasPrefix(t.branch, "-") {
continue // not a valid ref name; treat as non-git metadata
}
if branchExists(ctx, r.repoRoot, t.branch) {
continue
}
out = append(out, Finding{
Severity: "medium",
Kind: "doc-drift",
Title: fmt.Sprintf("任务 %s 声明的分支 %s 在本地仓库不存在", t.slug, t.branch),
DetailMD: fmt.Sprintf("任务 `%s`(%s)声明 branch=`%s`,但 `git show-ref --verify refs/heads/%s` 未命中。", t.slug, t.title, t.branch, t.branch),
Refs: []string{"cc_tasks/" + t.slug, "refs/heads/" + t.branch},
})
}
return out, nil
}
func branchExists(ctx context.Context, repoRoot, branch string) bool {
gitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(gitCtx, "git", "-C", repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
return cmd.Run() == nil
}
// sortFindings gives output a stable order: severity desc, then title.
func sortFindings(fs []Finding) {
rank := map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3}
sort.SliceStable(fs, func(i, j int) bool {
if rank[fs[i].Severity] != rank[fs[j].Severity] {
return rank[fs[i].Severity] < rank[fs[j].Severity]
}
return fs[i].Title < fs[j].Title
})
}
@@ -0,0 +1,372 @@
package audit
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/store"
)
type fakeChat struct {
resp string
err error
blockCh chan struct{} // when non-nil, Chat blocks until closed
}
func (f *fakeChat) Chat(ctx context.Context, system, user string) (string, error) {
if f.blockCh != nil {
select {
case <-f.blockCh:
case <-ctx.Done():
return "", ctx.Err()
}
}
return f.resp, f.err
}
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
}
// newTempRepo initializes a git repo with one commit and the given extra branches.
func newTempRepo(t *testing.T, extraBranches ...string) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, out)
}
run("init", "-b", "main")
run("config", "user.email", "test@example.com")
run("config", "user.name", "test")
require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644))
run("add", ".")
run("commit", "-m", "init")
for _, b := range extraBranches {
run("branch", b)
}
return dir
}
// runSync runs an audit, waiting out any concurrently active run: the gated
// integration tests share one dev DB, and the single-running guard is global.
func runSync(t *testing.T, r *Runner) int64 {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for {
id, err := r.Run(t.Context(), "manual")
if err == nil {
return id
}
if !errors.Is(err, ErrAlreadyRunning) || time.Now().After(deadline) {
require.NoError(t, err)
}
time.Sleep(200 * time.Millisecond)
}
}
func uniqSlug(prefix string) string {
return fmt.Sprintf("aud-it-%s-%d", prefix, time.Now().UnixNano())
}
func seedStaleTasks(t *testing.T, st *store.Store, repoRoot string) map[string]string {
t.Helper()
ctx := t.Context()
slugs := map[string]string{}
mk := func(key string) string {
s := uniqSlug(key)
slugs[key] = s
return s
}
ins := func(slug, sets string) {
_, err := st.Pool.Exec(ctx,
`INSERT INTO cc_tasks (slug, title) VALUES ($1, 't-'||$1)`, slug)
require.NoError(t, err)
if sets != "" {
_, err = st.Pool.Exec(ctx, `UPDATE cc_tasks SET `+sets+` WHERE slug=$1`, slug)
require.NoError(t, err)
}
}
// Hits.
ins(mk("review24"), `stage_state='awaiting_review', status='in_progress', updated_at=now()-interval '2 days'`)
ins(mk("stalehb"), `status='in_progress', stage_state='in_progress', heartbeat_at=now()-interval '1 hour'`)
ins(mk("blocked48"), `status='blocked_pending_decision', updated_at=now()-interval '3 days'`)
ins(mk("planned7"), `status='planned', updated_at=now()-interval '8 days'`)
ins(mk("missingbr"), `status='in_progress', branch='no-such-branch-`+fmt.Sprint(time.Now().UnixNano())+`'`)
// Controls: must NOT produce findings.
ins(mk("freshreview"), `stage_state='awaiting_review', status='in_progress'`)
ins(mk("livehb"), `status='in_progress', stage_state='in_progress', heartbeat_at=now(), lease_expires_at=now()+interval '1 hour'`)
ins(mk("okbranch"), `status='in_progress', branch='main'`)
return slugs
}
func findingTitles(t *testing.T, st *store.Store, runID int64) map[string]Finding {
t.Helper()
rows, err := st.Pool.Query(t.Context(),
`SELECT id, severity, kind, title, status FROM cc_audit_findings WHERE run_id=$1`, runID)
require.NoError(t, err)
defer rows.Close()
out := map[string]Finding{}
for rows.Next() {
var f Finding
require.NoError(t, rows.Scan(&f.ID, &f.Severity, &f.Kind, &f.Title, &f.Status))
out[f.Title] = f
}
return out
}
// openFindingBySlug finds the newest open finding whose title contains slug.
// Findings may belong to a run from another test binary sharing the dev DB:
// whichever run reaches the dedup check first owns the row.
func openFindingBySlug(t *testing.T, st *store.Store, slug string) *Finding {
t.Helper()
var f Finding
err := st.Pool.QueryRow(t.Context(),
`SELECT id, severity, kind, title, status FROM cc_audit_findings
WHERE status='open' AND strpos(title, $1) > 0
ORDER BY created_at DESC LIMIT 1`, slug).
Scan(&f.ID, &f.Severity, &f.Kind, &f.Title, &f.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
require.NoError(t, err)
return &f
}
func TestDeterministicChecksAndDedup(t *testing.T) {
st := newTestStore(t)
repo := newTempRepo(t)
slugs := seedStaleTasks(t, st, repo)
r := New(st, repo, nil)
ctx := t.Context()
runID := runSync(t, r)
var status, summary string
require.NoError(t, st.Pool.QueryRow(ctx,
`SELECT status, summary FROM cc_audit_runs WHERE id=$1`, runID).Scan(&status, &summary))
assert.Equal(t, "completed", status)
assert.Contains(t, summary, "仅确定性检查", "nil chat client degrades to deterministic-only")
expect := []struct{ key, kind, sev string }{
{"review24", "unfinished", "medium"},
{"stalehb", "unfinished", "high"},
{"blocked48", "gap", "high"},
{"planned7", "gap", "low"},
{"missingbr", "doc-drift", "medium"},
}
for _, e := range expect {
hit := openFindingBySlug(t, st, slugs[e.key])
require.NotNil(t, hit, "expected open finding for %s (%s)", e.key, slugs[e.key])
assert.Equal(t, e.kind, hit.Kind, e.key)
assert.Equal(t, e.sev, hit.Severity, e.key)
}
for _, key := range []string{"freshreview", "livehb", "okbranch"} {
assert.Nil(t, openFindingBySlug(t, st, slugs[key]),
"control task %s must not produce a finding", key)
}
// Second run: same open titles are deduped — zero new findings.
runID2 := runSync(t, r)
got2 := findingTitles(t, st, runID2)
assert.Empty(t, got2, "second run must not duplicate open findings")
}
func TestLLMReviewAndDegradation(t *testing.T) {
st := newTestStore(t)
repo := newTempRepo(t)
ctx := t.Context()
// Healthy LLM: fenced JSON output is parsed and stored.
good := &fakeChat{resp: "结果:\n```json\n[{\"severity\":\"high\",\"kind\":\"bug\",\"title\":\"LLM-发现的潜在 bug " + uniqSlug("llm") + "\",\"detail\":\"详情\",\"refs\":[\"x.go\"]}]\n```"}
r := New(st, repo, good)
runID := runSync(t, r)
var summary string
require.NoError(t, st.Pool.QueryRow(ctx, `SELECT summary FROM cc_audit_runs WHERE id=$1`, runID).Scan(&summary))
assert.Contains(t, summary, "LLM findings: 1")
got := findingTitles(t, st, runID)
found := false
for title := range got {
if strings.Contains(title, "LLM-发现的潜在 bug") {
found = true
}
}
assert.True(t, found, "LLM finding should be stored, got %v", got)
// Broken LLM output: run still completes, summary notes degradation.
bad := &fakeChat{resp: "我无法输出 JSON"}
r2 := New(st, repo, bad)
runID2 := runSync(t, r2)
var status2, summary2 string
require.NoError(t, st.Pool.QueryRow(ctx,
`SELECT status, summary FROM cc_audit_runs WHERE id=$1`, runID2).Scan(&status2, &summary2))
assert.Equal(t, "completed", status2, "LLM parse failure must not fail the run")
assert.Contains(t, summary2, "LLM 审查降级")
}
func TestConvertAndDismiss(t *testing.T) {
st := newTestStore(t)
ctx := t.Context()
mkFinding := func(title string) string {
var runID int64
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_runs (kind, status, finished_at) VALUES ('manual','completed',now()) RETURNING id`).Scan(&runID))
var id string
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_findings (run_id, severity, kind, title, detail_md, refs)
VALUES ($1,'medium','gap',$2,'细节内容','["ref-a","ref-b"]'::jsonb) RETURNING id`,
runID, title).Scan(&id))
return id
}
// Convert: proposal task created, finding converted + task_id backfilled.
fid := mkFinding("convert me " + uniqSlug("c"))
fi, err := ConvertFinding(ctx, st, fid)
require.NoError(t, err)
assert.Equal(t, "converted", fi.Status)
require.NotNil(t, fi.TaskID)
var kind, stage, stageState, status, title, instr string
require.NoError(t, st.Pool.QueryRow(ctx,
`SELECT kind, stage, stage_state, status, title, instruction_md FROM cc_tasks WHERE id=$1`,
*fi.TaskID).Scan(&kind, &stage, &stageState, &status, &title, &instr))
assert.Equal(t, "proposal", kind)
assert.Equal(t, "proposal", stage)
assert.Equal(t, "idle", stageState)
assert.Equal(t, "planned", status)
assert.True(t, len(title) > len("[audit] ") && title[:len("[audit] ")] == "[audit] ", "title prefix: %s", title)
assert.Contains(t, instr, "细节内容")
assert.Contains(t, instr, "ref-a")
// Converting again is a conflict.
_, err = ConvertFinding(ctx, st, fid)
require.ErrorIs(t, err, ErrBadState)
// Dismiss.
fid2 := mkFinding("dismiss me " + uniqSlug("d"))
require.NoError(t, DismissFinding(ctx, st, fid2))
var st2 string
require.NoError(t, st.Pool.QueryRow(ctx, `SELECT status FROM cc_audit_findings WHERE id=$1`, fid2).Scan(&st2))
assert.Equal(t, "dismissed", st2)
// Dismiss again / unknown id.
require.ErrorIs(t, DismissFinding(ctx, st, fid2), ErrBadState)
require.ErrorIs(t, DismissFinding(ctx, st, "00000000-0000-0000-0000-000000000000"), ErrNotFound)
}
func TestConcurrentRunConflict(t *testing.T) {
st := newTestStore(t)
repo := newTempRepo(t)
ctx := t.Context()
// A pre-existing running row blocks new runs.
var busyID int64
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_runs (kind, status) VALUES ('manual','running') RETURNING id`).Scan(&busyID))
r := New(st, repo, nil)
_, err := r.Run(ctx, "manual")
require.ErrorIs(t, err, ErrAlreadyRunning)
_, err = st.Pool.Exec(ctx, `DELETE FROM cc_audit_runs WHERE id=$1`, busyID)
require.NoError(t, err)
// True concurrency: a slow LLM holds the first run open while a second
// Run arrives and must get ErrAlreadyRunning. The gated tests share one
// dev DB, so both sides retry until the slow run holds the guard.
release := make(chan struct{})
slow := New(st, repo, &fakeChat{resp: "[]", blockCh: release})
done := make(chan error, 1)
go func() {
deadline := time.Now().Add(30 * time.Second)
for {
_, err := slow.Run(ctx, "manual")
if errors.Is(err, ErrAlreadyRunning) && time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
continue
}
done <- err
return
}
}()
deadline := time.Now().Add(30 * time.Second)
for {
_, err = New(st, repo, nil).Run(ctx, "manual")
if errors.Is(err, ErrAlreadyRunning) {
break
}
require.NoError(t, err)
require.False(t, time.Now().After(deadline), "slow run never held the guard")
time.Sleep(100 * time.Millisecond)
}
close(release)
require.NoError(t, <-done)
}
func TestListRunsAndFindings(t *testing.T) {
st := newTestStore(t)
repo := newTempRepo(t)
ctx := t.Context()
r := New(st, repo, &fakeChat{resp: "[]"})
runSync(t, r)
runs, err := ListRuns(ctx, st, 5)
require.NoError(t, err)
require.NotEmpty(t, runs)
assert.Equal(t, "completed", runs[0].Status)
assert.NotNil(t, runs[0].FinishedAt)
// No findings seeded here; check filtering plumbing with a manual finding.
var runID int64
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_runs (kind, status, finished_at) VALUES ('manual','completed',now()) RETURNING id`).Scan(&runID))
title := "list me " + uniqSlug("l")
var fid string
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_findings (run_id, severity, kind, title) VALUES ($1,'high','bug',$2) RETURNING id`,
runID, title).Scan(&fid))
items, err := ListFindings(ctx, st, "open", "high", 100)
require.NoError(t, err)
seen := false
for _, it := range items {
if it.ID == fid {
seen = true
assert.False(t, it.RunStartedAt.IsZero())
assert.Equal(t, []string{}, it.Refs)
}
}
assert.True(t, seen, "new finding should appear in open/high list")
items, err = ListFindings(ctx, st, "dismissed", "", 100)
require.NoError(t, err)
for _, it := range items {
assert.NotEqual(t, fid, it.ID, "status filter must exclude open findings")
}
}
+208
View File
@@ -0,0 +1,208 @@
package audit
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
const (
gitLogMaxLines = 200
taskCtxLimit = 50
eventCtxLimit = 100
openFindLimit = 50
)
const reviewSystemPrompt = `你是个人研发仓库的审查员。基于给出的最近 git 提交、任务状态、事件流与已有 findings,
找出潜在的 bug、不完善处与未完成的工作。只输出一个纯 JSON 数组,不要输出任何其他文字,
每个元素形如 {"severity":"low|medium|high|critical","kind":"bug|gap|unfinished|doc-drift",
"title":"简短标题","detail":"markdown 详情","refs":["相关文件或任务 slug"]}。
不要重复已有 open findings 中已列出的问题。没有问题就输出 []。`
// llmReview gathers repo context, asks the model, and parses findings.
// Parse failures and transport errors are returned to the caller, which folds
// them into the run summary instead of failing the run.
func (r *Runner) llmReview(ctx context.Context, runID int64) ([]Finding, error) {
since, err := r.lastCompletedRunStart(ctx, runID)
if err != nil {
return nil, err
}
var sb strings.Builder
sb.WriteString("## 最近 git 提交(自上次审查以来)\n")
sb.WriteString(r.gitLog(ctx, since))
sb.WriteString("\n\n## 当前未完成任务\n")
if err := r.appendTaskSummary(ctx, &sb); err != nil {
return nil, err
}
sb.WriteString("\n\n## 最近事件(最新 100 条)\n")
if err := r.appendEventSummary(ctx, &sb); err != nil {
return nil, err
}
sb.WriteString("\n\n## 已有 open findings\n")
if err := r.appendOpenFindings(ctx, &sb); err != nil {
return nil, err
}
raw, err := r.chat.Chat(ctx, reviewSystemPrompt, sb.String())
if err != nil {
return nil, err
}
return parseFindingsJSON(raw)
}
// lastCompletedRunStart returns the start time of the most recent completed
// run before runID; with no prior run it falls back to 24h ago.
func (r *Runner) lastCompletedRunStart(ctx context.Context, runID int64) (time.Time, error) {
var t time.Time
err := r.st.Pool.QueryRow(ctx,
`SELECT started_at FROM cc_audit_runs
WHERE status='completed' AND id <> $1
ORDER BY id DESC LIMIT 1`, runID).Scan(&t)
if err != nil {
return time.Now().Add(-24 * time.Hour), nil
}
return t, nil
}
func (r *Runner) gitLog(ctx context.Context, since time.Time) string {
gitCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(gitCtx, "git", "-C", r.repoRoot, "log",
"--oneline", "--stat", "--since="+since.Format(time.RFC3339))
raw, err := cmd.Output()
if err != nil {
return "(git log 不可用)\n"
}
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
if len(lines) > gitLogMaxLines {
lines = append(lines[:gitLogMaxLines], fmt.Sprintf("…(截断,共 %d 行)", len(lines)))
}
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
return "(无提交)\n"
}
return strings.Join(lines, "\n") + "\n"
}
func (r *Runner) appendTaskSummary(ctx context.Context, sb *strings.Builder) error {
rows, err := r.st.Pool.Query(ctx,
`SELECT slug, title, status, stage, stage_state, assigned_agent
FROM cc_tasks WHERE status NOT IN ('done','cancelled')
ORDER BY updated_at DESC LIMIT $1`, taskCtxLimit)
if err != nil {
return err
}
defer rows.Close()
n := 0
for rows.Next() {
var slug, title, status, stage, state, agent string
if err := rows.Scan(&slug, &title, &status, &stage, &state, &agent); err != nil {
return err
}
fmt.Fprintf(sb, "- %s [%s/%s/%s agent=%s] %s\n", slug, status, stage, state, agent, title)
n++
}
if n == 0 {
sb.WriteString("(无)\n")
}
return rows.Err()
}
func (r *Runner) appendEventSummary(ctx context.Context, sb *strings.Builder) error {
rows, err := r.st.Pool.Query(ctx,
`SELECT t.slug, e.agent, e.phase, e.message, e.ts
FROM cc_task_events e JOIN cc_tasks t ON t.id = e.task_id
ORDER BY e.id DESC LIMIT $1`, eventCtxLimit)
if err != nil {
return err
}
defer rows.Close()
n := 0
for rows.Next() {
var slug, agent, phase, msg string
var ts time.Time
if err := rows.Scan(&slug, &agent, &phase, &msg, &ts); err != nil {
return err
}
msg = strings.ReplaceAll(msg, "\n", " ")
if len(msg) > 160 {
msg = msg[:160] + "…"
}
fmt.Fprintf(sb, "- %s %s %s %s: %s\n", ts.Format("01-02 15:04"), slug, agent, phase, msg)
n++
}
if n == 0 {
sb.WriteString("(无)\n")
}
return rows.Err()
}
func (r *Runner) appendOpenFindings(ctx context.Context, sb *strings.Builder) error {
rows, err := r.st.Pool.Query(ctx,
`SELECT severity, kind, title FROM cc_audit_findings
WHERE status='open' ORDER BY created_at DESC LIMIT $1`, openFindLimit)
if err != nil {
return err
}
defer rows.Close()
n := 0
for rows.Next() {
var sev, kind, title string
if err := rows.Scan(&sev, &kind, &title); err != nil {
return err
}
fmt.Fprintf(sb, "- [%s/%s] %s\n", sev, kind, title)
n++
}
if n == 0 {
sb.WriteString("(无)\n")
}
return rows.Err()
}
type llmFinding struct {
Severity string `json:"severity"`
Kind string `json:"kind"`
Title string `json:"title"`
Detail string `json:"detail"`
Refs []string `json:"refs"`
}
// parseFindingsJSON tolerates ```json fences and prose around the payload by
// extracting the span from the first '[' to the last ']'. Invalid severity or
// kind values fall back to medium / gap; entries without a title are dropped.
func parseFindingsJSON(raw string) ([]Finding, error) {
start := strings.Index(raw, "[")
end := strings.LastIndex(raw, "]")
if start < 0 || end < start {
return nil, fmt.Errorf("audit: no JSON array in model output (%d bytes)", len(raw))
}
var parsed []llmFinding
if err := json.Unmarshal([]byte(raw[start:end+1]), &parsed); err != nil {
return nil, fmt.Errorf("audit: decode findings: %w", err)
}
out := []Finding{}
for _, f := range parsed {
title := strings.TrimSpace(f.Title)
if title == "" {
continue
}
sev := strings.ToLower(strings.TrimSpace(f.Severity))
if !validSeverity[sev] {
sev = "medium"
}
kind := strings.ToLower(strings.TrimSpace(f.Kind))
if !validKind[kind] {
kind = "gap"
}
refs := f.Refs
if refs == nil {
refs = []string{}
}
out = append(out, Finding{Severity: sev, Kind: kind, Title: title, DetailMD: f.Detail, Refs: refs})
}
return out, nil
}
+176
View File
@@ -0,0 +1,176 @@
package audit
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"chat-agent-system/internal/controlcenter/store"
)
// RunInfo is one row of cc_audit_runs plus its finding count.
type RunInfo struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Status string `json:"status"`
Summary string `json:"summary"`
Error string `json:"error"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Findings int `json:"findings"`
}
func ListRuns(ctx context.Context, st *store.Store, limit int) ([]RunInfo, error) {
if limit <= 0 || limit > 200 {
limit = 20
}
rows, err := st.Pool.Query(ctx,
`SELECT r.id, r.kind, r.status, r.summary, r.error, r.started_at, r.finished_at,
(SELECT count(*) FROM cc_audit_findings f WHERE f.run_id = r.id) AS findings
FROM cc_audit_runs r ORDER BY r.id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []RunInfo{}
for rows.Next() {
var ri RunInfo
if err := rows.Scan(&ri.ID, &ri.Kind, &ri.Status, &ri.Summary, &ri.Error,
&ri.StartedAt, &ri.FinishedAt, &ri.Findings); err != nil {
return nil, err
}
out = append(out, ri)
}
return out, rows.Err()
}
// FindingItem extends Finding with the run's start time for display.
type FindingItem struct {
Finding
RunStartedAt time.Time `json:"run_started_at"`
}
func ListFindings(ctx context.Context, st *store.Store, status, severity string, limit int) ([]FindingItem, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
q := `SELECT f.id, f.run_id, f.severity, f.kind, f.title, f.detail_md, f.refs,
f.status, f.task_id, f.created_at, r.started_at
FROM cc_audit_findings f JOIN cc_audit_runs r ON r.id = f.run_id
WHERE true`
args := []any{}
if status != "" {
args = append(args, status)
q += fmt.Sprintf(` AND f.status = $%d`, len(args))
}
if severity != "" {
args = append(args, severity)
q += fmt.Sprintf(` AND f.severity = $%d`, len(args))
}
args = append(args, limit)
q += fmt.Sprintf(` ORDER BY f.created_at DESC LIMIT $%d`, len(args))
rows, err := st.Pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []FindingItem{}
for rows.Next() {
var fi FindingItem
var refsRaw json.RawMessage
if err := rows.Scan(&fi.ID, &fi.RunID, &fi.Severity, &fi.Kind, &fi.Title,
&fi.DetailMD, &refsRaw, &fi.Status, &fi.TaskID, &fi.CreatedAt, &fi.RunStartedAt); err != nil {
return nil, err
}
if err := json.Unmarshal(refsRaw, &fi.Refs); err != nil {
fi.Refs = []string{}
}
out = append(out, fi)
}
return out, rows.Err()
}
// ConvertFinding creates a proposal task from an open finding and marks the
// finding converted. Returns the updated finding.
func ConvertFinding(ctx context.Context, st *store.Store, findingID string) (*FindingItem, error) {
tx, err := st.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var fi FindingItem
var refsRaw json.RawMessage
err = tx.QueryRow(ctx,
`SELECT id, run_id, severity, kind, title, detail_md, refs, status, task_id, created_at
FROM cc_audit_findings WHERE id = $1 FOR UPDATE`, findingID).
Scan(&fi.ID, &fi.RunID, &fi.Severity, &fi.Kind, &fi.Title, &fi.DetailMD,
&refsRaw, &fi.Status, &fi.TaskID, &fi.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if fi.Status != "open" {
return nil, fmt.Errorf("%w: finding is %s", ErrBadState, fi.Status)
}
if err := json.Unmarshal(refsRaw, &fi.Refs); err != nil {
fi.Refs = []string{}
}
var sb strings.Builder
sb.WriteString(fi.DetailMD)
if len(fi.Refs) > 0 {
sb.WriteString("\n\n## 引用\n")
for _, ref := range fi.Refs {
sb.WriteString("- " + ref + "\n")
}
}
slug := "audit-" + strings.ReplaceAll(fi.ID, "-", "")[:12]
var taskID string
if err := tx.QueryRow(ctx,
`INSERT INTO cc_tasks (slug, title, kind, instruction_md)
VALUES ($1, $2, 'proposal', $3) RETURNING id`,
slug, "[audit] "+fi.Title, sb.String()).Scan(&taskID); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx,
`UPDATE cc_audit_findings SET status='converted', task_id=$2 WHERE id=$1`,
findingID, taskID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
fi.Status = "converted"
fi.TaskID = &taskID
return &fi, nil
}
// DismissFinding marks an open finding dismissed.
func DismissFinding(ctx context.Context, st *store.Store, findingID string) error {
tag, err := st.Pool.Exec(ctx,
`UPDATE cc_audit_findings SET status='dismissed' WHERE id=$1 AND status='open'`, findingID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
var exists int
if err := st.Pool.QueryRow(ctx,
`SELECT count(*) FROM cc_audit_findings WHERE id=$1`, findingID).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return ErrNotFound
}
return fmt.Errorf("%w: finding is not open", ErrBadState)
}
return nil
}
+141
View File
@@ -0,0 +1,141 @@
// Package llm is a thin OpenAI-compatible chat-completions client used by the
// Control Center audit reviewer. Self-contained: modeled on
// internal/controlcenter/embed but for /chat/completions. API keys are never
// logged.
package llm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"unicode/utf8"
)
const httpTimeout = 180 * time.Second
// ErrNotConfigured is returned when the endpoint or model is missing; callers
// should degrade gracefully (skip the LLM stage) rather than fail.
var ErrNotConfigured = errors.New("llm: endpoint or model not configured")
// ChatClient is the minimal surface the audit runner needs.
type ChatClient interface {
Chat(ctx context.Context, system, user string) (string, error)
}
// HTTPClient talks to an OpenAI-compatible /chat/completions endpoint.
type HTTPClient struct {
endpoint string
apiKey string
model string
http *http.Client
}
// NewHTTP builds a client; endpoint may already include /v1 (or the full
// /chat/completions suffix). An empty endpoint or model yields a client whose
// Chat always returns ErrNotConfigured.
func NewHTTP(endpoint, apiKey, model string) *HTTPClient {
return &HTTPClient{
endpoint: normalizeEndpoint(endpoint),
apiKey: apiKey,
model: model,
http: &http.Client{Timeout: httpTimeout},
}
}
// Configured reports whether Chat can be attempted.
func (c *HTTPClient) Configured() bool {
return c.endpoint != "" && c.model != ""
}
// Chat sends a system+user pair and returns the assistant message content.
// 5xx and network errors are retried once; 4xx is returned immediately.
func (c *HTTPClient) Chat(ctx context.Context, system, user string) (string, error) {
if !c.Configured() {
return "", ErrNotConfigured
}
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
content, retryable, err := c.post(ctx, system, user)
if err == nil {
return content, nil
}
if !retryable {
return "", err
}
lastErr = err
}
return "", lastErr
}
func (c *HTTPClient) post(ctx context.Context, system, user string) (string, bool, error) {
body, _ := json.Marshal(map[string]any{
"model": c.model,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"temperature": 0.2,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return "", false, err
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.http.Do(req)
if err != nil {
return "", true, fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode >= 300 {
retryable := resp.StatusCode >= 500
return "", retryable, fmt.Errorf("llm http %d: %s", resp.StatusCode, truncateUTF8(strings.TrimSpace(string(raw)), 300))
}
var parsed struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", false, fmt.Errorf("llm decode: %w", err)
}
if len(parsed.Choices) == 0 {
return "", false, fmt.Errorf("llm: empty choices")
}
return parsed.Choices[0].Message.Content, false, nil
}
func normalizeEndpoint(endpoint string) string {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
if endpoint == "" {
return ""
}
if strings.HasSuffix(endpoint, "/chat/completions") {
return endpoint
}
if strings.HasSuffix(endpoint, "/v1") || strings.Contains(endpoint, "/v1/") {
return endpoint + "/chat/completions"
}
return endpoint + "/v1/chat/completions"
}
func truncateUTF8(s string, n int) string {
if n <= 0 || len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
+99
View File
@@ -0,0 +1,99 @@
package llm
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEndpointNormalization(t *testing.T) {
cases := map[string]string{
"": "",
"http://x": "http://x/v1/chat/completions",
"http://x/": "http://x/v1/chat/completions",
"http://x/v1": "http://x/v1/chat/completions",
"http://x/v1/": "http://x/v1/chat/completions",
"http://x/v1/chat/completions": "http://x/v1/chat/completions",
"http://x/proxy/v1": "http://x/proxy/v1/chat/completions",
}
for in, want := range cases {
assert.Equal(t, want, normalizeEndpoint(in), "input %q", in)
}
}
func TestNotConfigured(t *testing.T) {
_, err := NewHTTP("", "k", "m").Chat(context.Background(), "s", "u")
require.ErrorIs(t, err, ErrNotConfigured)
_, err = NewHTTP("http://x", "k", "").Chat(context.Background(), "s", "u")
require.ErrorIs(t, err, ErrNotConfigured)
assert.False(t, NewHTTP("", "k", "m").Configured())
assert.True(t, NewHTTP("http://x", "k", "m").Configured())
}
func TestChatSuccessAndKeyNotLogged(t *testing.T) {
const key = "sk-secret-never-log"
var gotAuth, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/chat/completions", r.URL.Path)
gotAuth = r.Header.Get("Authorization")
raw, _ := io.ReadAll(r.Body)
gotBody = string(raw)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"hello"}}]}`))
}))
defer srv.Close()
c := NewHTTP(srv.URL, key, "m1")
out, err := c.Chat(context.Background(), "sys", "usr")
require.NoError(t, err)
assert.Equal(t, "hello", out)
assert.Equal(t, "Bearer "+key, gotAuth)
assert.Contains(t, gotBody, `"temperature":0.2`)
assert.Contains(t, gotBody, `"model":"m1"`)
// Error paths must never echo the key.
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream died", http.StatusBadGateway)
}))
defer failSrv.Close()
_, err = NewHTTP(failSrv.URL, key, "m1").Chat(context.Background(), "s", "u")
require.Error(t, err)
assert.False(t, strings.Contains(err.Error(), key), "error leaks api key: %v", err)
}
func TestChatRetryPolicy(t *testing.T) {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if n == 1 {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok after retry"}}]}`))
}))
defer srv.Close()
out, err := NewHTTP(srv.URL, "", "m").Chat(context.Background(), "s", "u")
require.NoError(t, err)
assert.Equal(t, "ok after retry", out)
assert.EqualValues(t, 2, calls, "5xx retried once")
// 4xx: no retry.
atomic.StoreInt32(&calls, 0)
srv4 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
http.Error(w, "bad request", http.StatusBadRequest)
}))
defer srv4.Close()
_, err = NewHTTP(srv4.URL, "", "m").Chat(context.Background(), "s", "u")
require.Error(t, err)
assert.EqualValues(t, 1, calls, "4xx not retried")
assert.True(t, strings.Contains(err.Error(), "400"))
assert.NotErrorIs(t, err, ErrNotConfigured)
}
@@ -0,0 +1,83 @@
package server
import (
"errors"
"log/slog"
"net/http"
"strconv"
"chat-agent-system/internal/controlcenter/audit"
)
// SetAudit attaches the audit runner used by the /api/v1/audits routes.
func (s *Server) SetAudit(r *audit.Runner) { s.audit = r }
func (s *Server) handleAuditRunList(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
out, err := audit.ListRuns(r.Context(), s.store, limit)
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleAuditFindingList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
out, err := audit.ListFindings(r.Context(), s.store, q.Get("status"), q.Get("severity"), limit)
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleAuditRun triggers a manual run in the background and returns the run
// id immediately; a second trigger while one is running gets 409.
func (s *Server) handleAuditRun(w http.ResponseWriter, r *http.Request) {
if s.audit == nil {
writeError(w, http.StatusServiceUnavailable, "audit runner not configured")
return
}
runID, err := audit.StartManual(r.Context(), s.audit)
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"run_id": runID})
}
func (s *Server) handleAuditFindingConvert(w http.ResponseWriter, r *http.Request) {
fi, err := audit.ConvertFinding(r.Context(), s.store, r.PathValue("id"))
if err != nil {
writeAuditError(w, err)
return
}
slog.Info("audit finding converted", "finding", fi.ID, "task", fi.TaskID)
writeJSON(w, http.StatusOK, fi)
}
func (s *Server) handleAuditFindingDismiss(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := audit.DismissFinding(r.Context(), s.store, id); err != nil {
writeAuditError(w, err)
return
}
slog.Info("audit finding dismissed", "finding", id)
writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"})
}
func writeAuditError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, audit.ErrAlreadyRunning):
writeError(w, http.StatusConflict, err.Error())
case errors.Is(err, audit.ErrNotFound):
writeError(w, http.StatusNotFound, err.Error())
case errors.Is(err, audit.ErrBadState):
writeError(w, http.StatusConflict, err.Error())
default:
slog.Error("audit handler", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
}
+159
View File
@@ -0,0 +1,159 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/audit"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/store"
)
// newAuditTestServer builds a console server with an audit runner attached.
// The runner points at the real repo root so git checks behave like
// production (a temp dir would flag every declared branch as missing).
func newAuditTestServer(t *testing.T) (*httptest.Server, *store.Store) {
t.Helper()
ts, st := newTestServer(t)
ts.Close()
_, thisFile, _, _ := runtime.Caller(0)
repoRoot := filepath.Join(filepath.Dir(thisFile), "..", "..", "..")
srv := New(config.Config{ConsoleToken: testConsoleToken}, st)
srv.SetAudit(audit.New(st, repoRoot, nil))
ts = httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, st
}
func decodeArray(t *testing.T, raw []byte) []map[string]any {
t.Helper()
var m []map[string]any
require.NoError(t, json.Unmarshal(raw, &m))
return m
}
func TestAuditEndpoints(t *testing.T) {
ts, _ := newAuditTestServer(t)
// List endpoints work before any run.
code, raw := doReq(t, "GET", ts.URL+"/api/v1/audits/runs?limit=5", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
code, raw = doReq(t, "GET", ts.URL+"/api/v1/audits/findings?status=open", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
// Manual trigger returns a run id immediately. A parallel test binary
// sharing the dev DB may briefly hold the single-running guard: retry 409s.
var runID float64
triggerDeadline := time.Now().Add(30 * time.Second)
for {
code, raw = doReq(t, "POST", ts.URL+"/api/v1/audits/run", testConsoleToken, nil)
if code == http.StatusAccepted {
runID = decodeBody(t, raw)["run_id"].(float64)
break
}
require.Equal(t, http.StatusConflict, code, string(raw))
require.False(t, time.Now().After(triggerDeadline), "run never accepted")
time.Sleep(200 * time.Millisecond)
}
require.Greater(t, runID, float64(0))
// The run finishes (repo root is the real repo, so git checks work).
deadline := time.Now().Add(15 * time.Second)
for {
code, raw = doReq(t, "GET", ts.URL+"/api/v1/audits/runs?limit=20", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
var ours map[string]any
for _, r := range decodeArray(t, raw) {
if r["id"] == runID {
ours = r
}
}
require.NotNil(t, ours, "our run must appear in history")
if ours["status"] != "running" {
assert.Equal(t, "completed", ours["status"], "run error: %v", ours["error"])
break
}
require.False(t, time.Now().After(deadline), "run never finished")
time.Sleep(100 * time.Millisecond)
}
}
func TestAuditRunConflict(t *testing.T) {
ts, st := newAuditTestServer(t)
// A pre-existing running row makes the manual trigger 409.
var busyID int64
require.NoError(t, st.Pool.QueryRow(t.Context(),
`INSERT INTO cc_audit_runs (kind, status) VALUES ('manual','running') RETURNING id`).Scan(&busyID))
t.Cleanup(func() {
// Delete only this test's row; a parallel package may hold its own run.
_, _ = st.Pool.Exec(context.Background(),
`DELETE FROM cc_audit_runs WHERE id=$1`, busyID)
})
code, _ := doReq(t, "POST", ts.URL+"/api/v1/audits/run", testConsoleToken, nil)
assert.Equal(t, http.StatusConflict, code)
}
func TestAuditFindingConvertDismissEndpoints(t *testing.T) {
ts, st := newTestServer(t)
var runID int64
require.NoError(t, st.Pool.QueryRow(t.Context(),
`INSERT INTO cc_audit_runs (kind, status, finished_at) VALUES ('manual','completed',now()) RETURNING id`).Scan(&runID))
mk := func(title string) string {
var id string
require.NoError(t, st.Pool.QueryRow(t.Context(),
`INSERT INTO cc_audit_findings (run_id, severity, kind, title, detail_md, refs)
VALUES ($1,'high','bug',$2,'细节','["r1"]'::jsonb) RETURNING id`, runID, title).Scan(&id))
return id
}
// Convert creates a proposal task and flips the finding.
fid := mk("endpoint convert " + slug("c"))
code, raw := doReq(t, "POST", ts.URL+"/api/v1/audits/findings/"+fid+"/convert", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, "converted", m["status"])
require.NotNil(t, m["task_id"])
code, _ = doReq(t, "POST", ts.URL+"/api/v1/audits/findings/"+fid+"/convert", testConsoleToken, nil)
assert.Equal(t, http.StatusConflict, code, "converting twice must conflict")
// Dismiss.
fid2 := mk("endpoint dismiss " + slug("d"))
code, raw = doReq(t, "POST", ts.URL+"/api/v1/audits/findings/"+fid2+"/dismiss", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
code, _ = doReq(t, "POST", ts.URL+"/api/v1/audits/findings/"+fid2+"/dismiss", testConsoleToken, nil)
assert.Equal(t, http.StatusConflict, code)
// Unknown finding 404s.
code, _ = doReq(t, "POST", ts.URL+"/api/v1/audits/findings/00000000-0000-0000-0000-000000000000/dismiss", testConsoleToken, nil)
assert.Equal(t, http.StatusNotFound, code)
// Converted/dismissed findings show up under their status filter.
code, raw = doReq(t, "GET", ts.URL+"/api/v1/audits/findings?status=converted&limit=50", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
found := false
for _, it := range decodeArray(t, raw) {
if it["id"] == fid {
found = true
}
}
assert.True(t, found, "converted finding must appear in converted filter")
}
// Without an attached runner the manual trigger reports 503.
func TestAuditRunWithoutRunner(t *testing.T) {
ts, _ := newTestServer(t)
code, _ := doReq(t, "POST", ts.URL+"/api/v1/audits/run", testConsoleToken, nil)
assert.Equal(t, http.StatusServiceUnavailable, code)
}
+9 -2
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"chat-agent-system/internal/controlcenter/audit"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/docs"
"chat-agent-system/internal/controlcenter/embed"
@@ -37,8 +38,9 @@ type Server struct {
agents *AgentAuthenticator
mux *http.ServeMux
broker *Broker
emb embed.Embedder // nil = search degrades to keyword-only
docSync *docs.Syncer // nil = artifact indexing disabled
emb embed.Embedder // nil = search degrades to keyword-only
docSync *docs.Syncer // nil = artifact indexing disabled
audit *audit.Runner // nil = audit endpoints unavailable
}
func New(cfg config.Config, st *store.Store) *Server {
@@ -94,6 +96,11 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/v1/docs", s.handleDocList)
s.mux.HandleFunc("GET /api/v1/docs/search", s.handleDocSearch)
s.mux.HandleFunc("GET /api/v1/docs/{id}", s.handleDocGet)
s.mux.HandleFunc("GET /api/v1/audits/runs", s.handleAuditRunList)
s.mux.HandleFunc("GET /api/v1/audits/findings", s.handleAuditFindingList)
s.mux.HandleFunc("POST /api/v1/audits/run", s.handleAuditRun)
s.mux.HandleFunc("POST /api/v1/audits/findings/{id}/convert", s.handleAuditFindingConvert)
s.mux.HandleFunc("POST /api/v1/audits/findings/{id}/dismiss", s.handleAuditFindingDismiss)
// SPA (embedded build of control-center/web); auth middleware already
// passes non-/api paths through.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Control Center</title>
<script type="module" crossorigin src="/assets/index-COtJS61m.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D-QUwwcl.css">
<script type="module" crossorigin src="/assets/index-Dj6oWpHL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DyNgdwvP.css">
</head>
<body>
<div id="root"></div>