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

3436 lines
95 KiB
Go

package postgres
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"maps"
"slices"
"sort"
"strconv"
"strings"
"time"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/export"
)
const (
lastPushBoundaryStateKey = "last_push_boundary_state"
lastPushTargetFingerprintKey = "pg_target_fingerprint_v1"
sessionAliasBackfillStateKey = "pg_session_alias_backfill_v1"
projectIdentityPublicationStateKey = "project_identity_publication_revision_v2"
transcriptRevisionBackfillStateKey = "pg_transcript_revision_backfill_v1"
)
// pushMarkerIDStateKey names the local sync-state entry holding this DB's
// stable push-marker identifier. The push-marker prefixes form PG
// sync_metadata keys for reset-detection marker rows.
const (
pushMarkerIDStateKey = "pg_push_marker_id"
pushMarkerKeyPrefix = "push_marker:"
pushMarkerMachineAliasesKeyPrefix = "push_marker_machine_aliases:"
)
var errSessionOwnershipConflict = errors.New("session ownership conflict")
var errSessionExcluded = errors.New("session excluded")
type pushBoundaryState struct {
Cutoff string `json:"cutoff"`
Fingerprints map[string]string `json:"fingerprints"`
}
// PushResult summarizes a push sync operation.
type PushResult struct {
SessionsPushed int
MessagesPushed int
SkippedConflicts int
Errors int
Duration time.Duration
Vectors VectorPushResult
}
// pushPrepareProgressStride bounds how many sessions the fingerprint loop
// processes between "preparing" progress reports.
const pushPrepareProgressStride = 500
// timedPushSetupStep runs one pre-batch setup step and logs its duration when
// it exceeds a second, so a slow silent stretch of a push (per-row metadata
// upserts against a remote target, for example) is attributable in the log.
func timedPushSetupStep(name string, fn func() error) error {
start := time.Now()
if err := fn(); err != nil {
return err
}
if d := time.Since(start); d > time.Second {
log.Printf("pgsync: %s took %s", name, d.Round(time.Millisecond))
}
return nil
}
// PushProgress is reported after each batch during Push.
type PushProgress struct {
// Phase is "preparing" while per-session push fingerprints are computed
// (SessionsDone/SessionsTotal count candidate sessions fingerprinted; on
// a full push this covers every local session and can run for minutes),
// "" during the session/message push, and "vectors" during the vector
// phase, whose progress is carried by the Vector* fields.
Phase string
SessionsDone int
SessionsTotal int
MessagesDone int
SkippedConflicts int
Errors int
// VectorSessionsDone counts local sessions examined by the vector
// phase's delta scan (most are unchanged and skipped cheaply);
// VectorSessionsTotal is the local candidate count and
// VectorChunksPushed the embedding chunks written so far.
VectorSessionsDone int
VectorSessionsTotal int
VectorChunksPushed int
}
// Push syncs local sessions and messages to PostgreSQL.
// The onProgress callback, if non-nil, is called after each
// batch with current totals.
func (s *Sync) Push(
ctx context.Context, full bool,
onProgress func(PushProgress),
) (PushResult, error) {
start := time.Now()
var result PushResult
state := s.effectiveSyncState()
aliasBackfillState := s.aliasBackfillSyncStateOrDefault()
// Announce the preparation phase immediately: everything between here
// and the first batch (marker checks, metadata syncs, fingerprints)
// produces no per-batch reports, and some of it runs for minutes on a
// full push against a remote target.
if onProgress != nil {
onProgress(PushProgress{Phase: "preparing"})
}
if err := CheckDataVersionCompat(ctx, s.pg); err != nil {
return result, err
}
if err := s.normalizeSyncTimestamps(ctx); err != nil {
return result, err
}
lastPush, err := state.GetSyncState("last_push_at")
if err != nil {
return result, fmt.Errorf(
"reading last_push_at: %w", err,
)
}
storedTargetFingerprint, err := state.GetSyncState(
lastPushTargetFingerprintKey,
)
if err != nil {
return result, fmt.Errorf(
"reading %s: %w",
lastPushTargetFingerprintKey, err,
)
}
boundaryState, err := state.GetSyncState(
lastPushBoundaryStateKey,
)
if err != nil {
return result, fmt.Errorf(
"reading %s: %w",
lastPushBoundaryStateKey, err,
)
}
pushStateCleared := false
if reset, reason := pushTargetState(
lastPush,
boundaryState,
storedTargetFingerprint,
s.targetFingerprint,
); reset {
log.Printf(
"pgsync: %s; clearing local push watermark state",
reason,
)
if err := clearPushState(state); err != nil {
return result, err
}
lastPush = ""
full = true
pushStateCleared = true
}
markerID, err := s.pushMarkerID()
if err != nil {
return result, err
}
markerMachine, markerMachineAliases, markerExists, err := s.pgPushMarkerMachineState(ctx, markerID)
if err != nil {
return result, err
}
legacyMarkerMachines := pushMarkerLegacyMachines(
markerMachine, markerMachineAliases,
)
// Keep the backfill marker scoped to target only; all other push
// state remains scoped by full effective sync state (including filter
// fingerprint when present).
aliasBackfillNeeded := false
full, aliasBackfillNeeded, err = applySessionAliasBackfillRequirement(
aliasBackfillState, full,
)
if err != nil {
return result, err
}
if aliasBackfillNeeded {
log.Printf(
"pgsync: session alias backfill marker missing; forcing full push",
)
}
transcriptRevisionBackfillNeeded := false
full, transcriptRevisionBackfillNeeded, err =
applyTranscriptRevisionBackfillRequirement(state, full)
if err != nil {
return result, err
}
if transcriptRevisionBackfillNeeded {
log.Printf(
"pgsync: transcript revision backfill marker missing; forcing full push",
)
}
if full {
lastPush = ""
// Caller requested a full push — the PG schema
// may have been dropped since schemaDone was set.
// Clear the memo so EnsureSchema re-runs.
s.schemaMu.Lock()
s.schemaDone = false
s.schemaMu.Unlock()
if err := s.normalizeSyncTimestamps(
ctx,
); err != nil {
return result, err
}
// When a filtered full push runs, clear persisted
// watermark and boundary state so the next
// unfiltered push also starts from scratch.
if s.isFiltered() && !pushStateCleared {
if err := clearPushState(state); err != nil {
return result, err
}
}
}
// Coherence check: if local push state says we've pushed before
// but this host's push marker is gone from PG, the PG side was
// reset (schema dropped, DB recreated, etc.). Force a full push
// so fingerprint-matched sessions are not skipped while missing
// from PG. Boundary state counts here too: a partial first push
// can leave last_push_at empty while still caching fingerprints
// for successfully pushed sessions.
if lastPush != "" || boundaryState != "" {
if !markerExists {
log.Printf(
"pgsync: local push state set but PG push marker " +
"missing; PG was reset, forcing full push",
)
lastPush = ""
full = true
if len(legacyMarkerMachines) == 0 {
legacyMarkerMachines = nil
}
s.schemaMu.Lock()
s.schemaDone = false
s.schemaMu.Unlock()
if err := s.normalizeSyncTimestamps(
ctx,
); err != nil {
return result, err
}
// Filtered push against a reset PG: clear
// watermark and boundary state so the next
// unfiltered push also starts from scratch.
if s.isFiltered() && !pushStateCleared {
if err := clearPushState(state); err != nil {
return result, err
}
}
}
}
if err := timedPushSetupStep("model pricing sync",
func() error { return s.syncModelPricing(ctx) }); err != nil {
return result, err
}
if err := timedPushSetupStep("cursor usage event sync",
func() error { return s.syncCursorUsageEvents(ctx) }); err != nil {
return result, err
}
cutoff := time.Now().UTC().Format(LocalSyncTimestampLayout)
allSessions, err := s.local.ListSessionsModifiedBetween(
ctx, lastPush, cutoff, s.projects, s.excludeProjects,
)
if err != nil {
return result, fmt.Errorf(
"listing modified sessions: %w", err,
)
}
sessionByID := make(
map[string]db.Session, len(allSessions),
)
for _, sess := range allSessions {
sessionByID[sess.ID] = sess
}
var priorFingerprints map[string]string
sessionFingerprints := make(map[string]string, len(sessionByID))
if !full {
var bErr error
priorFingerprints, _, _, bErr = readBoundaryAndFingerprints(
state, lastPush,
)
if bErr != nil {
return result, bErr
}
}
if lastPush != "" {
windowStart, err := PreviousLocalSyncTimestamp(
lastPush,
)
if err != nil {
return result, fmt.Errorf(
"computing push boundary window before %s: %w",
lastPush, err,
)
}
boundarySessions, err := s.local.ListSessionsModifiedBetween(
ctx, windowStart, lastPush, s.projects, s.excludeProjects,
)
if err != nil {
return result, fmt.Errorf(
"listing push boundary sessions: %w", err,
)
}
for _, sess := range boundarySessions {
marker := localSessionSyncMarker(sess)
if marker != lastPush {
continue
}
if _, exists := sessionByID[sess.ID]; exists {
continue
}
sessionByID[sess.ID] = sess
}
}
if err := purgePGExcludedPushSessions(
ctx, s.pg, sessionByID,
); err != nil {
return result, err
}
usageFingerprints, err := s.local.UsageEventFingerprints(
mapKeys(sessionByID),
)
if err != nil {
return result, fmt.Errorf(
"computing local usage event fingerprints: %w", err,
)
}
// The fingerprint loop issues several local queries per candidate
// session; on a full push that covers every session and runs for
// minutes, so it reports its own progress phase rather than sitting
// silent until the first batch lands.
log.Printf("pgsync: computing push fingerprints for %d candidate session(s)",
len(sessionByID))
reportPrepare := func(done int) {
if onProgress == nil {
return
}
onProgress(PushProgress{
Phase: "preparing",
SessionsDone: done,
SessionsTotal: len(sessionByID),
})
}
reportPrepare(0)
prepared := 0
candidateIDs := mapKeys(sessionByID)
for start := 0; start < len(candidateIDs); start += pushComparisonBatchSize {
end := min(start+pushComparisonBatchSize, len(candidateIDs))
chunk := candidateIDs[start:end]
depState, err := readLocalPushDependencyState(ctx, s.local, chunk)
if err != nil {
return result, err
}
for _, id := range chunk {
usageFP, usageKnown := usageFingerprints[id]
dependencyFP, err := depState.dependencyFingerprint(
s.local, id, usageFP, usageKnown,
)
if err != nil {
return result, fmt.Errorf(
"computing local dependency fingerprint %s: %w",
id, err,
)
}
sess := sessionByID[id]
sessionFingerprints[id] = sessionPushFingerprint(
sess, pushedSessionMachine(sess, s.machine),
usageFP, markerID, dependencyFP,
)
prepared++
if prepared%pushPrepareProgressStride == 0 {
reportPrepare(prepared)
}
}
}
reportPrepare(prepared)
if len(priorFingerprints) > 0 {
for id := range sessionByID {
if priorFingerprints[id] == sessionFingerprints[id] {
delete(sessionByID, id)
}
}
}
var sessions []db.Session
for _, sess := range sessionByID {
sessions = append(sessions, sess)
}
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].ID < sessions[j].ID
})
if len(sessions) == 0 {
if s.isFiltered() {
// Filtered pushes use filter-scoped sync state, so
// they can advance their own watermark without
// moving the unfiltered/global cursor.
if err := finalizeFilteredPushState(
state, lastPush, cutoff, sessions,
priorFingerprints, sessionFingerprints,
result.Errors,
); err != nil {
return result, err
}
} else {
if err := finalizeUnfilteredPushState(
state, lastPush, cutoff, sessions,
priorFingerprints, sessionFingerprints,
result.Errors,
); err != nil {
return result, err
}
}
if err := persistPushTargetFingerprint(
state, s.targetFingerprint,
); err != nil {
return result, err
}
if err := s.writePushMarker(
ctx, markerID, markerMachine, markerMachineAliases,
); err != nil {
return result, err
}
if err := completeSessionAliasBackfill(
aliasBackfillState, aliasBackfillNeeded, result,
); err != nil {
return result, err
}
if err := completeTranscriptRevisionBackfill(
state, transcriptRevisionBackfillNeeded, result,
); err != nil {
return result, err
}
if err := s.syncProjectIdentityObservations(ctx, full); err != nil {
return result, err
}
result.Vectors, err = s.runVectorPushPhase(ctx, full, nil, onProgress)
if err != nil {
return result, err
}
result.Duration = time.Since(start)
return result, nil
}
var pushed []db.Session
// Sessions whose individual retry also failed: their PG sessions/messages
// rows are stale or absent, so the vector phase must not push their newer
// local vectors ahead of them.
var failedSessions map[string]struct{}
const batchSize = 50
for i := 0; i < len(sessions); i += batchSize {
end := min(i+batchSize, len(sessions))
batch := sessions[i:end]
batchResult, err := s.pushBatch(
ctx, batch, full, markerID, legacyMarkerMachines,
usageFingerprints, &pushed,
)
if err != nil {
return result, err
}
if batchResult.ok {
result.SessionsPushed += batchResult.sessions
result.MessagesPushed += batchResult.messages
result.SkippedConflicts += batchResult.skippedConflicts
} else {
// Batch failed — retry each session individually
// so one bad session doesn't block the rest.
for _, sess := range batch {
sr, retryErr := s.pushBatch(
ctx, []db.Session{sess},
full, markerID, legacyMarkerMachines,
usageFingerprints, &pushed,
)
if retryErr != nil {
return result, retryErr
}
if sr.ok {
result.SessionsPushed += sr.sessions
result.MessagesPushed += sr.messages
result.SkippedConflicts += sr.skippedConflicts
} else {
result.Errors++
if failedSessions == nil {
failedSessions = make(map[string]struct{})
}
failedSessions[sess.ID] = struct{}{}
}
}
}
if onProgress != nil {
onProgress(PushProgress{
SessionsDone: end,
SessionsTotal: len(sessions),
MessagesDone: result.MessagesPushed,
SkippedConflicts: result.SkippedConflicts,
Errors: result.Errors,
})
}
}
if s.isFiltered() {
// Filtered pushes use filter-scoped sync state, so
// they can advance their own watermark without moving
// the unfiltered/global cursor.
if err := finalizeFilteredPushState(
state, lastPush, cutoff, pushed,
priorFingerprints, sessionFingerprints,
result.Errors,
); err != nil {
return result, err
}
} else {
if err := finalizeUnfilteredPushState(
state, lastPush, cutoff, pushed,
priorFingerprints, sessionFingerprints,
result.Errors,
); err != nil {
return result, err
}
}
if err := persistPushTargetFingerprint(
state, s.targetFingerprint,
); err != nil {
return result, err
}
// Write the push marker only after the push and local finalization
// succeed. A reset-recovery push that fails before this point leaves
// the marker absent, so the next push re-detects the reset and retries
// rather than skipping the still-missing sessions.
if err := s.writePushMarker(
ctx, markerID, markerMachine, markerMachineAliases,
); err != nil {
return result, err
}
if err := completeSessionAliasBackfill(
aliasBackfillState, aliasBackfillNeeded, result,
); err != nil {
return result, err
}
if err := completeTranscriptRevisionBackfill(
state, transcriptRevisionBackfillNeeded, result,
); err != nil {
return result, err
}
if result.Errors == 0 {
if err := s.syncProjectIdentityObservations(ctx, full); err != nil {
return result, err
}
} else {
log.Printf(
"pgsync: skipping project identity publication after %d session push errors",
result.Errors,
)
}
result.Vectors, err = s.runVectorPushPhase(ctx, full, failedSessions, onProgress)
if err != nil {
return result, err
}
result.Duration = time.Since(start)
return result, nil
}
// runVectorPushPhase runs the vector push phase and wraps its error. With no
// source attached the phase never runs: it returns a Skipped result with an
// empty reason, which the summary printer renders as nothing (an unconfigured
// phase is not a diagnosable skip like an unavailable extension). Without this
// the zero-valued VectorPushResult would print "Vectors: 0 session(s) pushed".
// failedSessions names sessions whose session-phase push failed; their vectors
// are deferred so pgvector data never runs ahead of the sessions/messages rows.
// full bypasses the unchanged-hash skip so a --full push also repairs vector
// rows whose push state wrongly reports them current. onProgress, when
// non-nil, receives Phase "vectors" reports as the delta scan advances.
func (s *Sync) runVectorPushPhase(
ctx context.Context, full bool, failedSessions map[string]struct{},
onProgress func(PushProgress),
) (VectorPushResult, error) {
if s.vectorSource == nil {
return VectorPushResult{Skipped: true}, nil
}
res, err := s.pushVectors(ctx, full, failedSessions, onProgress)
if err != nil {
return res, fmt.Errorf("vector push: %w", err)
}
return res, nil
}
func (s *Sync) syncProjectIdentityObservations(
ctx context.Context, force bool,
) error {
revision, err := s.local.ProjectIdentityPublicationRevision(ctx)
if err != nil {
return err
}
databaseGeneration, err := s.local.GetDatabaseID(ctx)
if err != nil {
return fmt.Errorf("loading source database generation: %w", err)
}
revisionValue := strconv.FormatInt(revision, 10)
state := s.effectiveSyncState()
stateKey := projectIdentityPublicationStateKey + ":" + databaseGeneration
publishedRevisionValue, err := state.GetSyncState(stateKey)
if err != nil {
return fmt.Errorf("reading project identity publication revision: %w", err)
}
fullPublication := force || publishedRevisionValue == ""
var publishedRevision int64
if !fullPublication {
publishedRevision, err = strconv.ParseInt(publishedRevisionValue, 10, 64)
if err != nil || publishedRevision < 0 || publishedRevision > revision {
fullPublication = true
} else if publishedRevision == revision {
return nil
}
}
var observations []export.ProjectIdentityObservation
var snapshots []export.ProjectIdentityObservation
var delta db.ProjectIdentityPublicationDelta
if fullPublication {
observations, err = s.local.ListProjectIdentityObservations(ctx, nil)
if err != nil {
return fmt.Errorf("loading project identity observations: %w", err)
}
observations = filterProjectIdentityObservations(
observations, s.projects, s.excludeProjects,
)
snapshots, err = s.local.ListSessionProjectIdentitySnapshots(ctx)
if err != nil {
return fmt.Errorf("loading session project identity snapshots: %w", err)
}
snapshots = filterProjectIdentityObservations(
snapshots, s.projects, s.excludeProjects,
)
} else {
delta, err = s.local.LoadProjectIdentityPublicationDelta(
ctx, publishedRevision, revision, s.projects, s.excludeProjects,
)
if err != nil {
return err
}
observations = delta.Observations
snapshots = delta.Snapshots
}
archiveID, err := s.local.GetArchiveID(ctx)
if err != nil {
return fmt.Errorf("loading source archive id: %w", err)
}
archiveSalt, err := s.local.GetArchiveSalt(ctx)
if err != nil {
return fmt.Errorf("loading source archive salt: %w", err)
}
log.Printf(
"pgsync: syncing %d project identity observation(s), %d snapshot(s), "+
"and %d tombstone(s)",
len(observations), len(snapshots),
len(delta.ObservationDeletes)+len(delta.SnapshotDeletes),
)
tx, err := s.pg.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("beginning project identity observation sync: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := upsertSourceArchiveScope(ctx, tx, archiveID, archiveSalt); err != nil {
return err
}
if fullPublication {
if err := deleteProjectIdentityScope(
ctx, tx, archiveID, s.projects, s.excludeProjects,
); err != nil {
return err
}
} else if err := deleteProjectIdentityDelta(
ctx, tx, archiveID, databaseGeneration,
delta.ObservationDeletes, delta.SnapshotDeletes,
); err != nil {
return err
}
for i, obs := range observations {
obs.SourceArchiveID = archiveID
obs.SourceArchiveSalt = archiveSalt
observations[i] = export.SanitizeStoredProjectIdentityObservation(obs)
}
if err := syncProjectIdentityObservationsBatch(
ctx, tx, observations,
); err != nil {
return fmt.Errorf("syncing project identity observations: %w", err)
}
for i := range snapshots {
snapshots[i] = export.SanitizeStoredProjectIdentityObservation(snapshots[i])
}
if err := insertSessionProjectIdentitySnapshots(
ctx, tx, archiveID, databaseGeneration, snapshots,
); err != nil {
return fmt.Errorf("syncing session project identity snapshots: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing project identity observation sync: %w", err)
}
if err := state.SetSyncState(stateKey, revisionValue); err != nil {
return fmt.Errorf("recording project identity publication revision: %w", err)
}
return nil
}
func filterProjectIdentityObservations(
observations []export.ProjectIdentityObservation,
projects []string,
excludeProjects []string,
) []export.ProjectIdentityObservation {
if len(projects) == 0 && len(excludeProjects) == 0 {
return observations
}
out := observations[:0]
for _, obs := range observations {
if len(projects) > 0 && !slices.Contains(projects, obs.Project) {
continue
}
if slices.Contains(excludeProjects, obs.Project) {
continue
}
out = append(out, obs)
}
return out
}
// pgPushMarkerMachineState reports whether this host's push marker is present
// in PG and returns the current machine plus legacy machine aliases stored with
// the marker.
//
// Scoped marker keys are authoritative for reset detection. If a scoped marker
// is missing, legacy unscoped marker metadata is still returned as alias
// history so ownerless rows from older agentsview versions can be adopted after
// a machine rename.
// A missing marker while the local watermark is set means PG was reset (schema
// dropped or recreated) since this host last pushed, so a full re-push is
// needed. Counting rows by machine cannot detect this reliably: another host
// pushing to the same PG can repopulate rows under a machine value this host
// also writes -- a remote host's sessions synced in over SSH, or this host's
// own renamed identity -- masking the loss of this host's own rows. The marker
// is per-local-DB, so no other pusher can satisfy this check.
func (s *Sync) pgPushMarkerMachineState(
ctx context.Context, markerID string,
) (string, []string, bool, error) {
markerKey := s.pushMarkerMetadataKey(pushMarkerKeyPrefix, markerID)
machine, markerExists, err := s.pgPushMarkerMetadataValue(
ctx, markerKey,
)
if err != nil {
return "", nil, false, fmt.Errorf(
"checking pg push marker: %w", err,
)
}
if markerExists {
aliases, err := s.pgPushMarkerMachineAliases(
ctx,
s.pushMarkerMetadataKey(
pushMarkerMachineAliasesKeyPrefix, markerID,
),
)
if err != nil {
return "", nil, false, err
}
return machine, aliases, true, nil
}
if s.syncStateTarget == "" {
return "", nil, false, nil
}
legacyMachine, legacyMarkerExists, err := s.pgPushMarkerMetadataValue(
ctx, pushMarkerKeyPrefix+markerID,
)
if err != nil {
return "", nil, false, fmt.Errorf(
"checking legacy pg push marker: %w", err,
)
}
aliases, err := s.pgPushMarkerMachineAliases(
ctx, pushMarkerMachineAliasesKeyPrefix+markerID,
)
if err != nil {
return "", nil, false, err
}
if !legacyMarkerExists && len(aliases) == 0 {
return "", nil, false, nil
}
return legacyMachine, aliases, false, nil
}
func (s *Sync) pgPushMarkerMetadataValue(
ctx context.Context, key string,
) (string, bool, error) {
var value string
err := s.pg.QueryRowContext(ctx,
`SELECT value FROM sync_metadata WHERE key = $1`,
key,
).Scan(&value)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if isUndefinedTable(err) {
return "", false, nil
}
return "", false, err
}
return value, true, nil
}
func (s *Sync) pgPushMarkerMachineAliases(
ctx context.Context, key string,
) ([]string, error) {
var raw string
err := s.pg.QueryRowContext(ctx,
`SELECT value FROM sync_metadata WHERE key = $1`,
key,
).Scan(&raw)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if isUndefinedTable(err) {
return nil, nil
}
return nil, fmt.Errorf(
"reading pg push marker machine aliases: %w", err,
)
}
var aliases []string
if err := json.Unmarshal([]byte(raw), &aliases); err != nil {
return nil, fmt.Errorf(
"decoding pg push marker machine aliases: %w", err,
)
}
return normalizePushMarkerMachineAliases("", aliases), nil
}
// writePushMarker records this host's push marker in PG so a later push can
// tell whether PG still holds the rows this host pushed. The primary marker
// value carries the current machine name for debugging and reset detection;
// the alias key preserves previous marker machines so ownerless legacy rows can
// be adopted after renames across multiple incremental pushes.
func (s *Sync) writePushMarker(
ctx context.Context,
markerID, previousMarkerMachine string,
previousAliases []string,
) error {
tx, err := s.pg.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin push marker tx: %w", err)
}
aliases := pushMarkerMachineAliases(
s.machine, previousMarkerMachine, previousAliases,
)
aliasesJSON, err := json.Marshal(aliases)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("encoding pg push marker machine aliases: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO sync_metadata (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
s.pushMarkerMetadataKey(pushMarkerKeyPrefix, markerID), s.machine,
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("writing pg push marker: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO sync_metadata (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
s.pushMarkerMetadataKey(pushMarkerMachineAliasesKeyPrefix, markerID),
string(aliasesJSON),
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("writing pg push marker machine aliases: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing pg push marker: %w", err)
}
return nil
}
func (s *Sync) pushMarkerMetadataKey(prefix, markerID string) string {
if s.syncStateTarget == "" {
return prefix + markerID
}
sum := sha256.Sum256([]byte(s.syncStateTarget))
return prefix + markerID + ":scope:" + hex.EncodeToString(sum[:8])
}
func pushMarkerLegacyMachines(machine string, aliases []string) []string {
machines := append([]string{}, aliases...)
if machine != "" {
machines = append(machines, machine)
}
return normalizePushMarkerMachineAliases("", machines)
}
func pushMarkerMachineAliases(
currentMachine, previousMachine string,
previousAliases []string,
) []string {
aliases := append([]string{}, previousAliases...)
if previousMachine != "" && previousMachine != currentMachine {
aliases = append(aliases, previousMachine)
}
return normalizePushMarkerMachineAliases(currentMachine, aliases)
}
func normalizePushMarkerMachineAliases(
currentMachine string, aliases []string,
) []string {
seen := make(map[string]struct{}, len(aliases))
out := make([]string, 0, len(aliases))
for _, alias := range aliases {
if alias == "" || alias == currentMachine {
continue
}
if _, ok := seen[alias]; ok {
continue
}
seen[alias] = struct{}{}
out = append(out, alias)
}
return out
}
// pushMarkerID returns this local DB's stable push-marker identifier, creating
// and persisting a random one on first use. It is independent of the machine
// name, so a machine rename keeps the same marker, and unique per local DB, so
// a different host pushing to the same PG cannot mask this host's reset.
func (s *Sync) pushMarkerID() (string, error) {
state := s.local
if state == nil {
return "", fmt.Errorf("local db is required")
}
id, err := state.GetSyncState(pushMarkerIDStateKey)
if err != nil {
return "", fmt.Errorf("reading push marker id: %w", err)
}
if id != "" {
return id, nil
}
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generating push marker id: %w", err)
}
id = hex.EncodeToString(buf)
storedID, err := state.GetOrCreateSyncState(
pushMarkerIDStateKey, id,
)
if err != nil {
return "", fmt.Errorf("persisting push marker id: %w", err)
}
return storedID, nil
}
type batchResult struct {
ok bool
sessions int
messages int
skippedConflicts int
}
var errPushComparisonPreload = errors.New(
"push comparison preload failed",
)
// pushBatch pushes a slice of sessions within a single
// transaction. On success it appends to pushed and returns
// ok=true with session/message counts. On a session-level
// error it rolls back and returns ok=false so the caller
// can retry individually. Fatal errors (BeginTx failure)
// return a non-nil error.
func (s *Sync) pushBatch(
ctx context.Context,
batch []db.Session,
full bool,
markerID string,
legacyMarkerMachines []string,
sessionUsageFingerprints map[string]string,
pushed *[]db.Session,
) (batchResult, error) {
preloadComparisons := len(batch) > 0 && !full
result, err := s.pushBatchAttempt(
ctx, batch, full, markerID, legacyMarkerMachines,
sessionUsageFingerprints, pushed, preloadComparisons,
)
if err == nil || !errors.Is(err, errPushComparisonPreload) {
return result, err
}
log.Printf(
"pgsync: preloading pg comparison fingerprints failed, "+
"retrying batch without preload: %v",
err,
)
return s.pushBatchAttempt(
ctx, batch, full, markerID, legacyMarkerMachines,
sessionUsageFingerprints, pushed, false,
)
}
func (s *Sync) pushBatchAttempt(
ctx context.Context,
batch []db.Session,
full bool,
markerID string,
legacyMarkerMachines []string,
sessionUsageFingerprints map[string]string,
pushed *[]db.Session,
preloadComparisons bool,
) (batchResult, error) {
tx, err := s.pg.BeginTx(ctx, nil)
if err != nil {
return batchResult{}, fmt.Errorf(
"begin pg tx: %w", err,
)
}
n := 0
msgs := 0
skippedConflicts := 0
sessionIDs := make([]string, 0, len(batch))
for _, sess := range batch {
sessionIDs = append(sessionIDs, sess.ID)
}
comparisons := (*pushMessageComparison)(nil)
if preloadComparisons && len(sessionIDs) > 0 {
comparisonsBatch, err := readPushSessionMessageComparisons(
ctx, tx, sessionIDs,
)
if err != nil {
_ = tx.Rollback()
return batchResult{}, fmt.Errorf(
"%w: %w", errPushComparisonPreload, err,
)
}
comparisons = comparisonsBatch
}
for _, sess := range batch {
if err := s.pushSession(
ctx, tx, sess, markerID, legacyMarkerMachines,
); err != nil {
if errors.Is(err, errSessionOwnershipConflict) {
skippedConflicts++
continue
}
if errors.Is(err, errSessionExcluded) {
continue
}
log.Printf(
"pgsync: session %s: %v",
sess.ID, err,
)
_ = tx.Rollback()
*pushed = (*pushed)[:len(*pushed)-n]
return batchResult{}, nil
}
msgCount, err := s.pushMessages(
ctx, tx, sess.ID, full,
sessionUsageFingerprints, comparisons,
)
if err != nil {
log.Printf(
"pgsync: session %s: %v",
sess.ID, err,
)
_ = tx.Rollback()
*pushed = (*pushed)[:len(*pushed)-n]
return batchResult{}, nil
}
findingsChanged, err := s.pushSecretFindings(ctx, tx, sess.ID)
if err != nil {
log.Printf(
"pgsync: secret findings %s: %v",
sess.ID, err,
)
_ = tx.Rollback()
*pushed = (*pushed)[:len(*pushed)-n]
return batchResult{}, nil
}
// Bump updated_at when messages or secret findings were
// rewritten but pushSession was a metadata no-op (its
// WHERE clause skips unchanged rows). PG read-mode session
// watchers rely on updated_at to surface secret-only changes.
if msgCount > 0 || findingsChanged {
if _, err := tx.ExecContext(ctx, `
UPDATE sessions
SET updated_at = NOW()
WHERE id = $1`,
sess.ID,
); err != nil {
log.Printf(
"pgsync: bumping updated_at %s: %v",
sess.ID, err,
)
_ = tx.Rollback()
*pushed = (*pushed)[:len(*pushed)-n]
return batchResult{}, nil
}
}
*pushed = append(*pushed, sess)
n++
msgs += msgCount
}
if err := tx.Commit(); err != nil {
log.Printf(
"pgsync: batch commit failed: %v", err,
)
*pushed = (*pushed)[:len(*pushed)-n]
return batchResult{}, nil
}
return batchResult{ok: true, sessions: n, messages: msgs, skippedConflicts: skippedConflicts}, nil
}
func finalizePushState(
local syncStateStore,
cutoff string,
sessions []db.Session,
priorFingerprints map[string]string,
sessionFingerprints map[string]string,
) error {
if err := local.SetSyncState(
"last_push_at", cutoff,
); err != nil {
return fmt.Errorf("updating last_push_at: %w", err)
}
return writePushBoundaryState(
local, cutoff, sessions, priorFingerprints,
sessionFingerprints,
)
}
func finalizeUnfilteredPushState(
local syncStateStore,
lastPush, cutoff string,
sessions []db.Session,
priorFingerprints map[string]string,
sessionFingerprints map[string]string,
errors int,
) error {
// When all sessions succeeded, advance the watermark to cutoff.
// When some failed, keep the watermark at lastPush so the failed
// sessions (plus any already-pushed ones) are re-evaluated next
// time. Already-pushed sessions are fingerprint-matched and skipped
// cheaply.
finalizeCutoff := cutoff
if errors > 0 {
finalizeCutoff = lastPush
}
return finalizePushState(
local, finalizeCutoff, sessions,
priorFingerprints, sessionFingerprints,
)
}
func finalizeFilteredPushState(
local syncStateStore,
lastPush, cutoff string,
sessions []db.Session,
priorFingerprints map[string]string,
sessionFingerprints map[string]string,
errors int,
) error {
finalizeCutoff := cutoff
if errors > 0 {
finalizeCutoff = lastPush
}
return finalizePushState(
local, finalizeCutoff, sessions,
priorFingerprints, sessionFingerprints,
)
}
// clearPushState resets the active watermark and boundary state so
// that the next push for this sync-state scope starts from scratch.
func clearPushState(local syncStateStore) error {
if err := local.SetSyncState(
lastPushBoundaryStateKey, "",
); err != nil {
return fmt.Errorf(
"clearing boundary state: %w", err,
)
}
if err := local.SetSyncState(
"last_push_at", "",
); err != nil {
return fmt.Errorf(
"clearing last_push_at: %w", err,
)
}
return nil
}
func applySessionAliasBackfillRequirement(
local syncStateStore, full bool,
) (bool, bool, error) {
needed, err := sessionAliasBackfillNeeded(local)
if err != nil {
return full, false, err
}
if !needed {
return full, false, nil
}
return true, true, nil
}
func sessionAliasBackfillNeeded(local syncStateStore) (bool, error) {
done, err := local.GetSyncState(sessionAliasBackfillStateKey)
if err != nil {
return false, fmt.Errorf(
"reading %s: %w", sessionAliasBackfillStateKey, err,
)
}
return done != "1", nil
}
func markSessionAliasBackfillDone(local syncStateStore) error {
if err := local.SetSyncState(
sessionAliasBackfillStateKey, "1",
); err != nil {
return fmt.Errorf(
"updating %s: %w", sessionAliasBackfillStateKey, err,
)
}
return nil
}
func completeSessionAliasBackfill(
local syncStateStore, needed bool, result PushResult,
) error {
// Skipped ownership conflicts are sessions owned by another machine on
// the hub; this host neither can nor should re-push them, so they do not
// indicate an incomplete backfill of this host's own sessions. Only real
// push errors (this host's own sessions that failed to push) should defer
// the marker and re-force a full push. Gating on skipped conflicts made
// the one-time alias backfill impossible to complete on any shared hub —
// every push saw the marker missing and fell back to a full sweep.
if !needed || result.Errors > 0 {
return nil
}
return markSessionAliasBackfillDone(local)
}
func applyTranscriptRevisionBackfillRequirement(
local syncStateStore, full bool,
) (bool, bool, error) {
done, err := local.GetSyncState(transcriptRevisionBackfillStateKey)
if err != nil {
return full, false, fmt.Errorf(
"reading %s: %w", transcriptRevisionBackfillStateKey, err,
)
}
if done == "1" {
return full, false, nil
}
return true, true, nil
}
func markTranscriptRevisionBackfillDone(local syncStateStore) error {
if err := local.SetSyncState(
transcriptRevisionBackfillStateKey, "1",
); err != nil {
return fmt.Errorf(
"updating %s: %w", transcriptRevisionBackfillStateKey, err,
)
}
return nil
}
func completeTranscriptRevisionBackfill(
local syncStateStore, needed bool, result PushResult,
) error {
if !needed || result.Errors > 0 {
return nil
}
return markTranscriptRevisionBackfillDone(local)
}
func persistPushTargetFingerprint(
local syncStateStore,
fingerprint string,
) error {
if err := local.SetSyncState(
lastPushTargetFingerprintKey,
fingerprint,
); err != nil {
return fmt.Errorf(
"updating %s: %w",
lastPushTargetFingerprintKey, err,
)
}
return nil
}
func pushTargetState(
lastPush, boundaryState,
storedTargetFingerprint, currentTargetFingerprint string,
) (bool, string) {
if currentTargetFingerprint == "" {
return false, ""
}
if lastPush == "" && boundaryState == "" {
return false, ""
}
if storedTargetFingerprint == "" {
return true,
"local push state exists without a stored PG target fingerprint"
}
if storedTargetFingerprint != currentTargetFingerprint {
return true, "PG target fingerprint changed"
}
return false, ""
}
func readBoundaryAndFingerprints(
local syncStateStore,
cutoff string,
) (
fingerprints map[string]string,
boundary map[string]string,
boundaryOK bool,
err error,
) {
raw, err := local.GetSyncState(
lastPushBoundaryStateKey,
)
if err != nil {
return nil, nil, false, fmt.Errorf(
"reading %s: %w",
lastPushBoundaryStateKey, err,
)
}
if raw == "" {
return nil, nil, false, nil
}
var state pushBoundaryState
if err := json.Unmarshal(
[]byte(raw), &state,
); err != nil {
return nil, nil, false, nil
}
fingerprints = state.Fingerprints
if cutoff != "" &&
state.Cutoff == cutoff &&
state.Fingerprints != nil {
boundary = state.Fingerprints
boundaryOK = true
}
return fingerprints, boundary, boundaryOK, nil
}
func writePushBoundaryState(
local syncStateStore,
cutoff string,
sessions []db.Session,
priorFingerprints map[string]string,
sessionFingerprints map[string]string,
) error {
state := pushBoundaryState{
Cutoff: cutoff,
Fingerprints: make(
map[string]string,
len(priorFingerprints)+len(sessions),
),
}
maps.Copy(state.Fingerprints, priorFingerprints)
for _, sess := range sessions {
fp, ok := sessionFingerprints[sess.ID]
if !ok {
return fmt.Errorf(
"missing session fingerprint for %s",
sess.ID,
)
}
state.Fingerprints[sess.ID] = fp
}
data, err := json.Marshal(state)
if err != nil {
return fmt.Errorf(
"encoding %s: %w",
lastPushBoundaryStateKey, err,
)
}
if err := local.SetSyncState(
lastPushBoundaryStateKey, string(data),
); err != nil {
return fmt.Errorf(
"writing %s: %w",
lastPushBoundaryStateKey, err,
)
}
return nil
}
func mapKeys(m map[string]db.Session) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
func localSessionSyncMarker(sess db.Session) string {
marker, err := NormalizeLocalSyncTimestamp(sess.CreatedAt)
if err != nil || marker == "" {
if err != nil {
log.Printf(
"pgsync: normalizing CreatedAt %q for "+
"session %s: %v (skipping non-RFC3339 "+
"value)",
sess.CreatedAt, sess.ID, err,
)
}
marker = ""
}
for _, value := range []*string{
sess.LocalModifiedAt,
sess.EndedAt,
sess.StartedAt,
} {
if value == nil {
continue
}
normalized, err := NormalizeLocalSyncTimestamp(*value)
if err != nil {
continue
}
if normalized > marker {
marker = normalized
}
}
if sess.FileMtime != nil {
fileMtime := time.Unix(
0, *sess.FileMtime,
).UTC().Format(LocalSyncTimestampLayout)
if fileMtime > marker {
marker = fileMtime
}
}
if marker == "" {
log.Printf(
"pgsync: session %s: all timestamps failed "+
"normalization, falling back to raw "+
"CreatedAt %q",
sess.ID, sess.CreatedAt,
)
marker = sess.CreatedAt
}
return marker
}
func readPGExcludedSessionIDs(
ctx context.Context, pg pgSessionQueryer, ids []string,
) (map[string]struct{}, error) {
ids = uniqueNonEmptyStrings(ids)
if len(ids) == 0 {
return nil, nil
}
query, args := pgExcludedSessionIDsQuery(ids)
rows, err := pg.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf(
"reading pg excluded sessions: %w", err,
)
}
defer rows.Close()
excluded := make(map[string]struct{})
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf(
"scanning pg excluded session id: %w", err,
)
}
excluded[id] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"iterating pg excluded sessions: %w", err,
)
}
return excluded, nil
}
func pgExcludedSessionIDsQuery(ids []string) (string, []any) {
return `SELECT id FROM excluded_sessions
WHERE id = ANY($1)`, []any{ids}
}
func purgePGExcludedPushSessions(
ctx context.Context, pg *sql.DB, sessionByID map[string]db.Session,
) error {
tombstoneIDsBySession := make(map[string][]string, len(sessionByID))
candidateIDs := []string{}
for id, sess := range sessionByID {
tombstoneIDs := pgSessionTombstoneIDs(sess)
tombstoneIDsBySession[id] = tombstoneIDs
candidateIDs = append(candidateIDs, tombstoneIDs...)
}
excludedIDs, err := readPGExcludedSessionIDs(ctx, pg, candidateIDs)
if err != nil {
return err
}
if len(excludedIDs) == 0 {
return nil
}
purgeIDs := []string{}
for id, tombstoneIDs := range tombstoneIDsBySession {
if !hasPGExcludedSessionID(tombstoneIDs, excludedIDs) {
continue
}
purgeIDs = append(purgeIDs, tombstoneIDs...)
delete(sessionByID, id)
}
purgeIDs = uniqueNonEmptyStrings(purgeIDs)
if len(purgeIDs) == 0 {
return nil
}
if err := insertPGExcludedSessionIDs(ctx, pg, purgeIDs); err != nil {
return err
}
return deletePGExcludedSessionRows(ctx, pg, purgeIDs)
}
func hasPGExcludedSessionID(
ids []string, excluded map[string]struct{},
) bool {
for _, id := range ids {
if _, ok := excluded[id]; ok {
return true
}
}
return false
}
type pgSessionQueryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}
type pgSessionExecer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func deletePGExcludedSessionRows(
ctx context.Context, pg pgSessionExecer, ids []string,
) error {
if len(ids) == 0 {
return nil
}
if _, err := pg.ExecContext(ctx,
`DELETE FROM sessions WHERE id = ANY($1)`,
ids,
); err != nil {
return fmt.Errorf("deleting pg excluded session rows: %w", err)
}
return nil
}
func deletePGSessionIfExcluded(
ctx context.Context, tx *sql.Tx, sess db.Session,
) (bool, error) {
ids := pgSessionTombstoneIDs(sess)
excluded, err := readPGExcludedSessionIDs(ctx, tx, ids)
if err != nil {
return false, err
}
if len(excluded) == 0 {
return false, nil
}
if err := insertPGExcludedSessionIDs(ctx, tx, ids); err != nil {
return false, err
}
if err := deletePGExcludedSessionRows(ctx, tx, ids); err != nil {
return false, err
}
return true, nil
}
// sessionPushFingerprint builds the change-detection fingerprint for a
// session. pushedMachine is the value pushSession actually writes to PG
// (pushedSessionMachine), not the raw sess.Machine: a "local"/empty sentinel
// row is written under the fallback machine, so the fingerprint must track the
// fallback to force a re-push when s.machine changes.
func sessionPushFingerprint(
sess db.Session, pushedMachine,
usageEventFingerprint, ownerMarker, dependencyFingerprint string,
) string {
fields := []string{
sess.ID,
sess.Project,
pushedMachine,
ownerMarker,
dependencyFingerprint,
sess.Agent,
stringValue(sess.FirstMessage),
stringValue(sess.DisplayName),
stringValue(sess.SessionName),
stringValue(sess.StartedAt),
stringValue(sess.EndedAt),
stringValue(sess.DeletedAt),
fmt.Sprintf("%d", sess.MessageCount),
fmt.Sprintf("%d", sess.UserMessageCount),
fmt.Sprintf("%t", sess.IsAutomated),
fmt.Sprintf("%d", sess.TotalOutputTokens),
fmt.Sprintf("%d", sess.PeakContextTokens),
fmt.Sprintf("%t", sess.HasTotalOutputTokens),
fmt.Sprintf("%t", sess.HasPeakContextTokens),
stringValue(sess.ParentSessionID),
sess.RelationshipType,
stringValue(sess.FilePath),
stringValue(sess.FileHash),
sess.CreatedAt,
fmt.Sprintf("%d", sess.ToolFailureSignalCount),
fmt.Sprintf("%d", sess.ToolRetryCount),
fmt.Sprintf("%d", sess.EditChurnCount),
fmt.Sprintf("%d", sess.ConsecutiveFailureMax),
sess.Outcome,
sess.OutcomeConfidence,
sess.EndedWithRole,
fmt.Sprintf("%d", sess.FinalFailureStreak),
stringValue(sess.SignalsPendingSince),
fmt.Sprintf("%d", sess.CompactionCount),
fmt.Sprintf("%d", sess.MidTaskCompactionCount),
float64Value(sess.ContextPressureMax),
intPtrValue(sess.HealthScore),
stringValue(sess.HealthGrade),
fmt.Sprintf("%t", sess.HasToolCalls),
fmt.Sprintf("%t", sess.HasContextData),
fmt.Sprintf("%d", sess.QualitySignalVersion),
fmt.Sprintf("%d", sess.ShortPromptCount),
fmt.Sprintf("%t", sess.UnstructuredStart),
fmt.Sprintf("%d", sess.MissingSuccessCriteriaCount),
fmt.Sprintf("%d", sess.MissingVerificationCount),
fmt.Sprintf("%d", sess.DuplicatePromptCount),
fmt.Sprintf("%d", sess.NoCodeContextCount),
fmt.Sprintf("%d", sess.RunawayToolLoopCount),
fmt.Sprintf("%d", sess.DataVersion),
sess.Cwd,
sess.GitBranch,
sess.SourceSessionID,
sess.SourceVersion,
sess.TranscriptFidelity,
stringValue(sess.TranscriptRevision),
fmt.Sprintf("%d", sess.ParserMalformedLines),
fmt.Sprintf("%t", sess.IsTruncated),
stringValue(sess.TerminationStatus),
fmt.Sprintf("%d", sess.SecretLeakCount),
sess.SecretsRulesVersion,
usageEventFingerprint,
}
var b strings.Builder
for _, f := range fields {
fmt.Fprintf(&b, "%d:%s", len(f), f)
}
return b.String()
}
// pushedSessionMachine resolves the machine field for a PG row. Old rows
// pushed before this fix with machine="local" will be repaired gradually as
// each session is modified (message count change, etc.) and re-fingerprinted.
func pushedSessionMachine(sess db.Session, fallbackMachine string) string {
if sess.Machine != "" && sess.Machine != "local" {
return sess.Machine
}
return fallbackMachine
}
func sameSessionOwner(
existingOwnerMarker, existingMachine, markerID, pushedMachine string,
legacyMarkerMachines []string,
) bool {
if existingOwnerMarker != "" {
return existingOwnerMarker == markerID
}
if existingMachine == "" {
return true
}
if existingMachine == "local" {
return true
}
if slices.Contains(legacyMarkerMachines, existingMachine) {
return true
}
return existingMachine == pushedMachine
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func transcriptRevisionValue(value *string) string {
if value == nil || *value == "" {
return "0"
}
return *value
}
func float64Value(value *float64) string {
if value == nil {
return ""
}
return fmt.Sprintf("%g", *value)
}
func intPtrValue(value *int) string {
if value == nil {
return ""
}
return fmt.Sprintf("%d", *value)
}
// nilStr converts a nil or empty *string to SQL NULL.
// Sanitizes before checking emptiness so strings like "\x00"
// that reduce to "" are correctly returned as NULL.
func nilStr(s *string) any {
if s == nil {
return nil
}
v := sanitizePG(*s)
if v == "" {
return nil
}
return v
}
// nilStrTS converts a nil or empty *string timestamp to a
// *time.Time for PG TIMESTAMPTZ columns.
func nilStrTS(s *string) any {
if s == nil || *s == "" {
return nil
}
t, ok := ParseSQLiteTimestamp(*s)
if !ok {
return nil
}
return t
}
// pushSession upserts a single session into PG.
// Local file metadata remains SQLite-only. The file hash is copied into the
// backend-neutral transcript_revision column so PG readers can observe
// transcript content changes without depending on local sync metadata.
func (s *Sync) pushSession(
ctx context.Context, tx *sql.Tx, sess db.Session, markerID string,
legacyMarkerMachines []string,
) error {
createdAt, _ := ParseSQLiteTimestamp(sess.CreatedAt)
isAutomated := sess.IsAutomated
pushedMachine := pushedSessionMachine(sess, s.machine)
var existingMachine sql.NullString
var existingOwnerMarker sql.NullString
checkErr := tx.QueryRowContext(ctx,
`SELECT machine, owner_marker FROM sessions WHERE id = $1`, sess.ID,
).Scan(&existingMachine, &existingOwnerMarker)
if checkErr != nil && !errors.Is(checkErr, sql.ErrNoRows) {
return fmt.Errorf("checking session ownership %s: %w", sess.ID, checkErr)
}
if checkErr == nil && !sameSessionOwner(
existingOwnerMarker.String,
existingMachine.String,
markerID,
pushedMachine,
legacyMarkerMachines,
) {
log.Printf(
"pgsync: session %s: skipping — already owned by machine %q, "+
"this pusher is %q; sync from the origin machine to update",
sess.ID, existingMachine.String, pushedMachine,
)
return errSessionOwnershipConflict
}
if legacyMarkerMachines == nil {
legacyMarkerMachines = []string{}
}
legacyMarkerMachinesJSON, err := json.Marshal(legacyMarkerMachines)
if err != nil {
return fmt.Errorf("encoding legacy marker machines: %w", err)
}
result, err := tx.ExecContext(ctx, `
INSERT INTO sessions (
id, machine, owner_marker, project, agent,
first_message, display_name, source_display_name,
session_name, created_at, started_at, ended_at,
deleted_at, source_deleted_at,
message_count, user_message_count,
total_output_tokens, peak_context_tokens,
has_total_output_tokens, has_peak_context_tokens,
is_automated, data_version,
cwd, git_branch, source_session_id,
source_version, parser_malformed_lines,
is_truncated, termination_status,
parent_session_id, relationship_type,
tool_failure_signal_count, tool_retry_count,
edit_churn_count, consecutive_failure_max,
outcome, outcome_confidence,
ended_with_role, final_failure_streak,
signals_pending_since,
compaction_count, mid_task_compaction_count,
context_pressure_max,
health_score, health_grade,
has_tool_calls, has_context_data,
secret_leak_count, secrets_rules_version,
quality_signal_version,
short_prompt_count, unstructured_start,
missing_success_criteria_count,
missing_verification_count, duplicate_prompt_count,
no_code_context_count, runaway_tool_loop_count,
transcript_fidelity, transcript_revision,
updated_at
)
SELECT
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12, $13, $14,
$15, $16, $17, $18,
$19, $20, $21, $22,
$23, $24, $25, $26, $27, $28, $29,
$30, $31,
$32, $33, $34, $35,
$36, $37, $38, $39,
$40,
$41, $42,
$43,
$44, $45, $46, $47,
$48, $49,
$50, $51, $52, $53, $54, $55, $56, $57, $58, $59,
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM excluded_sessions WHERE id = $1
)
ON CONFLICT (id) DO UPDATE SET
machine = EXCLUDED.machine,
owner_marker = EXCLUDED.owner_marker,
project = EXCLUDED.project,
agent = EXCLUDED.agent,
first_message = EXCLUDED.first_message,
display_name = CASE
WHEN sessions.display_name IS DISTINCT FROM
sessions.source_display_name THEN sessions.display_name
ELSE EXCLUDED.display_name
END,
source_display_name = EXCLUDED.display_name,
session_name = EXCLUDED.session_name,
created_at = EXCLUDED.created_at,
started_at = EXCLUDED.started_at,
ended_at = EXCLUDED.ended_at,
deleted_at = CASE
WHEN sessions.deleted_at IS DISTINCT FROM
sessions.source_deleted_at THEN sessions.deleted_at
ELSE EXCLUDED.deleted_at
END,
source_deleted_at = EXCLUDED.deleted_at,
message_count = EXCLUDED.message_count,
user_message_count = EXCLUDED.user_message_count,
total_output_tokens = EXCLUDED.total_output_tokens,
peak_context_tokens = EXCLUDED.peak_context_tokens,
has_total_output_tokens = EXCLUDED.has_total_output_tokens,
has_peak_context_tokens = EXCLUDED.has_peak_context_tokens,
is_automated = EXCLUDED.is_automated,
data_version = EXCLUDED.data_version,
cwd = EXCLUDED.cwd,
git_branch = EXCLUDED.git_branch,
source_session_id = EXCLUDED.source_session_id,
source_version = EXCLUDED.source_version,
transcript_fidelity = EXCLUDED.transcript_fidelity,
transcript_revision = EXCLUDED.transcript_revision,
parser_malformed_lines = EXCLUDED.parser_malformed_lines,
is_truncated = EXCLUDED.is_truncated,
termination_status = EXCLUDED.termination_status,
parent_session_id = EXCLUDED.parent_session_id,
relationship_type = EXCLUDED.relationship_type,
tool_failure_signal_count = EXCLUDED.tool_failure_signal_count,
tool_retry_count = EXCLUDED.tool_retry_count,
edit_churn_count = EXCLUDED.edit_churn_count,
consecutive_failure_max = EXCLUDED.consecutive_failure_max,
outcome = EXCLUDED.outcome,
outcome_confidence = EXCLUDED.outcome_confidence,
ended_with_role = EXCLUDED.ended_with_role,
final_failure_streak = EXCLUDED.final_failure_streak,
signals_pending_since = EXCLUDED.signals_pending_since,
compaction_count = EXCLUDED.compaction_count,
mid_task_compaction_count = EXCLUDED.mid_task_compaction_count,
context_pressure_max = EXCLUDED.context_pressure_max,
health_score = EXCLUDED.health_score,
health_grade = EXCLUDED.health_grade,
has_tool_calls = EXCLUDED.has_tool_calls,
has_context_data = EXCLUDED.has_context_data,
secret_leak_count = EXCLUDED.secret_leak_count,
secrets_rules_version = EXCLUDED.secrets_rules_version,
quality_signal_version = EXCLUDED.quality_signal_version,
short_prompt_count = EXCLUDED.short_prompt_count,
unstructured_start = EXCLUDED.unstructured_start,
missing_success_criteria_count = EXCLUDED.missing_success_criteria_count,
missing_verification_count = EXCLUDED.missing_verification_count,
duplicate_prompt_count = EXCLUDED.duplicate_prompt_count,
no_code_context_count = EXCLUDED.no_code_context_count,
runaway_tool_loop_count = EXCLUDED.runaway_tool_loop_count,
updated_at = NOW()
WHERE ((
sessions.owner_marker = ''
AND (sessions.machine = EXCLUDED.machine
OR sessions.machine = 'local'
OR sessions.machine = ''
OR sessions.machine IN (
SELECT jsonb_array_elements_text($60::jsonb)
))
)
OR sessions.owner_marker = EXCLUDED.owner_marker)
AND NOT EXISTS (
SELECT 1 FROM excluded_sessions
WHERE id = EXCLUDED.id
)
AND (
sessions.machine IS DISTINCT FROM EXCLUDED.machine
OR sessions.owner_marker IS DISTINCT FROM EXCLUDED.owner_marker
OR sessions.project IS DISTINCT FROM EXCLUDED.project
OR sessions.agent IS DISTINCT FROM EXCLUDED.agent
OR sessions.first_message IS DISTINCT FROM EXCLUDED.first_message
OR sessions.source_display_name IS DISTINCT FROM EXCLUDED.display_name
OR sessions.session_name IS DISTINCT FROM EXCLUDED.session_name
OR sessions.created_at IS DISTINCT FROM EXCLUDED.created_at
OR sessions.started_at IS DISTINCT FROM EXCLUDED.started_at
OR sessions.ended_at IS DISTINCT FROM EXCLUDED.ended_at
OR sessions.source_deleted_at IS DISTINCT FROM EXCLUDED.deleted_at
OR sessions.message_count IS DISTINCT FROM EXCLUDED.message_count
OR sessions.user_message_count IS DISTINCT FROM EXCLUDED.user_message_count
OR sessions.total_output_tokens IS DISTINCT FROM EXCLUDED.total_output_tokens
OR sessions.peak_context_tokens IS DISTINCT FROM EXCLUDED.peak_context_tokens
OR sessions.has_total_output_tokens IS DISTINCT FROM EXCLUDED.has_total_output_tokens
OR sessions.has_peak_context_tokens IS DISTINCT FROM EXCLUDED.has_peak_context_tokens
OR sessions.is_automated IS DISTINCT FROM EXCLUDED.is_automated
OR sessions.data_version IS DISTINCT FROM EXCLUDED.data_version
OR sessions.cwd IS DISTINCT FROM EXCLUDED.cwd
OR sessions.git_branch IS DISTINCT FROM EXCLUDED.git_branch
OR sessions.source_session_id IS DISTINCT FROM EXCLUDED.source_session_id
OR sessions.source_version IS DISTINCT FROM EXCLUDED.source_version
OR sessions.transcript_fidelity IS DISTINCT FROM EXCLUDED.transcript_fidelity
OR sessions.transcript_revision IS DISTINCT FROM EXCLUDED.transcript_revision
OR sessions.parser_malformed_lines IS DISTINCT FROM EXCLUDED.parser_malformed_lines
OR sessions.is_truncated IS DISTINCT FROM EXCLUDED.is_truncated
OR sessions.termination_status IS DISTINCT FROM EXCLUDED.termination_status
OR sessions.parent_session_id IS DISTINCT FROM EXCLUDED.parent_session_id
OR sessions.relationship_type IS DISTINCT FROM EXCLUDED.relationship_type
OR sessions.tool_failure_signal_count IS DISTINCT FROM EXCLUDED.tool_failure_signal_count
OR sessions.tool_retry_count IS DISTINCT FROM EXCLUDED.tool_retry_count
OR sessions.edit_churn_count IS DISTINCT FROM EXCLUDED.edit_churn_count
OR sessions.consecutive_failure_max IS DISTINCT FROM EXCLUDED.consecutive_failure_max
OR sessions.outcome IS DISTINCT FROM EXCLUDED.outcome
OR sessions.outcome_confidence IS DISTINCT FROM EXCLUDED.outcome_confidence
OR sessions.ended_with_role IS DISTINCT FROM EXCLUDED.ended_with_role
OR sessions.final_failure_streak IS DISTINCT FROM EXCLUDED.final_failure_streak
OR sessions.signals_pending_since IS DISTINCT FROM EXCLUDED.signals_pending_since
OR sessions.compaction_count IS DISTINCT FROM EXCLUDED.compaction_count
OR sessions.mid_task_compaction_count IS DISTINCT FROM EXCLUDED.mid_task_compaction_count
OR sessions.context_pressure_max IS DISTINCT FROM EXCLUDED.context_pressure_max
OR sessions.health_score IS DISTINCT FROM EXCLUDED.health_score
OR sessions.health_grade IS DISTINCT FROM EXCLUDED.health_grade
OR sessions.has_tool_calls IS DISTINCT FROM EXCLUDED.has_tool_calls
OR sessions.has_context_data IS DISTINCT FROM EXCLUDED.has_context_data
OR sessions.secret_leak_count IS DISTINCT FROM EXCLUDED.secret_leak_count
OR sessions.secrets_rules_version IS DISTINCT FROM EXCLUDED.secrets_rules_version
OR sessions.quality_signal_version IS DISTINCT FROM EXCLUDED.quality_signal_version
OR sessions.short_prompt_count IS DISTINCT FROM EXCLUDED.short_prompt_count
OR sessions.unstructured_start IS DISTINCT FROM EXCLUDED.unstructured_start
OR sessions.missing_success_criteria_count IS DISTINCT FROM EXCLUDED.missing_success_criteria_count
OR sessions.missing_verification_count IS DISTINCT FROM EXCLUDED.missing_verification_count
OR sessions.duplicate_prompt_count IS DISTINCT FROM EXCLUDED.duplicate_prompt_count
OR sessions.no_code_context_count IS DISTINCT FROM EXCLUDED.no_code_context_count
OR sessions.runaway_tool_loop_count IS DISTINCT FROM EXCLUDED.runaway_tool_loop_count)`,
sess.ID, pushedMachine, markerID,
sanitizePG(sess.Project),
sess.Agent,
nilStr(sess.FirstMessage),
nilStr(sess.DisplayName),
nilStr(sess.DisplayName),
nilStr(sess.SessionName),
createdAt,
nilStrTS(sess.StartedAt),
nilStrTS(sess.EndedAt),
nilStrTS(sess.DeletedAt),
nilStrTS(sess.DeletedAt),
sess.MessageCount, sess.UserMessageCount,
sess.TotalOutputTokens, sess.PeakContextTokens,
sess.HasTotalOutputTokens, sess.HasPeakContextTokens,
isAutomated, sess.DataVersion,
sanitizePG(sess.Cwd), sanitizePG(sess.GitBranch),
sanitizePG(sess.SourceSessionID),
sanitizePG(sess.SourceVersion),
sess.ParserMalformedLines,
sess.IsTruncated, nilStr(sess.TerminationStatus),
nilStr(sess.ParentSessionID),
sess.RelationshipType,
sess.ToolFailureSignalCount, sess.ToolRetryCount,
sess.EditChurnCount, sess.ConsecutiveFailureMax,
sess.Outcome, sess.OutcomeConfidence,
sanitizePG(sess.EndedWithRole), sess.FinalFailureStreak,
nilStr(sess.SignalsPendingSince),
sess.CompactionCount, sess.MidTaskCompactionCount,
sess.ContextPressureMax,
sess.HealthScore, nilStr(sess.HealthGrade),
sess.HasToolCalls, sess.HasContextData,
sess.SecretLeakCount, sess.SecretsRulesVersion,
sess.QualitySignalVersion,
sess.ShortPromptCount, sess.UnstructuredStart,
sess.MissingSuccessCriteriaCount,
sess.MissingVerificationCount, sess.DuplicatePromptCount,
sess.NoCodeContextCount, sess.RunawayToolLoopCount,
sanitizePG(sess.TranscriptFidelity),
transcriptRevisionValue(sess.TranscriptRevision),
string(legacyMarkerMachinesJSON),
)
if err != nil {
return err
}
if rowsAffected, rowsErr := result.RowsAffected(); rowsErr == nil && rowsAffected == 0 {
excluded, excludedErr := deletePGSessionIfExcluded(ctx, tx, sess)
if excludedErr != nil {
return excludedErr
}
if excluded {
return errSessionExcluded
}
refreshErr := tx.QueryRowContext(ctx,
`SELECT machine, owner_marker FROM sessions WHERE id = $1`, sess.ID,
).Scan(&existingMachine, &existingOwnerMarker)
if refreshErr != nil {
// The guarded upsert changed no rows and we cannot
// re-read the current owner, so we cannot prove this
// pusher owns the session. Surface the error instead of
// reporting a blocked write as success, so the caller's
// retry path handles it rather than pushing messages for
// a row this pusher did not write.
return fmt.Errorf(
"re-reading session %s ownership after blocked upsert: %w",
sess.ID, refreshErr,
)
}
if !sameSessionOwner(
existingOwnerMarker.String, existingMachine.String,
markerID, pushedMachine, legacyMarkerMachines,
) {
log.Printf(
"pgsync: session %s: skipping — already owned by machine %q, this pusher is %q; sync from the origin machine to update",
sess.ID, existingMachine.String, pushedMachine,
)
return errSessionOwnershipConflict
}
}
excluded, excludedErr := deletePGSessionIfExcluded(ctx, tx, sess)
if excludedErr != nil {
return excludedErr
}
if excluded {
return errSessionExcluded
}
if err := replacePGSessionAliases(ctx, tx, sess); err != nil {
return err
}
return nil
}
// pushMessages replaces a session's messages and tool calls
// in PG. It skips the replacement when the PG message count
// already matches the local count, avoiding redundant work
// for metadata-only changes.
func (s *Sync) pushMessages(
ctx context.Context,
tx *sql.Tx,
sessionID string,
full bool,
sessionUsageFingerprints map[string]string,
comparisons *pushMessageComparison,
) (int, error) {
localCount, err := s.local.MessageCount(sessionID)
if err != nil {
return 0, fmt.Errorf(
"counting local messages: %w", err,
)
}
if localCount == 0 {
if _, err := tx.ExecContext(ctx,
`DELETE FROM tool_result_events WHERE session_id = $1`,
sessionID,
); err != nil {
return 0, fmt.Errorf(
"deleting stale pg tool_result_events: %w", err,
)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM tool_calls WHERE session_id = $1`,
sessionID,
); err != nil {
return 0, fmt.Errorf(
"deleting stale pg tool_calls: %w", err,
)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM messages WHERE session_id = $1`,
sessionID,
); err != nil {
return 0, fmt.Errorf(
"deleting stale pg messages: %w", err,
)
}
// Usage events are independent of transcript messages: a
// session can carry token/cost accounting (e.g. a hermes
// state.db-only session) with zero messages. Sync them here
// too so their cost reaches PG instead of being dropped with
// the rest of the message-replace path below.
if err := s.replaceUsageEvents(ctx, tx, sessionID); err != nil {
return 0, err
}
if err := reconcilePinnedMessages(
ctx, tx, sessionID,
); err != nil {
return 0, err
}
return 0, nil
}
pgAgg, pgToolAgg, hasPreloadedComparisons := comparisonAggregates(
sessionID, comparisons,
)
if !hasPreloadedComparisons {
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*),
COALESCE(SUM(content_length), 0),
COALESCE(MAX(content_length), 0),
COALESCE(MIN(content_length), 0),
COALESCE(
STRING_AGG(ordinal::text, ',' ORDER BY ordinal)
FILTER (WHERE is_system),
''
)
FROM messages
WHERE session_id = $1`,
sessionID,
).Scan(
&pgAgg.Count, &pgAgg.Sum,
&pgAgg.Max, &pgAgg.Min,
&pgAgg.SysFP,
); err != nil {
return 0, fmt.Errorf(
"counting pg messages: %w", err,
)
}
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*),
COALESCE(SUM(result_content_length), 0)
FROM tool_calls
WHERE session_id = $1`,
sessionID,
).Scan(&pgToolAgg.Count, &pgToolAgg.Sum); err != nil {
return 0, fmt.Errorf(
"counting pg tool_calls: %w", err,
)
}
}
if !full && pgAgg.Count == localCount && pgAgg.Count > 0 {
localFP := pushLocalMessageFingerprint{}
localFP.Sum, localFP.Max, localFP.Min, err = s.local.MessageContentFingerprint(
sessionID,
)
if err != nil {
return 0, fmt.Errorf(
"computing local content fingerprint: %w",
err,
)
}
localFP.ContentHashFP, err = s.local.MessageContentHashFingerprint(
sessionID,
)
if err != nil {
return 0, fmt.Errorf(
"computing local content hash fingerprint: %w",
err,
)
}
localFP.RoleTimeFP, err = localMessageRoleTimePGFingerprint(
s.local, sessionID,
)
if err != nil {
return 0, fmt.Errorf(
"computing local role/time fingerprint: %w",
err,
)
}
localFP.FlagsFP, err = s.local.MessageFlagsFingerprint(sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing local message flags fingerprint: %w",
err,
)
}
localFP.SystemFP, err = s.local.SystemMessageFingerprint(sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing local system message fingerprint: %w", err,
)
}
localFP.ToolCallCount, err = s.local.ToolCallCount(sessionID)
if err != nil {
return 0, fmt.Errorf(
"counting local tool_calls: %w", err,
)
}
localFP.ToolCallSum, err = s.local.ToolCallContentFingerprint(
sessionID,
)
if err != nil {
return 0, fmt.Errorf(
"computing local tool_call content fingerprint: %w",
err,
)
}
localFP.ToolCallFP, err = s.local.ToolCallFingerprint(sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing local tool_call fingerprint: %w", err,
)
}
localFP.ToolResultFP, err = localToolResultEventPGFingerprint(
s.local, sessionID,
)
if err != nil {
return 0, fmt.Errorf(
"computing local tool_result_event fingerprint: %w", err,
)
}
localFP.TokenFP, err = s.local.MessageTokenFingerprint(sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing local token fingerprint: %w",
err,
)
}
usageFromMap := false
if sessionUsageFingerprints != nil {
var ok bool
localFP.UsageEventFP, ok = sessionUsageFingerprints[sessionID]
usageFromMap = ok
}
if !usageFromMap {
localFP.UsageEventFP, err = s.local.UsageEventFingerprint(sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing local usage event fingerprint: %w",
err,
)
}
}
if comparisons == nil {
pgContentHashFP, err := pgMessageContentHashFingerprint(
ctx, tx, sessionID,
)
if err != nil {
return 0, fmt.Errorf(
"computing pg content hash fingerprint: %w",
err,
)
}
pgRoleTimeFP, err := pgMessageRoleTimeFingerprint(
ctx, tx, sessionID,
)
if err != nil {
return 0, fmt.Errorf(
"computing pg role/time fingerprint: %w",
err,
)
}
pgFlagsFP, err := pgMessageFlagsFingerprint(ctx, tx, sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing pg message flags fingerprint: %w",
err,
)
}
pgTokenFP, err := pgMessageTokenFingerprint(ctx, tx, sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing pg token fingerprint: %w",
err,
)
}
pgTCFP, err := pgToolCallFingerprint(ctx, tx, sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing pg tool_call fingerprint: %w",
err,
)
}
pgResultFP, err := pgToolResultEventFingerprint(ctx, tx, sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing pg tool_result_event fingerprint: %w",
err,
)
}
pgUsageFP, err := pgUsageEventFingerprint(ctx, tx, sessionID)
if err != nil {
return 0, fmt.Errorf(
"computing pg usage event fingerprint: %w",
err,
)
}
if localFP.Sum == pgAgg.Sum &&
localFP.Max == pgAgg.Max &&
localFP.Min == pgAgg.Min &&
localFP.ContentHashFP == pgContentHashFP &&
localFP.RoleTimeFP == pgRoleTimeFP &&
localFP.FlagsFP == pgFlagsFP &&
localFP.SystemFP == pgAgg.SysFP &&
localFP.ToolCallCount == pgToolAgg.Count &&
localFP.ToolCallSum == pgToolAgg.Sum &&
localFP.ToolCallFP == pgTCFP &&
localFP.ToolResultFP == pgResultFP &&
localFP.TokenFP == pgTokenFP &&
localFP.UsageEventFP == pgUsageFP {
return 0, nil
}
} else if shouldSkipSessionMessages(
sessionID, localCount, localFP, full, comparisons,
) {
return 0, nil
}
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM tool_result_events
WHERE session_id = $1
`, sessionID); err != nil {
return 0, fmt.Errorf(
"deleting pg tool_result_events: %w", err,
)
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM tool_calls
WHERE session_id = $1
`, sessionID); err != nil {
return 0, fmt.Errorf(
"deleting pg tool_calls: %w", err,
)
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM messages
WHERE session_id = $1
`, sessionID); err != nil {
return 0, fmt.Errorf(
"deleting pg messages: %w", err,
)
}
if err := s.replaceUsageEvents(ctx, tx, sessionID); err != nil {
return 0, err
}
count := 0
startOrdinal := 0
for {
msgs, err := s.local.GetMessages(
ctx, sessionID, startOrdinal,
db.MaxMessageLimit, true,
)
if err != nil {
return count, fmt.Errorf(
"reading local messages: %w", err,
)
}
if len(msgs) == 0 {
break
}
nextOrdinal := msgs[len(msgs)-1].Ordinal + 1
if nextOrdinal <= startOrdinal {
return count, fmt.Errorf(
"pushMessages %s: ordinal did not "+
"advance (start=%d, last=%d)",
sessionID, startOrdinal,
msgs[len(msgs)-1].Ordinal,
)
}
if err := bulkInsertMessages(
ctx, tx, sessionID, msgs,
); err != nil {
return count, err
}
if err := bulkInsertToolCalls(
ctx, tx, sessionID, msgs,
); err != nil {
return count, err
}
if err := bulkInsertToolResultEvents(
ctx, tx, sessionID, msgs,
); err != nil {
return count, err
}
count += len(msgs)
startOrdinal = nextOrdinal
}
if err := reconcilePinnedMessages(ctx, tx, sessionID); err != nil {
return count, err
}
return count, nil
}
// replaceUsageEvents replaces a session's usage_events in PG with the
// current local set. Usage events are synced independently of transcript
// messages because a session can have token/cost accounting with no
// messages at all (e.g. a hermes state.db-only session). Both the
// zero-message and the normal message-replace paths in pushMessages call
// this so a session's cost always reaches PG.
func (s *Sync) replaceUsageEvents(
ctx context.Context, tx *sql.Tx, sessionID string,
) error {
if _, err := tx.ExecContext(ctx, `
DELETE FROM usage_events
WHERE session_id = $1
`, sessionID); err != nil {
return fmt.Errorf("deleting pg usage_events: %w", err)
}
usageEvents, err := s.local.GetUsageEvents(ctx, sessionID)
if err != nil {
return fmt.Errorf("reading local usage events: %w", err)
}
if err := bulkInsertUsageEvents(ctx, tx, usageEvents); err != nil {
return err
}
return nil
}
func reconcilePinnedMessages(
ctx context.Context, tx *sql.Tx, sessionID string,
) error {
if _, err := tx.ExecContext(ctx, `
UPDATE pinned_messages p
SET source_uuid = m.source_uuid
FROM messages m
WHERE p.session_id = $1
AND m.session_id = p.session_id
AND m.ordinal = p.message_id
AND p.source_uuid = ''
AND m.source_uuid <> ''`,
sessionID,
); err != nil {
return fmt.Errorf(
"backfilling pg pin source_uuid: %w", err,
)
}
// Move shifted source-backed pins out of the real ordinal range
// first. Pins already on their resolved target stay in place so
// duplicate repairs prefer the current target row's metadata.
// When multiple messages share a source_uuid (the schema allows
// it), prefer the message at the pin's current message_id so a
// correctly-placed pin is not relocated to a different duplicate.
if _, err := tx.ExecContext(ctx, `
WITH matched AS (
SELECT DISTINCT ON (p.id)
p.id, p.message_id, p.ordinal,
m.ordinal AS target_ordinal
FROM pinned_messages p
JOIN messages m
ON m.session_id = p.session_id
AND m.source_uuid = p.source_uuid
WHERE p.session_id = $1
AND p.source_uuid <> ''
ORDER BY p.id,
CASE WHEN m.ordinal = p.message_id THEN 0 ELSE 1 END,
m.ordinal
),
numbered AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY id) AS temp_ordinal
FROM matched
WHERE target_ordinal <> message_id
OR target_ordinal <> ordinal
)
UPDATE pinned_messages p
SET message_id = (-2000000000 + numbered.temp_ordinal::INT),
ordinal = (-2000000000 + numbered.temp_ordinal::INT)
FROM numbered
WHERE p.id = numbered.id`,
sessionID,
); err != nil {
return fmt.Errorf(
"staging pg pins for source_uuid realignment: %w", err,
)
}
if _, err := tx.ExecContext(ctx, `
WITH matched AS (
SELECT DISTINCT ON (p.id)
p.id, p.message_id, p.created_at,
m.ordinal AS target_ordinal
FROM pinned_messages p
JOIN messages m
ON m.session_id = p.session_id
AND m.source_uuid = p.source_uuid
WHERE p.session_id = $1
AND p.source_uuid <> ''
ORDER BY p.id,
CASE WHEN m.ordinal = p.message_id THEN 0 ELSE 1 END,
m.ordinal
),
ranked AS (
SELECT id, target_ordinal,
ROW_NUMBER() OVER (
PARTITION BY target_ordinal
ORDER BY
(message_id = target_ordinal) DESC,
created_at DESC,
id DESC
) AS target_rank
FROM matched
)
DELETE FROM pinned_messages p
USING ranked r
WHERE p.session_id = $1
AND r.target_rank = 1
AND p.message_id = r.target_ordinal
AND p.id <> r.id`,
sessionID,
); err != nil {
return fmt.Errorf(
"clearing pg pin target conflicts: %w", err,
)
}
if _, err := tx.ExecContext(ctx, `
WITH matched AS (
SELECT DISTINCT ON (p.id)
p.id, p.message_id, p.created_at,
m.ordinal AS target_ordinal
FROM pinned_messages p
JOIN messages m
ON m.session_id = p.session_id
AND m.source_uuid = p.source_uuid
WHERE p.session_id = $1
AND p.source_uuid <> ''
ORDER BY p.id,
CASE WHEN m.ordinal = p.message_id THEN 0 ELSE 1 END,
m.ordinal
),
ranked AS (
SELECT id, target_ordinal,
ROW_NUMBER() OVER (
PARTITION BY target_ordinal
ORDER BY
(message_id = target_ordinal) DESC,
created_at DESC,
id DESC
) AS target_rank
FROM matched
)
UPDATE pinned_messages p
SET message_id = r.target_ordinal,
ordinal = r.target_ordinal
FROM ranked r
WHERE p.id = r.id
AND r.target_rank = 1`,
sessionID,
); err != nil {
return fmt.Errorf(
"realigning pg pins by source_uuid: %w", err,
)
}
// Prune pins whose anchor no longer exists. For source-backed
// pins (source_uuid <> '') the canonical anchor is source_uuid,
// so a pin must be dropped when no message in this session has
// that source_uuid — otherwise a stale pin can survive on top
// of an unrelated message that now occupies the same ordinal.
// The ordinal-NOT-EXISTS clause additionally removes legacy
// pins (source_uuid = '') with a stale ordinal and clears any
// non-rank-1 duplicate left at the sentinel ordinal by step 2.
if _, err := tx.ExecContext(ctx, `
DELETE FROM pinned_messages p
WHERE p.session_id = $1
AND (
(
p.source_uuid <> ''
AND NOT EXISTS (
SELECT 1 FROM messages m
WHERE m.session_id = p.session_id
AND m.source_uuid = p.source_uuid
)
)
OR NOT EXISTS (
SELECT 1 FROM messages m
WHERE m.session_id = p.session_id
AND m.ordinal = p.message_id
)
)`,
sessionID,
); err != nil {
return fmt.Errorf(
"pruning stale pg pins: %w", err,
)
}
return nil
}
func pgMessageTokenFingerprint(
ctx context.Context, tx *sql.Tx, sessionID string,
) (string, error) {
rows, err := tx.QueryContext(ctx,
`SELECT ordinal, model, token_usage, context_tokens,
output_tokens, has_context_tokens, has_output_tokens,
claude_message_id, claude_request_id,
source_type, source_subtype, source_uuid,
source_parent_uuid, is_sidechain, is_compact_boundary
FROM messages
WHERE session_id = $1
ORDER BY ordinal ASC`,
sessionID,
)
if err != nil {
return "", err
}
defer rows.Close()
var b strings.Builder
for rows.Next() {
var ordinal, contextTokens, outputTokens int
var model, tokenUsage string
var hasContextTokens, hasOutputTokens bool
var claudeMsgID, claudeReqID string
var srcType, srcSubtype, srcUUID, srcParentUUID string
var isSidechain, isCompactBoundary bool
if err := rows.Scan(
&ordinal, &model, &tokenUsage, &contextTokens,
&outputTokens, &hasContextTokens, &hasOutputTokens,
&claudeMsgID, &claudeReqID,
&srcType, &srcSubtype, &srcUUID, &srcParentUUID,
&isSidechain, &isCompactBoundary,
); err != nil {
return "", err
}
fmt.Fprintf(&b,
"%d|%d:%s|%d:%s|%d|%d|%t|%t|%s|%s|"+
"%d:%s|%d:%s|%d:%s|%d:%s|%t|%t;",
ordinal,
len(model), model,
len(tokenUsage), tokenUsage,
contextTokens, outputTokens,
hasContextTokens, hasOutputTokens,
claudeMsgID, claudeReqID,
len(srcType), srcType,
len(srcSubtype), srcSubtype,
len(srcUUID), srcUUID,
len(srcParentUUID), srcParentUUID,
isSidechain, isCompactBoundary,
)
}
return b.String(), rows.Err()
}
func pgMessageContentHashFingerprint(
ctx context.Context, tx *sql.Tx, sessionID string,
) (string, error) {
rows, err := tx.QueryContext(ctx,
`SELECT ordinal, COALESCE(content, ''), content_length
FROM messages
WHERE session_id = $1
ORDER BY ordinal ASC`,
sessionID,
)
if err != nil {
return "", err
}
defer rows.Close()
var b strings.Builder
for rows.Next() {
var ordinal, contentLength int
var content string
if err := rows.Scan(
&ordinal, &content, &contentLength,
); err != nil {
return "", err
}
sum := sha256.Sum256([]byte(db.SanitizeUTF8(content)))
fmt.Fprintf(&b, "%d|%d|%x;", ordinal, contentLength, sum)
}
return b.String(), rows.Err()
}
func localMessageRoleTimePGFingerprint(
local *db.DB, sessionID string,
) (string, error) {
return local.MessageRoleTimeFingerprintWithTimestampNormalizer(
sessionID,
pgPushTimestampFingerprintText,
)
}
func pgPushTimestampFingerprintText(value string) string {
t, ok := ParseSQLiteTimestamp(value)
if !ok {
return ""
}
return FormatISO8601(t.Truncate(time.Microsecond))
}
func pgMessageRoleTimeFingerprint(
ctx context.Context, tx *sql.Tx, sessionID string,
) (string, error) {
rows, err := tx.QueryContext(ctx,
`SELECT ordinal, role, timestamp
FROM messages
WHERE session_id = $1
ORDER BY ordinal ASC`,
sessionID,
)
if err != nil {
return "", err
}
defer rows.Close()
var b strings.Builder
for rows.Next() {
var ordinal int
var role string
var timestamp sql.NullTime
if err := rows.Scan(
&ordinal, &role, &timestamp,
); err != nil {
return "", err
}
role = db.SanitizeUTF8(role)
timestampText := ""
if timestamp.Valid {
timestampText = FormatISO8601(timestamp.Time)
}
fmt.Fprintf(&b, "%d|%d:%s|%d:%s;",
ordinal, len(role), role,
len(timestampText), timestampText,
)
}
return b.String(), rows.Err()
}
func pgMessageFlagsFingerprint(
ctx context.Context, tx *sql.Tx, sessionID string,
) (string, error) {
rows, err := tx.QueryContext(ctx,
`SELECT ordinal, is_system, has_thinking, has_tool_use,
COALESCE(thinking_text, '')
FROM messages
WHERE session_id = $1
ORDER BY ordinal ASC`,
sessionID,
)
if err != nil {
return "", err
}
defer rows.Close()
var b strings.Builder
for rows.Next() {
var ordinal int
var isSystem, hasThinking, hasToolUse bool
var thinkingText string
if err := rows.Scan(
&ordinal, &isSystem, &hasThinking, &hasToolUse,
&thinkingText,
); err != nil {
return "", err
}
sum := sha256.Sum256([]byte(db.SanitizeUTF8(thinkingText)))
fmt.Fprintf(&b, "%d|%t|%t|%t|%x;",
ordinal, isSystem, hasThinking, hasToolUse, sum)
}
return b.String(), rows.Err()
}
func pgToolCallFingerprint(
ctx context.Context, tx *sql.Tx, sessionID string,
) (string, error) {
rows, err := tx.QueryContext(ctx,
`SELECT message_ordinal, call_index, tool_name, category,
tool_use_id, COALESCE(input_json, ''),
COALESCE(skill_name, ''), COALESCE(subagent_session_id, ''),
COALESCE(result_content_length, 0),
COALESCE(result_content, ''),
COALESCE(file_path, '')
FROM tool_calls
WHERE session_id = $1
ORDER BY message_ordinal ASC, call_index ASC`,
sessionID,
)
if err != nil {
return "", err
}
defer rows.Close()
var b strings.Builder
for rows.Next() {
var messageOrdinal, callIndex, resultContentLength int
var toolName, category, toolUseID, inputJSON string
var skillName, subagentSessionID, resultContent, filePath string
if err := rows.Scan(
&messageOrdinal, &callIndex, &toolName, &category,
&toolUseID, &inputJSON, &skillName, &subagentSessionID,
&resultContentLength, &resultContent, &filePath,
); err != nil {
return "", err
}
fmt.Fprintf(&b,
"%d|%d|%d:%s|%d:%s|%d:%s|%d:%s|%d:%s|%d:%s|%d|%d:%s|%d:%s;",
messageOrdinal, callIndex,
len(toolName), toolName,
len(category), category,
len(toolUseID), toolUseID,
len(inputJSON), inputJSON,
len(skillName), skillName,
len(subagentSessionID), subagentSessionID,
resultContentLength,
len(resultContent), resultContent,
len(filePath), filePath,
)
}
return b.String(), rows.Err()
}
func pgUsageEventFingerprint(
ctx context.Context, tx *sql.Tx, sessionID string,
) (string, error) {
rows, err := tx.QueryContext(ctx,
`SELECT message_ordinal, source, model,
input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens,
reasoning_tokens, cost_usd, cost_status, cost_source,
occurred_at, dedup_key
FROM usage_events
WHERE session_id = $1
ORDER BY occurred_at NULLS FIRST, id`,
sessionID,
)
if err != nil {
return "", err
}
defer rows.Close()
var b strings.Builder
for rows.Next() {
var ordinal sql.NullInt64
var source, model, costStatus, costSource string
var inputTokens, outputTokens int
var cacheCreationInputTokens, cacheReadInputTokens int
var reasoningTokens int
var cost sql.NullFloat64
var occurredAt sql.NullTime
var dedupKey sql.NullString
if err := rows.Scan(
&ordinal, &source, &model,
&inputTokens, &outputTokens,
&cacheCreationInputTokens, &cacheReadInputTokens,
&reasoningTokens, &cost, &costStatus, &costSource,
&occurredAt, &dedupKey,
); err != nil {
return "", err
}
occurred := ""
if occurredAt.Valid {
occurred = FormatISO8601(occurredAt.Time)
}
fmt.Fprintf(&b,
"%t|%d|%d:%s|%d:%s|%d|%d|%d|%d|%d|%t|%g|%d:%s|%d:%s|%d:%s|%d:%s;",
ordinal.Valid,
ordinal.Int64,
len(source), source,
len(model), model,
inputTokens,
outputTokens,
cacheCreationInputTokens,
cacheReadInputTokens,
reasoningTokens,
cost.Valid,
cost.Float64,
len(costStatus), costStatus,
len(costSource), costSource,
len(occurred), occurred,
len(dedupKey.String), dedupKey.String,
)
}
return b.String(), rows.Err()
}
const msgInsertBatch = 100
// bulkInsertMessages inserts messages using multi-row VALUES.
func bulkInsertMessages(
ctx context.Context, tx *sql.Tx,
sessionID string, msgs []db.Message,
) error {
for i := 0; i < len(msgs); i += msgInsertBatch {
end := min(i+msgInsertBatch, len(msgs))
batch := msgs[i:end]
var b strings.Builder
b.WriteString(`INSERT INTO messages (
session_id, ordinal, role, content, thinking_text,
timestamp, has_thinking, has_tool_use,
content_length, is_system, model, token_usage,
context_tokens, output_tokens,
has_context_tokens, has_output_tokens,
claude_message_id, claude_request_id,
source_type, source_subtype, source_uuid,
source_parent_uuid, is_sidechain,
is_compact_boundary) VALUES `)
args := make([]any, 0, len(batch)*24)
for j, m := range batch {
if j > 0 {
b.WriteByte(',')
}
p := j*24 + 1
fmt.Fprintf(&b,
"($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)",
p, p+1, p+2, p+3, p+4,
p+5, p+6, p+7, p+8, p+9,
p+10, p+11, p+12, p+13, p+14, p+15,
p+16, p+17, p+18, p+19, p+20,
p+21, p+22, p+23,
)
var ts any
if m.Timestamp != "" {
if t, ok := ParseSQLiteTimestamp(
m.Timestamp,
); ok {
ts = t
}
}
// Sanitize every parser-derived string, not just
// content: model and source fields come from
// third-party session files and have carried NUL
// bytes (e.g. raw protobuf fragments), which PG
// rejects with SQLSTATE 22021.
args = append(args,
sessionID, m.Ordinal, sanitizePG(m.Role),
sanitizePG(m.Content),
sanitizePG(m.ThinkingText), ts,
m.HasThinking,
m.HasToolUse, m.ContentLength, m.IsSystem,
sanitizePG(m.Model),
sanitizePG(string(m.TokenUsage)),
m.ContextTokens, m.OutputTokens,
m.HasContextTokens, m.HasOutputTokens,
sanitizePG(m.ClaudeMessageID),
sanitizePG(m.ClaudeRequestID),
sanitizePG(m.SourceType),
sanitizePG(m.SourceSubtype),
sanitizePG(m.SourceUUID),
sanitizePG(m.SourceParentUUID),
m.IsSidechain,
m.IsCompactBoundary,
)
}
if _, err := tx.ExecContext(
ctx, b.String(), args...,
); err != nil {
return fmt.Errorf(
"bulk inserting messages: %w", err,
)
}
}
return nil
}
func bulkInsertUsageEvents(
ctx context.Context, tx *sql.Tx, events []db.UsageEvent,
) error {
if len(events) == 0 {
return nil
}
const usageBatch = 100
for i := 0; i < len(events); i += usageBatch {
end := min(i+usageBatch, len(events))
batch := events[i:end]
var b strings.Builder
b.WriteString(`INSERT INTO usage_events (
session_id, message_ordinal, source, model,
input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens,
reasoning_tokens, cost_usd, cost_status, cost_source,
occurred_at, dedup_key) VALUES `)
args := make([]any, 0, len(batch)*14)
for j, ev := range batch {
if j > 0 {
b.WriteByte(',')
}
p := j*14 + 1
fmt.Fprintf(&b,
"($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)",
p, p+1, p+2, p+3, p+4, p+5, p+6,
p+7, p+8, p+9, p+10, p+11, p+12, p+13,
)
var occurred any
if ev.OccurredAt != "" {
if t, ok := ParseSQLiteTimestamp(ev.OccurredAt); ok {
occurred = t
}
}
var ordinal any
if ev.MessageOrdinal != nil {
ordinal = *ev.MessageOrdinal
}
var cost any
if ev.CostUSD != nil {
cost = *ev.CostUSD
}
args = append(args,
ev.SessionID,
ordinal,
sanitizePG(ev.Source),
sanitizePG(ev.Model),
ev.InputTokens,
ev.OutputTokens,
ev.CacheCreationInputTokens,
ev.CacheReadInputTokens,
ev.ReasoningTokens,
cost,
sanitizePG(ev.CostStatus),
sanitizePG(ev.CostSource),
occurred,
sanitizePG(ev.DedupKey),
)
}
if _, err := tx.ExecContext(ctx, b.String(), args...); err != nil {
return fmt.Errorf(
"bulk inserting usage_events: %w", err,
)
}
}
return nil
}
func bulkInsertCursorUsageEvents(
ctx context.Context, tx *sql.Tx, events []db.CursorUsageEvent,
) error {
if len(events) == 0 {
return nil
}
const cursorBatch = 100
for i := 0; i < len(events); i += cursorBatch {
end := min(i+cursorBatch, len(events))
batch := events[i:end]
var b strings.Builder
b.WriteString(`INSERT INTO cursor_usage_events (
occurred_at, model, kind,
input_tokens, output_tokens,
cache_write_tokens, cache_read_tokens,
charged_cents, cursor_token_fee,
user_id, user_email, is_headless, dedup_key
) VALUES `)
args := make([]any, 0, len(batch)*13)
for j, ev := range batch {
if j > 0 {
b.WriteByte(',')
}
p := j*13 + 1
fmt.Fprintf(&b,
"($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)",
p, p+1, p+2, p+3, p+4, p+5, p+6,
p+7, p+8, p+9, p+10, p+11, p+12,
)
occurredAt, ok := ParseSQLiteTimestamp(ev.OccurredAt)
if !ok {
return fmt.Errorf("parsing cursor usage occurred_at %q", ev.OccurredAt)
}
args = append(args,
occurredAt,
sanitizePG(ev.Model),
sanitizePG(ev.Kind),
ev.InputTokens,
ev.OutputTokens,
ev.CacheWriteTokens,
ev.CacheReadTokens,
ev.ChargedCents,
ev.CursorTokenFee,
sanitizePG(ev.UserID),
sanitizePG(ev.UserEmail),
ev.IsHeadless,
sanitizePG(ev.DedupKey),
)
}
b.WriteString(` ON CONFLICT DO NOTHING`)
if _, err := tx.ExecContext(ctx, b.String(), args...); err != nil {
return fmt.Errorf("bulk inserting cursor_usage_events: %w", err)
}
}
return nil
}
// bulkInsertToolCalls inserts tool calls using multi-row VALUES.
func bulkInsertToolCalls(
ctx context.Context, tx *sql.Tx,
sessionID string, msgs []db.Message,
) error {
// Collect all tool calls from messages.
type tcRow struct {
ordinal int
index int
tc db.ToolCall
}
var rows []tcRow
for _, m := range msgs {
for i, tc := range m.ToolCalls {
rows = append(rows, tcRow{m.Ordinal, i, tc})
}
}
if len(rows) == 0 {
return nil
}
const tcBatch = 50
for i := 0; i < len(rows); i += tcBatch {
end := min(i+tcBatch, len(rows))
batch := rows[i:end]
var b strings.Builder
b.WriteString(`INSERT INTO tool_calls (
session_id, tool_name, category,
call_index, tool_use_id, input_json,
skill_name, result_content_length,
result_content, subagent_session_id,
message_ordinal, file_path) VALUES `)
args := make([]any, 0, len(batch)*12)
for j, r := range batch {
if j > 0 {
b.WriteByte(',')
}
p := j*12 + 1
fmt.Fprintf(&b,
"($%d,$%d,$%d,$%d,$%d,$%d,"+
"$%d,$%d,$%d,$%d,$%d,$%d)",
p, p+1, p+2, p+3, p+4, p+5,
p+6, p+7, p+8, p+9, p+10, p+11,
)
args = append(args,
sessionID,
sanitizePG(r.tc.ToolName),
sanitizePG(r.tc.Category),
r.index,
sanitizePG(r.tc.ToolUseID),
nilIfEmpty(r.tc.InputJSON),
nilIfEmpty(r.tc.SkillName),
nilIfZero(r.tc.ResultContentLength),
nilIfEmpty(r.tc.ResultContent),
nilIfEmpty(r.tc.SubagentSessionID),
r.ordinal,
nilIfEmpty(r.tc.FilePath),
)
}
if _, err := tx.ExecContext(
ctx, b.String(), args...,
); err != nil {
return fmt.Errorf(
"bulk inserting tool_calls: %w", err,
)
}
}
return nil
}
func bulkInsertToolResultEvents(
ctx context.Context, tx *sql.Tx,
sessionID string, msgs []db.Message,
) error {
type evRow struct {
ordinal int
index int
ev db.ToolResultEvent
}
var rows []evRow
for _, m := range msgs {
for i, tc := range m.ToolCalls {
for _, ev := range tc.ResultEvents {
rows = append(rows, evRow{m.Ordinal, i, ev})
}
}
}
if len(rows) == 0 {
return nil
}
const evBatch = 100
for i := 0; i < len(rows); i += evBatch {
end := min(i+evBatch, len(rows))
batch := rows[i:end]
var b strings.Builder
b.WriteString(`INSERT INTO tool_result_events (
session_id, tool_call_message_ordinal, call_index,
tool_use_id, agent_id, subagent_session_id,
source, status, content, content_length,
timestamp, event_index) VALUES `)
args := make([]any, 0, len(batch)*12)
for j, r := range batch {
if j > 0 {
b.WriteByte(',')
}
p := j*12 + 1
fmt.Fprintf(&b,
"($%d,$%d,$%d,$%d,$%d,$%d,"+
"$%d,$%d,$%d,$%d,$%d,$%d)",
p, p+1, p+2, p+3, p+4, p+5,
p+6, p+7, p+8, p+9, p+10, p+11,
)
var ts any
if r.ev.Timestamp != "" {
if t, ok := ParseSQLiteTimestamp(r.ev.Timestamp); ok {
ts = t
}
}
args = append(args,
sessionID,
r.ordinal,
r.index,
nilIfEmpty(r.ev.ToolUseID),
nilIfEmpty(r.ev.AgentID),
nilIfEmpty(r.ev.SubagentSessionID),
sanitizePG(r.ev.Source),
sanitizePG(r.ev.Status),
sanitizePG(r.ev.Content),
r.ev.ContentLength,
ts,
r.ev.EventIndex,
)
}
if _, err := tx.ExecContext(ctx, b.String(), args...); err != nil {
return fmt.Errorf("bulk inserting tool_result_events: %w", err)
}
}
return nil
}
// pushSecretFindings replaces a session's secret findings in PG.
// It deletes all existing rows for the session then bulk-inserts
// the current local set. It reports whether it changed any rows
// (deleted existing or inserted new) so the caller can bump
// sessions.updated_at for secret-only changes that pushSession and
// pushMessages would otherwise miss. Per-finding rules_version is
// pushed via this table; the session-level
// sessions.secrets_rules_version is pushed by pushSession alongside
// the rest of the session columns.
func (s *Sync) pushSecretFindings(
ctx context.Context, tx *sql.Tx, sessionID string,
) (bool, error) {
res, err := tx.ExecContext(ctx,
`DELETE FROM secret_findings WHERE session_id = $1`,
sessionID,
)
if err != nil {
return false, fmt.Errorf(
"deleting pg secret_findings for %s: %w",
sessionID, err,
)
}
deleted, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf(
"counting deleted secret_findings for %s: %w",
sessionID, err,
)
}
findings, err := s.local.SessionSecretFindings(ctx, sessionID)
if err != nil {
return false, fmt.Errorf(
"reading local secret_findings for %s: %w",
sessionID, err,
)
}
if len(findings) == 0 {
return deleted > 0, nil
}
const sfBatch = 50
for i := 0; i < len(findings); i += sfBatch {
end := min(i+sfBatch, len(findings))
batch := findings[i:end]
var b strings.Builder
b.WriteString(`INSERT INTO secret_findings (
session_id, rule_name, confidence,
location_kind, message_ordinal,
call_index, event_index,
match_start, match_end, match_index,
redacted_match, rules_version) VALUES `)
const cols = 12
args := make([]any, 0, len(batch)*cols)
for j, f := range batch {
if j > 0 {
b.WriteByte(',')
}
p := j*cols + 1
fmt.Fprintf(&b,
"($%d,$%d,$%d,$%d,$%d,"+
"$%d,$%d,$%d,$%d,$%d,$%d,$%d)",
p, p+1, p+2, p+3, p+4,
p+5, p+6, p+7, p+8, p+9, p+10, p+11,
)
args = append(args,
f.SessionID, f.RuleName, f.Confidence,
f.LocationKind, f.MessageOrdinal,
f.CallIndex, f.EventIndex,
f.MatchStart, f.MatchEnd, f.MatchIndex,
sanitizePG(f.RedactedMatch), f.RulesVersion,
)
}
if _, err := tx.ExecContext(
ctx, b.String(), args...,
); err != nil {
return false, fmt.Errorf(
"bulk inserting secret_findings for %s: %w",
sessionID, err,
)
}
}
return true, nil
}
// normalizeSyncTimestamps ensures schema exists and normalizes
// local sync state timestamps.
func (s *Sync) normalizeSyncTimestamps(
ctx context.Context,
) error {
s.schemaMu.Lock()
defer s.schemaMu.Unlock()
if err := s.ensureSchemaLocked(ctx); err != nil {
return err
}
return NormalizeLocalSyncStateTimestamps(s.effectiveSyncState())
}
// sanitizePG strips null bytes and replaces invalid UTF-8
// sequences so text can be safely inserted into PostgreSQL,
// which enforces strict UTF-8 encoding. It delegates to
// db.SanitizeUTF8 so the local fingerprint builders apply the
// exact same normalization.
func sanitizePG(s string) string {
return db.SanitizeUTF8(s)
}
func nilIfEmpty(s string) any {
s = sanitizePG(s)
if s == "" {
return nil
}
return s
}
func nilIfZero(n int) any {
if n == 0 {
return nil
}
return n
}
func (s *Sync) syncCursorUsageEvents(ctx context.Context) error {
// Cursor admin rows are global and unattributed, so project-filtered pushes
// cannot sync them honestly.
if s.isFiltered() {
return nil
}
events, err := s.local.GetCursorUsageEvents(ctx)
if err != nil {
return fmt.Errorf("loading local cursor usage events: %w", err)
}
if len(events) == 0 {
return nil
}
tx, err := s.pg.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("beginning cursor usage sync tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := bulkInsertCursorUsageEvents(ctx, tx, events); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing pg cursor usage sync: %w", err)
}
return nil
}