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

1271 lines
40 KiB
Go

package db
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"go.kenn.io/agentsview/internal/export"
)
const (
archiveMetadataDatabaseIDKey = "database_id"
archiveMetadataArchiveIDKey = "archive_id"
archiveMetadataArchiveSaltKey = "archive_salt"
archiveMetadataProjectIdentityRevisionKey = "project_identity_publication_revision"
)
// ProjectIdentityPublicationRevision is an O(1) change token for the complete
// local identity publication. SQLite triggers advance it whenever an aggregate
// observation or immutable session snapshot changes.
func (db *DB) ProjectIdentityPublicationRevision(ctx context.Context) (int64, error) {
if ctx == nil {
ctx = context.Background()
}
var raw string
err := db.getReader().QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`,
archiveMetadataProjectIdentityRevisionKey,
).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("reading project identity publication revision: %w", err)
}
revision, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || revision < 0 {
return 0, fmt.Errorf("invalid project identity publication revision %q", raw)
}
return revision, nil
}
// ProjectIdentityObservationKey identifies one aggregate observation row in a
// downstream publication. SourceArchiveID is supplied by the publisher.
type ProjectIdentityObservationKey struct {
Project string
Machine string
RootPath string
GitRemote string
}
// SessionProjectIdentitySnapshotKey identifies one immutable snapshot that a
// downstream publication must remove. Project is retained to apply filters.
type SessionProjectIdentitySnapshotKey struct {
SessionID string
Project string
}
// ProjectIdentityPublicationDelta contains current rows and durable tombstones
// whose latest local change falls inside one publication revision window.
type ProjectIdentityPublicationDelta struct {
Observations []export.ProjectIdentityObservation
ObservationDeletes []ProjectIdentityObservationKey
Snapshots []export.ProjectIdentityObservation
SnapshotDeletes []SessionProjectIdentitySnapshotKey
}
// LoadProjectIdentityPublicationDelta returns the compact identity changes in
// (afterRevision, throughRevision]. Project filters are applied to both current
// rows and tombstones so filtered targets can maintain independent cursors.
func (db *DB) LoadProjectIdentityPublicationDelta(
ctx context.Context,
afterRevision, throughRevision int64,
projects, excludeProjects []string,
) (ProjectIdentityPublicationDelta, error) {
var delta ProjectIdentityPublicationDelta
if ctx == nil {
ctx = context.Background()
}
if afterRevision < 0 || throughRevision < afterRevision {
return delta, fmt.Errorf(
"invalid project identity publication window (%d, %d]",
afterRevision, throughRevision,
)
}
if afterRevision == throughRevision {
return delta, nil
}
where, args := projectIdentityPublicationChangeWhere(
"c", afterRevision, throughRevision, projects, excludeProjects,
)
rows, err := db.getReader().QueryContext(ctx, `
SELECT o.source_archive_id, o.source_archive_salt,
o.project, o.machine, o.root_path, o.git_remote, o.git_remote_name,
o.repository_path, o.worktree_name, o.worktree_root_path,
o.worktree_relationship, o.checkout_state, o.git_branch,
o.remote_resolution, o.remote_candidate_count, o.observed_at,
o.normalized_remote, o.key_source, o.key
FROM project_identity_observation_changes c
JOIN project_identity_observations o
ON o.project = c.project AND o.machine = c.machine
AND o.root_path = c.root_path AND o.git_remote = c.git_remote
`+where+` AND c.deleted = 0
ORDER BY c.project, c.machine, c.root_path, c.git_remote`, args...)
if err != nil {
return delta, fmt.Errorf("listing changed project identity observations: %w", err)
}
for rows.Next() {
var obs export.ProjectIdentityObservation
var observedAt string
if err := rows.Scan(
&obs.SourceArchiveID, &obs.SourceArchiveSalt,
&obs.Project, &obs.Machine, &obs.RootPath, &obs.GitRemote,
&obs.GitRemoteName, &obs.RepositoryPath, &obs.WorktreeName,
&obs.WorktreeRootPath, &obs.WorktreeRelationship, &obs.CheckoutState,
&obs.GitBranch, &obs.RemoteResolution, &obs.RemoteCandidateCount,
&observedAt, &obs.NormalizedRemote, &obs.KeySource, &obs.Key,
); err != nil {
rows.Close()
return delta, fmt.Errorf("scanning changed project identity observation: %w", err)
}
obs.ObservedAt, err = time.Parse(time.RFC3339Nano, observedAt)
if err != nil {
rows.Close()
return delta, fmt.Errorf("parsing changed project identity timestamp: %w", err)
}
delta.Observations = append(delta.Observations, obs)
}
if err := rows.Err(); err != nil {
rows.Close()
return delta, fmt.Errorf("iterating changed project identity observations: %w", err)
}
if err := rows.Close(); err != nil {
return delta, fmt.Errorf("closing changed project identity observations: %w", err)
}
rows, err = db.getReader().QueryContext(ctx, `
SELECT c.project, c.machine, c.root_path, c.git_remote
FROM project_identity_observation_changes c
`+where+` AND c.deleted = 1
ORDER BY c.project, c.machine, c.root_path, c.git_remote`, args...)
if err != nil {
return delta, fmt.Errorf("listing project identity observation tombstones: %w", err)
}
for rows.Next() {
var key ProjectIdentityObservationKey
if err := rows.Scan(
&key.Project, &key.Machine, &key.RootPath, &key.GitRemote,
); err != nil {
rows.Close()
return delta, fmt.Errorf("scanning project identity observation tombstone: %w", err)
}
delta.ObservationDeletes = append(delta.ObservationDeletes, key)
}
if err := rows.Err(); err != nil {
rows.Close()
return delta, fmt.Errorf("iterating project identity observation tombstones: %w", err)
}
if err := rows.Close(); err != nil {
return delta, fmt.Errorf("closing project identity observation tombstones: %w", err)
}
rows, err = db.getReader().QueryContext(ctx, `
SELECT s.session_id, s.project, s.machine, s.root_path, s.git_remote,
s.git_remote_name, s.repository_path, s.worktree_name,
s.worktree_root_path, s.worktree_relationship, s.checkout_state,
s.git_branch, s.remote_resolution, s.remote_candidate_count,
s.observed_at, s.normalized_remote, s.key_source, s.key
FROM session_project_identity_snapshot_changes c
JOIN session_project_identity_snapshots s
ON s.session_id = c.session_id AND s.project = c.project
`+where+` AND c.deleted = 0
ORDER BY c.session_id, c.project`, args...)
if err != nil {
return delta, fmt.Errorf("listing changed session project identity snapshots: %w", err)
}
for rows.Next() {
var snapshot export.ProjectIdentityObservation
var observedAt string
if err := rows.Scan(
&snapshot.SessionID, &snapshot.Project, &snapshot.Machine,
&snapshot.RootPath, &snapshot.GitRemote, &snapshot.GitRemoteName,
&snapshot.RepositoryPath, &snapshot.WorktreeName,
&snapshot.WorktreeRootPath, &snapshot.WorktreeRelationship,
&snapshot.CheckoutState, &snapshot.GitBranch,
&snapshot.RemoteResolution, &snapshot.RemoteCandidateCount,
&observedAt, &snapshot.NormalizedRemote,
&snapshot.KeySource, &snapshot.Key,
); err != nil {
rows.Close()
return delta, fmt.Errorf("scanning changed session project identity snapshot: %w", err)
}
snapshot.ObservedAt, err = time.Parse(time.RFC3339Nano, observedAt)
if err != nil {
rows.Close()
return delta, fmt.Errorf("parsing changed session identity timestamp: %w", err)
}
delta.Snapshots = append(delta.Snapshots, snapshot)
}
if err := rows.Err(); err != nil {
rows.Close()
return delta, fmt.Errorf("iterating changed session project identity snapshots: %w", err)
}
if err := rows.Close(); err != nil {
return delta, fmt.Errorf("closing changed session project identity snapshots: %w", err)
}
rows, err = db.getReader().QueryContext(ctx, `
SELECT c.session_id, c.project
FROM session_project_identity_snapshot_changes c
`+where+` AND c.deleted = 1
ORDER BY c.session_id, c.project`, args...)
if err != nil {
return delta, fmt.Errorf("listing session project identity snapshot tombstones: %w", err)
}
defer rows.Close()
for rows.Next() {
var key SessionProjectIdentitySnapshotKey
if err := rows.Scan(&key.SessionID, &key.Project); err != nil {
return delta, fmt.Errorf("scanning session project identity snapshot tombstone: %w", err)
}
delta.SnapshotDeletes = append(delta.SnapshotDeletes, key)
}
if err := rows.Err(); err != nil {
return delta, fmt.Errorf("iterating session project identity snapshot tombstones: %w", err)
}
return delta, nil
}
func projectIdentityPublicationChangeWhere(
alias string,
afterRevision, throughRevision int64,
projects, excludeProjects []string,
) (string, []any) {
where := "WHERE " + alias + ".revision > ? AND " + alias + ".revision <= ?"
args := []any{afterRevision, throughRevision}
appendProjects := func(values []string, negate bool) {
if len(values) == 0 {
return
}
placeholders := make([]string, len(values))
for i, value := range values {
placeholders[i] = "?"
args = append(args, value)
}
op := " IN "
if negate {
op = " NOT IN "
}
where += " AND " + alias + ".project" + op +
"(" + strings.Join(placeholders, ",") + ")"
}
appendProjects(projects, false)
appendProjects(excludeProjects, true)
return where, args
}
var ErrDatabaseIDMissing = errors.New("database id is missing")
var ErrArchiveIDMissing = errors.New("archive id is missing")
var ErrArchiveSaltMissing = errors.New("archive salt is missing")
var ErrArchiveSaltInvalid = errors.New("archive salt is invalid")
func validateArchiveSalt(salt string) (string, error) {
salt = strings.TrimSpace(salt)
if salt == "" {
return "", ErrArchiveSaltMissing
}
decoded, err := hex.DecodeString(salt)
if err != nil || len(decoded) != 32 || salt != strings.ToLower(salt) {
return "", fmt.Errorf("%w: expected 64 lowercase hexadecimal characters",
ErrArchiveSaltInvalid)
}
return salt, nil
}
// CopyArchiveIdentityFrom preserves the logical archive identity in a fresh
// resync database before any session is parsed. The database ID is deliberately
// not copied because it identifies the new physical generation.
func (db *DB) CopyArchiveIdentityFrom(sourcePath string) error {
db.mu.Lock()
defer db.mu.Unlock()
ctx := context.Background()
conn, err := db.getWriter().Conn(ctx)
if err != nil {
return fmt.Errorf("acquiring archive identity connection: %w", err)
}
defer conn.Close()
if _, err := conn.ExecContext(ctx, "ATTACH DATABASE ? AS identity_source", sourcePath); err != nil {
return fmt.Errorf("attaching archive identity source: %w", err)
}
defer func() {
_, _ = execWithoutCancel(ctx, conn, "DETACH DATABASE identity_source")
}()
type metadataRow struct {
value string
createdAt string
updatedAt string
}
metadata := make(map[string]metadataRow, 2)
rows, err := conn.QueryContext(ctx, `
SELECT key, value, created_at, updated_at
FROM identity_source.archive_metadata
WHERE key IN (?, ?)`,
archiveMetadataArchiveIDKey, archiveMetadataArchiveSaltKey,
)
if err != nil {
return fmt.Errorf("reading archive identity source: %w", err)
}
for rows.Next() {
var key string
var row metadataRow
if err := rows.Scan(&key, &row.value, &row.createdAt, &row.updatedAt); err != nil {
_ = rows.Close()
return fmt.Errorf("scanning archive identity source: %w", err)
}
metadata[key] = row
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fmt.Errorf("iterating archive identity source: %w", err)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("closing archive identity source rows: %w", err)
}
if strings.TrimSpace(metadata[archiveMetadataArchiveIDKey].value) == "" {
return ErrArchiveIDMissing
}
if _, err := validateArchiveSalt(
metadata[archiveMetadataArchiveSaltKey].value,
); err != nil {
return err
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("beginning archive identity copy: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, key := range []string{
archiveMetadataArchiveIDKey,
archiveMetadataArchiveSaltKey,
} {
row := metadata[key]
if _, err := tx.ExecContext(ctx, `
INSERT INTO archive_metadata (key, value, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
created_at = excluded.created_at,
updated_at = excluded.updated_at`,
key, row.value, row.createdAt, row.updatedAt,
); err != nil {
return fmt.Errorf("copying archive identity %s: %w", key, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing archive identity copy: %w", err)
}
for _, key := range []string{
archiveMetadataArchiveIDKey,
archiveMetadataArchiveSaltKey,
} {
var persisted string
if err := conn.QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`, key,
).Scan(&persisted); err != nil {
return fmt.Errorf("verifying archive identity %s: %w", key, err)
}
if persisted != metadata[key].value {
return fmt.Errorf("verifying archive identity %s: value mismatch", key)
}
}
return nil
}
func (db *DB) GetDatabaseID(ctx context.Context) (string, error) {
if ctx == nil {
ctx = context.Background()
}
var id string
err := db.getReader().QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`,
archiveMetadataDatabaseIDKey,
).Scan(&id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrDatabaseIDMissing
}
return "", fmt.Errorf("reading database id: %w", err)
}
id = strings.TrimSpace(id)
if id == "" {
return "", ErrDatabaseIDMissing
}
return id, nil
}
func (db *DB) GetOrCreateDatabaseID(ctx context.Context) (string, error) {
if ctx == nil {
ctx = context.Background()
}
id, err := db.GetDatabaseID(ctx)
if err == nil {
return id, nil
}
if !errors.Is(err, ErrDatabaseIDMissing) {
return "", err
}
if err := db.requireWritable(); err != nil {
return "", ErrDatabaseIDMissing
}
db.mu.Lock()
defer db.mu.Unlock()
err = db.getWriter().QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`,
archiveMetadataDatabaseIDKey,
).Scan(&id)
if err == nil && strings.TrimSpace(id) != "" {
return id, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("reading database id: %w", err)
}
id, err = newUUIDv4()
if err != nil {
return "", err
}
if _, err := db.getWriter().ExecContext(ctx, `
INSERT INTO archive_metadata (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE trim(archive_metadata.value) = ''`,
archiveMetadataDatabaseIDKey, id,
); err != nil {
return "", fmt.Errorf("creating database id: %w", err)
}
var persisted string
if err := db.getWriter().QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`,
archiveMetadataDatabaseIDKey,
).Scan(&persisted); err != nil {
return "", fmt.Errorf("rereading database id: %w", err)
}
return persisted, nil
}
func (db *DB) GetArchiveID(ctx context.Context) (string, error) {
if ctx == nil {
ctx = context.Background()
}
var id string
err := db.getReader().QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`,
archiveMetadataArchiveIDKey,
).Scan(&id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrArchiveIDMissing
}
return "", fmt.Errorf("reading archive id: %w", err)
}
id = strings.TrimSpace(id)
if id == "" {
return "", ErrArchiveIDMissing
}
return id, nil
}
func (db *DB) GetOrCreateArchiveID(ctx context.Context) (string, error) {
if ctx == nil {
ctx = context.Background()
}
id, err := db.GetArchiveID(ctx)
if err == nil {
return id, nil
}
if !errors.Is(err, ErrArchiveIDMissing) {
return "", err
}
if err := db.requireWritable(); err != nil {
return "", ErrArchiveIDMissing
}
db.mu.Lock()
defer db.mu.Unlock()
err = db.getWriter().QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`,
archiveMetadataArchiveIDKey,
).Scan(&id)
if err == nil && strings.TrimSpace(id) != "" {
return id, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("reading archive id: %w", err)
}
id, err = newUUIDv4()
if err != nil {
return "", err
}
if _, err := db.getWriter().ExecContext(ctx, `
INSERT INTO archive_metadata (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE trim(archive_metadata.value) = ''`,
archiveMetadataArchiveIDKey, id,
); err != nil {
return "", fmt.Errorf("creating archive id: %w", err)
}
var persisted string
if err := db.getWriter().QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`,
archiveMetadataArchiveIDKey,
).Scan(&persisted); err != nil {
return "", fmt.Errorf("rereading archive id: %w", err)
}
return persisted, nil
}
func (db *DB) GetArchiveSalt(ctx context.Context) (string, error) {
if ctx == nil {
ctx = context.Background()
}
var salt string
err := db.getReader().QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`,
archiveMetadataArchiveSaltKey,
).Scan(&salt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrArchiveSaltMissing
}
return "", fmt.Errorf("reading archive salt: %w", err)
}
return validateArchiveSalt(salt)
}
func (db *DB) GetOrCreateArchiveSalt(ctx context.Context) (string, error) {
if ctx == nil {
ctx = context.Background()
}
salt, err := db.GetArchiveSalt(ctx)
if err == nil {
return salt, nil
}
if !errors.Is(err, ErrArchiveSaltMissing) {
return "", err
}
if err := db.requireWritable(); err != nil {
return "", ErrArchiveSaltMissing
}
db.mu.Lock()
defer db.mu.Unlock()
err = db.getWriter().QueryRowContext(ctx,
`SELECT value FROM archive_metadata WHERE key = ?`,
archiveMetadataArchiveSaltKey,
).Scan(&salt)
if err == nil && strings.TrimSpace(salt) != "" {
return validateArchiveSalt(salt)
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("reading archive salt: %w", err)
}
random := make([]byte, 32)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("generating archive salt: %w", err)
}
salt = hex.EncodeToString(random)
if _, err := db.getWriter().ExecContext(ctx, `
INSERT INTO archive_metadata (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE trim(archive_metadata.value) = ''`,
archiveMetadataArchiveSaltKey, salt,
); err != nil {
return "", fmt.Errorf("creating archive salt: %w", err)
}
return salt, nil
}
func (db *DB) SetDatabaseIDForTest(ctx context.Context, id string) error {
if err := db.requireWritable(); err != nil {
return err
}
if ctx == nil {
ctx = context.Background()
}
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("database id is required")
}
db.mu.Lock()
defer db.mu.Unlock()
_, err := db.getWriter().ExecContext(ctx, `
INSERT INTO archive_metadata (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
archiveMetadataDatabaseIDKey, id,
)
if err != nil {
return fmt.Errorf("setting database id: %w", err)
}
return nil
}
func (db *DB) SetArchiveIdentityForTest(ctx context.Context, id, salt string) error {
if err := db.requireWritable(); err != nil {
return err
}
if ctx == nil {
ctx = context.Background()
}
id = strings.TrimSpace(id)
salt = strings.TrimSpace(salt)
if id == "" || salt == "" {
return fmt.Errorf("archive id and salt are required")
}
db.mu.Lock()
defer db.mu.Unlock()
for key, value := range map[string]string{
archiveMetadataArchiveIDKey: id, archiveMetadataArchiveSaltKey: salt,
} {
if _, err := db.getWriter().ExecContext(ctx, `
INSERT INTO archive_metadata (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
key, value,
); err != nil {
return fmt.Errorf("setting archive identity: %w", err)
}
}
return nil
}
func (db *DB) UpsertProjectIdentityObservation(
ctx context.Context,
obs export.ProjectIdentityObservation,
) error {
if err := db.requireWritable(); err != nil {
return err
}
if ctx == nil {
ctx = context.Background()
}
obs, err := normalizeProjectIdentityObservation(obs)
if err != nil {
return err
}
db.mu.Lock()
defer db.mu.Unlock()
tx, err := db.getWriter().BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("beginning project identity observation upsert: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := upsertProjectIdentityObservationTx(tx, obs); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing project identity observation upsert: %w", err)
}
return nil
}
func upsertProjectIdentityObservationTx(
tx *sql.Tx,
obs export.ProjectIdentityObservation,
) error {
normalized, err := normalizeProjectIdentityObservation(obs)
if err != nil {
return err
}
if err := upsertProjectIdentityObservationExec(
context.Background(), tx,
func(ctx context.Context, query string, args ...any) rowScanner {
return tx.QueryRowContext(ctx, query, args...)
},
normalized,
); err != nil {
return err
}
if err := upsertSessionProjectIdentitySnapshotExec(
context.Background(), tx,
func(ctx context.Context, query string, args ...any) rowScanner {
return tx.QueryRowContext(ctx, query, args...)
},
normalized,
); err != nil {
return err
}
return nil
}
func upsertSessionProjectIdentitySnapshotExec(
ctx context.Context,
exec contextExecer,
queryRow contextQueryRow,
obs export.ProjectIdentityObservation,
) error {
if obs.SessionID == "" {
return nil
}
var sessionExists int
if err := queryRow(ctx, `SELECT 1 FROM sessions WHERE id = ?`,
obs.SessionID).Scan(&sessionExists); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return fmt.Errorf("checking session for project identity snapshot: %w", err)
}
var existing export.ProjectResolution
var existingKey string
err := queryRow(ctx, `
SELECT remote_resolution, key
FROM session_project_identity_snapshots
WHERE session_id = ?`, obs.SessionID).Scan(&existing, &existingKey)
if err == nil {
if existing == export.ProjectResolutionResolved ||
existing == export.ProjectResolutionAmbiguous ||
(obs.RemoteResolution == export.ProjectResolutionUnknown &&
(obs.Key == "" || strings.TrimSpace(existingKey) != "")) {
return nil
}
} else if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("checking session project identity snapshot: %w", err)
}
_, err = exec.ExecContext(ctx, `
INSERT INTO session_project_identity_snapshots (
session_id, project, machine, root_path, git_remote,
git_remote_name, repository_path, worktree_name,
worktree_root_path, worktree_relationship, checkout_state,
git_branch, remote_resolution, remote_candidate_count,
observed_at, normalized_remote, key_source, key
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
project = excluded.project,
machine = excluded.machine,
root_path = excluded.root_path,
git_remote = excluded.git_remote,
git_remote_name = excluded.git_remote_name,
repository_path = excluded.repository_path,
worktree_name = excluded.worktree_name,
worktree_root_path = excluded.worktree_root_path,
worktree_relationship = excluded.worktree_relationship,
checkout_state = excluded.checkout_state,
git_branch = excluded.git_branch,
remote_resolution = excluded.remote_resolution,
remote_candidate_count = excluded.remote_candidate_count,
observed_at = excluded.observed_at,
normalized_remote = excluded.normalized_remote,
key_source = excluded.key_source,
key = excluded.key`,
obs.SessionID, obs.Project, obs.Machine, obs.RootPath,
obs.GitRemote, obs.GitRemoteName, obs.RepositoryPath,
obs.WorktreeName, obs.WorktreeRootPath, obs.WorktreeRelationship,
obs.CheckoutState, obs.GitBranch, obs.RemoteResolution,
obs.RemoteCandidateCount, obs.ObservedAt.UTC().Format(time.RFC3339Nano),
obs.NormalizedRemote, obs.KeySource, obs.Key,
)
if err != nil {
return fmt.Errorf("upserting session project identity snapshot: %w", err)
}
return nil
}
type contextExecer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
type contextQueryRow func(context.Context, string, ...any) rowScanner
func upsertProjectIdentityObservationExec(
ctx context.Context,
exec contextExecer,
queryRow contextQueryRow,
obs export.ProjectIdentityObservation,
) error {
return upsertProjectIdentityObservationExecExcludingRemote(
ctx, exec, queryRow, obs, "")
}
func upsertProjectIdentityObservationExecExcludingRemote(
ctx context.Context,
exec contextExecer,
queryRow contextQueryRow,
obs export.ProjectIdentityObservation,
excludeRemote string,
) error {
if obs.GitRemote == "" && obs.RemoteResolution != export.ProjectResolutionAmbiguous {
var exists int
query := `
SELECT 1 FROM project_identity_observations
WHERE project = ? AND machine = ? AND root_path = ?
AND (git_remote != '' OR remote_resolution = ?)`
args := []any{
obs.Project, obs.Machine, obs.RootPath,
export.ProjectResolutionAmbiguous,
}
if excludeRemote != "" {
query += ` AND git_remote != ?`
args = append(args, excludeRemote)
}
query += ` LIMIT 1`
err := queryRow(ctx, `
`+strings.TrimSpace(query),
args...,
).Scan(&exists)
if err == nil && exists == 1 {
return nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("checking project identity remote observation: %w", err)
}
} else if _, err := exec.ExecContext(ctx, `
DELETE FROM project_identity_observations
WHERE project = ? AND machine = ? AND root_path = ?
AND git_remote = '' AND remote_resolution != ?`,
obs.Project, obs.Machine, obs.RootPath,
export.ProjectResolutionAmbiguous,
); err != nil {
return fmt.Errorf("removing stale project identity root fallback: %w", err)
}
_, err := exec.ExecContext(ctx, `
INSERT INTO project_identity_observations (
source_archive_id, source_archive_salt,
project, machine, root_path, git_remote, git_remote_name,
repository_path, worktree_name, worktree_root_path,
worktree_relationship, checkout_state, git_branch,
remote_resolution, remote_candidate_count, observed_at,
normalized_remote, key_source, key
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project, machine, root_path, git_remote) DO UPDATE SET
source_archive_id = excluded.source_archive_id,
source_archive_salt = excluded.source_archive_salt,
git_remote_name = excluded.git_remote_name,
repository_path = excluded.repository_path,
worktree_name = excluded.worktree_name,
worktree_root_path = excluded.worktree_root_path,
worktree_relationship = excluded.worktree_relationship,
checkout_state = excluded.checkout_state,
git_branch = excluded.git_branch,
remote_resolution = excluded.remote_resolution,
remote_candidate_count = excluded.remote_candidate_count,
observed_at = excluded.observed_at,
normalized_remote = excluded.normalized_remote,
key_source = excluded.key_source,
key = excluded.key`,
obs.SourceArchiveID, obs.SourceArchiveSalt,
obs.Project, obs.Machine, obs.RootPath, obs.GitRemote,
obs.GitRemoteName, obs.RepositoryPath, obs.WorktreeName,
obs.WorktreeRootPath, obs.WorktreeRelationship, obs.CheckoutState,
obs.GitBranch, obs.RemoteResolution, obs.RemoteCandidateCount,
obs.ObservedAt.UTC().Format(time.RFC3339Nano),
obs.NormalizedRemote, obs.KeySource, obs.Key,
)
if err != nil {
return fmt.Errorf("upserting project identity observation: %w", err)
}
return nil
}
func normalizeProjectIdentityObservation(
obs export.ProjectIdentityObservation,
) (export.ProjectIdentityObservation, error) {
obs.Project = strings.TrimSpace(obs.Project)
obs.SessionID = strings.TrimSpace(obs.SessionID)
obs.SourceArchiveID = strings.TrimSpace(obs.SourceArchiveID)
obs.SourceArchiveSalt = strings.TrimSpace(obs.SourceArchiveSalt)
obs.Machine = strings.TrimSpace(obs.Machine)
obs.RootPath = strings.TrimSpace(obs.RootPath)
obs.GitRemote = export.SanitizeGitRemoteForStorage(obs.GitRemote)
obs.GitRemoteName = strings.TrimSpace(obs.GitRemoteName)
obs.RepositoryPath = strings.TrimSpace(obs.RepositoryPath)
obs.WorktreeName = strings.TrimSpace(obs.WorktreeName)
obs.WorktreeRootPath = strings.TrimSpace(obs.WorktreeRootPath)
obs.GitBranch = strings.TrimSpace(obs.GitBranch)
if obs.Project == "" {
return obs, fmt.Errorf("project is required")
}
if obs.Machine == "" {
return obs, fmt.Errorf("machine is required")
}
if obs.ObservedAt.IsZero() {
obs.ObservedAt = time.Now().UTC()
}
if obs.WorktreeRelationship == "" {
obs.WorktreeRelationship = export.WorktreeUnknown
}
if obs.CheckoutState == "" {
obs.CheckoutState = export.CheckoutUnknown
}
if obs.RemoteResolution == "" {
if obs.GitRemote != "" {
obs.RemoteResolution = export.ProjectResolutionResolved
} else if obs.RemoteCandidateCount > 1 {
obs.RemoteResolution = export.ProjectResolutionAmbiguous
} else {
obs.RemoteResolution = export.ProjectResolutionUnknown
}
}
if obs.RemoteResolution == export.ProjectResolutionAmbiguous {
obs.GitRemote = ""
obs.GitRemoteName = ""
obs.NormalizedRemote = ""
obs.KeySource = ""
obs.Key = ""
return obs, nil
}
identity := export.BuildStoredProjectIdentity(
export.ProjectIdentityInput{
RootPath: obs.RootPath,
GitRemote: obs.GitRemote,
GitRemoteName: obs.GitRemoteName,
WorktreeName: obs.WorktreeName,
WorktreeRootPath: obs.WorktreeRootPath,
},
)
obs.NormalizedRemote = identity.NormalizedRemote
obs.KeySource = identity.KeySource
obs.Key = identity.Key
return obs, nil
}
func scrubProjectIdentityGitRemoteCredentialsTx(
ctx context.Context, tx *sql.Tx,
) error {
rows, err := tx.QueryContext(ctx, `
SELECT source_archive_id, source_archive_salt,
project, machine, root_path, git_remote, git_remote_name,
repository_path, worktree_name, worktree_root_path,
worktree_relationship, checkout_state, git_branch,
remote_resolution, remote_candidate_count, observed_at,
normalized_remote, key_source, key
FROM project_identity_observations
WHERE git_remote != ''`)
if err != nil {
return fmt.Errorf("listing project identity remotes for scrub: %w", err)
}
type pendingScrub struct {
obs export.ProjectIdentityObservation
rawRemote string
}
var pending []pendingScrub
for rows.Next() {
var obs export.ProjectIdentityObservation
var observedAt string
if err := rows.Scan(
&obs.SourceArchiveID,
&obs.SourceArchiveSalt,
&obs.Project,
&obs.Machine,
&obs.RootPath,
&obs.GitRemote,
&obs.GitRemoteName,
&obs.RepositoryPath,
&obs.WorktreeName,
&obs.WorktreeRootPath,
&obs.WorktreeRelationship,
&obs.CheckoutState,
&obs.GitBranch,
&obs.RemoteResolution,
&obs.RemoteCandidateCount,
&observedAt,
&obs.NormalizedRemote,
&obs.KeySource,
&obs.Key,
); err != nil {
return fmt.Errorf("scanning project identity remote for scrub: %w", err)
}
if t, err := time.Parse(time.RFC3339Nano, observedAt); err == nil {
obs.ObservedAt = t
}
sanitized := export.SanitizeGitRemoteForStorage(obs.GitRemote)
if sanitized == obs.GitRemote {
continue
}
pending = append(pending, pendingScrub{obs: obs, rawRemote: obs.GitRemote})
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating project identity remotes for scrub: %w", err)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("closing project identity remotes scrub rows: %w", err)
}
for _, scrub := range pending {
obs := scrub.obs
obs = export.SanitizeStoredProjectIdentityObservation(obs)
normalized, err := normalizeProjectIdentityObservation(obs)
if err != nil {
return fmt.Errorf("normalizing project identity remote scrub: %w", err)
}
if err := upsertProjectIdentityObservationExecExcludingRemote(
ctx, tx,
func(ctx context.Context, query string, args ...any) rowScanner {
return tx.QueryRowContext(ctx, query, args...)
},
normalized, scrub.rawRemote,
); err != nil {
return fmt.Errorf("scrubbing project identity remote: %w", err)
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM project_identity_observations
WHERE project = ? AND machine = ? AND root_path = ?
AND git_remote = ?`,
scrub.obs.Project, scrub.obs.Machine, scrub.obs.RootPath,
scrub.rawRemote,
); err != nil {
return fmt.Errorf("removing raw project identity remote: %w", err)
}
}
return nil
}
func (db *DB) ListProjectIdentityObservations(
ctx context.Context,
labels []string,
) ([]export.ProjectIdentityObservation, error) {
return db.listProjectIdentityObservationsFrom(ctx, db.getReader(), labels)
}
func (db *DB) listProjectIdentityObservationsFrom(
ctx context.Context,
q sessionExportQuerier,
labels []string,
) ([]export.ProjectIdentityObservation, error) {
if ctx == nil {
ctx = context.Background()
}
if labels != nil && len(labels) == 0 {
return []export.ProjectIdentityObservation{}, nil
}
query := `SELECT source_archive_id, source_archive_salt,
project, machine, root_path, git_remote, git_remote_name,
repository_path, worktree_name, worktree_root_path,
worktree_relationship, checkout_state, git_branch,
remote_resolution, remote_candidate_count, observed_at,
normalized_remote, key_source, key
FROM project_identity_observations`
args := make([]any, 0, len(labels))
if len(labels) > 0 {
placeholders := make([]string, 0, len(labels))
for _, label := range labels {
placeholders = append(placeholders, "?")
args = append(args, label)
}
query += " WHERE project IN (" + strings.Join(placeholders, ",") + ")"
}
query += " ORDER BY project, machine, root_path, git_remote"
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing project identity observations: %w", err)
}
defer rows.Close()
var out []export.ProjectIdentityObservation
for rows.Next() {
var obs export.ProjectIdentityObservation
var observedAt string
if err := rows.Scan(
&obs.SourceArchiveID,
&obs.SourceArchiveSalt,
&obs.Project,
&obs.Machine,
&obs.RootPath,
&obs.GitRemote,
&obs.GitRemoteName,
&obs.RepositoryPath,
&obs.WorktreeName,
&obs.WorktreeRootPath,
&obs.WorktreeRelationship,
&obs.CheckoutState,
&obs.GitBranch,
&obs.RemoteResolution,
&obs.RemoteCandidateCount,
&observedAt,
&obs.NormalizedRemote,
&obs.KeySource,
&obs.Key,
); err != nil {
return nil, fmt.Errorf("scanning project identity observation: %w", err)
}
if t, err := time.Parse(time.RFC3339Nano, observedAt); err == nil {
obs.ObservedAt = t
}
out = append(out, obs)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating project identity observations: %w", err)
}
return out, nil
}
func (db *DB) listSessionProjectIdentitySnapshots(
ctx context.Context,
sessionIDs []string,
) (map[string]export.ProjectIdentityObservation, error) {
return db.listSessionProjectIdentitySnapshotsFrom(
ctx, db.getReader(), sessionIDs)
}
func (db *DB) listSessionProjectIdentitySnapshotsFrom(
ctx context.Context,
q sessionExportQuerier,
sessionIDs []string,
) (map[string]export.ProjectIdentityObservation, error) {
out := make(map[string]export.ProjectIdentityObservation, len(sessionIDs))
if len(sessionIDs) == 0 {
return out, nil
}
placeholders := make([]string, len(sessionIDs))
args := make([]any, len(sessionIDs))
for i, id := range sessionIDs {
placeholders[i] = "?"
args[i] = id
}
rows, err := q.QueryContext(ctx, `
SELECT session_id, project, machine, root_path, git_remote,
git_remote_name, repository_path, worktree_name,
worktree_root_path, worktree_relationship, checkout_state,
git_branch, remote_resolution, remote_candidate_count,
observed_at, normalized_remote, key_source, key
FROM session_project_identity_snapshots
WHERE session_id IN (`+strings.Join(placeholders, ",")+`)`, args...)
if err != nil {
return nil, fmt.Errorf("listing session project identity snapshots: %w", err)
}
defer rows.Close()
for rows.Next() {
var obs export.ProjectIdentityObservation
var observedAt string
if err := rows.Scan(
&obs.SessionID, &obs.Project, &obs.Machine, &obs.RootPath,
&obs.GitRemote, &obs.GitRemoteName, &obs.RepositoryPath,
&obs.WorktreeName, &obs.WorktreeRootPath,
&obs.WorktreeRelationship, &obs.CheckoutState, &obs.GitBranch,
&obs.RemoteResolution, &obs.RemoteCandidateCount, &observedAt,
&obs.NormalizedRemote, &obs.KeySource, &obs.Key,
); err != nil {
return nil, fmt.Errorf("scanning session project identity snapshot: %w", err)
}
obs.ObservedAt, err = time.Parse(time.RFC3339Nano, observedAt)
if err != nil {
return nil, fmt.Errorf(
"parsing session project identity snapshot timestamp: %w", err,
)
}
out[obs.SessionID] = obs
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating session project identity snapshots: %w", err)
}
return out, nil
}
// ListSessionProjectIdentitySnapshots returns the immutable per-session
// identity facts used by mirror push. Aggregate observations are deliberately
// not substituted because they lose historical worktree and remote context.
func (db *DB) ListSessionProjectIdentitySnapshots(
ctx context.Context,
) ([]export.ProjectIdentityObservation, error) {
rows, err := db.getReader().QueryContext(ctx, `
SELECT session_id, project, machine, root_path, git_remote,
git_remote_name, repository_path, worktree_name,
worktree_root_path, worktree_relationship, checkout_state,
git_branch, remote_resolution, remote_candidate_count,
observed_at, normalized_remote, key_source, key
FROM session_project_identity_snapshots
ORDER BY session_id`)
if err != nil {
return nil, fmt.Errorf("listing all session project identity snapshots: %w", err)
}
defer rows.Close()
var out []export.ProjectIdentityObservation
for rows.Next() {
var obs export.ProjectIdentityObservation
var observedAt string
if err := rows.Scan(
&obs.SessionID, &obs.Project, &obs.Machine, &obs.RootPath,
&obs.GitRemote, &obs.GitRemoteName, &obs.RepositoryPath,
&obs.WorktreeName, &obs.WorktreeRootPath,
&obs.WorktreeRelationship, &obs.CheckoutState, &obs.GitBranch,
&obs.RemoteResolution, &obs.RemoteCandidateCount, &observedAt,
&obs.NormalizedRemote, &obs.KeySource, &obs.Key,
); err != nil {
return nil, fmt.Errorf("scanning session project identity snapshot: %w", err)
}
obs.ObservedAt, err = time.Parse(time.RFC3339Nano, observedAt)
if err != nil {
return nil, fmt.Errorf(
"parsing session project identity snapshot timestamp: %w", err,
)
}
out = append(out, obs)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating session project identity snapshots: %w", err)
}
return out, nil
}
func (db *DB) BuildProjectIdentityMap(
ctx context.Context,
labels []string,
) (map[string]export.ProjectMapEntry, error) {
if ctx == nil {
ctx = context.Background()
}
if labels != nil && len(labels) == 0 {
return map[string]export.ProjectMapEntry{}, nil
}
observations, err := db.ListProjectIdentityObservations(ctx, labels)
if err != nil {
return nil, err
}
archiveID, err := db.GetArchiveID(ctx)
if err != nil {
return nil, err
}
archiveSalt, err := db.GetArchiveSalt(ctx)
if err != nil {
return nil, err
}
return export.BuildProjectsMapWithScope(labels, observations, export.IdentityScope{
ArchiveID: archiveID, ArchiveSalt: archiveSalt,
}), nil
}
func newUUIDv4() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generating database id: %w", err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(b[:])
return fmt.Sprintf(
"%s-%s-%s-%s-%s",
encoded[0:8], encoded[8:12], encoded[12:16],
encoded[16:20], encoded[20:32],
), nil
}