Files
wehub-resource-sync f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

506 lines
13 KiB
Go

package db
import (
"context"
"database/sql"
"errors"
"fmt"
"go.kenn.io/agentsview/internal/export"
)
// SessionBatchWrite is one full session rewrite for a bulk
// rebuild. Callers must provide a complete session row, the
// complete message set to store, the computed signal values,
// and the data version to stamp after messages are written.
type SessionBatchWrite struct {
Session Session
Messages []Message
UsageEvents []UsageEvent
IdentityObservation export.ProjectIdentityObservation
Signals SessionSignalUpdate
Findings []SecretFinding
DataVersion int
ReplaceMessages bool
}
// SessionBatchResult summarizes a WriteSessionBatch call.
type SessionBatchResult struct {
WrittenSessions int
WrittenMessages int
ExcludedSessions int
ExcludedIDs []string
FailedSessions int
Errors []error
}
// WriteSessionBatch writes multiple complete sessions inside
// one transaction. Each session is wrapped in a savepoint so a
// single bad row rolls back only that session and does not
// poison the rest of the batch.
//
// This is intended for full-resync temp databases, where there
// are no user pins to preserve yet. Use ReplaceSessionMessages
// for ordinary single-session replacement on a live database.
func (db *DB) WriteSessionBatch(
writes []SessionBatchWrite,
) (SessionBatchResult, error) {
var result SessionBatchResult
if err := db.requireWritable(); err != nil {
return result, err
}
if len(writes) == 0 {
return result, nil
}
db.mu.Lock()
defer db.mu.Unlock()
tx, err := db.getWriter().Begin()
if err != nil {
return result, fmt.Errorf("beginning batch tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
var pendingRecallRevocations recallEvidenceRevocationEvents
for i, write := range writes {
write = sanitizeSessionBatchWrite(write)
savepoint := fmt.Sprintf("session_batch_%d", i)
if _, err := tx.Exec("SAVEPOINT " + savepoint); err != nil {
return result, fmt.Errorf(
"creating savepoint %s: %w", savepoint, err,
)
}
var sessionRecallRevocations recallEvidenceRevocationEvents
messagesWritten, err := writeOneSessionBatchTx(
tx,
write,
&sessionRecallRevocations,
)
switch {
case err == nil:
if _, err := tx.Exec("RELEASE SAVEPOINT " + savepoint); err != nil {
return result, fmt.Errorf(
"releasing savepoint %s: %w",
savepoint, err,
)
}
pendingRecallRevocations = append(
pendingRecallRevocations,
sessionRecallRevocations...,
)
result.WrittenSessions++
result.WrittenMessages += messagesWritten
case errors.Is(err, ErrSessionExcluded),
errors.Is(err, ErrSessionTrashed):
if rerr := rollbackSavepoint(tx, savepoint); rerr != nil {
return result, rerr
}
result.ExcludedSessions++
result.ExcludedIDs = append(
result.ExcludedIDs,
write.Session.ID,
)
default:
if rerr := rollbackSavepoint(tx, savepoint); rerr != nil {
return result, rerr
}
result.FailedSessions++
result.Errors = append(result.Errors, err)
}
}
if err := tx.Commit(); err != nil {
return result, fmt.Errorf("committing batch tx: %w", err)
}
pendingRecallRevocations.flush()
return result, nil
}
// WriteSessionBatchAtomic writes all sessions in one
// transaction. Any rejected or failed row rolls back the whole
// batch.
func (db *DB) WriteSessionBatchAtomic(
writes []SessionBatchWrite,
beforeCommit ...func() error,
) (SessionBatchResult, error) {
var result SessionBatchResult
if err := db.requireWritable(); err != nil {
return result, err
}
if len(writes) == 0 {
return result, nil
}
db.mu.Lock()
defer db.mu.Unlock()
tx, err := db.getWriter().Begin()
if err != nil {
return result, fmt.Errorf("beginning batch tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
var pendingRecallRevocations recallEvidenceRevocationEvents
for _, write := range writes {
write = sanitizeSessionBatchWrite(write)
messagesWritten, err := writeOneSessionBatchTx(
tx,
write,
&pendingRecallRevocations,
)
if err != nil {
result.WrittenSessions = 0
result.WrittenMessages = 0
switch {
case errors.Is(err, ErrSessionExcluded),
errors.Is(err, ErrSessionTrashed):
result.ExcludedSessions++
result.ExcludedIDs = append(
result.ExcludedIDs,
write.Session.ID,
)
default:
result.FailedSessions++
result.Errors = append(result.Errors, err)
}
return result, err
}
result.WrittenSessions++
result.WrittenMessages += messagesWritten
}
if len(beforeCommit) > 0 && beforeCommit[0] != nil {
if err := beforeCommit[0](); err != nil {
result.WrittenSessions = 0
result.WrittenMessages = 0
return result, err
}
}
if err := tx.Commit(); err != nil {
return result, fmt.Errorf("committing batch tx: %w", err)
}
pendingRecallRevocations.flush()
return result, nil
}
func sanitizeSessionBatchWrite(write SessionBatchWrite) SessionBatchWrite {
write.Messages = append([]Message(nil), write.Messages...)
write.UsageEvents = append([]UsageEvent(nil), write.UsageEvents...)
msgTotal, msgHasOut, msgPeak, msgHasCtx :=
batchMessageTokenTotals(write.Messages)
evtTotal, evtHasOut, evtPeak, evtHasCtx :=
batchUsageEventTokenTotals(write.UsageEvents)
totalFromMsgs := write.Session.HasTotalOutputTokens == msgHasOut &&
write.Session.TotalOutputTokens == msgTotal
totalFromEvts := write.Session.HasTotalOutputTokens == evtHasOut &&
write.Session.TotalOutputTokens == evtTotal
peakFromMsgs := write.Session.HasPeakContextTokens == msgHasCtx &&
write.Session.PeakContextTokens == msgPeak
peakFromEvts := write.Session.HasPeakContextTokens == evtHasCtx &&
write.Session.PeakContextTokens == evtPeak
_ = ValidateAndSanitize(&write.Session, write.Messages, write.UsageEvents)
if totalFromMsgs {
t, h, _, _ := batchMessageTokenTotals(write.Messages)
write.Session.TotalOutputTokens = t
write.Session.HasTotalOutputTokens = h
} else if totalFromEvts {
t, h, _, _ := batchUsageEventTokenTotals(write.UsageEvents)
write.Session.TotalOutputTokens = t
write.Session.HasTotalOutputTokens = h
}
if peakFromMsgs {
_, _, p, h := batchMessageTokenTotals(write.Messages)
write.Session.PeakContextTokens = p
write.Session.HasPeakContextTokens = h
} else if peakFromEvts {
_, _, p, h := batchUsageEventTokenTotals(write.UsageEvents)
write.Session.PeakContextTokens = p
write.Session.HasPeakContextTokens = h
}
return write
}
func batchMessageTokenTotals(
msgs []Message,
) (totalOut int, hasOut bool, peakCtx int, hasCtx bool) {
for _, msg := range msgs {
if msg.HasOutputTokens {
hasOut = true
totalOut += msg.OutputTokens
}
if msg.HasContextTokens {
hasCtx = true
if msg.ContextTokens > peakCtx {
peakCtx = msg.ContextTokens
}
}
}
return totalOut, hasOut, peakCtx, hasCtx
}
func batchUsageEventTokenTotals(
events []UsageEvent,
) (totalOut int, hasOut bool, peakCtx int, hasCtx bool) {
for _, ev := range events {
if ev.Source == "session" {
continue
}
if ev.OutputTokens > 0 {
hasOut = true
totalOut += ev.OutputTokens
}
context := ev.InputTokens +
ev.CacheCreationInputTokens +
ev.CacheReadInputTokens
if context > 0 {
hasCtx = true
if context > peakCtx {
peakCtx = context
}
}
}
return totalOut, hasOut, peakCtx, hasCtx
}
func rollbackSavepoint(tx *sql.Tx, savepoint string) error {
if _, err := tx.Exec("ROLLBACK TO SAVEPOINT " + savepoint); err != nil {
return fmt.Errorf(
"rolling back savepoint %s: %w", savepoint, err,
)
}
if _, err := tx.Exec("RELEASE SAVEPOINT " + savepoint); err != nil {
return fmt.Errorf(
"releasing rolled back savepoint %s: %w",
savepoint, err,
)
}
return nil
}
func writeOneSessionBatchTx(
tx *sql.Tx,
write SessionBatchWrite,
pendingRecallRevocations *recallEvidenceRevocationEvents,
) (int, error) {
var excluded int
err := tx.QueryRow(
"SELECT 1 FROM excluded_sessions WHERE id = ?",
write.Session.ID,
).Scan(&excluded)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf(
"checking exclusion for %s: %w",
write.Session.ID, err,
)
}
if excluded == 1 {
return 0, ErrSessionExcluded
}
var deletedAt sql.NullString
err = tx.QueryRow(
"SELECT deleted_at FROM sessions WHERE id = ?",
write.Session.ID,
).Scan(&deletedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf(
"checking trash for %s: %w",
write.Session.ID, err,
)
}
sessionExists := err == nil
if deletedAt.Valid {
return 0, ErrSessionTrashed
}
replacementTranscriptChanged := false
if write.ReplaceMessages && sessionExists {
stored, err := sessionMessagesTx(
context.Background(), tx, write.Session.ID,
)
if err != nil {
return 0, err
}
replacementTranscriptChanged = !transcriptMessagesEqual(
stored, write.Messages,
)
}
if _, err := tx.Exec(
upsertSessionSQL,
upsertSessionArgs(write.Session)...,
); err != nil {
return 0, fmt.Errorf(
"upserting session %s: %w",
write.Session.ID, err,
)
}
if write.IdentityObservation.Project != "" {
if err := upsertProjectIdentityObservationTx(
tx, write.IdentityObservation,
); err != nil {
return 0, err
}
}
if err := replaceSessionUsageEventsTx(
tx, write.Session.ID, write.UsageEvents,
); err != nil {
return 0, err
}
msgs := write.Messages
var pins []savedPin
if write.ReplaceMessages && sessionExists {
pins, err = savePinsTx(tx, write.Session.ID)
if err != nil {
return 0, err
}
if err := deleteSessionMessagesTx(tx, write.Session.ID); err != nil {
return 0, err
}
} else {
maxOrd, err := maxOrdinalTx(tx, write.Session.ID)
if err != nil {
return 0, err
}
msgs = messagesAfterOrdinal(msgs, maxOrd)
}
transcriptChanged := len(msgs) > 0
if write.ReplaceMessages && sessionExists {
transcriptChanged = replacementTranscriptChanged
}
if len(msgs) > 0 {
ids, err := insertMessagesTx(tx, msgs)
if err != nil {
return 0, err
}
toolCalls := resolveToolCalls(msgs, ids)
if err := insertToolCallsTx(tx, toolCalls); err != nil {
return 0, err
}
events := resolveToolResultEvents(msgs)
if err := insertToolResultEventsTx(tx, events); err != nil {
return 0, err
}
}
if transcriptChanged {
if err := bumpTranscriptRevisionTx(tx, write.Session.ID); err != nil {
return 0, err
}
}
if write.ReplaceMessages && sessionExists {
if err := reconcileRecallEvidenceForSessionTx(
context.Background(),
tx,
write.Session.ID,
pendingRecallRevocations,
); err != nil {
return 0, err
}
}
if write.ReplaceMessages {
if err := restorePinsTx(tx, write.Session.ID, pins); err != nil {
return 0, err
}
// A full message replacement re-normalizes every row, so this row is
// no longer incremental-append skew. The append-only branch
// (ReplaceMessages=false) deliberately leaves the marker untouched so
// earlier incrementally written rows stay flagged for parse-diff.
if err := resetIncrementalMarkerTx(tx, write.Session.ID); err != nil {
return 0, err
}
}
if err := updateSessionAutomationFromMessagesTx(
tx, write.Session.ID,
); err != nil {
return 0, err
}
if write.DataVersion > 0 {
if _, err := tx.Exec(
`UPDATE sessions SET
data_version = ?,
local_modified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?`,
write.DataVersion, write.Session.ID,
); err != nil {
return 0, fmt.Errorf(
"setting data_version for %s: %w",
write.Session.ID, err,
)
}
}
if err := updateSessionSignalsTx(tx, write.Session.ID, write.Signals); err != nil {
return 0, err
}
if err := replaceSecretFindingsTx(tx, write.Session.ID, write.Findings,
write.Signals.SecretLeakCount, write.Signals.SecretsRulesVersion); err != nil {
return 0, err
}
return len(msgs), nil
}
func sessionMessagesTx(
ctx context.Context, tx *sql.Tx, sessionID string,
) ([]Message, error) {
rows, err := tx.QueryContext(ctx, fmt.Sprintf(`
SELECT %s
FROM messages
WHERE session_id = ?
ORDER BY ordinal ASC`, selectMessageCols), sessionID)
if err != nil {
return nil, fmt.Errorf(
"querying stored batch messages for %s: %w",
sessionID, err,
)
}
msgs, scanErr := scanMessages(rows)
closeErr := rows.Close()
if scanErr != nil {
return nil, scanErr
}
if closeErr != nil {
return nil, closeErr
}
if err := attachToolCallsWithQuerier(ctx, tx, msgs); err != nil {
return nil, err
}
return msgs, nil
}
func maxOrdinalTx(tx *sql.Tx, sessionID string) (int, error) {
var n sql.NullInt64
err := tx.QueryRow(
"SELECT MAX(ordinal) FROM messages WHERE session_id = ?",
sessionID,
).Scan(&n)
if err != nil {
return -1, fmt.Errorf(
"reading max ordinal for %s: %w", sessionID, err,
)
}
if !n.Valid {
return -1, nil
}
return int(n.Int64), nil
}
func messagesAfterOrdinal(msgs []Message, maxOrd int) []Message {
if maxOrd < 0 {
return msgs
}
for i, m := range msgs {
if m.Ordinal > maxOrd {
return msgs[i:]
}
}
return nil
}