Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

167 lines
5.2 KiB
Go

package storesqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
// Querier is the query surface handed to injected queriers. The store passes
// the *sql.Tx of the transaction it is currently running (or its *sql.DB when
// outside one); implementations must run all reads through it so they share
// the store's connection.
type Querier interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
// ProjectPathsQuerier lists project root paths used to classify agent
// sessions into rail sections. Hosts typically back this with their own
// project table in the same database.
type ProjectPathsQuerier interface {
ProjectPaths(ctx context.Context, q Querier) ([]string, error)
}
// Options injects the host concerns the store was decoupled from. Every
// field is optional; the zero value yields a store with no workspace
// validation, no project-based rail classification, and no target
// normalization or seeding.
type Options struct {
// WorkspaceExists validates a workspace ID before writes that create
// rows for it (and before generated-file listings). It must return an
// error for unknown workspaces; nil disables validation.
WorkspaceExists func(ctx context.Context, workspaceID string) error
// ProjectPaths supplies project roots for rail section classification;
// nil classifies every session into the conversations section.
ProjectPaths ProjectPathsQuerier
// NormalizeTarget canonicalizes agent targets on write and on read;
// nil stores and returns targets verbatim.
NormalizeTarget func(Target) (Target, error)
// IsSkippableTargetError reports whether a normalization error on a
// stored row should skip that row (with a warning) instead of failing
// the whole listing.
IsSkippableTargetError func(error) bool
// SeedSystemTargets returns targets inserted (INSERT OR IGNORE) on every
// Migrate, stamped with the given time.
SeedSystemTargets func(nowUnixMS int64) []Target
// LegacySystemTargetIDRenames maps retired system target IDs to their
// current IDs; Migrate rewrites sessions and target rows accordingly.
LegacySystemTargetIDRenames map[string]string
// TargetIDBackfillByProvider maps a provider to the agent target ID
// assigned to its sessions that predate target tracking.
TargetIDBackfillByProvider map[string]string
// TransactionParticipant joins host-owned durable markers to canonical
// writes. The participant runs before commit through a restricted writer;
// returning an error rolls back both the canonical facts and participant
// writes. It must not perform network IO or other non-transactional work.
TransactionParticipant TransactionParticipant
}
// Store is the SQLite implementation of Repository plus agent target
// storage. It owns the workspace_agent_sessions, workspace_agent_messages,
// and agent_targets tables and records applied migrations in
// agent_store_schema_migrations.
type Store struct {
db *sql.DB
opts Options
}
// New wraps an opened database. The caller keeps ownership of db (including
// closing it) and must call Migrate before using the store.
func New(db *sql.DB, opts Options) *Store {
return &Store{db: db, opts: opts}
}
func (s *Store) ensureWorkspaceExists(ctx context.Context, workspaceID string) error {
if s.opts.WorkspaceExists == nil {
return nil
}
return s.opts.WorkspaceExists(ctx, workspaceID)
}
type rowScanner interface {
Scan(dest ...any) error
}
func unixMs(value time.Time) int64 {
return value.UnixMilli()
}
func rowsWereAffected(result sql.Result, operation string) (bool, error) {
rowsAffected, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("%s rows affected: %w", operation, err)
}
return rowsAffected > 0, nil
}
func nullString(value string) sql.NullString {
value = strings.TrimSpace(value)
if value == "" {
return sql.NullString{}
}
return sql.NullString{String: value, Valid: true}
}
func marshalJSONMap(payload map[string]any) (string, error) {
if len(payload) == 0 {
return "{}", nil
}
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("encode workspace agent session json: %w", err)
}
return string(data), nil
}
func unmarshalJSONMap(input string) (map[string]any, error) {
if strings.TrimSpace(input) == "" {
return nil, nil
}
var payload map[string]any
if err := json.Unmarshal([]byte(input), &payload); err != nil {
return nil, err
}
if len(payload) == 0 {
return nil, nil
}
return payload, nil
}
func normalizeJSONMap(payload map[string]any) (map[string]any, error) {
if len(payload) == 0 {
return nil, nil
}
// Round-trip through the durable JSON representation so fresh reports and
// rows decoded from SQLite use the same map, slice, and number types.
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return unmarshalJSONMap(string(data))
}
func cloneJSONMap(payload map[string]any) map[string]any {
if len(payload) == 0 {
return nil
}
out := make(map[string]any, len(payload))
for key, value := range payload {
out[key] = value
}
return out
}
func cloneOptionalJSONMap(payload map[string]any) map[string]any {
if payload == nil {
return nil
}
cloned := cloneJSONMap(payload)
if cloned == nil {
return map[string]any{}
}
return cloned
}