feat: M1 determinism fixes for gossip
Refs #1 - F1: canonical fingerprints (git identity by remote URL, log by basename, obsidian by relative vault path) so identical facts get identical ids across machines - F2: node_id (persisted in settings) + Hybrid Logical Clock in all observation "when" columns; removed time.Now() fact-time fallbacks - F3: stable TF-IDF tie-break sort (score desc, term asc) - F4: observations carry (node_id, hcl) locator; dedup ordered by hcl
This commit is contained in:
+12
-12
@@ -8,7 +8,6 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GitIngester tracks project status for local git repositories.
|
||||
@@ -141,7 +140,7 @@ type GitStatus struct {
|
||||
func (g *GitIngester) repoToResult(repoPath string) *IngestResult {
|
||||
status := gitStatus(repoPath)
|
||||
name := filepath.Base(repoPath)
|
||||
fp := Fingerprint([]byte("git:repo:" + repoPath))
|
||||
fp := Fingerprint([]byte("git:repo:" + gitIdentity(repoPath, status.Branch)))
|
||||
|
||||
clean := "clean"
|
||||
if status.Dirty > 0 {
|
||||
@@ -157,9 +156,6 @@ func (g *GitIngester) repoToResult(repoPath string) *IngestResult {
|
||||
}
|
||||
|
||||
createdAt := status.LastCommit
|
||||
if createdAt == "" {
|
||||
createdAt = gitStatusTime(status)
|
||||
}
|
||||
|
||||
return &IngestResult{
|
||||
Fingerprint: fp,
|
||||
@@ -186,13 +182,6 @@ func (g *GitIngester) repoToResult(repoPath string) *IngestResult {
|
||||
}
|
||||
}
|
||||
|
||||
func gitStatusTime(s GitStatus) string {
|
||||
if s.LastCommit != "" {
|
||||
return s.LastCommit
|
||||
}
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func gitStatus(repoPath string) GitStatus {
|
||||
out, err := gitOut(repoPath, "status", "--porcelain")
|
||||
if err != nil {
|
||||
@@ -257,6 +246,17 @@ func gitOut(repoPath string, args ...string) (string, error) {
|
||||
return sanitizeGitField(string(out)), nil
|
||||
}
|
||||
|
||||
// gitIdentity returns a machine-independent identity for a repo: the remote
|
||||
// origin URL when configured (works across clones/machines), else the repo
|
||||
// directory name. The branch is appended so entries are per-branch like the
|
||||
// underlying gitStatus.
|
||||
func gitIdentity(repoPath, branch string) string {
|
||||
if out, err := gitOut(repoPath, "remote", "get-url", "origin"); err == nil && out != "" {
|
||||
return out + ":" + branch
|
||||
}
|
||||
return filepath.Base(repoPath) + ":" + branch
|
||||
}
|
||||
|
||||
func (g *GitIngester) Ingest(_ string) (*IngestResult, error) {
|
||||
return nil, fmt.Errorf("use IngestAll() for git")
|
||||
}
|
||||
|
||||
@@ -148,14 +148,16 @@ func (g *GiteaIngester) fetchPulls() ([]teaPull, error) {
|
||||
return pulls, nil
|
||||
}
|
||||
|
||||
// signalTime returns the item's last-activity time, falling back to now.
|
||||
// signalTime returns the item's last-activity time, or "" when unknown. An
|
||||
// empty value is an explicit unknown — downstream uses it as a missing signal,
|
||||
// never fabricates a "now" timestamp that would differ across nodes.
|
||||
func signalTime(updated string) string {
|
||||
if updated != "" {
|
||||
if _, err := time.Parse(time.RFC3339, updated); err == nil {
|
||||
return updated
|
||||
}
|
||||
}
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (g *GiteaIngester) repoToResult(r teaRepo) *IngestResult {
|
||||
|
||||
@@ -69,7 +69,7 @@ func (l *LogIngester) Ingest(path string) (*IngestResult, error) {
|
||||
}
|
||||
|
||||
base := filepath.Base(path)
|
||||
fp := Fingerprint([]byte(path))
|
||||
fp := Fingerprint([]byte("log:" + base))
|
||||
title := fmt.Sprintf("Log %s (%d lines)", base, totalLines)
|
||||
createdAt := ""
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
@@ -99,7 +99,7 @@ func (l *LogIngester) Ingest(path string) (*IngestResult, error) {
|
||||
return &IngestResult{
|
||||
Fingerprint: fp,
|
||||
SourceID: l.SourceID(),
|
||||
SourcePath: path,
|
||||
SourcePath: base,
|
||||
ContentType: ".log",
|
||||
Title: title,
|
||||
Summary: summary,
|
||||
|
||||
@@ -84,13 +84,14 @@ func (o *ObsidianIngester) ingestNote(path, vault string) (*IngestResult, error)
|
||||
modified = created
|
||||
}
|
||||
|
||||
// Fingerprint by content (so edits create updates)
|
||||
// Fingerprint by content + relative vault path (machine-independent):
|
||||
// the same note on two hosts maps to the same fingerprint.
|
||||
relPath, _ := filepath.Rel(vault, path)
|
||||
fp := FingerprintWithMeta(data, map[string]string{
|
||||
"source": "obsidian",
|
||||
"path": path,
|
||||
"path": relPath,
|
||||
})
|
||||
|
||||
relPath, _ := filepath.Rel(vault, path)
|
||||
summary := extractBodyPreview(content, 200)
|
||||
|
||||
return &IngestResult{
|
||||
|
||||
@@ -40,12 +40,12 @@ func (o *ObsidianFileIngester) Ingest(path string) (*IngestResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(o.Vault, path)
|
||||
fp := FingerprintWithMeta(data, map[string]string{
|
||||
"source": "obsidian",
|
||||
"path": path,
|
||||
"path": relPath,
|
||||
})
|
||||
|
||||
relPath, _ := filepath.Rel(o.Vault, path)
|
||||
summary := extractBodyPreview(content, 200)
|
||||
|
||||
return &IngestResult{
|
||||
|
||||
Reference in New Issue
Block a user