diff --git a/cmd/agentsview/main.go b/cmd/agentsview/main.go index 16ed46806..ec9f721cf 100644 --- a/cmd/agentsview/main.go +++ b/cmd/agentsview/main.go @@ -874,43 +874,132 @@ func collectWatchRoots(cfg config.Config) (roots []watchRoot, unwatchedDirs []st continue } for _, d := range cfg.ResolveDirs(def.Type) { - if def.ShallowWatchRootsFunc != nil { - for _, watchDir := range def.ShallowWatchRootsFunc(d) { - if _, err := os.Stat(watchDir); err == nil { - addRoot(d, watchDir, true) - } - } - } - if def.WatchRootsFunc != nil { - watchDirs := def.WatchRootsFunc(d) - if len(watchDirs) == 0 { - unwatchedDirs = append(unwatchedDirs, d) - continue - } - for _, watchDir := range watchDirs { - if _, err := os.Stat(watchDir); err == nil { - addRoot(d, watchDir, def.ShallowWatch) - continue - } - unwatchedDirs = append(unwatchedDirs, d) - } + if providerWatched, providerUnwatched := collectProviderWatchRoots(def, d, addRoot); providerWatched { + unwatchedDirs = append(unwatchedDirs, providerUnwatched...) continue } - if len(def.WatchSubdirs) == 0 { - if _, err := os.Stat(d); err == nil { - addRoot(d, d, def.ShallowWatch) - } - continue + fallbackUnwatched := collectLegacyWatchRoots(def, d, addRoot) + unwatchedDirs = append(unwatchedDirs, fallbackUnwatched...) + } + } + return roots, unwatchedDirs +} + +func collectProviderWatchRoots( + def parser.AgentDef, + dir string, + addRoot func(dir, root string, shallow bool), +) (bool, []string) { + factory, ok := parser.ProviderFactoryByType(def.Type) + if !ok { + return false, nil + } + provider := factory.NewProvider(parser.ProviderConfig{ + Roots: []string{dir}, + }) + plan, err := provider.WatchPlan(context.Background()) + if err != nil || len(plan.Roots) == 0 { + if err != nil && !errors.Is(err, parser.ErrUnsupportedProviderFeature) { + log.Printf("%s provider watch plan: %v", def.Type, err) + } + return false, nil + } + added := false + var addedRoots []watchRoot + var missingRoots []string + for _, providerRoot := range plan.Roots { + root := filepath.Clean(providerRoot.Path) + if root == "" || root == "." { + continue + } + if _, err := os.Stat(root); err == nil { + addRoot(dir, root, !providerRoot.Recursive) + added = true + addedRoots = append(addedRoots, watchRoot{ + root: root, + shallow: !providerRoot.Recursive, + }) + continue + } + missingRoots = append(missingRoots, root) + } + if !added { + return false, nil + } + // A watch target that does not exist yet but lives under an already-watched + // root needs no separate polling only when the ancestor is recursive or + // when a shallow root can observe creation of the missing root itself. A + // shallow ancestor sees only immediate child creation, so it cannot cover a + // missing nested provider root. + for _, missing := range missingRoots { + if !pathCoveredByAnyWatchRootCreation(missing, addedRoots) { + return true, []string{dir} + } + } + return true, nil +} + +// pathCoveredByAnyWatchRootCreation reports whether path is covered by an +// existing watch root strongly enough to observe creation of the missing root. +// Recursive roots cover the whole subtree. Shallow roots only cover direct +// children because fsnotify can report that immediate directory creation, after +// which the next watcher setup can add the provider's deeper watch root. +func pathCoveredByAnyWatchRootCreation(path string, roots []watchRoot) bool { + for _, root := range roots { + if root.shallow { + if filepath.Dir(path) == root.root { + return true } - for _, sub := range def.WatchSubdirs { - watchDir := filepath.Join(d, sub) - if _, err := os.Stat(watchDir); err == nil { - addRoot(d, watchDir, def.ShallowWatch) - } + continue + } + if path == root.root || + strings.HasPrefix(path, root.root+string(filepath.Separator)) { + return true + } + } + return false +} + +func collectLegacyWatchRoots( + def parser.AgentDef, + dir string, + addRoot func(dir, root string, shallow bool), +) []string { + var unwatchedDirs []string + if def.ShallowWatchRootsFunc != nil { + for _, watchDir := range def.ShallowWatchRootsFunc(dir) { + if _, err := os.Stat(watchDir); err == nil { + addRoot(dir, watchDir, true) } } } - return roots, unwatchedDirs + if def.WatchRootsFunc != nil { + watchDirs := def.WatchRootsFunc(dir) + if len(watchDirs) == 0 { + return append(unwatchedDirs, dir) + } + for _, watchDir := range watchDirs { + if _, err := os.Stat(watchDir); err == nil { + addRoot(dir, watchDir, def.ShallowWatch) + continue + } + unwatchedDirs = append(unwatchedDirs, dir) + } + return unwatchedDirs + } + if len(def.WatchSubdirs) == 0 { + if _, err := os.Stat(dir); err == nil { + addRoot(dir, dir, def.ShallowWatch) + } + return unwatchedDirs + } + for _, sub := range def.WatchSubdirs { + watchDir := filepath.Join(dir, sub) + if _, err := os.Stat(watchDir); err == nil { + addRoot(dir, watchDir, def.ShallowWatch) + } + } + return unwatchedDirs } func startPeriodicSync( diff --git a/cmd/agentsview/main_test.go b/cmd/agentsview/main_test.go index 27e1eb708..59b4850e7 100644 --- a/cmd/agentsview/main_test.go +++ b/cmd/agentsview/main_test.go @@ -592,6 +592,31 @@ func TestCollectWatchRootsHermesSessionsWatchesStateDBParent(t *testing.T) { assert.Equal(t, []string{sessionsDir}, roots[1].dirs) } +func TestCollectWatchRootsUsesProviderWatchPlan(t *testing.T) { + root := t.TempDir() + for _, dir := range []string{"brain", "conversations", "implicit"} { + require.NoError(t, os.Mkdir(filepath.Join(root, dir), 0o755), "mkdir %s", dir) + } + cfg := config.Config{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentAntigravityCLI: {root}, + }, + } + + roots, unwatchedDirs := collectWatchRoots(cfg) + + require.Empty(t, unwatchedDirs, "unwatched dirs before watcher setup") + require.Len(t, roots, 4) + assert.Equal(t, filepath.Join(root, "brain"), roots[0].root) + assert.False(t, roots[0].shallow) + assert.Equal(t, filepath.Join(root, "conversations"), roots[1].root) + assert.True(t, roots[1].shallow) + assert.Equal(t, root, roots[2].root) + assert.True(t, roots[2].shallow, "history.jsonl root should be watched shallowly") + assert.Equal(t, filepath.Join(root, "implicit"), roots[3].root) + assert.True(t, roots[3].shallow) +} + func TestResyncCoversSignals(t *testing.T) { tests := []struct { name string diff --git a/internal/parser/antigravity.go b/internal/parser/antigravity.go index 4a8448abc..edb7aab21 100644 --- a/internal/parser/antigravity.go +++ b/internal/parser/antigravity.go @@ -30,53 +30,6 @@ var antigravityUUIDLikeRE = regexp.MustCompile( `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, ) -// DiscoverAntigravitySessions returns one DiscoveredFile per -// conversations/.db under the IDE root. -func DiscoverAntigravitySessions(root string) []DiscoveredFile { - if root == "" { - return nil - } - dir := filepath.Join(root, "conversations") - entries, err := os.ReadDir(dir) - if err != nil { - return nil - } - var files []DiscoveredFile - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasSuffix(name, ".db") { - continue - } - id := strings.TrimSuffix(name, ".db") - if !IsValidSessionID(id) { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(dir, name), - Agent: AgentAntigravity, - }) - } - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindAntigravitySourceFile locates a session DB by id. -func FindAntigravitySourceFile(root, id string) string { - if root == "" || !IsValidSessionID(id) { - return "" - } - p := filepath.Join(root, "conversations", id+".db") - if _, err := os.Stat(p); err == nil { - return p - } - return "" -} - // AntigravityFileInfo returns the effective file info for an IDE // session .db, combining the main file with its -wal/-shm sidecars, // the annotations/.pbtxt sidecar, and the brain/ artifacts @@ -89,6 +42,13 @@ func AntigravityFileInfo(path string) (os.FileInfo, error) { if err != nil { return nil, err } + return antigravityCLICombinedFileInfo( + info, + antigravityIDECompanionPaths(path)..., + ), nil +} + +func antigravityIDECompanionPaths(path string) []string { id := strings.TrimSuffix(filepath.Base(path), ".db") root := filepath.Dir(filepath.Dir(path)) companions := []string{ @@ -96,14 +56,15 @@ func AntigravityFileInfo(path string) (os.FileInfo, error) { path + "-shm", filepath.Join(root, "annotations", id+".pbtxt"), } - companions = append(companions, antigravityBrainCompanions( + return append(companions, antigravityBrainCompanions( filepath.Join(root, "brain", id), )...) - return antigravityCLICombinedFileInfo(info, companions...), nil } -// ParseAntigravitySession parses one IDE session DB. -func ParseAntigravitySession( +// parseSession parses one IDE session DB. It is owned by the +// antigravityProvider; the package-level ParseAntigravitySession +// entrypoint was folded onto the provider. +func (p *antigravityProvider) parseSession( path, project, machine string, ) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, error) { info, err := os.Stat(path) diff --git a/internal/parser/antigravity_cli.go b/internal/parser/antigravity_cli.go index 5a5b674dd..d388495fa 100644 --- a/internal/parser/antigravity_cli.go +++ b/internal/parser/antigravity_cli.go @@ -2,6 +2,7 @@ package parser import ( "bufio" + "crypto/sha256" "database/sql" "encoding/json" "fmt" @@ -49,85 +50,6 @@ const ( antigravityImplicitTag = "implicit-" ) -// DiscoverAntigravityCLISessions enumerates conversations/*.db, -// conversations/*.pb, and implicit/*.pb under the CLI root and tags each with -// its workspace (resolved via history.jsonl). -func DiscoverAntigravityCLISessions(root string) []DiscoveredFile { - if root == "" { - return nil - } - projects := buildAntigravityProjectMap( - filepath.Join(root, "history.jsonl"), - ) - var files []DiscoveredFile - for _, sub := range []string{"conversations", "implicit"} { - dir := filepath.Join(root, sub) - entries, err := os.ReadDir(dir) - if err != nil { - continue - } - byID := make(map[string]string) - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - id, ext, ok := antigravityCLIPathID(name) - if !ok || (sub == "implicit" && ext != ".pb") { - continue - } - // Prefer the new SQLite source when both old and new files - // exist for a conversation. They share a storage ID. - if prev := byID[id]; prev == "" || - (strings.HasSuffix(prev, ".pb") && ext == ".db") { - byID[id] = filepath.Join(dir, name) - } - } - for id, path := range byID { - files = append(files, DiscoveredFile{ - Path: path, - Project: projects[id], - Agent: AgentAntigravityCLI, - }) - } - } - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindAntigravityCLISourceFile locates the source file for a session -// id (without the agent prefix). An "implicit-" prefix routes to -// the implicit/ subdir; bare ids resolve under conversations/. -func FindAntigravityCLISourceFile(root, id string) string { - if root == "" { - return "" - } - if uuid, ok := strings.CutPrefix(id, antigravityImplicitTag); ok { - if !IsValidSessionID(uuid) { - return "" - } - for _, ext := range []string{".pb"} { - p := filepath.Join(root, "implicit", uuid+ext) - if _, err := os.Stat(p); err == nil { - return p - } - } - return "" - } - if !IsValidSessionID(id) { - return "" - } - for _, ext := range []string{".db", ".pb"} { - p := filepath.Join(root, "conversations", id+ext) - if _, err := os.Stat(p); err == nil { - return p - } - } - return "" -} - func antigravityCLIPathID(name string) (string, string, bool) { for _, ext := range []string{".db", ".pb"} { if !strings.HasSuffix(name, ext) { @@ -141,17 +63,6 @@ func antigravityCLIPathID(name string) (string, string, bool) { return "", "", false } -// ParseAntigravityCLISession parses one CLI session into the -// canonical ParsedSession + messages shape. -func ParseAntigravityCLISession( - path, project, machine string, -) (*ParsedSession, []ParsedMessage, error) { - sess, msgs, _, _, err := ParseAntigravityCLISessionWithStatus( - path, project, machine, - ) - return sess, msgs, err -} - // AntigravityCLIParseStatus carries sync-relevant parser metadata // that is not part of the canonical session/message shape. type AntigravityCLIParseStatus struct { @@ -162,10 +73,12 @@ type AntigravityCLIParseStatus struct { NeedsRetry bool } -// ParseAntigravityCLISessionWithStatus parses one CLI session into -// the canonical ParsedSession + messages shape and reports whether -// the result should be retried on the next sync. -func ParseAntigravityCLISessionWithStatus( +// parseSessionWithStatus parses one CLI session into the canonical +// ParsedSession + messages shape and reports whether the result should be +// retried on the next sync. It is owned by the antigravityCLIProvider; the +// package-level ParseAntigravityCLISessionWithStatus entrypoint was folded onto +// the provider. +func (p *antigravityCLIProvider) parseSessionWithStatus( path, project, machine string, ) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, AntigravityCLIParseStatus, error) { var status AntigravityCLIParseStatus @@ -758,36 +671,46 @@ func decryptAntigravityCLITranscript( // AntigravityCLIFileInfo returns a fake os.FileInfo whose size and // mtime combine the session file with everything else the parser // renders: SQLite WAL/SHM siblings, the .trajectory.json sidecar, -// and the brain/ artifacts. +// history.jsonl, and the brain/ artifacts. History stays here while +// legacy sync skip checks use this effective file info; provider hashes +// additionally scope tagged history rows by conversation ID. func AntigravityCLIFileInfo(path string) (os.FileInfo, error) { info, err := os.Stat(path) if err != nil { return nil, err } + return antigravityCLICombinedFileInfo( + info, + antigravityCLICompanionPaths(path)..., + ), nil +} + +func antigravityCLICompanionPaths(path string) []string { root := filepath.Dir(filepath.Dir(path)) + historyPath := filepath.Join(root, "history.jsonl") if base, ok := strings.CutSuffix(path, ".db"); ok { // The trajectory sidecar is a transcript source for .db sessions // too, so an agy-reader sync must change the fingerprint even when // the database files themselves are untouched. companions := []string{ + historyPath, path + "-wal", path + "-shm", base + ".trajectory.json", } - companions = append(companions, antigravityBrainCompanions( + return append(companions, antigravityBrainCompanions( filepath.Join(root, "brain", filepath.Base(base)), )...) - return antigravityCLICombinedFileInfo(info, companions...), nil } id := strings.TrimSuffix(filepath.Base(path), ".pb") companions := []string{ + historyPath, strings.TrimSuffix(path, ".pb") + ".trajectory.json", } - companions = append(companions, antigravityBrainCompanions( + return append(companions, antigravityBrainCompanions( filepath.Join(root, "brain", id), )...) - return antigravityCLICombinedFileInfo(info, companions...), nil } // antigravityBrainCompanions lists the brain artifact files the @@ -836,6 +759,160 @@ func antigravityCLICombinedFileInfo( } } +func antigravityCompositeHash(path string, companions ...string) (string, error) { + return antigravityCompositeHashWithExtra(path, companions, nil) +} + +func antigravityCompositeHashWithExtra( + path string, + companions []string, + extra func(interface{ Write([]byte) (int, error) }) error, +) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("stat %s: %w", path, err) + } + if info.IsDir() { + return "", fmt.Errorf("stat %s: source is a directory", path) + } + + h := sha256.New() + if err := addAntigravityFingerprintPart(h, "source", path, info); err != nil { + return "", err + } + + sort.Strings(companions) + var prev string + for _, companion := range companions { + if companion == "" || companion == prev { + continue + } + prev = companion + info, err := os.Stat(companion) + if err != nil || info.IsDir() { + continue + } + if err := addAntigravityFingerprintPart( + h, + "companion", + companion, + info, + ); err != nil { + continue + } + } + if extra != nil { + if err := extra(h); err != nil { + return "", err + } + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +func antigravityCLICompositeHash(path, id string) (string, error) { + return antigravityCompositeHashWithExtra( + path, + antigravityCLIProviderCompanionPaths(path), + func(h interface{ Write([]byte) (int, error) }) error { + return addAntigravityCLIHistoryFingerprintPart( + h, + filepath.Join(filepath.Dir(filepath.Dir(path)), "history.jsonl"), + strings.TrimPrefix(id, antigravityImplicitTag), + ) + }, + ) +} + +func antigravityCLIProviderCompanionPaths(path string) []string { + historyPath := filepath.Join(filepath.Dir(filepath.Dir(path)), "history.jsonl") + companions := antigravityCLICompanionPaths(path) + filtered := companions[:0] + for _, companion := range companions { + if samePath(companion, historyPath) { + continue + } + filtered = append(filtered, companion) + } + return filtered +} + +func addAntigravityCLIHistoryFingerprintPart( + h interface{ Write([]byte) (int, error) }, + historyPath string, + id string, +) error { + f, err := os.Open(historyPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("open %s: %w", historyPath, err) + } + defer f.Close() + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 64*1024), 4*1024*1024) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + cid := gjson.GetBytes(line, "conversationId").Str + if cid != "" && cid != id { + continue + } + label := "history" + if cid == "" { + // Untagged rows are used by the project fallback matcher, whose + // source cannot be known from the row alone. + label = "history-untagged" + } + if _, err := fmt.Fprintf(h, "%s\x00%d\x00", label, len(line)); err != nil { + return err + } + if _, err := h.Write(line); err != nil { + return err + } + if _, err := h.Write([]byte{0}); err != nil { + return err + } + } + if err := sc.Err(); err != nil { + return fmt.Errorf("scan %s: %w", historyPath, err) + } + return nil +} + +func addAntigravityFingerprintPart( + h interface{ Write([]byte) (int, error) }, + label string, + path string, + info os.FileInfo, +) error { + if _, err := fmt.Fprintf( + h, + "%s\x00%s\x00%d\x00%d\x00", + label, + path, + info.Size(), + info.ModTime().UnixNano(), + ); err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("hash %s: %w", path, err) + } + if _, err := h.Write([]byte{0}); err != nil { + return err + } + return nil +} + type fakeFileInfo struct { name string size int64 diff --git a/internal/parser/antigravity_cli_provider.go b/internal/parser/antigravity_cli_provider.go new file mode 100644 index 000000000..864108016 --- /dev/null +++ b/internal/parser/antigravity_cli_provider.go @@ -0,0 +1,638 @@ +package parser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +var _ Provider = (*antigravityCLIProvider)(nil) + +type antigravityCLIProviderFactory struct { + def AgentDef +} + +func newAntigravityCLIProviderFactory(def AgentDef) ProviderFactory { + return antigravityCLIProviderFactory{def: cloneAgentDef(def)} +} + +func (f antigravityCLIProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f antigravityCLIProviderFactory) Capabilities() Capabilities { + return antigravityCLIProviderCapabilities() +} + +func (f antigravityCLIProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &antigravityCLIProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: antigravityCLIProviderCapabilities(), + Config: cfg, + }, + sources: newAntigravityCLISourceSet(cfg.Roots), + } +} + +type antigravityCLIProvider struct { + ProviderBase + sources antigravityCLISourceSet +} + +func (p *antigravityCLIProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *antigravityCLIProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *antigravityCLIProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *antigravityCLIProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = providerFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *antigravityCLIProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *antigravityCLIProvider) Parse( + ctx context.Context, + req ParseRequest, +) (ParseOutcome, error) { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + src, ok := p.sources.sourceFromRef(req.Source) + if !ok { + return ParseOutcome{}, fmt.Errorf("antigravity cli source path unavailable") + } + if _, err := os.Stat(src.Path); err != nil { + if os.IsNotExist(err) { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + return ParseOutcome{}, fmt.Errorf("stat %s: %w", src.Path, err) + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + sess, msgs, usageEvents, status, err := p.parseSessionWithStatus( + src.Path, + req.Source.ProjectHint, + machine, + ) + if err != nil { + return ParseOutcome{}, err + } + if sess == nil { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + } + result := ParseResultOutcome{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + UsageEvents: usageEvents, + }, + DataVersion: DataVersionCurrent, + } + if status.NeedsRetry { + result.DataVersion = DataVersionNeedsRetry + result.RetryReason = "antigravity cli source needs high-fidelity retry" + } + return ParseOutcome{ + Results: []ParseResultOutcome{result}, + ResultSetComplete: true, + ForceReplace: true, + }, nil +} + +type antigravityCLISource struct { + Root string + Path string + ID string + Project string +} + +type antigravityCLISourceSet struct { + roots []string +} + +func newAntigravityCLISourceSet(roots []string) antigravityCLISourceSet { + roots = cleanJSONLRoots(roots) + return antigravityCLISourceSet{ + roots: roots, + } +} + +func (s antigravityCLISourceSet) Discover(ctx context.Context) ([]SourceRef, error) { + var sources []SourceRef + seen := make(map[string]struct{}) + for _, root := range s.roots { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, file := range s.discoverSessions(root) { + source, ok := s.sourceRef(root, file.Path, file.Project, false) + if ok { + addJSONLSource(source, &sources, seen) + } + } + } + sortJSONLSources(sources) + return sources, nil +} + +// discoverSessions enumerates conversations/*.db, conversations/*.pb, and +// implicit/*.pb under the CLI root and tags each with its workspace (resolved +// via history.jsonl). It owns the on-disk discovery the package-level +// DiscoverAntigravityCLISessions free function used to provide. The result +// keeps the legacy DiscoveredFile shape so the project hint travels with each +// path. +func (s antigravityCLISourceSet) discoverSessions(root string) []DiscoveredFile { + if root == "" { + return nil + } + projects := buildAntigravityProjectMap( + filepath.Join(root, "history.jsonl"), + ) + var files []DiscoveredFile + for _, sub := range []string{"conversations", "implicit"} { + dir := filepath.Join(root, sub) + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + byID := make(map[string]string) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + id, ext, ok := antigravityCLIPathID(name) + if !ok || (sub == "implicit" && ext != ".pb") { + continue + } + // Prefer the new SQLite source when both old and new files + // exist for a conversation. They share a storage ID. + if prev := byID[id]; prev == "" || + (strings.HasSuffix(prev, ".pb") && ext == ".db") { + byID[id] = filepath.Join(dir, name) + } + } + for id, path := range byID { + files = append(files, DiscoveredFile{ + Path: path, + Project: projects[id], + Agent: AgentAntigravityCLI, + }) + } + } + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + return files +} + +// findSourceFile locates the source file for a session id (without the agent +// prefix). An "implicit-" prefix routes to the implicit/ subdir; bare ids +// resolve under conversations/. It owns the lookup the package-level +// FindAntigravityCLISourceFile free function used to provide. +func (s antigravityCLISourceSet) findSourceFile(root, id string) string { + if root == "" { + return "" + } + if uuid, ok := strings.CutPrefix(id, antigravityImplicitTag); ok { + if !IsValidSessionID(uuid) { + return "" + } + for _, ext := range []string{".pb"} { + p := filepath.Join(root, "implicit", uuid+ext) + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" + } + if !IsValidSessionID(id) { + return "" + } + for _, ext := range []string{".db", ".pb"} { + p := filepath.Join(root, "conversations", id+ext) + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +func (s antigravityCLISourceSet) WatchPlan(context.Context) (WatchPlan, error) { + roots := make([]WatchRoot, 0, len(s.roots)*4) + for _, root := range s.roots { + roots = append(roots, + WatchRoot{ + Path: filepath.Join(root, "brain"), + Recursive: true, + IncludeGlobs: []string{"*.md", "*.md.metadata.json"}, + DebounceKey: string(AgentAntigravityCLI) + ":brain:" + root, + }, + WatchRoot{ + Path: filepath.Join(root, "conversations"), + Recursive: false, + IncludeGlobs: []string{"*.db", "*.db-*", "*.pb", "*.trajectory.json"}, + DebounceKey: string(AgentAntigravityCLI) + ":conversations:" + root, + }, + WatchRoot{ + Path: root, + Recursive: false, + IncludeGlobs: []string{"history.jsonl"}, + DebounceKey: string(AgentAntigravityCLI) + ":history:" + root, + }, + WatchRoot{ + Path: filepath.Join(root, "implicit"), + Recursive: false, + IncludeGlobs: []string{"*.pb", "*.trajectory.json"}, + DebounceKey: string(AgentAntigravityCLI) + ":implicit:" + root, + }, + ) + } + return WatchPlan{Roots: roots}, nil +} + +func (s antigravityCLISourceSet) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, root := range s.roots { + if req.WatchRoot != "" && !antigravityCLIWatchRootMatches(root, req.WatchRoot) { + continue + } + if sources := s.sourcesForChangedPath(root, req); len(sources) > 0 { + return sources, nil + } + } + return nil, nil +} + +func (s antigravityCLISourceSet) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + if err := ctx.Err(); err != nil { + return SourceRef{}, false, err + } + freshStoredSource := req.RequireFreshSource && + (req.StoredFilePath != "" || req.FingerprintKey != "") + for _, path := range []string{req.StoredFilePath, req.FingerprintKey} { + if path == "" { + continue + } + for _, root := range s.roots { + if source, ok := s.storedSourceRef( + root, path, req.RawSessionID, req.RequireFreshSource, + ); ok { + return source, true, nil + } + } + } + if freshStoredSource { + return SourceRef{}, false, nil + } + if req.RawSessionID == "" { + return SourceRef{}, false, nil + } + projects := make(map[string]map[string]string) + for _, root := range s.roots { + path := s.findSourceFile(root, req.RawSessionID) + if path == "" { + continue + } + project := "" + id := strings.TrimPrefix(req.RawSessionID, antigravityImplicitTag) + if projects[root] == nil { + projects[root] = buildAntigravityProjectMap( + filepath.Join(root, "history.jsonl"), + ) + } + project = projects[root][id] + if source, ok := s.sourceRef(root, path, project, false); ok { + return source, true, nil + } + } + return SourceRef{}, false, nil +} + +func (s antigravityCLISourceSet) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + if err := ctx.Err(); err != nil { + return SourceFingerprint{}, err + } + src, ok := s.sourceFromRef(source) + if !ok { + return SourceFingerprint{}, fmt.Errorf("antigravity cli source path unavailable") + } + key := firstNonEmptyJSONLString(source.FingerprintKey, source.Key, src.Path) + info, err := AntigravityCLIFileInfo(src.Path) + if err != nil { + if os.IsNotExist(err) { + return SourceFingerprint{Key: key}, nil + } + return SourceFingerprint{}, err + } + hash, err := antigravityCLICompositeHash(src.Path, src.ID) + if err != nil { + return SourceFingerprint{}, err + } + return SourceFingerprint{ + Key: key, + Size: info.Size(), + MTimeNS: info.ModTime().UnixNano(), + Hash: hash, + }, nil +} + +func (s antigravityCLISourceSet) sourceFromRef( + source SourceRef, +) (antigravityCLISource, bool) { + switch src := source.Opaque.(type) { + case antigravityCLISource: + return src, src.Path != "" + case *antigravityCLISource: + if src != nil && src.Path != "" { + return *src, true + } + } + for _, candidate := range []string{source.DisplayPath, source.FingerprintKey, source.Key} { + for _, root := range s.roots { + if ref, ok := s.sourceRef(root, candidate, source.ProjectHint, true); ok { + src := ref.Opaque.(antigravityCLISource) + return src, true + } + } + } + return antigravityCLISource{}, false +} + +func (s antigravityCLISourceSet) sourcesForChangedPath( + root string, + req ChangedPathRequest, +) []SourceRef { + root = filepath.Clean(root) + path := filepath.Clean(req.Path) + if samePath(path, filepath.Join(root, "history.jsonl")) { + return s.sourcesForHistoryChange(root, req) + } + if sourcePath, id, ok := antigravityCLISourcePathForEvent(root, path); ok { + if source, ok := s.sourceRef(root, sourcePath, s.projectForID(root, id), true); ok { + return []SourceRef{source} + } + } + if id, ok := antigravityBrainID(root, path); ok { + var sources []SourceRef + for _, sourcePath := range []string{ + antigravityCLIConversationSource(root, id), + filepath.Join(root, "implicit", id+".pb"), + } { + if sourcePath == "" || !IsRegularFile(sourcePath) { + continue + } + source, ok := s.sourceRef(root, sourcePath, s.projectForID(root, id), false) + if ok { + sources = append(sources, source) + } + } + sortJSONLSources(sources) + return sources + } + return nil +} + +func (s antigravityCLISourceSet) sourcesForHistoryChange( + root string, + _ ChangedPathRequest, +) []SourceRef { + return s.sourcesForUntaggedHistoryChange(root) +} + +func (s antigravityCLISourceSet) sourcesForUntaggedHistoryChange(root string) []SourceRef { + var sources []SourceRef + seen := make(map[string]struct{}) + for _, file := range s.discoverSessions(root) { + source, ok := s.sourceRef(root, file.Path, file.Project, false) + if ok { + addJSONLSource(source, &sources, seen) + } + } + sortJSONLSources(sources) + return sources +} + +func (s antigravityCLISourceSet) storedSourceRef( + root, path, rawSessionID string, + requireFresh bool, +) (SourceRef, bool) { + id, ok := antigravityCLISessionIDForPath(root, path) + if !ok { + return SourceRef{}, false + } + if rawSessionID != "" && id != rawSessionID { + return SourceRef{}, false + } + projectID := strings.TrimPrefix(id, antigravityImplicitTag) + if currentPath := s.findSourceFile(root, id); currentPath != "" { + return s.sourceRef(root, currentPath, s.projectForID(root, projectID), false) + } + if requireFresh { + return SourceRef{}, false + } + return s.sourceRef(root, path, s.projectForID(root, projectID), true) +} + +func (s antigravityCLISourceSet) sourceRef( + root, path, project string, + allowMissing bool, +) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + id, ok := antigravityCLISessionIDForPath(root, path) + if !ok { + return SourceRef{}, false + } + if !allowMissing && !IsRegularFile(path) { + return SourceRef{}, false + } + if project == "" { + project = s.projectForID(root, strings.TrimPrefix(id, antigravityImplicitTag)) + } + return s.newSourceRef(root, path, id, project), true +} + +func (s antigravityCLISourceSet) newSourceRef( + root, path, id, project string, +) SourceRef { + return SourceRef{ + Provider: AgentAntigravityCLI, + Key: path, + DisplayPath: path, + FingerprintKey: path, + ProjectHint: project, + Opaque: antigravityCLISource{ + Root: root, + Path: path, + ID: id, + Project: project, + }, + } +} + +func (s antigravityCLISourceSet) projectForID(root, id string) string { + return buildAntigravityProjectMap(filepath.Join(root, "history.jsonl"))[id] +} + +func antigravityCLISourcePathForEvent(root, path string) (string, string, bool) { + rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path)) + if !ok { + return "", "", false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 2 || (parts[0] != "conversations" && parts[0] != "implicit") { + return "", "", false + } + name := parts[1] + switch { + case strings.HasSuffix(name, ".db") || + strings.HasSuffix(name, ".db-wal") || + strings.HasSuffix(name, ".db-shm"): + if parts[0] != "conversations" { + return "", "", false + } + base := strings.TrimSuffix(strings.TrimSuffix(name, "-wal"), "-shm") + id := strings.TrimSuffix(base, ".db") + if !IsValidSessionID(id) { + return "", "", false + } + return filepath.Join(root, "conversations", id+".db"), id, true + case strings.HasSuffix(name, ".pb"): + id := strings.TrimSuffix(name, ".pb") + if !IsValidSessionID(id) { + return "", "", false + } + if parts[0] == "conversations" { + if dbPath := antigravityCLIConversationSource(root, id); dbPath != "" { + return dbPath, id, true + } + } + return filepath.Join(root, parts[0], id+".pb"), id, true + case strings.HasSuffix(name, ".trajectory.json"): + id := strings.TrimSuffix(name, ".trajectory.json") + if !IsValidSessionID(id) { + return "", "", false + } + if parts[0] == "conversations" { + sourcePath := antigravityCLIConversationSource(root, id) + if sourcePath == "" { + return "", "", false + } + return sourcePath, id, true + } + sourcePath := filepath.Join(root, parts[0], id+".pb") + if !IsRegularFile(sourcePath) { + return "", "", false + } + return sourcePath, id, true + default: + return "", "", false + } +} + +func antigravityCLIConversationSource(root, id string) string { + for _, path := range []string{ + filepath.Join(root, "conversations", id+".db"), + filepath.Join(root, "conversations", id+".pb"), + } { + if IsRegularFile(path) { + return path + } + } + return "" +} + +func antigravityCLISessionIDForPath(root, path string) (string, bool) { + rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path)) + if !ok { + return "", false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 2 || (parts[0] != "conversations" && parts[0] != "implicit") { + return "", false + } + id, ext, ok := antigravityCLIPathID(parts[1]) + if !ok { + return "", false + } + if parts[0] == "implicit" { + if ext != ".pb" { + return "", false + } + return antigravityImplicitTag + id, true + } + return id, true +} + +func antigravityCLIWatchRootMatches(root, watchRoot string) bool { + watchRoot = filepath.Clean(watchRoot) + for _, subdir := range []string{"brain", "conversations", "implicit"} { + if samePath(watchRoot, filepath.Join(root, subdir)) { + return true + } + } + return samePath(watchRoot, filepath.Clean(root)) +} + +func antigravityCLIProviderCapabilities() Capabilities { + source := jsonlFileProviderSourceCapabilities() + source.ForceReplaceOnParse = CapabilitySupported + return Capabilities{ + Source: source, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + PerMessageTokenUsage: CapabilitySupported, + AggregateUsageEvents: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/antigravity_provider.go b/internal/parser/antigravity_provider.go new file mode 100644 index 000000000..930baae85 --- /dev/null +++ b/internal/parser/antigravity_provider.go @@ -0,0 +1,471 @@ +package parser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +var _ Provider = (*antigravityProvider)(nil) + +type antigravityProviderFactory struct { + def AgentDef +} + +func newAntigravityProviderFactory(def AgentDef) ProviderFactory { + return antigravityProviderFactory{def: cloneAgentDef(def)} +} + +func (f antigravityProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f antigravityProviderFactory) Capabilities() Capabilities { + return antigravityProviderCapabilities() +} + +func (f antigravityProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &antigravityProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: antigravityProviderCapabilities(), + Config: cfg, + }, + sources: newAntigravitySourceSet(cfg.Roots), + } +} + +type antigravityProvider struct { + ProviderBase + sources antigravitySourceSet +} + +func (p *antigravityProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *antigravityProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *antigravityProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *antigravityProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = providerFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *antigravityProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *antigravityProvider) Parse( + ctx context.Context, + req ParseRequest, +) (ParseOutcome, error) { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + src, ok := p.sources.sourceFromRef(req.Source) + if !ok { + return ParseOutcome{}, fmt.Errorf("antigravity source path unavailable") + } + if _, err := os.Stat(src.Path); err != nil { + if os.IsNotExist(err) { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + return ParseOutcome{}, fmt.Errorf("stat %s: %w", src.Path, err) + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + sess, msgs, usageEvents, err := p.parseSession( + src.Path, + req.Source.ProjectHint, + machine, + ) + if err != nil { + return ParseOutcome{}, err + } + if sess == nil { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + } + return ParseOutcome{ + Results: []ParseResultOutcome{{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + UsageEvents: usageEvents, + }, + DataVersion: DataVersionCurrent, + }}, + ResultSetComplete: true, + ForceReplace: true, + }, nil +} + +type antigravitySource struct { + Root string + Path string + ID string +} + +type antigravitySourceSet struct { + roots []string +} + +func newAntigravitySourceSet(roots []string) antigravitySourceSet { + return antigravitySourceSet{roots: cleanJSONLRoots(roots)} +} + +func (s antigravitySourceSet) Discover(ctx context.Context) ([]SourceRef, error) { + var sources []SourceRef + seen := make(map[string]struct{}) + for _, root := range s.roots { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, path := range s.discoverSessionPaths(root) { + source, ok := s.sourceRef(root, path, false) + if ok { + addJSONLSource(source, &sources, seen) + } + } + } + sortJSONLSources(sources) + return sources, nil +} + +// discoverSessionPaths returns one conversations/.db path per IDE session +// under root, sorted by path. It owns the on-disk discovery the package-level +// DiscoverAntigravitySessions free function used to provide. +func (s antigravitySourceSet) discoverSessionPaths(root string) []string { + if root == "" { + return nil + } + dir := filepath.Join(root, "conversations") + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var paths []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".db") { + continue + } + id := strings.TrimSuffix(name, ".db") + if !IsValidSessionID(id) { + continue + } + paths = append(paths, filepath.Join(dir, name)) + } + slices.Sort(paths) + return paths +} + +// findSourceFile locates an IDE session DB by id under root. It owns the lookup +// the package-level FindAntigravitySourceFile free function used to provide. +func (s antigravitySourceSet) findSourceFile(root, id string) string { + if root == "" || !IsValidSessionID(id) { + return "" + } + p := filepath.Join(root, "conversations", id+".db") + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +func (s antigravitySourceSet) WatchPlan(context.Context) (WatchPlan, error) { + roots := make([]WatchRoot, 0, len(s.roots)*3) + for _, root := range s.roots { + roots = append(roots, + WatchRoot{ + Path: filepath.Join(root, "annotations"), + Recursive: false, + IncludeGlobs: []string{"*.pbtxt"}, + DebounceKey: string(AgentAntigravity) + ":annotations:" + root, + }, + WatchRoot{ + Path: filepath.Join(root, "brain"), + Recursive: true, + IncludeGlobs: []string{"*.md", "*.md.metadata.json"}, + DebounceKey: string(AgentAntigravity) + ":brain:" + root, + }, + WatchRoot{ + Path: filepath.Join(root, "conversations"), + Recursive: false, + IncludeGlobs: []string{"*.db", "*.db-*"}, + DebounceKey: string(AgentAntigravity) + ":conversations:" + root, + }, + ) + } + return WatchPlan{Roots: roots}, nil +} + +func (s antigravitySourceSet) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, root := range s.roots { + if req.WatchRoot != "" && !antigravityWatchRootMatches(root, req.WatchRoot) { + continue + } + source, ok := s.sourceForChangedPath(root, req.Path) + if ok { + return []SourceRef{source}, nil + } + } + return nil, nil +} + +func (s antigravitySourceSet) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + if err := ctx.Err(); err != nil { + return SourceRef{}, false, err + } + freshStoredSource := req.RequireFreshSource && + (req.StoredFilePath != "" || req.FingerprintKey != "") + for _, path := range []string{req.StoredFilePath, req.FingerprintKey} { + if path == "" { + continue + } + for _, root := range s.roots { + if source, ok := s.sourceRef(root, path, true); ok { + src := source.Opaque.(antigravitySource) + if req.RawSessionID != "" && src.ID != req.RawSessionID { + continue + } + if req.RequireFreshSource && !IsRegularFile(src.Path) { + continue + } + return source, true, nil + } + } + } + if freshStoredSource { + return SourceRef{}, false, nil + } + if req.RawSessionID == "" { + return SourceRef{}, false, nil + } + for _, root := range s.roots { + path := s.findSourceFile(root, req.RawSessionID) + if path == "" { + continue + } + if source, ok := s.sourceRef(root, path, false); ok { + return source, true, nil + } + } + return SourceRef{}, false, nil +} + +func (s antigravitySourceSet) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + if err := ctx.Err(); err != nil { + return SourceFingerprint{}, err + } + src, ok := s.sourceFromRef(source) + if !ok { + return SourceFingerprint{}, fmt.Errorf("antigravity source path unavailable") + } + key := firstNonEmptyJSONLString(source.FingerprintKey, source.Key, src.Path) + info, err := AntigravityFileInfo(src.Path) + if err != nil { + if os.IsNotExist(err) { + return SourceFingerprint{Key: key}, nil + } + return SourceFingerprint{}, err + } + hash, err := antigravityCompositeHash( + src.Path, + antigravityIDECompanionPaths(src.Path)..., + ) + if err != nil { + return SourceFingerprint{}, err + } + return SourceFingerprint{ + Key: key, + Size: info.Size(), + MTimeNS: info.ModTime().UnixNano(), + Hash: hash, + }, nil +} + +func (s antigravitySourceSet) sourceFromRef(source SourceRef) (antigravitySource, bool) { + switch src := source.Opaque.(type) { + case antigravitySource: + return src, src.Path != "" + case *antigravitySource: + if src != nil && src.Path != "" { + return *src, true + } + } + for _, candidate := range []string{source.DisplayPath, source.FingerprintKey, source.Key} { + for _, root := range s.roots { + if ref, ok := s.sourceRef(root, candidate, true); ok { + src := ref.Opaque.(antigravitySource) + return src, true + } + } + } + return antigravitySource{}, false +} + +func (s antigravitySourceSet) sourceForChangedPath(root, path string) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + if dbPath, id, ok := antigravityConversationDBForPath(root, path); ok { + return s.newSourceRef(root, dbPath, id), true + } + if id, ok := antigravityAnnotationID(root, path); ok { + dbPath := filepath.Join(root, "conversations", id+".db") + if IsRegularFile(dbPath) { + return s.newSourceRef(root, dbPath, id), true + } + } + if id, ok := antigravityBrainID(root, path); ok { + dbPath := filepath.Join(root, "conversations", id+".db") + if IsRegularFile(dbPath) { + return s.newSourceRef(root, dbPath, id), true + } + } + return SourceRef{}, false +} + +func (s antigravitySourceSet) sourceRef( + root, path string, + allowMissing bool, +) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + dbPath, id, ok := antigravityConversationDBForPath(root, path) + if !ok || dbPath != path { + return SourceRef{}, false + } + if !allowMissing && !IsRegularFile(path) { + return SourceRef{}, false + } + return s.newSourceRef(root, path, id), true +} + +func (s antigravitySourceSet) newSourceRef(root, path, id string) SourceRef { + return SourceRef{ + Provider: AgentAntigravity, + Key: path, + DisplayPath: path, + FingerprintKey: path, + Opaque: antigravitySource{ + Root: root, + Path: path, + ID: id, + }, + } +} + +func antigravityConversationDBForPath(root, path string) (string, string, bool) { + rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path)) + if !ok { + return "", "", false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 2 || parts[0] != "conversations" { + return "", "", false + } + name := strings.TrimSuffix(parts[1], "-wal") + name = strings.TrimSuffix(name, "-shm") + if !strings.HasSuffix(name, ".db") { + return "", "", false + } + id := strings.TrimSuffix(name, ".db") + if !IsValidSessionID(id) { + return "", "", false + } + return filepath.Join(root, "conversations", id+".db"), id, true +} + +func antigravityAnnotationID(root, path string) (string, bool) { + rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path)) + if !ok { + return "", false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 2 || parts[0] != "annotations" || + !strings.HasSuffix(parts[1], ".pbtxt") { + return "", false + } + id := strings.TrimSuffix(parts[1], ".pbtxt") + return id, IsValidSessionID(id) +} + +func antigravityBrainID(root, path string) (string, bool) { + rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path)) + if !ok { + return "", false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 3 || parts[0] != "brain" { + return "", false + } + return parts[1], IsValidSessionID(parts[1]) +} + +func antigravityWatchRootMatches(root, watchRoot string) bool { + watchRoot = filepath.Clean(watchRoot) + for _, subdir := range []string{"annotations", "brain", "conversations"} { + if samePath(watchRoot, filepath.Join(root, subdir)) { + return true + } + } + return samePath(watchRoot, filepath.Clean(root)) +} + +func antigravityProviderCapabilities() Capabilities { + source := jsonlFileProviderSourceCapabilities() + source.ForceReplaceOnParse = CapabilitySupported + return Capabilities{ + Source: source, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + ToolCalls: CapabilitySupported, + PerMessageTokenUsage: CapabilitySupported, + AggregateUsageEvents: CapabilitySupported, + }, + } +} diff --git a/internal/parser/antigravity_provider_test.go b/internal/parser/antigravity_provider_test.go new file mode 100644 index 000000000..860a34ec1 --- /dev/null +++ b/internal/parser/antigravity_provider_test.go @@ -0,0 +1,835 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAntigravityProvidersOwnLegacyEntrypoints guards the fold: the +// provider-specific Discover/Find/Parse free functions for both the +// Antigravity IDE and CLI providers must stay deleted, and the provider files +// must not reach back into them as shims. Discovery and source lookup live on +// the provider source sets; parse lives on the provider methods. +func TestAntigravityProvidersOwnLegacyEntrypoints(t *testing.T) { + legacySources := map[string]string{} + for _, file := range []string{ + "antigravity.go", + "antigravity_cli.go", + "antigravity_provider.go", + "antigravity_cli_provider.go", + } { + data, err := os.ReadFile(file) + require.NoErrorf(t, err, "read %s", file) + legacySources[file] = string(data) + } + + symbols := []string{ + "func DiscoverAntigravitySessions", + "func FindAntigravitySourceFile", + "func ParseAntigravitySession", + "func DiscoverAntigravityCLISessions", + "func FindAntigravityCLISourceFile", + "func ParseAntigravityCLISessionWithStatus", + "func ParseAntigravityCLISession", + } + for _, symbol := range symbols { + for file, src := range legacySources { + assert.NotContainsf(t, src, symbol, "%s still defines %s", file, symbol) + } + } + + providerCalls := []string{ + "DiscoverAntigravitySessions(", + "FindAntigravitySourceFile(", + "ParseAntigravitySession(", + "DiscoverAntigravityCLISessions(", + "FindAntigravityCLISourceFile(", + "ParseAntigravityCLISessionWithStatus(", + "ParseAntigravityCLISession(", + } + for _, file := range []string{"antigravity_provider.go", "antigravity_cli_provider.go"} { + for _, call := range providerCalls { + assert.NotContainsf(t, legacySources[file], call, + "%s still references legacy entrypoint %s", file, call) + } + } +} + +func TestAntigravityProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentAntigravity) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentAntigravity, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestAntigravityProviderSourceMethods(t *testing.T) { + root := t.TempDir() + id := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + dbPath := filepath.Join(root, "conversations", id+".db") + writeAntigravityIDEProviderFixture(t, root, id) + + provider, ok := NewProvider(AgentAntigravity, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 3) + assert.Equal(t, filepath.Join(root, "annotations"), plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.Equal(t, filepath.Join(root, "brain"), plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + assert.Equal(t, filepath.Join(root, "conversations"), plan.Roots[2].Path) + assert.False(t, plan.Roots[2].Recursive) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, dbPath, discovered[0].DisplayPath) + assert.Equal(t, dbPath, discovered[0].FingerprintKey) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~antigravity:" + id, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, dbPath, found.DisplayPath) + + for _, changedPath := range []string{ + dbPath + "-wal", + filepath.Join(root, "annotations", id+".pbtxt"), + filepath.Join(root, "brain", id, "plan.md"), + } { + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: changedPath, EventKind: "write"}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, dbPath, changed[0].DisplayPath) + } +} + +func TestAntigravityProviderFingerprintAndParse(t *testing.T) { + root := t.TempDir() + id := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + dbPath := filepath.Join(root, "conversations", id+".db") + writeAntigravityIDEProviderFixture(t, root, id) + + provider, ok := NewProvider(AgentAntigravity, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: id, + }) + require.NoError(t, err) + require.True(t, ok) + + before, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.Equal(t, dbPath, before.Key) + assert.NotEmpty(t, before.Hash) + + walPath := dbPath + "-wal" + writeSourceFile(t, walPath, "wal") + walTime := time.Unix(0, before.MTimeNS+int64(time.Second)) + require.NoError(t, os.Chtimes(walPath, walTime, walTime)) + after, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.Greater(t, after.MTimeNS, before.MTimeNS) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Fingerprint: after, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.True(t, outcome.ForceReplace) + require.Len(t, outcome.Results, 1) + result := outcome.Results[0] + assert.Equal(t, DataVersionCurrent, result.DataVersion) + assert.Equal(t, "antigravity:"+id, result.Result.Session.ID) + assert.Equal(t, "devbox", result.Result.Session.Machine) + assert.Equal(t, after.Hash, result.Result.Session.File.Hash) + assert.Len(t, result.Result.Messages, 3) +} + +func TestAntigravityProviderStoredPathFreshness(t *testing.T) { + root := t.TempDir() + id := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + dbPath := filepath.Join(root, "conversations", id+".db") + writeAntigravityIDEProviderFixture(t, root, id) + + provider, ok := NewProvider(AgentAntigravity, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: dbPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, dbPath, found.DisplayPath) + + require.NoError(t, os.Remove(dbPath)) + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: dbPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "fresh lookup must reject a deleted Antigravity DB") + + staleSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: dbPath, + }) + require.NoError(t, err) + require.True(t, ok, "non-fresh lookup keeps tombstone source identity") + assert.Equal(t, dbPath, staleSource.DisplayPath) + outcome, err := provider.Parse(context.Background(), ParseRequest{Source: staleSource}) + require.NoError(t, err) + assert.True(t, outcome.ResultSetComplete) + assert.True(t, outcome.ForceReplace) + assert.Equal(t, SkipNoSession, outcome.SkipReason) + assert.Empty(t, outcome.Results) +} + +func TestAntigravityProviderRejectsInvalidStoredPaths(t *testing.T) { + root := t.TempDir() + id := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + otherID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" + dbPath := filepath.Join(root, "conversations", id+".db") + otherDBPath := filepath.Join(root, "conversations", otherID+".db") + writeAntigravityIDEProviderFixture(t, root, id) + writeAntigravityIDEProviderFixture(t, root, otherID) + + provider, ok := NewProvider(AgentAntigravity, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + for _, path := range []string{ + dbPath + "#stale", + filepath.Join(root, "debug", id+".db"), + filepath.Join(root, "conversations", id+".txt"), + } { + _, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: path, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "stored path %q", path) + } + + _, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: id, + StoredFilePath: otherDBPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "fresh lookup must reject a stored path for a different session") +} + +func TestAntigravityCLIProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentAntigravityCLI) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestAntigravityCLIProviderSourceMethods(t *testing.T) { + root := t.TempDir() + id := "33333333-4444-5555-6666-777777777777" + dbPath := filepath.Join(root, "conversations", id+".db") + implicitPath := filepath.Join(root, "implicit", id+".pb") + writeAntigravityCLIProviderFixture(t, root, id) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 4) + assert.Equal(t, filepath.Join(root, "brain"), plan.Roots[0].Path) + assert.True(t, plan.Roots[0].Recursive) + assert.Equal(t, filepath.Join(root, "conversations"), plan.Roots[1].Path) + assert.False(t, plan.Roots[1].Recursive) + assert.Equal(t, root, plan.Roots[2].Path) + assert.False(t, plan.Roots[2].Recursive) + assert.Equal(t, []string{"history.jsonl"}, plan.Roots[2].IncludeGlobs) + assert.Equal(t, filepath.Join(root, "implicit"), plan.Roots[3].Path) + assert.False(t, plan.Roots[3].Recursive) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.Equal(t, dbPath, discovered[0].DisplayPath) + assert.Equal(t, "/tmp/db-proj", discovered[0].ProjectHint) + assert.Equal(t, implicitPath, discovered[1].DisplayPath) + + foundConversation, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~antigravity-cli:" + id, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, dbPath, foundConversation.DisplayPath) + + foundImplicit, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "implicit-" + id, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, implicitPath, foundImplicit.DisplayPath) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: dbPath + "-wal", EventKind: "write"}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, dbPath, changed[0].DisplayPath) + + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "brain", id, "task.md"), + EventKind: "write", + }, + ) + require.NoError(t, err) + require.Len(t, changed, 2) + assert.Equal(t, dbPath, changed[0].DisplayPath) + assert.Equal(t, implicitPath, changed[1].DisplayPath) + + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "history.jsonl"), + WatchRoot: root, + EventKind: "write", + }, + ) + require.NoError(t, err) + require.Len(t, changed, 2) + assert.Equal(t, dbPath, changed[0].DisplayPath) + assert.Equal(t, implicitPath, changed[1].DisplayPath) + + otherID := "88888888-9999-aaaa-bbbb-cccccccccccc" + mustWrite(t, filepath.Join(root, "conversations", otherID+".db"), []byte("db")) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "history.jsonl"), + WatchRoot: root, + EventKind: "write", + }, + ) + require.NoError(t, err) + assertAntigravityCLISourcePaths(t, changed, + dbPath, + filepath.Join(root, "conversations", otherID+".db"), + implicitPath, + ) +} + +func TestAntigravityCLIProviderHistoryRemovalInvalidatesAllSources(t *testing.T) { + root := t.TempDir() + id := "33333333-4444-5555-6666-777777777777" + otherID := "88888888-9999-aaaa-bbbb-cccccccccccc" + writeAntigravityCLIProviderFixture(t, root, id) + mustWrite(t, filepath.Join(root, "conversations", otherID+".db"), []byte("db")) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + historyPath := filepath.Join(root, "history.jsonl") + require.NoError(t, os.Remove(historyPath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: historyPath, + WatchRoot: root, + EventKind: "remove", + }, + ) + require.NoError(t, err) + assertAntigravityCLISourcePaths(t, changed, + filepath.Join(root, "conversations", id+".db"), + filepath.Join(root, "conversations", otherID+".db"), + filepath.Join(root, "implicit", id+".pb"), + ) +} + +func TestAntigravityCLIProviderHistoryTruncationInvalidatesAllSources(t *testing.T) { + root := t.TempDir() + id := "33333333-4444-5555-6666-777777777777" + otherID := "88888888-9999-aaaa-bbbb-cccccccccccc" + writeAntigravityCLIProviderFixture(t, root, id) + mustWrite(t, filepath.Join(root, "conversations", otherID+".db"), []byte("db")) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + historyPath := filepath.Join(root, "history.jsonl") + mustWrite(t, historyPath, nil) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: historyPath, + WatchRoot: root, + EventKind: "write", + }, + ) + require.NoError(t, err) + assertAntigravityCLISourcePaths(t, changed, + filepath.Join(root, "conversations", id+".db"), + filepath.Join(root, "conversations", otherID+".db"), + filepath.Join(root, "implicit", id+".pb"), + ) +} + +func TestAntigravityCLIProviderHistoryReadErrorInvalidatesAllSources(t *testing.T) { + root := t.TempDir() + id := "33333333-4444-5555-6666-777777777777" + otherID := "88888888-9999-aaaa-bbbb-cccccccccccc" + writeAntigravityCLIProviderFixture(t, root, id) + mustWrite(t, filepath.Join(root, "conversations", otherID+".db"), []byte("db")) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + historyPath := filepath.Join(root, "history.jsonl") + mustWrite(t, historyPath, []byte(strings.Repeat("x", 4*1024*1024+1))) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: historyPath, + WatchRoot: root, + EventKind: "write", + }, + ) + require.NoError(t, err) + assertAntigravityCLISourcePaths(t, changed, + filepath.Join(root, "conversations", id+".db"), + filepath.Join(root, "conversations", otherID+".db"), + filepath.Join(root, "implicit", id+".pb"), + ) +} + +func TestAntigravityCLIProviderHistoryRetagInvalidatesAllSources(t *testing.T) { + root := t.TempDir() + id := "33333333-4444-5555-6666-777777777777" + otherID := "88888888-9999-aaaa-bbbb-cccccccccccc" + writeAntigravityCLIProviderFixture(t, root, id) + mustWrite(t, filepath.Join(root, "conversations", otherID+".db"), []byte("db")) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + historyPath := filepath.Join(root, "history.jsonl") + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: historyPath, + WatchRoot: root, + EventKind: "write", + }, + ) + require.NoError(t, err) + assertAntigravityCLISourcePaths(t, changed, + filepath.Join(root, "conversations", id+".db"), + filepath.Join(root, "conversations", otherID+".db"), + filepath.Join(root, "implicit", id+".pb"), + ) + + mustWrite(t, historyPath, + []byte(`{"display":"retagged prompt","timestamp":1779000000000,`+ + `"workspace":"/tmp/other","conversationId":"`+otherID+`"}`)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: historyPath, + WatchRoot: root, + EventKind: "write", + }, + ) + require.NoError(t, err) + assertAntigravityCLISourcePaths(t, changed, + filepath.Join(root, "conversations", id+".db"), + filepath.Join(root, "conversations", otherID+".db"), + filepath.Join(root, "implicit", id+".pb"), + ) +} + +func TestAntigravityCLIProviderUntaggedHistoryInvalidatesAllSources(t *testing.T) { + root := t.TempDir() + id := "33333333-4444-5555-6666-777777777777" + otherID := "88888888-9999-aaaa-bbbb-cccccccccccc" + writeAntigravityCLIProviderFixture(t, root, id) + mustWrite(t, filepath.Join(root, "conversations", otherID+".db"), []byte("db")) + mustWrite(t, filepath.Join(root, "history.jsonl"), + []byte(`{"display":"untagged prompt","timestamp":1779000000000,`+ + `"workspace":"/tmp/fallback"}`)) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "history.jsonl"), + WatchRoot: root, + EventKind: "write", + }, + ) + require.NoError(t, err) + assertAntigravityCLISourcePaths(t, changed, + filepath.Join(root, "conversations", id+".db"), + filepath.Join(root, "conversations", otherID+".db"), + filepath.Join(root, "implicit", id+".pb"), + ) +} + +func TestAntigravityCLIProviderFingerprintParseAndRetry(t *testing.T) { + root := t.TempDir() + id := "44444444-5555-6666-7777-888888888888" + mustMkdir(t, filepath.Join(root, "conversations")) + dbPath := filepath.Join(root, "conversations", id+".db") + createAntigravityUndecodableDB(t, dbPath, 3) + writeAntigravityTestSidecar(t, root, id, 2) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: id, + }) + require.NoError(t, err) + require.True(t, ok) + + before, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.Equal(t, dbPath, before.Key) + assert.NotEmpty(t, before.Hash) + + sidecarPath := filepath.Join(root, "conversations", id+".trajectory.json") + sidecarTime := time.Unix(0, before.MTimeNS+int64(time.Second)) + require.NoError(t, os.Chtimes(sidecarPath, sidecarTime, sidecarTime)) + after, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.Greater(t, after.MTimeNS, before.MTimeNS) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Fingerprint: after, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.True(t, outcome.ForceReplace) + require.Len(t, outcome.Results, 1) + result := outcome.Results[0] + assert.Equal(t, DataVersionNeedsRetry, result.DataVersion) + assert.NotEmpty(t, result.RetryReason) + assert.Equal(t, "antigravity-cli:"+id, result.Result.Session.ID) + assert.Equal(t, "devbox", result.Result.Session.Machine) + assert.Equal(t, after.Hash, result.Result.Session.File.Hash) + assert.NotEmpty(t, result.Result.Messages) +} + +func TestAntigravityProviderFingerprintTracksSideInputs(t *testing.T) { + root := t.TempDir() + id := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + writeAntigravityIDEProviderFixture(t, root, id) + + provider, ok := NewProvider(AgentAntigravity, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: id, + }) + require.NoError(t, err) + require.True(t, ok) + + before, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + + mustWrite(t, + filepath.Join(root, "annotations", id+".pbtxt"), + []byte("last_user_view_time:{seconds:1779326599 nanos:0}\n")) + afterAnnotation, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.NotEqual(t, before.Hash, afterAnnotation.Hash) + + mustWrite(t, filepath.Join(root, "brain", id, "plan.md"), []byte("# Changed")) + afterBrain, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.NotEqual(t, afterAnnotation.Hash, afterBrain.Hash) + + require.NoError(t, os.Remove(filepath.Join(root, "brain", id, "plan.md"))) + afterDelete, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.NotEqual(t, afterBrain.Hash, afterDelete.Hash) +} + +func TestAntigravityCLIProviderFindSourceCanonicalizesStoredConversationPath(t *testing.T) { + root := t.TempDir() + id := "55555555-6666-7777-8888-999999999999" + mustMkdir(t, filepath.Join(root, "conversations")) + pbPath := filepath.Join(root, "conversations", id+".pb") + dbPath := filepath.Join(root, "conversations", id+".db") + mustWrite(t, pbPath, []byte("pb")) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: pbPath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, pbPath, found.DisplayPath) + + mustWrite(t, dbPath, []byte("db")) + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: pbPath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, dbPath, found.DisplayPath) + + require.NoError(t, os.Remove(dbPath)) + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: dbPath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, pbPath, found.DisplayPath) +} + +func TestAntigravityCLIProviderStoredPathFreshness(t *testing.T) { + root := t.TempDir() + id := "33333333-4444-5555-6666-777777777777" + dbPath := filepath.Join(root, "conversations", id+".db") + writeAntigravityCLIProviderFixture(t, root, id) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: dbPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, dbPath, found.DisplayPath) + + require.NoError(t, os.Remove(dbPath)) + require.NoError(t, os.Remove(filepath.Join(root, "conversations", id+".pb"))) + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: dbPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "fresh lookup must reject a deleted Antigravity CLI source") + + staleSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: dbPath, + }) + require.NoError(t, err) + require.True(t, ok, "non-fresh lookup keeps tombstone source identity") + assert.Equal(t, dbPath, staleSource.DisplayPath) + outcome, err := provider.Parse(context.Background(), ParseRequest{Source: staleSource}) + require.NoError(t, err) + assert.True(t, outcome.ResultSetComplete) + assert.True(t, outcome.ForceReplace) + assert.Equal(t, SkipNoSession, outcome.SkipReason) + assert.Empty(t, outcome.Results) +} + +func TestAntigravityCLIProviderRejectsInvalidStoredPaths(t *testing.T) { + root := t.TempDir() + id := "33333333-4444-5555-6666-777777777777" + otherID := "88888888-9999-aaaa-bbbb-cccccccccccc" + dbPath := filepath.Join(root, "conversations", id+".db") + otherDBPath := filepath.Join(root, "conversations", otherID+".db") + writeAntigravityCLIProviderFixture(t, root, id) + writeAntigravityCLIProviderFixture(t, root, otherID) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + for _, path := range []string{ + dbPath + "#stale", + filepath.Join(root, "debug", id+".db"), + filepath.Join(root, "conversations", id+".txt"), + filepath.Join(root, "implicit", id+".db"), + } { + _, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: path, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "stored path %q", path) + } + + _, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: id, + StoredFilePath: otherDBPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "fresh lookup must reject a stored path for a different session") +} + +func TestAntigravityCLIProviderFingerprintTracksSideInputs(t *testing.T) { + root := t.TempDir() + id := "33333333-4444-5555-6666-777777777777" + implicitPath := filepath.Join(root, "implicit", id+".pb") + writeAntigravityCLIProviderFixture(t, root, id) + + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: id, + }) + require.NoError(t, err) + require.True(t, ok) + + before, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + + relevantHistory := `{"display":"changed prompt","timestamp":1779000000000,` + + `"workspace":"/tmp/db-proj","conversationId":"` + id + `"}` + mustWrite(t, filepath.Join(root, "history.jsonl"), []byte(relevantHistory)) + afterHistory, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.NotEqual(t, before.Hash, afterHistory.Hash) + + unrelatedHistory := relevantHistory + "\n" + + `{"display":"other prompt","timestamp":1779000000000,` + + `"workspace":"/tmp/other","conversationId":"88888888-9999-aaaa-bbbb-cccccccccccc"}` + mustWrite(t, filepath.Join(root, "history.jsonl"), []byte(unrelatedHistory)) + afterUnrelatedHistory, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.Equal(t, afterHistory.Hash, afterUnrelatedHistory.Hash) + + mustWrite(t, filepath.Join(root, "history.jsonl"), + []byte(unrelatedHistory+"\n"+ + `{"display":"untagged prompt","timestamp":1779000000000,`+ + `"workspace":"/tmp/fallback"}`)) + afterUntaggedHistory, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.NotEqual(t, afterUnrelatedHistory.Hash, afterUntaggedHistory.Hash) + + mustWrite(t, filepath.Join(root, "brain", id, "task.md"), []byte("# Changed")) + afterBrain, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.NotEqual(t, afterUntaggedHistory.Hash, afterBrain.Hash) + + writeAntigravityTestSidecar(t, root, id, 3) + afterSidecar, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.NotEqual(t, afterBrain.Hash, afterSidecar.Hash) + + implicitSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: antigravityImplicitTag + id, + }) + require.NoError(t, err) + require.True(t, ok) + beforeImplicit, err := provider.Fingerprint(context.Background(), implicitSource) + require.NoError(t, err) + + mustWrite(t, + strings.TrimSuffix(implicitPath, ".pb")+".trajectory.json", + []byte(`{"trajectoryId":"implicit","steps":[]}`)) + afterImplicit, err := provider.Fingerprint(context.Background(), implicitSource) + require.NoError(t, err) + assert.NotEqual(t, beforeImplicit.Hash, afterImplicit.Hash) +} + +func writeAntigravityIDEProviderFixture(t *testing.T, root, id string) { + t.Helper() + mustMkdir(t, filepath.Join(root, "conversations")) + mustMkdir(t, filepath.Join(root, "annotations")) + mustMkdir(t, filepath.Join(root, "brain", id)) + createAntigravityTestDB(t, filepath.Join(root, "conversations", id+".db")) + mustWrite(t, + filepath.Join(root, "annotations", id+".pbtxt"), + []byte("last_user_view_time:{seconds:1779326586 nanos:0}\n")) + mustWrite(t, filepath.Join(root, "brain", id, "plan.md"), []byte("# Plan")) + mustWrite(t, + filepath.Join(root, "brain", id, "plan.md.metadata.json"), + []byte(`{"summary":"Plan summary","updatedAt":"2026-05-20T22:47:27Z"}`)) +} + +func writeAntigravityCLIProviderFixture(t *testing.T, root, id string) { + t.Helper() + mustMkdir(t, filepath.Join(root, "conversations")) + mustMkdir(t, filepath.Join(root, "implicit")) + mustMkdir(t, filepath.Join(root, "brain", id)) + createAntigravityTestDB(t, filepath.Join(root, "conversations", id+".db")) + mustWrite(t, filepath.Join(root, "conversations", id+".pb"), + []byte("old-encrypted-placeholder")) + mustWrite(t, filepath.Join(root, "implicit", id+".pb"), []byte("implicit")) + mustWrite(t, filepath.Join(root, "brain", id, "task.md"), []byte("# Task")) + mustWrite(t, filepath.Join(root, "history.jsonl"), + []byte(`{"display":"db prompt fallback","timestamp":1779000000000,`+ + `"workspace":"/tmp/db-proj","conversationId":"`+id+`"}`)) +} + +func assertAntigravityCLISourcePaths( + t *testing.T, + sources []SourceRef, + want ...string, +) { + t.Helper() + got := make([]string, 0, len(sources)) + for _, source := range sources { + got = append(got, source.DisplayPath) + } + assert.Equal(t, want, got) +} diff --git a/internal/parser/antigravity_test.go b/internal/parser/antigravity_test.go index af6992c11..899e0f43f 100644 --- a/internal/parser/antigravity_test.go +++ b/internal/parser/antigravity_test.go @@ -18,6 +18,92 @@ import ( "github.com/stretchr/testify/require" ) +// newAntigravityTestProvider builds a concrete antigravityProvider for the given +// roots so package tests can exercise the folded discovery, source-lookup, and +// parse behavior directly through provider methods. +func newAntigravityTestProvider(t *testing.T, roots ...string) *antigravityProvider { + t.Helper() + provider, ok := NewProvider(AgentAntigravity, ProviderConfig{Roots: roots}) + require.True(t, ok) + ap, ok := provider.(*antigravityProvider) + require.True(t, ok) + return ap +} + +// newAntigravityCLITestProvider builds a concrete antigravityCLIProvider for the +// given roots. +func newAntigravityCLITestProvider(t *testing.T, roots ...string) *antigravityCLIProvider { + t.Helper() + provider, ok := NewProvider(AgentAntigravityCLI, ProviderConfig{Roots: roots}) + require.True(t, ok) + cp, ok := provider.(*antigravityCLIProvider) + require.True(t, ok) + return cp +} + +// discoverAntigravityTestSessions discovers IDE sessions under root through the +// provider, returning the legacy DiscoveredFile shape the tests assert against. +// It replaces the removed package-level DiscoverAntigravitySessions entrypoint. +func discoverAntigravityTestSessions(t *testing.T, root string) []DiscoveredFile { + t.Helper() + paths := newAntigravityTestProvider(t, root).sources.discoverSessionPaths(root) + files := make([]DiscoveredFile, 0, len(paths)) + for _, path := range paths { + files = append(files, DiscoveredFile{Path: path, Agent: AgentAntigravity}) + } + return files +} + +// findAntigravityTestSourceFile resolves an IDE session id to a DB path through +// the provider, replacing the removed FindAntigravitySourceFile. +func findAntigravityTestSourceFile(t *testing.T, root, id string) string { + t.Helper() + return newAntigravityTestProvider(t, root).sources.findSourceFile(root, id) +} + +// parseAntigravityTestSession parses an IDE session DB through the provider-owned +// parse method, replacing the removed package-level ParseAntigravitySession. +func parseAntigravityTestSession( + t *testing.T, path, project, machine string, +) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, error) { + t.Helper() + return newAntigravityTestProvider(t).parseSession(path, project, machine) +} + +// discoverAntigravityCLITestSessions discovers CLI sessions under root through +// the provider, replacing the removed DiscoverAntigravityCLISessions. +func discoverAntigravityCLITestSessions(t *testing.T, root string) []DiscoveredFile { + t.Helper() + return newAntigravityCLITestProvider(t, root).sources.discoverSessions(root) +} + +// findAntigravityCLITestSourceFile resolves a CLI session id to a source path +// through the provider, replacing the removed FindAntigravityCLISourceFile. +func findAntigravityCLITestSourceFile(t *testing.T, root, id string) string { + t.Helper() + return newAntigravityCLITestProvider(t, root).sources.findSourceFile(root, id) +} + +// parseAntigravityCLITestSessionWithStatus parses a CLI session through the +// provider-owned parse method, replacing the removed package-level +// ParseAntigravityCLISessionWithStatus. +func parseAntigravityCLITestSessionWithStatus( + t *testing.T, path, project, machine string, +) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, AntigravityCLIParseStatus, error) { + t.Helper() + return newAntigravityCLITestProvider(t).parseSessionWithStatus(path, project, machine) +} + +// parseAntigravityCLITestSession is the no-status convenience wrapper the tests +// use, replacing the removed package-level ParseAntigravityCLISession. +func parseAntigravityCLITestSession( + t *testing.T, path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + sess, msgs, _, _, err := parseAntigravityCLITestSessionWithStatus(t, path, project, machine) + return sess, msgs, err +} + // ---- protobuf wire walker ------------------------------------- // agProtoEncode is a tiny test-only encoder used to hand-craft @@ -264,14 +350,14 @@ func TestAntigravityCLIDiscoverAndParse(t *testing.T) { {"display":"other","timestamp":1779000001000,"workspace":"/tmp/x","conversationId":"other-id"}`)) // Discovery should return the .pb with the right project. - files := DiscoverAntigravityCLISessions(root) + files := discoverAntigravityCLITestSessions(t, root) require.Len(t, files, 1, "discover") assert.Equal(t, "/tmp/proj", files[0].Project, "project") // Find by id should locate the same .pb. - assert.Equal(t, files[0].Path, FindAntigravityCLISourceFile(root, id), "find") + assert.Equal(t, files[0].Path, findAntigravityCLITestSourceFile(t, root, id), "find") - sess, msgs, err := ParseAntigravityCLISession( + sess, msgs, err := parseAntigravityCLITestSession(t, files[0].Path, files[0].Project, "test-machine", ) require.NoError(t, err, "parse") @@ -305,13 +391,13 @@ func TestAntigravityCLIDiscoverAndParseDB(t *testing.T) { []byte(`{"display":"db prompt fallback","timestamp":1779000000000,`+ `"workspace":"/tmp/db-proj","conversationId":"`+id+`"}`)) - files := DiscoverAntigravityCLISessions(root) + files := discoverAntigravityCLITestSessions(t, root) require.Len(t, files, 1, "discover") assert.Equal(t, dbPath, files[0].Path, "prefer db over pb") assert.Equal(t, "/tmp/db-proj", files[0].Project, "project") - assert.Equal(t, dbPath, FindAntigravityCLISourceFile(root, id), "find") + assert.Equal(t, dbPath, findAntigravityCLITestSourceFile(t, root, id), "find") - sess, msgs, err := ParseAntigravityCLISession( + sess, msgs, err := parseAntigravityCLITestSession(t, files[0].Path, files[0].Project, "test-machine", ) require.NoError(t, err, "parse") @@ -343,7 +429,7 @@ func TestAntigravityCLIProjectFallbackPromptAndProximity(t *testing.T) { mustWrite(t, filepath.Join(root, "history.jsonl"), []byte(`{"display":" user prompt text goes here ","timestamp":1779000010000,"workspace":"/tmp/fallback-proj"}`)) - sess, msgs, err := ParseAntigravityCLISession(dbPath, "", "m") + sess, msgs, err := parseAntigravityCLITestSession(t, dbPath, "", "m") require.NoError(t, err) require.Len(t, msgs, 2) assert.Equal(t, "/tmp/fallback-proj", sess.Project, "should successfully fallback infer project") @@ -363,7 +449,7 @@ func TestAntigravityCLIProjectFallbackStrictWindow(t *testing.T) { mustWrite(t, filepath.Join(root, "history.jsonl"), []byte(`{"display":"user prompt text goes here","timestamp":1779000065000,"workspace":"/tmp/too-late-proj"}`)) - sess, _, err := ParseAntigravityCLISession(dbPath, "", "m") + sess, _, err := parseAntigravityCLITestSession(t, dbPath, "", "m") require.NoError(t, err) assert.Empty(t, sess.Project, "should reject match outside 1-minute window") } @@ -383,7 +469,7 @@ func TestAntigravityCLIProjectFallbackAmbiguous(t *testing.T) { []byte(`{"display":"user prompt text goes here","timestamp":1779000005000,"workspace":"/tmp/proj-a"} {"display":"user prompt text goes here","timestamp":1779000005000,"workspace":"/tmp/proj-b"}`)) - sess, _, err := ParseAntigravityCLISession(dbPath, "", "m") + sess, _, err := parseAntigravityCLITestSession(t, dbPath, "", "m") require.NoError(t, err) assert.Empty(t, sess.Project, "should reject ambiguous match with different workspaces at same time closeness") } @@ -402,7 +488,7 @@ func TestAntigravityCLIProjectFallbackShortPrompt(t *testing.T) { mustWrite(t, filepath.Join(root, "history.jsonl"), []byte(`{"display":"hi","timestamp":1779000005000,"workspace":"/tmp/short-proj"}`)) - sess, _, err := ParseAntigravityCLISession(dbPath, "", "m") + sess, _, err := parseAntigravityCLITestSession(t, dbPath, "", "m") require.NoError(t, err) assert.Empty(t, sess.Project, "should reject matching short prompts") } @@ -453,6 +539,47 @@ func TestAntigravityCLIDBFileInfoIncludesSQLiteSidecars(t *testing.T) { assert.Equal(t, late.UnixNano(), info.ModTime().UnixNano()) } +func TestAntigravityCLIFileInfoIncludesHistoryForLegacySync(t *testing.T) { + early := time.Unix(1779000000, 0) + late := time.Unix(1779000300, 0) + history := []byte(`{"display":"history prompt","timestamp":1779000000000,` + + `"workspace":"/tmp/proj","conversationId":"id"}` + "\n") + + t.Run("db session", func(t *testing.T) { + root := t.TempDir() + id := "14141414-2525-3636-4747-585858585858" + mustMkdir(t, filepath.Join(root, "conversations")) + dbPath := filepath.Join(root, "conversations", id+".db") + historyPath := filepath.Join(root, "history.jsonl") + mustWrite(t, dbPath, []byte("db")) + mustWrite(t, historyPath, history) + require.NoError(t, os.Chtimes(dbPath, early, early)) + require.NoError(t, os.Chtimes(historyPath, late, late)) + + info, err := AntigravityCLIFileInfo(dbPath) + require.NoError(t, err) + assert.Equal(t, int64(len("db")+len(history)), info.Size()) + assert.Equal(t, late.UnixNano(), info.ModTime().UnixNano()) + }) + + t.Run("pb session", func(t *testing.T) { + root := t.TempDir() + id := "15151515-2626-3737-4848-595959595959" + mustMkdir(t, filepath.Join(root, "implicit")) + pbPath := filepath.Join(root, "implicit", id+".pb") + historyPath := filepath.Join(root, "history.jsonl") + mustWrite(t, pbPath, []byte("pb")) + mustWrite(t, historyPath, history) + require.NoError(t, os.Chtimes(pbPath, early, early)) + require.NoError(t, os.Chtimes(historyPath, late, late)) + + info, err := AntigravityCLIFileInfo(pbPath) + require.NoError(t, err) + assert.Equal(t, int64(len("pb")+len(history)), info.Size()) + assert.Equal(t, late.UnixNano(), info.ModTime().UnixNano()) + }) +} + // TestAntigravityCLIFileInfoIncludesBrainArtifacts pins brain // artifacts into the CLI composite fingerprint: the parser renders // brain//*.md (+ .metadata.json) as messages, so a brain-only @@ -515,7 +642,7 @@ func TestAntigravityCLIDBInsertsShortHistoryPrompt(t *testing.T) { []byte(`{"display":"fix lint","timestamp":1779000000000,`+ `"workspace":"/tmp/db-proj","conversationId":"`+id+`"}`)) - sess, msgs, err := ParseAntigravityCLISession( + sess, msgs, err := parseAntigravityCLITestSession(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -540,7 +667,7 @@ func TestAntigravityCLIDiscoverIgnoresJunk(t *testing.T) { mustWrite(t, filepath.Join(root, "conversations", "bad.name.pb"), []byte("x")) - assert.Empty(t, DiscoverAntigravityCLISessions(root)) + assert.Empty(t, discoverAntigravityCLITestSessions(t, root)) } // ---- IDE parser ----------------------------------------------- @@ -566,12 +693,12 @@ func TestAntigravityIDEDiscoverAndParse(t *testing.T) { filepath.Join(root, "brain", id, "plan.md.metadata.json"), []byte(`{"summary":"Plan summary","updatedAt":"2026-05-20T22:47:27Z"}`)) - files := DiscoverAntigravitySessions(root) + files := discoverAntigravityTestSessions(t, root) require.Len(t, files, 1) assert.Equal(t, dbPath, files[0].Path) - assert.Equal(t, dbPath, FindAntigravitySourceFile(root, id)) + assert.Equal(t, dbPath, findAntigravityTestSourceFile(t, root, id)) - sess, msgs, _, err := ParseAntigravitySession( + sess, msgs, _, err := parseAntigravityTestSession(t, dbPath, "", "test-machine", ) require.NoError(t, err, "parse") @@ -972,7 +1099,7 @@ func TestAntigravityCLIDiscoverImplicit(t *testing.T) { filepath.Join(root, "implicit", implID+".pb"), []byte("x")) - files := DiscoverAntigravityCLISessions(root) + files := discoverAntigravityCLITestSessions(t, root) require.Len(t, files, 2, "got files, want 2 (one per subdir)") var sawConv, sawImpl bool for _, f := range files { @@ -986,18 +1113,18 @@ func TestAntigravityCLIDiscoverImplicit(t *testing.T) { assert.True(t, sawConv, "missing conv subdir") assert.True(t, sawImpl, "missing impl subdir") - // FindAntigravityCLISourceFile routes implicit-tagged ids to - // the implicit/ subdir; bare ids resolve under conversations/. + // The provider source lookup routes implicit-tagged ids to the + // implicit/ subdir; bare ids resolve under conversations/. wantImpl := filepath.Join("implicit", implID+".pb") - gotImpl := FindAntigravityCLISourceFile(root, "implicit-"+implID) + gotImpl := findAntigravityCLITestSourceFile(t, root, "implicit-"+implID) require.NotEmpty(t, gotImpl) assert.True(t, strings.HasSuffix(gotImpl, wantImpl), "find implicit: %q", gotImpl) wantConv := filepath.Join("conversations", convID+".pb") - gotConv := FindAntigravityCLISourceFile(root, convID) + gotConv := findAntigravityCLITestSourceFile(t, root, convID) require.NotEmpty(t, gotConv) assert.True(t, strings.HasSuffix(gotConv, wantConv), "find conv: %q", gotConv) // A bare implicit-only UUID must NOT resolve under conversations/. - assert.Empty(t, FindAntigravityCLISourceFile(root, implID), + assert.Empty(t, findAntigravityCLITestSourceFile(t, root, implID), "bare implicit id should not resolve") } @@ -1015,17 +1142,17 @@ func TestAntigravityCLIImplicitSessionIDDistinct(t *testing.T) { mustWrite(t, convPath, []byte("x")) mustWrite(t, implPath, []byte("x")) - convSess, _, err := ParseAntigravityCLISession(convPath, "", "m") + convSess, _, err := parseAntigravityCLITestSession(t, convPath, "", "m") require.NoError(t, err, "parse conv") - implSess, _, err := ParseAntigravityCLISession(implPath, "", "m") + implSess, _, err := parseAntigravityCLITestSession(t, implPath, "", "m") require.NoError(t, err, "parse impl") assert.NotEqual(t, implSess.ID, convSess.ID, "session ids collide") assert.Equal(t, "antigravity-cli:"+id, convSess.ID, "conv id") assert.Equal(t, "antigravity-cli:implicit-"+id, implSess.ID, "impl id") // Round-trip: each storage id resolves back to its own file. - assert.Equal(t, convPath, FindAntigravityCLISourceFile(root, id), "round-trip conv") - assert.Equal(t, implPath, FindAntigravityCLISourceFile(root, "implicit-"+id), "round-trip impl") + assert.Equal(t, convPath, findAntigravityCLITestSourceFile(t, root, id), "round-trip conv") + assert.Equal(t, implPath, findAntigravityCLITestSourceFile(t, root, "implicit-"+id), "round-trip impl") } func TestBuildAntigravityProjectMapRobust(t *testing.T) { @@ -1228,7 +1355,7 @@ func TestDecodeAntigravityStepToolCall(t *testing.T) { dbPath := filepath.Join(root, "conversations", id+".db") createAntigravityToolCallDB(t, dbPath) - sess, msgs, _, err := ParseAntigravitySession(dbPath, "/tmp/proj", "m") + sess, msgs, _, err := parseAntigravityTestSession(t, dbPath, "/tmp/proj", "m") require.NoError(t, err) require.NotNil(t, sess) @@ -1278,7 +1405,7 @@ func TestDecodeAntigravityStepUserNoToolCalls(t *testing.T) { mustExec(t, db, `INSERT INTO steps (idx, step_type, step_payload) VALUES (?, ?, ?)`, 0, 14, userPayload) - sess, msgs, _, err := ParseAntigravitySession(dbPath, "/tmp/proj", "m") + sess, msgs, _, err := parseAntigravityTestSession(t, dbPath, "/tmp/proj", "m") require.NoError(t, err) require.NotNil(t, sess) @@ -1311,7 +1438,7 @@ func TestDecodeAntigravityStepNoFalsePositives(t *testing.T) { mustExec(t, db, `INSERT INTO steps (idx, step_type, step_payload) VALUES (?, ?, ?)`, 0, 17, asstPayload) - sess, msgs, _, err := ParseAntigravitySession(dbPath, "/tmp/proj", "m") + sess, msgs, _, err := parseAntigravityTestSession(t, dbPath, "/tmp/proj", "m") require.NoError(t, err) require.NotNil(t, sess) @@ -1354,7 +1481,7 @@ func TestDecodeAntigravityStepMultipleToolCalls(t *testing.T) { mustExec(t, db, `INSERT INTO steps (idx, step_type, step_payload) VALUES (?, ?, ?)`, 0, 17, asstPayload) - sess, msgs, _, err := ParseAntigravitySession(dbPath, "/tmp/proj", "m") + sess, msgs, _, err := parseAntigravityTestSession(t, dbPath, "/tmp/proj", "m") require.NoError(t, err) require.NotNil(t, sess) @@ -1458,7 +1585,7 @@ func TestAntigravityCLITrajectoryParse(t *testing.T) { sidecarPath := filepath.Join(root, "conversations", id+".trajectory.json") mustWrite(t, sidecarPath, []byte(trajectoryJSON)) - sess, msgs, err := ParseAntigravityCLISession(pbPath, "", "test-machine") + sess, msgs, err := parseAntigravityCLITestSession(t, pbPath, "", "test-machine") require.NoError(t, err) assert.Equal(t, "antigravity-cli:"+id, sess.ID) @@ -1564,7 +1691,7 @@ func TestAntigravityCLITrajectoryWithoutSupportedMessagesFallsBack(t *testing.T) []byte(`{"display":"history fallback","timestamp":1779000000000,`+ `"workspace":"/tmp/proj","conversationId":"`+id+`"}`)) - sess, msgs, err := ParseAntigravityCLISession(pbPath, "", "test-machine") + sess, msgs, err := parseAntigravityCLITestSession(t, pbPath, "", "test-machine") require.NoError(t, err) require.Len(t, msgs, 1) @@ -1652,7 +1779,7 @@ func TestAntigravityCLIDBPrefersSidecarWithEqualCoverage(t *testing.T) { []byte(`{"display":"history prompt","timestamp":1779000000000,`+ `"workspace":"/tmp/db-proj","conversationId":"`+id+`"}`)) - sess, msgs, _, status, err := ParseAntigravityCLISessionWithStatus( + sess, msgs, _, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -1685,7 +1812,7 @@ func TestAntigravityCLIDBKeepsDBDecodeWhenSidecarLags(t *testing.T) { []byte(`{"display":"history prompt","timestamp":1779000000000,`+ `"workspace":"/tmp/db-proj","conversationId":"`+id+`"}`)) - _, msgs, _, status, err := ParseAntigravityCLISessionWithStatus( + _, msgs, _, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -1707,7 +1834,7 @@ func TestAntigravityCLIDBSidecarUsedWhenDBDecodeEmpty(t *testing.T) { require.NoError(t, db.Close()) writeAntigravityTestSidecar(t, root, id, 2) - _, msgs, _, status, err := ParseAntigravityCLISessionWithStatus( + _, msgs, _, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -1763,7 +1890,7 @@ func TestAntigravityCLIPBUsesSidecarDespiteOlderMtime(t *testing.T) { []byte(`{"display":"history prompt","timestamp":1779000000000,`+ `"workspace":"/tmp/pb-proj","conversationId":"`+id+`"}`)) - sess, msgs, err := ParseAntigravityCLISession(pbPath, "", "test-machine") + sess, msgs, err := parseAntigravityCLITestSession(t, pbPath, "", "test-machine") require.NoError(t, err) require.Len(t, msgs, 2) assert.Equal(t, "sidecar prompt", msgs[0].Content) @@ -1799,7 +1926,7 @@ func TestAntigravityCLIDBPartialSidecarNotPersistedAsCurrent(t *testing.T) { // the row must stay retryable rather than persist as current. writeAntigravityTestSidecar(t, root, id, 2) - _, msgs, _, status, err := ParseAntigravityCLISessionWithStatus( + _, msgs, _, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -1818,7 +1945,7 @@ func TestAntigravityCLIDBCoveringSidecarRescuesUndecodableRows(t *testing.T) { createAntigravityUndecodableDB(t, dbPath, 3) writeAntigravityTestSidecar(t, root, id, 3) - _, msgs, _, status, err := ParseAntigravityCLISessionWithStatus( + _, msgs, _, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -1862,7 +1989,7 @@ func TestAntigravityCLISidecarWinsKeepsTokenUsage(t *testing.T) { // gen_metadata events. writeAntigravityTestSidecar(t, root, id, 2) - sess, msgs, usageEvents, status, err := ParseAntigravityCLISessionWithStatus( + sess, msgs, usageEvents, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -1905,7 +2032,7 @@ func TestAntigravityCLISidecarRescueKeepsGenMetadataUsage(t *testing.T) { // Covering sidecar rescues the undecodable rows. writeAntigravityTestSidecar(t, root, id, 2) - sess, msgs, usageEvents, status, err := ParseAntigravityCLISessionWithStatus( + sess, msgs, usageEvents, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -1968,7 +2095,7 @@ func TestAntigravityCLIPBSidecarEmitsUsageEvents(t *testing.T) { ]` writeAntigravityTestSidecarWithGenMetadata(t, root, id, 3, genJSON) - sess, msgs, usageEvents, status, err := ParseAntigravityCLISessionWithStatus( + sess, msgs, usageEvents, status, err := parseAntigravityCLITestSessionWithStatus(t, pbPath, "", "test-machine", ) require.NoError(t, err) @@ -2067,7 +2194,7 @@ func TestAntigravityCLIDBGenMetadataWinsOverSidecarUsage(t *testing.T) { }]` writeAntigravityTestSidecarWithGenMetadata(t, root, id, 2, genJSON) - sess, msgs, usageEvents, status, err := ParseAntigravityCLISessionWithStatus( + sess, msgs, usageEvents, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -2118,7 +2245,7 @@ func TestAntigravityCLIDBWithoutGenMetadataGetsSidecarUsage(t *testing.T) { }]` writeAntigravityTestSidecarWithGenMetadata(t, root, id, 2, genJSON) - sess, msgs, usageEvents, status, err := ParseAntigravityCLISessionWithStatus( + sess, msgs, usageEvents, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -2166,7 +2293,7 @@ func TestAntigravityCLINonCoveringSidecarUsageRejected(t *testing.T) { }]` writeAntigravityTestSidecarWithGenMetadata(t, root, id, 1, genJSON) - sess, msgs, usageEvents, status, err := ParseAntigravityCLISessionWithStatus( + sess, msgs, usageEvents, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -2272,7 +2399,7 @@ func TestAntigravityCLISidecarUsageStepIndexEdgeCases(t *testing.T) { t, root, id, 3, tc.gens, ) - _, msgs, usageEvents, _, err := ParseAntigravityCLISessionWithStatus( + _, msgs, usageEvents, _, err := parseAntigravityCLITestSessionWithStatus(t, pbPath, "", "test-machine", ) require.NoError(t, err) @@ -2322,7 +2449,7 @@ func TestAntigravitySessionFileMetadataIncludesWAL(t *testing.T) { walTime := mainInfo.ModTime().Add(5 * time.Second) require.NoError(t, os.Chtimes(walPath, walTime, walTime)) - sess, _, _, err := ParseAntigravitySession(dbPath, "p", "m") + sess, _, _, err := parseAntigravityTestSession(t, dbPath, "p", "m") require.NoError(t, err) // The parse's own read-only open can create or touch -shm/-wal @@ -2345,7 +2472,7 @@ func TestAntigravitySessionFileMetadataIncludesWAL(t *testing.T) { } // TestAntigravityFileInfoIncludesBrainArtifacts pins brain artifacts -// into the IDE composite fingerprint: ParseAntigravitySession renders +// into the IDE composite fingerprint: the provider parse renders // brain//*.md (+ .metadata.json) as messages, so a brain-only // add/edit/delete must change the effective file info or skip checks // keep stale brain messages. @@ -2420,7 +2547,7 @@ func TestAntigravityTokenUsage(t *testing.T) { genData := createAntigravityMockGenMetadata(t, 2400, 180, 0, "Test Gemini 3.5") mustExec(t, db, `INSERT INTO gen_metadata (idx, data, size) VALUES (1, ?, ?)`, genData, len(genData)) - sess, msgs, usageEvents, err := ParseAntigravitySession(dbPath, "test-project", "test-machine") + sess, msgs, usageEvents, err := parseAntigravityTestSession(t, dbPath, "test-project", "test-machine") require.NoError(t, err) // 1. Verify model and message token counts @@ -2484,7 +2611,7 @@ func TestAntigravityTokenUsageCachedTokens(t *testing.T) { genData := createAntigravityMockGenMetadata(t, 2200, 180, 200, "Test Gemini 3.5") mustExec(t, db, `INSERT INTO gen_metadata (idx, data, size) VALUES (1, ?, ?)`, genData, len(genData)) - sess, msgs, usageEvents, err := ParseAntigravitySession(dbPath, "test-project", "test-machine") + sess, msgs, usageEvents, err := parseAntigravityTestSession(t, dbPath, "test-project", "test-machine") require.NoError(t, err) // Message token counts: context = uncached + cache-read. @@ -2545,7 +2672,7 @@ func TestAntigravityTokenUsageCachedTokensAllInputs(t *testing.T) { genData := createAntigravityMockGenMetadata(t, 50, 100, 200, "Test Gemini 3.5") mustExec(t, db, `INSERT INTO gen_metadata (idx, data, size) VALUES (1, ?, ?)`, genData, len(genData)) - sess, msgs, usageEvents, err := ParseAntigravitySession(dbPath, "test-project", "test-machine") + sess, msgs, usageEvents, err := parseAntigravityTestSession(t, dbPath, "test-project", "test-machine") require.NoError(t, err) require.Len(t, msgs, 2) @@ -2603,7 +2730,7 @@ func TestAntigravityTokenUsageMixedDecode(t *testing.T) { genUndecoded := createAntigravityMockGenMetadata(t, 3000, 220, 0, "Test Gemini 3.5") mustExec(t, db, `INSERT INTO gen_metadata (idx, data, size) VALUES (2, ?, ?)`, genUndecoded, len(genUndecoded)) - sess, msgs, usageEvents, err := ParseAntigravitySession(dbPath, "test-project", "test-machine") + sess, msgs, usageEvents, err := parseAntigravityTestSession(t, dbPath, "test-project", "test-machine") require.NoError(t, err) require.Len(t, msgs, 2, "undecodable step contributes no message") require.Len(t, usageEvents, 2, "both gen rows emit usage events") @@ -2642,7 +2769,7 @@ func TestAntigravityCLITokenUsageMixedDecode(t *testing.T) { mustExec(t, db, `INSERT INTO gen_metadata (idx, data, size) VALUES (1, ?, ?)`, genUndecoded, len(genUndecoded)) require.NoError(t, db.Close()) - sess, msgs, usageEvents, _, err := ParseAntigravityCLISessionWithStatus( + sess, msgs, usageEvents, _, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -2672,7 +2799,7 @@ func TestAntigravityZeroMessageKeepsUsageEvents(t *testing.T) { genData := createAntigravityMockGenMetadata(t, 2400, 180, 0, "Test Gemini 3.5") mustExec(t, db, `INSERT INTO gen_metadata (idx, data, size) VALUES (0, ?, ?)`, genData, len(genData)) - sess, msgs, usageEvents, err := ParseAntigravitySession(dbPath, "test-project", "test-machine") + sess, msgs, usageEvents, err := parseAntigravityTestSession(t, dbPath, "test-project", "test-machine") require.NoError(t, err) assert.Empty(t, msgs) require.Len(t, usageEvents, 1, "usage events must survive zero-message parses") @@ -2699,7 +2826,7 @@ func TestAntigravityCLIZeroMessageKeepsUsageEvents(t *testing.T) { mustExec(t, db, `INSERT INTO gen_metadata (idx, data, size) VALUES (0, ?, ?)`, genData, len(genData)) require.NoError(t, db.Close()) - sess, msgs, usageEvents, status, err := ParseAntigravityCLISessionWithStatus( + sess, msgs, usageEvents, status, err := parseAntigravityCLITestSessionWithStatus(t, dbPath, "", "test-machine", ) require.NoError(t, err) @@ -2746,7 +2873,7 @@ func TestAntigravityTokenUsageDynamicField(t *testing.T) { genData := createAntigravityMockGenMetadataWithField(t, 1187, 5000, 400, 0, "Test Gemini 3.5 Flash") mustExec(t, db, `INSERT INTO gen_metadata (idx, data, size) VALUES (1, ?, ?)`, genData, len(genData)) - sess, msgs, usageEvents, err := ParseAntigravitySession(dbPath, "test-project", "test-machine") + sess, msgs, usageEvents, err := parseAntigravityTestSession(t, dbPath, "test-project", "test-machine") require.NoError(t, err) require.Len(t, msgs, 2) diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 0c5dea9e3..9a0ff2bd1 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -352,6 +352,10 @@ func ProviderFactories() []ProviderFactory { func providerFactoryForDef(def AgentDef) ProviderFactory { def = cloneAgentDef(def) switch def.Type { + case AgentAntigravity: + return newAntigravityProviderFactory(def) + case AgentAntigravityCLI: + return newAntigravityCLIProviderFactory(def) case AgentAmp: return newAmpProviderFactory(def) case AgentClaude: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 40b597295..77d9cd8d6 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -50,8 +50,8 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentPiebald: ProviderMigrationLegacyOnly, AgentWarp: ProviderMigrationLegacyOnly, AgentPositron: ProviderMigrationProviderAuthoritative, - AgentAntigravity: ProviderMigrationLegacyOnly, - AgentAntigravityCLI: ProviderMigrationLegacyOnly, + AgentAntigravity: ProviderMigrationProviderAuthoritative, + AgentAntigravityCLI: ProviderMigrationProviderAuthoritative, AgentVibe: ProviderMigrationProviderAuthoritative, AgentZed: ProviderMigrationProviderAuthoritative, AgentQwenPaw: ProviderMigrationProviderAuthoritative, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index 2ae6ca068..d86b106cd 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -47,8 +47,6 @@ var providerNeutralEntrypoints = map[string]bool{ // tip (the zero-legacy gate) asserts this list is empty, so a provider cannot // remain a permanent shim. var pendingShimProviderFiles = map[string]bool{ - "antigravity_cli_provider.go": true, - "antigravity_provider.go": true, "claude_provider.go": true, "codex_provider.go": true, "copilot_provider.go": true, diff --git a/internal/parser/types.go b/internal/parser/types.go index 2c62d805c..439c48b10 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -496,9 +496,7 @@ var Registry = []AgentDef{ "brain", "annotations", }, - FileBased: true, - DiscoverFunc: DiscoverAntigravitySessions, - FindSourceFunc: FindAntigravitySourceFile, + FileBased: true, }, { Type: AgentAntigravityCLI, @@ -512,9 +510,7 @@ var Registry = []AgentDef{ "implicit", "brain", }, - FileBased: true, - DiscoverFunc: DiscoverAntigravityCLISessions, - FindSourceFunc: FindAntigravityCLISourceFile, + FileBased: true, }, { Type: AgentQwenPaw, diff --git a/internal/sync/antigravity_cli_integration_test.go b/internal/sync/antigravity_cli_integration_test.go index a83945afb..02840db58 100644 --- a/internal/sync/antigravity_cli_integration_test.go +++ b/internal/sync/antigravity_cli_integration_test.go @@ -573,6 +573,93 @@ func TestSyncSingleSessionAntigravityCLI_InferredProjectWithoutConversationID(t assertSessionMessageCount(t, env.db, sessionID, 1) } +func TestSyncPathsAntigravityCLIHistoryOnlyUpdateRefreshesProject(t *testing.T) { + env := setupTestEnv(t) + uuid := "de45fa67-8888-9999-aaaa-bbbbccccdddd" + sessionID := "antigravity-cli:" + uuid + early := time.UnixMilli(1716244800000) + late := early.Add(time.Minute) + + convDir := filepath.Join(env.antigravityCLIDir, "conversations") + require.NoError(t, os.MkdirAll(convDir, 0o755)) + dbPath := filepath.Join(convDir, uuid+".db") + createAntigravityCLIDisplayStepDB(t, dbPath, "History arrives later") + require.NoError(t, os.Chtimes(dbPath, early, early)) + + runSyncAndAssert(t, env.engine, sync.SyncStats{ + TotalSessions: 1, + Synced: 1, + Skipped: 0, + }) + assertSessionProject(t, env.db, sessionID, "") + + historyPath := filepath.Join(env.antigravityCLIDir, "history.jsonl") + historyLine := fmt.Sprintf( + `{"conversationId": %q, "workspace": "/home/user/history-arrived", "timestamp": %d, "display": "History arrives later"}`+"\n", + uuid, late.UnixMilli(), + ) + require.NoError(t, os.WriteFile(historyPath, []byte(historyLine), 0o644)) + require.NoError(t, os.Chtimes(historyPath, late, late)) + + env.engine.SyncPaths([]string{historyPath}) + + assertSessionProject(t, env.db, sessionID, "/home/user/history-arrived") + assertSessionMessageCount(t, env.db, sessionID, 1) +} + +func TestSyncPathsAntigravityCLIHistoryRetagClearsRemovedProject(t *testing.T) { + env := setupTestEnv(t) + removedID := "ee45fa67-8888-9999-aaaa-bbbbccccdddd" + retaggedID := "ff45fa67-8888-9999-aaaa-bbbbccccdddd" + removedSessionID := "antigravity-cli:" + removedID + retaggedSessionID := "antigravity-cli:" + retaggedID + base := time.UnixMilli(1716244800000) + + convDir := filepath.Join(env.antigravityCLIDir, "conversations") + require.NoError(t, os.MkdirAll(convDir, 0o755)) + for id, prompt := range map[string]string{ + removedID: "Original history prompt", + retaggedID: "Retagged history prompt", + } { + dbPath := filepath.Join(convDir, id+".db") + createAntigravityCLIDisplayStepDB(t, dbPath, prompt) + require.NoError(t, os.Chtimes(dbPath, base, base)) + } + historyPath := filepath.Join(env.antigravityCLIDir, "history.jsonl") + initialHistory := fmt.Sprintf( + `{"conversationId": %q, "workspace": "/home/user/removed", "timestamp": %d, "display": "Original history prompt"}`+"\n", + removedID, base.UnixMilli(), + ) + fmt.Sprintf( + `{"conversationId": %q, "workspace": "/home/user/retagged", "timestamp": %d, "display": "Retagged history prompt"}`+"\n", + retaggedID, base.UnixMilli(), + ) + require.NoError(t, os.WriteFile(historyPath, []byte(initialHistory), 0o644)) + require.NoError(t, os.Chtimes(historyPath, base, base)) + + runSyncAndAssert(t, env.engine, sync.SyncStats{ + TotalSessions: 2, + Synced: 2, + Skipped: 0, + }) + assertSessionProject(t, env.db, removedSessionID, "/home/user/removed") + assertSessionProject(t, env.db, retaggedSessionID, "/home/user/retagged") + + updated := base.Add(time.Minute) + retaggedHistory := fmt.Sprintf( + `{"conversationId": %q, "workspace": "/home/user/retagged-now", "timestamp": %d, "display": "Retagged history prompt"}`+"\n", + retaggedID, updated.UnixMilli(), + ) + require.NoError(t, os.WriteFile(historyPath, []byte(retaggedHistory), 0o644)) + require.NoError(t, os.Chtimes(historyPath, updated, updated)) + + env.engine.SyncPaths([]string{historyPath}) + + assertSessionProject(t, env.db, removedSessionID, "") + assertSessionProject(t, env.db, retaggedSessionID, "/home/user/retagged-now") + assertSessionMessageCount(t, env.db, removedSessionID, 1) + assertSessionMessageCount(t, env.db, retaggedSessionID, 1) +} + func TestSyncEngineAntigravityCLI_MissingPbOrphanSidecar(t *testing.T) { env := setupTestEnv(t) uuid := "66666666-7777-8888-9999-000000000000" diff --git a/internal/sync/classify_antigravity_cli_test.go b/internal/sync/classify_antigravity_cli_test.go deleted file mode 100644 index be05aba3e..000000000 --- a/internal/sync/classify_antigravity_cli_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package sync - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.kenn.io/agentsview/internal/parser" -) - -func TestClassifyOnePath_AntigravityCLI(t *testing.T) { - dir := t.TempDir() - uuid := "11111111-2222-3333-4444-555555555555" - dbUUID := "33333333-4444-5555-6666-777777777777" - - // Create conversations and implicit subdirectories - convDir := filepath.Join(dir, "conversations") - implDir := filepath.Join(dir, "implicit") - require.NoError(t, os.MkdirAll(convDir, 0o755)) - require.NoError(t, os.MkdirAll(implDir, 0o755)) - - // Files under conversations - pbPath := filepath.Join(convDir, uuid+".pb") - trajPath := filepath.Join(convDir, uuid+".trajectory.json") - dbPath := filepath.Join(convDir, dbUUID+".db") - dbWalPath := dbPath + "-wal" - dbShmPath := dbPath + "-shm" - - require.NoError(t, os.WriteFile(pbPath, []byte("pb-data"), 0o644)) - require.NoError(t, os.WriteFile(trajPath, []byte("trajectory-data"), 0o644)) - require.NoError(t, os.WriteFile(dbPath, []byte("sqlite-data"), 0o644)) - require.NoError(t, os.WriteFile(dbWalPath, []byte("wal-data"), 0o644)) - require.NoError(t, os.WriteFile(dbShmPath, []byte("shm-data"), 0o644)) - - // Files under implicit - implPbPath := filepath.Join(implDir, uuid+".pb") - implTrajPath := filepath.Join(implDir, uuid+".trajectory.json") - - require.NoError(t, os.WriteFile(implPbPath, []byte("pb-data"), 0o644)) - require.NoError(t, os.WriteFile(implTrajPath, []byte("trajectory-data"), 0o644)) - - // Sessions for brain-artifact mapping: one with both .db and .pb - // sources, one implicit-only, and one with no source at all. - bothUUID := "44444444-5555-6666-7777-888888888888" - implUUID := "55555555-6666-7777-8888-999999999999" - orphanUUID := "66666666-7777-8888-9999-aaaaaaaaaaaa" - bothDBPath := filepath.Join(convDir, bothUUID+".db") - bothPbPath := filepath.Join(convDir, bothUUID+".pb") - implOnlyPbPath := filepath.Join(implDir, implUUID+".pb") - require.NoError(t, os.WriteFile(bothDBPath, []byte("db"), 0o644)) - require.NoError(t, os.WriteFile(bothPbPath, []byte("pb"), 0o644)) - require.NoError(t, os.WriteFile(implOnlyPbPath, []byte("pb"), 0o644)) - - brainFiles := map[string]string{} - for _, id := range []string{uuid, dbUUID, bothUUID, implUUID, orphanUUID} { - brainDir := filepath.Join(dir, "brain", id) - require.NoError(t, os.MkdirAll(brainDir, 0o755)) - p := filepath.Join(brainDir, "task.md") - require.NoError(t, os.WriteFile(p, []byte("brain"), 0o644)) - brainFiles[id] = p - } - - eng := &Engine{ - agentDirs: map[parser.AgentType][]string{ - parser.AgentAntigravityCLI: {dir}, - }, - } - - tests := []struct { - name string - path string - want bool - retPath string // expected Path in DiscoveredFile - }{ - { - name: "conversations pb file is classified", - path: pbPath, - want: true, - retPath: pbPath, - }, - { - name: "conversations trajectory file maps to pb file", - path: trajPath, - want: true, - retPath: pbPath, - }, - { - name: "conversations db file is classified", - path: dbPath, - want: true, - retPath: dbPath, - }, - { - name: "conversations db wal maps to db file", - path: dbWalPath, - want: true, - retPath: dbPath, - }, - { - name: "conversations db shm maps to db file", - path: dbShmPath, - want: true, - retPath: dbPath, - }, - { - name: "implicit pb file is classified", - path: implPbPath, - want: true, - retPath: implPbPath, - }, - { - name: "implicit trajectory file maps to implicit pb file", - path: implTrajPath, - want: true, - retPath: implPbPath, - }, - { - name: "unrelated files are ignored", - path: filepath.Join(convDir, "readme.md"), - want: false, - }, - { - name: "nested files under subdirs are ignored", - path: filepath.Join(convDir, "subdir", uuid+".pb"), - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := eng.classifyOnePath(tt.path) - assert.Equal(t, tt.want, ok) - if ok { - assert.Equal(t, parser.AgentAntigravityCLI, got.Agent) - assert.Equal(t, tt.retPath, got.Path) - } - }) - } - - // Test missing pb file behavior - t.Run("trajectory without pb is ignored", func(t *testing.T) { - orphanUUID := "22222222-3333-4444-5555-666666666666" - orphanTraj := filepath.Join(convDir, orphanUUID+".trajectory.json") - require.NoError(t, os.WriteFile(orphanTraj, []byte("orphan"), 0o644)) - - _, ok := eng.classifyOnePath(orphanTraj) - assert.False(t, ok, "should not classify sidecar when pb file does not exist") - }) - - // Brain artifact events can affect more than one session (the - // same storage UUID can hold a conversation and an implicit - // session, and both render brain artifacts), so they classify - // through classifyPaths, which returns every affected source. - brainTests := []struct { - name string - path string - wantPaths []string - }{ - { - name: "brain artifact maps to db source", - path: brainFiles[dbUUID], - wantPaths: []string{dbPath}, - }, - { - name: "brain artifact prefers db over pb for the conversation", - path: brainFiles[bothUUID], - wantPaths: []string{bothDBPath}, - }, - { - name: "brain artifact maps to conversation and implicit sources", - path: brainFiles[uuid], - wantPaths: []string{pbPath, implPbPath}, - }, - { - name: "brain artifact maps to implicit pb source", - path: brainFiles[implUUID], - wantPaths: []string{implOnlyPbPath}, - }, - { - // Deleted brain paths must still classify so the session - // reparses and drops the stale message. - name: "deleted brain artifact maps to db source", - path: filepath.Join(dir, "brain", dbUUID, "gone.md"), - wantPaths: []string{dbPath}, - }, - { - name: "brain artifact without source is ignored", - path: brainFiles[orphanUUID], - wantPaths: nil, - }, - { - name: "brain artifact with invalid id is ignored", - path: filepath.Join(dir, "brain", "not-a-uuid", "task.md"), - wantPaths: nil, - }, - { - name: "nested brain files are ignored", - path: filepath.Join( - dir, "brain", dbUUID, "sub", "task.md", - ), - wantPaths: nil, - }, - } - - for _, tt := range brainTests { - t.Run(tt.name, func(t *testing.T) { - got := eng.classifyPaths([]string{tt.path}) - var gotPaths []string - for _, df := range got { - assert.Equal(t, parser.AgentAntigravityCLI, df.Agent) - gotPaths = append(gotPaths, df.Path) - } - assert.ElementsMatch(t, tt.wantPaths, gotPaths) - }) - } -} diff --git a/internal/sync/classify_antigravity_test.go b/internal/sync/classify_antigravity_test.go deleted file mode 100644 index f8b955be6..000000000 --- a/internal/sync/classify_antigravity_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package sync - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.kenn.io/agentsview/internal/parser" -) - -func TestClassifyOnePath_Antigravity(t *testing.T) { - dir := t.TempDir() - uuid := "11111111-2222-3333-4444-555555555555" - orphan := "22222222-3333-4444-5555-666666666666" - // Session whose sidecars were deleted: only the .db remains. - bare := "33333333-4444-5555-6666-777777777777" - - convDir := filepath.Join(dir, "conversations") - annDir := filepath.Join(dir, "annotations") - brainDir := filepath.Join(dir, "brain", uuid) - orphanBrainDir := filepath.Join(dir, "brain", orphan) - require.NoError(t, os.MkdirAll(convDir, 0o755)) - require.NoError(t, os.MkdirAll(annDir, 0o755)) - require.NoError(t, os.MkdirAll(brainDir, 0o755)) - require.NoError(t, os.MkdirAll(orphanBrainDir, 0o755)) - - dbPath := filepath.Join(convDir, uuid+".db") - dbWalPath := dbPath + "-wal" - dbShmPath := dbPath + "-shm" - annPath := filepath.Join(annDir, uuid+".pbtxt") - brainMdPath := filepath.Join(brainDir, "task.md") - brainMetaPath := filepath.Join(brainDir, "task.md.metadata.json") - bareDBPath := filepath.Join(convDir, bare+".db") - - // Sidecars whose conversation .db does not exist. - orphanAnnPath := filepath.Join(annDir, orphan+".pbtxt") - orphanBrainPath := filepath.Join(orphanBrainDir, "task.md") - // Annotation-like file whose stem is not a session id. - badAnnPath := filepath.Join(annDir, "readme.pbtxt") - - for _, p := range []string{ - dbPath, dbWalPath, dbShmPath, annPath, brainMdPath, - brainMetaPath, orphanAnnPath, orphanBrainPath, badAnnPath, - bareDBPath, filepath.Join(convDir, "readme.md"), - } { - require.NoError(t, os.WriteFile(p, []byte("x"), 0o644)) - } - - eng := &Engine{ - agentDirs: map[parser.AgentType][]string{ - parser.AgentAntigravity: {dir}, - }, - } - - tests := []struct { - name string - path string - wantPaths []string - }{ - { - name: "conversations db file is classified", - path: dbPath, - wantPaths: []string{dbPath}, - }, - { - name: "db wal maps to db file", - path: dbWalPath, - wantPaths: []string{dbPath}, - }, - { - name: "db shm maps to db file", - path: dbShmPath, - wantPaths: []string{dbPath}, - }, - { - name: "annotation maps to db file", - path: annPath, - wantPaths: []string{dbPath}, - }, - { - name: "brain artifact maps to db file", - path: brainMdPath, - wantPaths: []string{dbPath}, - }, - { - name: "brain artifact metadata maps to db file", - path: brainMetaPath, - wantPaths: []string{dbPath}, - }, - { - // Deleted sidecar paths must still classify so the - // session reparses and drops the stale message. - name: "deleted annotation maps to db file", - path: filepath.Join(annDir, bare+".pbtxt"), - wantPaths: []string{bareDBPath}, - }, - { - name: "deleted brain artifact maps to db file", - path: filepath.Join( - dir, "brain", bare, "gone.md", - ), - wantPaths: []string{bareDBPath}, - }, - { - name: "annotation without db is ignored", - path: orphanAnnPath, - wantPaths: nil, - }, - { - name: "brain artifact without db is ignored", - path: orphanBrainPath, - wantPaths: nil, - }, - { - name: "annotation with invalid id is ignored", - path: badAnnPath, - wantPaths: nil, - }, - { - name: "unrelated conversations file is ignored", - path: filepath.Join(convDir, "readme.md"), - wantPaths: nil, - }, - { - name: "nested files under conversations subdirs are ignored", - path: filepath.Join(convDir, "subdir", uuid+".db"), - wantPaths: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := eng.classifyPaths([]string{tt.path}) - var gotPaths []string - for _, df := range got { - assert.Equal(t, parser.AgentAntigravity, df.Agent) - gotPaths = append(gotPaths, df.Path) - } - assert.ElementsMatch(t, tt.wantPaths, gotPaths) - }) - } -} diff --git a/internal/sync/engine.go b/internal/sync/engine.go index bd619af0b..6d70b5ffb 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -511,13 +511,12 @@ func (e *Engine) classifyPaths( seen := make(map[string]int, len(paths)) files := make([]parser.DiscoveredFile, 0, len(paths)) for _, p := range paths { - // Antigravity sidecar events map to potentially several - // session sources and must classify even when the event - // path was deleted, so they bypass classifyOnePath. - dfs := e.classifyAntigravitySidecarPath(p) - if len(dfs) == 0 { - dfs = e.classifyCodexIndexPath(p) - } + // Codex resolved-index events map to potentially several session + // sources and must classify even when the event path was deleted, + // so they bypass classifyOnePath. Antigravity's analogous sidecar + // fan-out (annotations, brain, history.jsonl) is owned by the + // provider-authoritative SourcesForChangedPath path below. + dfs := e.classifyCodexIndexPath(p) if len(dfs) == 0 { if df, ok := e.classifyOnePath(p); ok { dfs = []parser.DiscoveredFile{df} @@ -948,7 +947,6 @@ func (e *Engine) classifyContainerPath( func (e *Engine) classifyOnePath( path string, ) (parser.DiscoveredFile, bool) { - sep := string(filepath.Separator) pathExists := true if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { @@ -985,88 +983,11 @@ func (e *Engine) classifyOnePath( return df, true } - // Antigravity IDE: /conversations/.db (+ -wal, -shm). - // annotations/.pbtxt and brain//* sidecar events are - // handled in classifyPaths via classifyAntigravitySidecarPath, - // which runs without the path-existence requirement above. - for _, agDir := range e.agentDirs[parser.AgentAntigravity] { - if agDir == "" { - continue - } - rel, ok := isUnder(agDir, path) - if !ok { - continue - } - parts := strings.Split(rel, sep) - if len(parts) != 2 || parts[0] != "conversations" { - continue - } - name := strings.TrimSuffix(parts[1], "-wal") - name = strings.TrimSuffix(name, "-shm") - if !strings.HasSuffix(name, ".db") { - continue - } - id := strings.TrimSuffix(name, ".db") - if !parser.IsValidSessionID(id) { - continue - } - return parser.DiscoveredFile{ - Path: filepath.Join(agDir, "conversations", id+".db"), - Agent: parser.AgentAntigravity, - }, true - } - - // Antigravity CLI: /conversations/.db or - // /conversations|implicit/.pb (+ trajectory.json sidecars) - for _, agDir := range e.agentDirs[parser.AgentAntigravityCLI] { - if agDir == "" { - continue - } - if rel, ok := isUnder(agDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) != 2 || - (parts[0] != "conversations" && - parts[0] != "implicit") { - continue - } - name := parts[1] - var sourcePath string - var id string - if strings.HasSuffix(name, ".pb") { - sourcePath = path - id = strings.TrimSuffix(name, ".pb") - } else if strings.HasSuffix(name, ".db") || - strings.HasSuffix(name, ".db-wal") || - strings.HasSuffix(name, ".db-shm") { - name = strings.TrimSuffix(name, "-wal") - name = strings.TrimSuffix(name, "-shm") - sourcePath = filepath.Join(agDir, parts[0], name) - id = strings.TrimSuffix(name, ".db") - } else if strings.HasSuffix(name, ".trajectory.json") { - sourcePath = strings.TrimSuffix(path, ".trajectory.json") + ".pb" - id = strings.TrimSuffix(name, ".trajectory.json") - } else { - continue - } - if !parser.IsValidSessionID(id) { - continue - } - if parts[0] == "conversations" && - strings.HasSuffix(sourcePath, ".pb") { - dbPath := filepath.Join(agDir, parts[0], id+".db") - if _, err := os.Stat(dbPath); err == nil { - sourcePath = dbPath - } - } - if _, err := os.Stat(sourcePath); err != nil { - continue - } - return parser.DiscoveredFile{ - Path: sourcePath, - Agent: parser.AgentAntigravityCLI, - }, true - } - } + // Antigravity IDE and CLI source/sidecar paths (conversations/.db, + // conversations|implicit/.pb, their WAL/SHM and trajectory.json + // sidecars, and annotations/brain/history.jsonl) are owned by the + // provider-authoritative SourcesForChangedPath path in + // classifyProviderChangedPath. return parser.DiscoveredFile{}, false } @@ -1096,111 +1017,6 @@ func (e *Engine) classifyAiderPath( return parser.DiscoveredFile{}, false } -// classifyAntigravitySidecarPath maps Antigravity sidecar events -- -// IDE annotations/.pbtxt plus IDE and CLI brain//* artifacts -// -- to every session source file that renders them. A CLI storage -// UUID can hold both a conversation and an implicit session, so one -// brain event can affect two sources. The sidecar path itself may no -// longer exist (deletes must reparse the session too), so only the -// mapped source files are required to exist. -func (e *Engine) classifyAntigravitySidecarPath( - path string, -) []parser.DiscoveredFile { - if df, ok := e.classifyAntigravityIDESidecar(path); ok { - return []parser.DiscoveredFile{df} - } - return e.classifyAntigravityCLIBrainPath(path) -} - -func (e *Engine) classifyAntigravityIDESidecar( - path string, -) (parser.DiscoveredFile, bool) { - sep := string(filepath.Separator) - for _, agDir := range e.agentDirs[parser.AgentAntigravity] { - if agDir == "" { - continue - } - rel, ok := isUnder(agDir, path) - if !ok { - continue - } - parts := strings.Split(rel, sep) - var id string - switch { - case len(parts) == 2 && parts[0] == "annotations" && - strings.HasSuffix(parts[1], ".pbtxt"): - id = strings.TrimSuffix(parts[1], ".pbtxt") - case len(parts) == 3 && parts[0] == "brain": - id = parts[1] - default: - continue - } - if !parser.IsValidSessionID(id) { - continue - } - dbPath := filepath.Join(agDir, "conversations", id+".db") - if _, err := os.Stat(dbPath); err != nil { - continue - } - return parser.DiscoveredFile{ - Path: dbPath, - Agent: parser.AgentAntigravity, - }, true - } - return parser.DiscoveredFile{}, false -} - -func (e *Engine) classifyAntigravityCLIBrainPath( - path string, -) []parser.DiscoveredFile { - sep := string(filepath.Separator) - for _, agDir := range e.agentDirs[parser.AgentAntigravityCLI] { - if agDir == "" { - continue - } - rel, ok := isUnder(agDir, path) - if !ok { - continue - } - parts := strings.Split(rel, sep) - if len(parts) != 3 || parts[0] != "brain" { - continue - } - id := parts[1] - if !parser.IsValidSessionID(id) { - continue - } - var out []parser.DiscoveredFile - // Conversation session: prefer the SQLite source when both - // old and new files exist, matching discovery. - for _, src := range []string{ - filepath.Join(agDir, "conversations", id+".db"), - filepath.Join(agDir, "conversations", id+".pb"), - } { - if _, err := os.Stat(src); err == nil { - out = append(out, parser.DiscoveredFile{ - Path: src, - Agent: parser.AgentAntigravityCLI, - }) - break - } - } - // The implicit session is distinct from the conversation - // session and renders the same brain artifacts. - implicit := filepath.Join(agDir, "implicit", id+".pb") - if _, err := os.Stat(implicit); err == nil { - out = append(out, parser.DiscoveredFile{ - Path: implicit, - Agent: parser.AgentAntigravityCLI, - }) - } - if len(out) > 0 { - return out - } - } - return nil -} - // shelleyDBFile is the shared Shelley conversation database basename. Zed and // Shelley are provider-authoritative, so their changed-path classification and // parse run through the provider facade; this constant remains for the @@ -3262,30 +3078,19 @@ func (e *Engine) processFile( return res } - var info os.FileInfo - var err error - switch file.Agent { - case parser.AgentAntigravityCLI: - info, err = parser.AntigravityCLIFileInfo(file.Path) - case parser.AgentAntigravity: - // WAL-only commits and annotation updates do not touch - // the main .db, so skip checks need the composite stat. - info, err = parser.AntigravityFileInfo(file.Path) - default: - statPath := file.Path - if dbPath, _, ok := parseKiroSQLiteVirtualPath(file.Path); ok { - statPath = dbPath - } else if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, "threads.db"); ok { - statPath = dbPath - } else if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, shelleyDBFile); ok { - statPath = dbPath - } else if historyPath, _, ok := parser.ParseAiderVirtualPath(file.Path); ok { - // aider stores "#"; stat the physical file - // so SyncSingleSession (live watcher / on-demand re-sync) works. - statPath = historyPath - } - info, err = os.Stat(statPath) + statPath := file.Path + if dbPath, _, ok := parseKiroSQLiteVirtualPath(file.Path); ok { + statPath = dbPath + } else if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, "threads.db"); ok { + statPath = dbPath + } else if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, shelleyDBFile); ok { + statPath = dbPath + } else if historyPath, _, ok := parser.ParseAiderVirtualPath(file.Path); ok { + // aider stores "#"; stat the physical file + // so SyncSingleSession (live watcher / on-demand re-sync) works. + statPath = historyPath } + info, err := os.Stat(statPath) if err != nil { if os.IsNotExist(err) && file.ForceParse && @@ -3346,10 +3151,6 @@ func (e *Engine) processFile( switch file.Agent { case parser.AgentReasonix: res = e.processReasonix(file, info) - case parser.AgentAntigravity: - res = e.processAntigravity(file, info) - case parser.AgentAntigravityCLI: - res = e.processAntigravityCLI(file, info) case parser.AgentAider: res = e.processAider(file, info) default: @@ -3545,6 +3346,24 @@ func (e *Engine) processProviderFile( }, true } + // DB-stored-file-info skip: a session whose persisted file_size/file_mtime + // already match the source fingerprint (and whose data_version is current) + // is unchanged and need not be reparsed. This reproduces the legacy + // shouldSkipByPath behavior the per-agent process methods provided, so a + // repeat full sync of an untouched provider-authoritative session skips + // instead of rewriting. It only skips on an exact size+mtime match, so a + // provider whose fingerprint mtime differs from the stored value simply + // reparses, matching the prior behavior. + if !e.forceParse && !file.ForceParse && + e.providerSourceUnchangedInDB(source, fingerprint) { + return processResult{ + skip: true, + mtime: fingerprint.MTimeNS, + cacheSkip: cacheSkip, + cacheKey: cacheKey, + }, true + } + outcome, err := provider.Parse(ctx, parser.ParseRequest{ Source: source, Fingerprint: fingerprint, @@ -4170,6 +3989,45 @@ func (e *Engine) shouldSkipFile( return true } +// providerSourceUnchangedInDB reports whether a provider source's persisted +// file metadata already matches its current fingerprint, so a reparse would be +// a no-op. It compares the DB-stored file_size/file_mtime for the source's +// path against the fingerprint and requires a current data_version, mirroring +// shouldSkipByPath for the provider-authoritative runtime. A source with no +// stored row, an empty key, or a non-fingerprint identity (no size, e.g. a +// tombstone) never matches and therefore reparses. +func (e *Engine) providerSourceUnchangedInDB( + source parser.SourceRef, + fingerprint parser.SourceFingerprint, +) bool { + if fingerprint.MTimeNS == 0 && fingerprint.Size == 0 { + return false + } + lookupPath := providerDiscoveredPath(source) + if lookupPath == "" { + return false + } + if e.pathRewriter != nil { + lookupPath = e.pathRewriter(lookupPath) + } + storedSize, storedMtime, ok := e.db.GetFileInfoByPath(lookupPath) + if !ok { + return false + } + if storedSize != fingerprint.Size || storedMtime != fingerprint.MTimeNS { + return false + } + // A stale stored project (e.g. a generated roborev CI worktree name) + // must defeat the unchanged-source skip so the corrected project is + // reparsed, mirroring shouldSkipCodexFingerprint and the in-memory + // skip-cache bypass in processProviderFile. + if project, ok := e.db.GetProjectByPath(lookupPath); ok && + parser.NeedsProjectReparse(project) { + return false + } + return e.db.GetDataVersionByPath(lookupPath) >= db.CurrentDataVersion() +} + // shouldSkipByPath checks file size and mtime against what is // stored in the database by file_path. Used for codex/gemini // files where the session ID requires parsing. @@ -5239,73 +5097,6 @@ func (e *Engine) processAider( } } -func (e *Engine) processAntigravity( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, usageEvents, err := parser.ParseAntigravitySession( - file.Path, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs, UsageEvents: usageEvents}, - }, - } -} - -func (e *Engine) processAntigravityCLI( - file parser.DiscoveredFile, effectiveInfo os.FileInfo, -) processResult { - // processFile supplies AntigravityCLIFileInfo here, so .db WAL/SHM - // sidecars and .pb trajectory sidecars participate in skip checks. - if e.shouldSkipByPath(file.Path, effectiveInfo) { - return processResult{skip: true} - } - - sess, msgs, usageEvents, parseStatus, err := parser.ParseAntigravityCLISessionWithStatus( - file.Path, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - sess.File.Size = effectiveInfo.Size() - sess.File.Mtime = effectiveInfo.ModTime().UnixNano() - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - - return processResult{ - needsRetry: parseStatus.NeedsRetry, - results: []parser.ParseResult{ - { - Session: *sess, - Messages: msgs, - UsageEvents: usageEvents, - }, - }, - } -} - func commandCodeEffectiveInfo(path string, info os.FileInfo) os.FileInfo { size := info.Size() mtime := info.ModTime().UnixNano() diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index 754de22e2..da25df69d 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -1168,10 +1168,16 @@ func TestSyncSingleSession_QwenPawPreservesWorkspaceFromDB(t *testing.T) { // the sidecar set or the session never reparses. func TestProcessAntigravityWALOnlyUpdateNotSkipped(t *testing.T) { database := openTestDB(t) - e := &Engine{db: database} ctx := context.Background() root := t.TempDir() + e := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentAntigravity: {root}, + }, + Machine: "local", + }) + convDir := filepath.Join(root, "conversations") require.NoError(t, os.MkdirAll(convDir, 0o755)) dbPath := filepath.Join( @@ -1186,10 +1192,13 @@ func TestProcessAntigravityWALOnlyUpdateNotSkipped(t *testing.T) { require.NoError(t, err) require.NoError(t, sqlDB.Close()) + // The Antigravity provider is provider-authoritative, so processFile + // routes through processProviderFile. The provider resolves the source, + // fingerprints (folding the WAL/SHM and sidecar set into the freshness + // identity), and parses. file := parser.DiscoveredFile{ - Agent: parser.AgentAntigravity, - Path: dbPath, - Project: "proj", + Agent: parser.AgentAntigravity, + Path: dbPath, } res := e.processFile(ctx, file) @@ -1198,15 +1207,21 @@ func TestProcessAntigravityWALOnlyUpdateNotSkipped(t *testing.T) { require.Len(t, res.results, 1) pw := pendingWrite{ - sess: res.results[0].Session, - msgs: res.results[0].Messages, - usageEvents: res.results[0].UsageEvents, + sess: res.results[0].Session, + msgs: res.results[0].Messages, + usageEvents: res.results[0].UsageEvents, + forceReplace: res.forceReplace, } written, _, failed := e.writeBatch( []pendingWrite{pw}, syncWriteDefault, false, ) require.Equal(t, 0, failed) require.Equal(t, 1, written) + // Record the skip-cache entry the collectAndBatch flow would write so the + // next unchanged processFile sees a cached, current fingerprint. + if res.cacheSkip && res.mtime != 0 && !res.noCacheSkip { + e.cacheSkip(res.skipCacheKey(file.Path), res.mtime) + } res = e.processFile(ctx, file) require.True(t, res.skip, "unchanged session should skip") @@ -1284,10 +1299,16 @@ func TestProcessVibeMetaOnlyUpdateNotSkipped(t *testing.T) { func TestProcessAntigravityBrainOnlyUpdateNotSkipped(t *testing.T) { database := openTestDB(t) - e := &Engine{db: database} ctx := context.Background() root := t.TempDir() + e := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentAntigravity: {root}, + }, + Machine: "local", + }) + convDir := filepath.Join(root, "conversations") require.NoError(t, os.MkdirAll(convDir, 0o755)) id := "abcdabcd-1111-2222-3333-444455557777" @@ -1301,10 +1322,12 @@ func TestProcessAntigravityBrainOnlyUpdateNotSkipped(t *testing.T) { require.NoError(t, err) require.NoError(t, sqlDB.Close()) + // Provider-authoritative: the provider Fingerprint folds the brain + // artifacts into the freshness identity, so a brain-only change busts the + // skip cache and triggers a reparse. file := parser.DiscoveredFile{ - Agent: parser.AgentAntigravity, - Path: dbPath, - Project: "proj", + Agent: parser.AgentAntigravity, + Path: dbPath, } res := e.processFile(ctx, file) @@ -1313,15 +1336,19 @@ func TestProcessAntigravityBrainOnlyUpdateNotSkipped(t *testing.T) { require.Len(t, res.results, 1) pw := pendingWrite{ - sess: res.results[0].Session, - msgs: res.results[0].Messages, - usageEvents: res.results[0].UsageEvents, + sess: res.results[0].Session, + msgs: res.results[0].Messages, + usageEvents: res.results[0].UsageEvents, + forceReplace: res.forceReplace, } written, _, failed := e.writeBatch( []pendingWrite{pw}, syncWriteDefault, false, ) require.Equal(t, 0, failed) require.Equal(t, 1, written) + if res.cacheSkip && res.mtime != 0 && !res.noCacheSkip { + e.cacheSkip(res.skipCacheKey(file.Path), res.mtime) + } res = e.processFile(ctx, file) require.True(t, res.skip, "unchanged session should skip")