Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

687 lines
17 KiB
Go

package postgres
import (
"context"
"database/sql"
"fmt"
"io"
"strings"
"time"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
)
// Compile-time check: *Store satisfies db.Store.
var _ db.Store = (*Store)(nil)
// NewStore opens a PostgreSQL connection using the shared Open()
// helper and returns a Store.
// When allowInsecure is true, non-loopback connections without
// TLS produce a warning instead of failing.
func NewStore(
pgURL, schema string, allowInsecure bool,
) (*Store, error) {
pg, err := Open(pgURL, schema, allowInsecure)
if err != nil {
return nil, err
}
return &Store{pg: pg}, nil
}
// DB returns the underlying *sql.DB for operations that need
// direct access (e.g. schema compatibility checks).
func (s *Store) DB() *sql.DB { return s.pg }
// Close closes the underlying database connection.
func (s *Store) Close() error {
return s.pg.Close()
}
func (s *Store) SetCustomPricing(p map[string]config.CustomModelRate) {
s.pricingMu.Lock()
defer s.pricingMu.Unlock()
s.customPricing = p
s.forgetPricingLoad()
}
// SetCursorSecret sets the HMAC key used for cursor signing.
func (s *Store) SetCursorSecret(secret []byte) {
s.cursorMu.Lock()
defer s.cursorMu.Unlock()
s.cursorSecret = append([]byte(nil), secret...)
}
// ReadOnly returns true because PG serve still treats the remote
// session store as remote; local file, upload, and batch-ingest
// paths stay blocked while dashboard curation uses dedicated methods.
func (s *Store) ReadOnly() bool { return true }
// InsightGenerationAvailable reports whether startup proved this PG role can
// persist generated insights.
func (s *Store) InsightGenerationAvailable() bool {
s.insightCapabilityMu.RLock()
defer s.insightCapabilityMu.RUnlock()
return s.insightGenerationAvailable
}
func (s *Store) setInsightGenerationAvailable(available bool) {
s.insightCapabilityMu.Lock()
defer s.insightCapabilityMu.Unlock()
s.insightGenerationAvailable = available
}
// DetectInsightGenerationAvailability probes whether this PG connection can
// insert into insights. PG serve uses it to expose generate routes only when
// the configured role can actually persist the result.
func (s *Store) DetectInsightGenerationAvailability(
ctx context.Context,
) error {
tx, err := s.pg.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf(
"beginning insight generation capability probe: %w",
err,
)
}
defer func() { _ = tx.Rollback() }()
available, err := probeInsightGenerationAvailabilityTx(
ctx, tx,
)
if err != nil {
return err
}
s.setInsightGenerationAvailable(available)
return nil
}
func probeInsightGenerationAvailabilityTx(
ctx context.Context, tx *sql.Tx,
) (bool, error) {
var id int64
if err := tx.QueryRowContext(ctx,
`INSERT INTO insights DEFAULT VALUES RETURNING id`,
).Scan(&id); err != nil {
if IsReadOnlyError(err) {
return false, nil
}
return false, fmt.Errorf(
"probing insight generation capability: %w", err,
)
}
return true, nil
}
// GetSessionVersion returns the message count and a compact version
// marker for SSE change detection.
func (s *Store) GetSessionVersion(
id string,
) (int, int64, bool) {
var count int
var updatedAt time.Time
err := s.pg.QueryRow(
`SELECT message_count, COALESCE(updated_at, created_at)
FROM sessions WHERE id = $1`,
id,
).Scan(&count, &updatedAt)
if err != nil {
return 0, 0, false
}
return count, db.SessionVersionMarker(FormatISO8601(updatedAt)), true
}
// ------------------------------------------------------------
// Unsupported write stubs (return db.ErrReadOnly)
// ------------------------------------------------------------
const maxPGInsights = 500
type pgInsightRowScanner interface {
Scan(...any) error
}
func scanPGInsight(rs pgInsightRowScanner) (db.Insight, error) {
var s db.Insight
var project, model, prompt sql.NullString
var createdAt time.Time
err := rs.Scan(
&s.ID, &s.Type, &s.DateFrom, &s.DateTo,
&project, &s.Agent, &model, &prompt, &s.Content,
&s.Kind, &s.SchemaVersion, &s.TemplateID,
&s.TemplateVersion, &s.AggregateHash, &s.CacheKey,
&s.CacheStatus, &s.ProvenanceJSON, &s.StructuredJSON,
&createdAt,
)
if err != nil {
return s, err
}
if project.Valid {
s.Project = &project.String
}
if model.Valid {
s.Model = &model.String
}
if prompt.Valid {
s.Prompt = &prompt.String
}
s.CreatedAt = FormatISO8601(createdAt)
return s, nil
}
func mapPGWriteError(action string, err error) error {
if err == nil {
return nil
}
if IsReadOnlyError(err) {
return fmt.Errorf("%s: %w: %w", action, db.ErrReadOnly, err)
}
return fmt.Errorf("%s: %w", action, err)
}
func buildPGInsightFilter(
f db.InsightFilter, pb *paramBuilder,
) string {
var preds []string
add := func(expr string, val any) {
preds = append(preds, expr+" = "+pb.add(val))
}
if f.Type != "" {
add("type", f.Type)
}
if f.GlobalOnly {
preds = append(preds, "project IS NULL")
} else if f.Project != "" {
add("project", f.Project)
}
if f.DateFrom != "" {
preds = append(preds, "date_from >= "+pb.add(f.DateFrom))
}
if f.DateTo != "" {
preds = append(preds, "date_to <= "+pb.add(f.DateTo))
}
if len(preds) == 0 {
return "TRUE"
}
return strings.Join(preds, " AND ")
}
// InsertInsight stores a dashboard insight in PG.
func (s *Store) InsertInsight(insight db.Insight) (int64, error) {
var id int64
err := s.pg.QueryRow(
`INSERT INTO insights (
type, date_from, date_to, project,
agent, model, prompt, content,
kind, schema_version, template_id,
template_version, aggregate_hash, cache_key,
cache_status, provenance_json, structured_json
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8,
$9, $10, $11,
$12, $13, $14,
$15, $16, $17
) RETURNING id`,
insight.Type, insight.DateFrom, insight.DateTo, insight.Project,
insight.Agent, insight.Model, insight.Prompt, insight.Content,
insight.Kind, insight.SchemaVersion, insight.TemplateID,
insight.TemplateVersion, insight.AggregateHash, insight.CacheKey,
insight.CacheStatus, insight.ProvenanceJSON, insight.StructuredJSON,
).Scan(&id)
if err != nil {
return 0, mapPGWriteError("inserting insight", err)
}
return id, nil
}
// DeleteInsight removes a dashboard insight from PG.
func (s *Store) DeleteInsight(id int64) error {
_, err := s.pg.Exec(
"DELETE FROM insights WHERE id = $1",
id,
)
if err != nil {
return mapPGWriteError(fmt.Sprintf("deleting insight %d", id), err)
}
return nil
}
// ListInsights returns dashboard insights in created_at order.
func (s *Store) ListInsights(
ctx context.Context, f db.InsightFilter,
) ([]db.Insight, error) {
pb := &paramBuilder{}
where := buildPGInsightFilter(f, pb)
rows, err := s.pg.QueryContext(ctx,
`SELECT id, type, date_from, date_to,
project, agent, model, prompt, content,
kind, schema_version, template_id,
template_version, aggregate_hash, cache_key,
cache_status, provenance_json, structured_json,
created_at
FROM insights
WHERE `+where+`
ORDER BY created_at DESC, id DESC
LIMIT `+fmt.Sprintf("%d", maxPGInsights),
pb.args...,
)
if err != nil {
return nil, fmt.Errorf("querying insights: %w", err)
}
defer rows.Close()
insights := make([]db.Insight, 0)
for rows.Next() {
row, err := scanPGInsight(rows)
if err != nil {
return nil, fmt.Errorf("scanning insight: %w", err)
}
insights = append(insights, row)
}
return insights, rows.Err()
}
// GetInsight returns a single insight by ID.
func (s *Store) GetInsight(
ctx context.Context, id int64,
) (*db.Insight, error) {
row := s.pg.QueryRowContext(ctx,
`SELECT id, type, date_from, date_to,
project, agent, model, prompt, content,
kind, schema_version, template_id,
template_version, aggregate_hash, cache_key,
cache_status, provenance_json, structured_json,
created_at
FROM insights
WHERE id = $1`,
id,
)
insight, err := scanPGInsight(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting insight %d: %w", id, err)
}
return &insight, nil
}
// GetCachedInsight returns the newest insight with the cache key.
func (s *Store) GetCachedInsight(
ctx context.Context, cacheKey string,
) (*db.Insight, error) {
if strings.TrimSpace(cacheKey) == "" {
return nil, nil
}
row := s.pg.QueryRowContext(ctx,
`SELECT id, type, date_from, date_to,
project, agent, model, prompt, content,
kind, schema_version, template_id,
template_version, aggregate_hash, cache_key,
cache_status, provenance_json, structured_json,
created_at
FROM insights
WHERE cache_key = $1
ORDER BY created_at DESC, id DESC
LIMIT 1`,
cacheKey,
)
insight, err := scanPGInsight(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting cached insight: %w", err)
}
return &insight, nil
}
func (s *Store) ListRecallEntries(
_ context.Context, _ db.RecallQuery,
) ([]db.RecallEntry, error) {
return nil, db.ErrReadOnly
}
func (s *Store) GetRecallEntry(
_ context.Context, _ string,
) (*db.RecallEntry, error) {
return nil, db.ErrReadOnly
}
func (s *Store) QueryRecallEntries(
_ context.Context, _ db.RecallQuery,
) (db.RecallPage, error) {
return db.RecallPage{}, db.ErrReadOnly
}
func (s *Store) RecordRecallQueryEvent(
_ context.Context, _ db.RecallQueryEvent,
) (string, error) {
return "", db.ErrReadOnly
}
func (s *Store) InsertRecallEntry(_ db.RecallEntry) (string, error) {
return "", db.ErrReadOnly
}
func (s *Store) ImportAcceptedRecallEntriesJSONL(
_ context.Context, _ io.Reader,
) (db.RecallImportResult, error) {
return db.RecallImportResult{}, db.ErrReadOnly
}
func (s *Store) ImportAcceptedRecallEntriesJSONLWithOptions(
_ context.Context, _ io.Reader, _ db.RecallImportOptions,
) (db.RecallImportResult, error) {
return db.RecallImportResult{}, db.ErrReadOnly
}
func (s *Store) IngestEvalTrajectory(
_ context.Context, _ db.EvalTrajectoryIngest,
) (db.EvalTrajectoryIngestResult, error) {
return db.EvalTrajectoryIngestResult{}, db.ErrReadOnly
}
// RenameSession updates the visible session name in PG.
func (s *Store) RenameSession(
id string, displayName *string,
) error {
_, err := s.pg.Exec(
`UPDATE sessions
SET display_name = $2,
updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL`,
id, displayName,
)
if err != nil {
return mapPGWriteError(fmt.Sprintf("renaming session %s", id), err)
}
return nil
}
// SoftDeleteSession moves a session to the trash.
func (s *Store) SoftDeleteSession(id string) error {
_, err := s.pg.Exec(
`UPDATE sessions
SET deleted_at = NOW(),
updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL`,
id,
)
if err != nil {
return mapPGWriteError(
fmt.Sprintf("soft deleting session %s", id), err,
)
}
return nil
}
// SoftDeleteSessions moves multiple sessions to the trash.
func (s *Store) SoftDeleteSessions(ids []string) (int, error) {
if len(ids) == 0 {
return 0, nil
}
total := 0
const batchSize = 500
for start := 0; start < len(ids); start += batchSize {
end := min(start+batchSize, len(ids))
pb := &paramBuilder{}
placeholders := make([]string, 0, end-start)
for _, id := range ids[start:end] {
placeholders = append(placeholders, pb.add(id))
}
res, err := s.pg.Exec(
`UPDATE sessions
SET deleted_at = NOW(),
updated_at = NOW()
WHERE id IN (`+strings.Join(placeholders, ",")+
`) AND deleted_at IS NULL`,
pb.args...,
)
if err != nil {
return total, mapPGWriteError("soft deleting sessions", err)
}
n, err := res.RowsAffected()
if err != nil {
return total, fmt.Errorf("counting soft deleted sessions: %w", err)
}
total += int(n)
}
return total, nil
}
// RestoreSession restores a trashed session.
func (s *Store) RestoreSession(id string) (int64, error) {
res, err := s.pg.Exec(
`UPDATE sessions
SET deleted_at = NULL,
updated_at = NOW()
WHERE id = $1 AND deleted_at IS NOT NULL`,
id,
)
if err != nil {
return 0, mapPGWriteError(
fmt.Sprintf("restoring session %s", id), err,
)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("counting restored session %s: %w", id, err)
}
return n, nil
}
// DeleteSessionIfTrashed permanently deletes a trashed session.
func (s *Store) DeleteSessionIfTrashed(
id string,
) (int64, error) {
ctx := context.Background()
tx, err := s.pg.BeginTx(ctx, nil)
if err != nil {
return 0, mapPGWriteError(
fmt.Sprintf("begin delete-if-trashed tx for %s", id),
err,
)
}
defer func() { _ = tx.Rollback() }()
sessionIDs, excludedIDs, err := readPGTrashedSessionExclusions(
ctx, tx, "s.id = $1 AND s.deleted_at IS NOT NULL", id,
)
if err != nil {
return 0, mapPGWriteError(
fmt.Sprintf("locking trashed session %s", id),
err,
)
}
if len(sessionIDs) == 0 {
return 0, nil
}
if err := insertPGExcludedSessionIDs(ctx, tx, excludedIDs); err != nil {
return 0, mapPGWriteError(
fmt.Sprintf("recording excluded trashed session %s", id),
err,
)
}
n, err := deletePGTrashedSessionRows(ctx, tx, sessionIDs)
if err != nil {
return 0, mapPGWriteError(
fmt.Sprintf("deleting trashed session %s", id), err,
)
}
if err := deletePGExcludedSessionRows(ctx, tx, excludedIDs); err != nil {
return 0, mapPGWriteError(
fmt.Sprintf("purging excluded session aliases for %s", id), err,
)
}
if err := tx.Commit(); err != nil {
return 0, mapPGWriteError(
fmt.Sprintf("commit delete-if-trashed %s", id),
err,
)
}
return n, nil
}
// ListTrashedSessions returns trashed sessions in most-recent order.
func (s *Store) ListTrashedSessions(
ctx context.Context,
) ([]db.Session, error) {
rows, err := s.pg.QueryContext(ctx,
"SELECT "+pgSessionCols+
" FROM sessions WHERE deleted_at IS NOT NULL"+
" ORDER BY deleted_at DESC LIMIT 500",
)
if err != nil {
return nil, fmt.Errorf("querying trashed sessions: %w", err)
}
defer rows.Close()
return scanPGSessionRows(rows)
}
// EmptyTrash permanently deletes every trashed session.
func (s *Store) EmptyTrash() (int, error) {
ctx := context.Background()
tx, err := s.pg.BeginTx(ctx, nil)
if err != nil {
return 0, mapPGWriteError("begin empty-trash tx", err)
}
defer func() { _ = tx.Rollback() }()
sessionIDs, excludedIDs, err := readPGTrashedSessionExclusions(
ctx, tx, "s.deleted_at IS NOT NULL",
)
if err != nil {
return 0, mapPGWriteError("locking trashed sessions", err)
}
if len(sessionIDs) == 0 {
if err := tx.Commit(); err != nil {
return 0, mapPGWriteError("commit empty-trash", err)
}
return 0, nil
}
if err := insertPGExcludedSessionIDs(ctx, tx, excludedIDs); err != nil {
return 0, mapPGWriteError("recording excluded trashed sessions", err)
}
n, err := deletePGTrashedSessionRows(ctx, tx, sessionIDs)
if err != nil {
return 0, mapPGWriteError("emptying trash", err)
}
if err := deletePGExcludedSessionRows(ctx, tx, excludedIDs); err != nil {
return 0, mapPGWriteError("purging excluded trashed session aliases", err)
}
if err := tx.Commit(); err != nil {
return 0, mapPGWriteError("commit empty-trash", err)
}
return int(n), nil
}
func readPGTrashedSessionExclusions(
ctx context.Context, tx *sql.Tx, where string, args ...any,
) ([]string, []string, error) {
rows, err := tx.QueryContext(ctx,
`SELECT s.id, sa.alias_id, rsa.session_id, rsaa.alias_id
FROM sessions s
LEFT JOIN session_aliases sa ON sa.session_id = s.id
LEFT JOIN session_aliases rsa ON rsa.alias_id = s.id
LEFT JOIN session_aliases rsaa ON rsaa.session_id = rsa.session_id
WHERE `+where+`
FOR UPDATE OF s`,
args...,
)
if err != nil {
return nil, nil, err
}
defer rows.Close()
sessionIDs := []string{}
excludedIDs := []string{}
sessionSeen := map[string]struct{}{}
excludedSeen := map[string]struct{}{}
addExcludedID := func(id string) {
if id == "" {
return
}
if _, ok := excludedSeen[id]; ok {
return
}
excludedSeen[id] = struct{}{}
excludedIDs = append(excludedIDs, id)
}
for rows.Next() {
var id string
var aliasID sql.NullString
var reverseSessionID sql.NullString
var reverseAliasID sql.NullString
if err := rows.Scan(
&id, &aliasID, &reverseSessionID, &reverseAliasID,
); err != nil {
return nil, nil, err
}
if _, ok := sessionSeen[id]; !ok {
sessionSeen[id] = struct{}{}
sessionIDs = append(sessionIDs, id)
}
addExcludedID(id)
if aliasID.Valid {
addExcludedID(aliasID.String)
}
if reverseSessionID.Valid {
addExcludedID(reverseSessionID.String)
}
if reverseAliasID.Valid {
addExcludedID(reverseAliasID.String)
}
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
return sessionIDs, excludedIDs, nil
}
func deletePGTrashedSessionRows(
ctx context.Context, tx *sql.Tx, ids []string,
) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
res, err := tx.ExecContext(ctx,
`DELETE FROM sessions
WHERE id = ANY($1) AND deleted_at IS NOT NULL`,
ids,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return n, nil
}
// UpsertSession is not supported in read-only mode.
func (s *Store) UpsertSession(_ db.Session) error {
return db.ErrReadOnly
}
// ReplaceSessionMessages is not supported in read-only mode.
func (s *Store) ReplaceSessionMessages(
_ string, _ []db.Message,
) error {
return db.ErrReadOnly
}
// WriteSessionBatchAtomic is not supported in read-only mode.
func (s *Store) WriteSessionBatchAtomic(
_ []db.SessionBatchWrite,
_ ...func() error,
) (db.SessionBatchResult, error) {
return db.SessionBatchResult{}, db.ErrReadOnly
}