feat(control-center): P5 scheduled repo audits — deterministic checks, LLM review, findings UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user