First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
This commit is contained in:
+353
@@ -0,0 +1,353 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CommandRecord is a paired tool_use + tool_result from the activity table
|
||||
// (any tool, not just Bash). Command holds the raw tool input (JSON).
|
||||
type CommandRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
ExpID int64 `json:"exploration_id"`
|
||||
Worker string `json:"worker"`
|
||||
Tool string `json:"tool"`
|
||||
Command string `json:"command"`
|
||||
Output string `json:"output"`
|
||||
IsError bool `json:"is_error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// commandFilter builds the WHERE clause shared by the tool-execution list and
|
||||
// its per-tool tally, so the summary always describes exactly the rows the table
|
||||
// pages through. Returns the clause, its args, and the next placeholder index.
|
||||
func commandFilter(expID *int64, q string) (string, []any, int) {
|
||||
where := `WHERE u.kind = 'tool_use'`
|
||||
args := []any{}
|
||||
argN := 1
|
||||
|
||||
if expID != nil {
|
||||
where += fmt.Sprintf(` AND u.exploration_id = $%d`, argN)
|
||||
args = append(args, *expID)
|
||||
argN++
|
||||
}
|
||||
if q != "" {
|
||||
where += fmt.Sprintf(` AND (u.tool ILIKE $%d OR u.detail ILIKE $%d)`, argN, argN)
|
||||
args = append(args, "%"+q+"%")
|
||||
argN++
|
||||
}
|
||||
return where, args, argN
|
||||
}
|
||||
|
||||
// ToolStat is one tool's execution tally for the usage summary.
|
||||
type ToolStat struct {
|
||||
Tool string `json:"tool"`
|
||||
Total int `json:"total"`
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
|
||||
// ToolStats counts executions grouped by tool under the same filters
|
||||
// ListCommands takes. Unpaginated on purpose: the tally describes the whole
|
||||
// filtered set, not the page currently on screen.
|
||||
func (d *DB) ToolStats(expID *int64, q string) ([]ToolStat, error) {
|
||||
where, args, _ := commandFilter(expID, q)
|
||||
|
||||
rows, err := d.Query(`
|
||||
SELECT COALESCE(NULLIF(u.tool,''),'-') AS tool, COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE COALESCE(r.is_error,false)) AS errors
|
||||
FROM activity u
|
||||
LEFT JOIN activity r ON r.tool_use_id = u.tool_use_id AND r.kind = 'tool_result'
|
||||
`+where+`
|
||||
GROUP BY 1
|
||||
ORDER BY total DESC, tool ASC`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ToolStat{}
|
||||
for rows.Next() {
|
||||
var s ToolStat
|
||||
if err := rows.Scan(&s.Tool, &s.Total, &s.Errors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListCommands returns tool executions (tool_use + paired tool_result) across all
|
||||
// explorations, with optional filtering and pagination. Covers every tool, not
|
||||
// just Bash; q matches the tool name or its input.
|
||||
func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) {
|
||||
if size <= 0 {
|
||||
size = 50
|
||||
}
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
offset := page * size
|
||||
|
||||
where, args, argN := commandFilter(expID, q)
|
||||
|
||||
// count
|
||||
var total int
|
||||
countQ := `SELECT COUNT(*) FROM activity u ` + where
|
||||
if err := d.QueryRow(countQ, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// data query: join tool_use with its tool_result
|
||||
dataQ := `
|
||||
SELECT u.id, u.exploration_id, COALESCE(u.worker,''), COALESCE(u.tool,''), COALESCE(u.detail,''),
|
||||
COALESCE(r.detail,''), COALESCE(r.is_error, false), u.created_at
|
||||
FROM activity u
|
||||
LEFT JOIN activity r ON r.tool_use_id = u.tool_use_id AND r.kind = 'tool_result'
|
||||
` + where + `
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $` + fmt.Sprintf("%d", argN) + ` OFFSET $` + fmt.Sprintf("%d", argN+1)
|
||||
|
||||
args = append(args, size, offset)
|
||||
rows, err := d.Query(dataQ, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []CommandRecord{}
|
||||
for rows.Next() {
|
||||
var c CommandRecord
|
||||
if err := rows.Scan(&c.ID, &c.ExpID, &c.Worker, &c.Tool, &c.Command, &c.Output, &c.IsError, &c.CreatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// LLMRecord is one recorded LLM API call (request + response).
|
||||
type LLMRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
Ts time.Time `json:"ts"`
|
||||
Model string `json:"model"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
SessionID string `json:"session_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Worker string `json:"worker"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheRead int `json:"cache_read"`
|
||||
CacheWrite int `json:"cache_write"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RequestBody string `json:"request_body,omitempty"`
|
||||
ResponseBody string `json:"response_body,omitempty"`
|
||||
// RawRequest / RawResponse are the untouched HTTP bodies exchanged with the
|
||||
// provider — the request as buildBody() sent it (full tool schemas included)
|
||||
// and the raw SSE frames. RequestBody/ResponseBody above are the normalized
|
||||
// view, which drops tool schemas and tool_use blocks entirely. Empty for
|
||||
// records written before this was added, or when the call never reached HTTP.
|
||||
RawRequest string `json:"raw_request,omitempty"`
|
||||
RawResponse string `json:"raw_response,omitempty"`
|
||||
}
|
||||
|
||||
const llmRecordsSchema = `
|
||||
CREATE TABLE IF NOT EXISTS llm_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ DEFAULT now(),
|
||||
model TEXT,
|
||||
profile_name TEXT,
|
||||
session_id TEXT,
|
||||
task_id TEXT,
|
||||
worker TEXT,
|
||||
latency_ms INTEGER,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
cache_read INTEGER,
|
||||
cache_write INTEGER,
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
request_body TEXT,
|
||||
response_body TEXT,
|
||||
raw_request TEXT,
|
||||
raw_response TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_records_ts ON llm_records(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_records_session ON llm_records(session_id);
|
||||
`
|
||||
|
||||
// llmRecordsMigrate adds new columns to existing tables.
|
||||
const llmRecordsMigrate = `
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS task_id TEXT;
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS worker TEXT;
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS profile_name TEXT;
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_request TEXT;
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_response TEXT;
|
||||
`
|
||||
|
||||
// EnsureLLMRecordsTable creates the llm_records table if it does not exist.
|
||||
func (d *DB) EnsureLLMRecordsTable() error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
if err := coordinateWithSchemaMigration(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(llmRecordsSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(llmRecordsMigrate); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// InsertLLMRecord stores one LLM call record.
|
||||
func (d *DB) InsertLLMRecord(r *LLMRecord) error {
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body, raw_request, raw_response)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
|
||||
r.Model, nullIfEmpty(r.ProfileName), r.SessionID, nullIfEmpty(r.TaskID), nullIfEmpty(r.Worker),
|
||||
r.LatencyMs, r.InputTokens, r.OutputTokens, r.CacheRead, r.CacheWrite,
|
||||
r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody),
|
||||
nullIfEmpty(r.RawRequest), nullIfEmpty(r.RawResponse))
|
||||
return err
|
||||
}
|
||||
|
||||
// ListLLMRecords returns paginated LLM records with optional filters.
|
||||
func (d *DB) ListLLMRecords(model, session, task string, page, size int) ([]LLMRecord, int, error) {
|
||||
if size <= 0 {
|
||||
size = 50
|
||||
}
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
offset := page * size
|
||||
|
||||
where := `WHERE true`
|
||||
args := []any{}
|
||||
argN := 1
|
||||
if model != "" {
|
||||
where += fmt.Sprintf(` AND model = $%d`, argN)
|
||||
args = append(args, model)
|
||||
argN++
|
||||
}
|
||||
if session != "" {
|
||||
where += fmt.Sprintf(` AND session_id ILIKE $%d`, argN)
|
||||
args = append(args, "%"+session+"%")
|
||||
argN++
|
||||
}
|
||||
if task != "" {
|
||||
where += fmt.Sprintf(` AND COALESCE(task_id,'') = $%d`, argN)
|
||||
args = append(args, task)
|
||||
argN++
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := d.QueryRow(`SELECT COUNT(*) FROM llm_records `+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
dataQ := `SELECT id, ts, COALESCE(model,''), COALESCE(profile_name,''), COALESCE(session_id,''), COALESCE(task_id,''), COALESCE(worker,''),
|
||||
COALESCE(latency_ms,0), COALESCE(input_tokens,0), COALESCE(output_tokens,0), COALESCE(cache_read,0), COALESCE(cache_write,0),
|
||||
COALESCE(status,''), COALESCE(error,'')
|
||||
FROM llm_records ` + where + ` ORDER BY id DESC LIMIT $` + fmt.Sprintf("%d", argN) + ` OFFSET $` + fmt.Sprintf("%d", argN+1)
|
||||
args = append(args, size, offset)
|
||||
|
||||
rows, err := d.Query(dataQ, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []LLMRecord{}
|
||||
for rows.Next() {
|
||||
var r LLMRecord
|
||||
if err := rows.Scan(&r.ID, &r.Ts, &r.Model, &r.ProfileName, &r.SessionID, &r.TaskID, &r.Worker, &r.LatencyMs,
|
||||
&r.InputTokens, &r.OutputTokens, &r.CacheRead, &r.CacheWrite, &r.Status, &r.Error); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// ModelTokenStat is one model's aggregated token usage for a task, summed from the
|
||||
// llm_usage metering ledger (see db/llm_usage.go). Calls is the number of LLM calls
|
||||
// that hit this model.
|
||||
type ModelTokenStat struct {
|
||||
Model string `json:"model"`
|
||||
Calls int `json:"calls"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheReadTokens int `json:"cache_read_tokens"`
|
||||
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||
}
|
||||
|
||||
// LLMTask is one distinct task with its LLM-record count.
|
||||
type LLMTask struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// LLMTasks returns distinct non-empty task_ids with record counts, most recent
|
||||
// first — powers the LLM-records page's task picker.
|
||||
func (d *DB) LLMTasks() ([]LLMTask, error) {
|
||||
rows, err := d.Query(`SELECT task_id, COUNT(*) AS n FROM llm_records
|
||||
WHERE COALESCE(task_id,'') <> '' GROUP BY task_id ORDER BY MAX(id) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []LLMTask
|
||||
for rows.Next() {
|
||||
var t LLMTask
|
||||
if err := rows.Scan(&t.TaskID, &t.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteLLMRecords removes every LLM record for one exact task_id — the same
|
||||
// match the page's task picker/filter uses. Returns rows deleted.
|
||||
func (d *DB) DeleteLLMRecords(task string) (int64, error) {
|
||||
res, err := d.Exec(`DELETE FROM llm_records WHERE COALESCE(task_id,'') = $1`, task)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// GetLLMRecord returns a single LLM record with full request/response bodies.
|
||||
func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) {
|
||||
var r LLMRecord
|
||||
var reqBody, respBody, rawReq, rawResp sql.NullString
|
||||
err := d.QueryRow(`SELECT id, ts, COALESCE(model,''), COALESCE(profile_name,''), COALESCE(session_id,''), COALESCE(task_id,''), COALESCE(worker,''),
|
||||
COALESCE(latency_ms,0), COALESCE(input_tokens,0), COALESCE(output_tokens,0), COALESCE(cache_read,0), COALESCE(cache_write,0),
|
||||
COALESCE(status,''), COALESCE(error,''), request_body, response_body, raw_request, raw_response
|
||||
FROM llm_records WHERE id=$1`, id).
|
||||
Scan(&r.ID, &r.Ts, &r.Model, &r.ProfileName, &r.SessionID, &r.TaskID, &r.Worker, &r.LatencyMs,
|
||||
&r.InputTokens, &r.OutputTokens, &r.CacheRead, &r.CacheWrite, &r.Status, &r.Error,
|
||||
&reqBody, &respBody, &rawReq, &rawResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.RequestBody = reqBody.String
|
||||
r.ResponseBody = respBody.String
|
||||
r.RawRequest = rawReq.String
|
||||
r.RawResponse = rawResp.String
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user