diff --git a/internal/parser/discovery.go b/internal/parser/discovery.go index d360274d3..4fa6a824b 100644 --- a/internal/parser/discovery.go +++ b/internal/parser/discovery.go @@ -1574,81 +1574,3 @@ func extractIflowBaseSessionID(sessionID string) string { // If we didn't find 5 hyphens, this is not a fork ID return sessionID } - -// DiscoverVibeSessions finds all Vibe session files under the given root directory. -// Vibe stores sessions in: ~/.vibe/logs/session/session_YYYYMMDD_HHMMSS_uuid/ -// Each session directory contains messages.jsonl -func DiscoverVibeSessions(root string) []DiscoveredFile { - var results []DiscoveredFile - - entries, err := os.ReadDir(root) - if err != nil { - return results - } - - for _, entry := range entries { - if !isDirOrSymlink(entry, root) { - continue - } - - // Vibe session directories match pattern: session_YYYYMMDD_HHMMSS_uuid - // The uuid part can contain hyphens - if !strings.HasPrefix(entry.Name(), "session_") || !strings.Contains(entry.Name(), "_") { - continue - } - - sessionDir := filepath.Join(root, entry.Name()) - messagesPath := filepath.Join(sessionDir, "messages.jsonl") - - if info, err := os.Stat(messagesPath); err == nil && !info.IsDir() { - results = append(results, DiscoveredFile{ - Path: messagesPath, - Agent: AgentVibe, - Project: entry.Name(), - }) - } - } - - return results -} - -// FindVibeSourceFile locates a specific Vibe session file by ID. The ID is the -// session_id recorded in meta.json (a uuid), which usually differs from the -// session directory name. Sessions without meta.json fall back to the directory -// name, so a direct path is tried first before scanning meta.json files. -func FindVibeSourceFile(root, sessionID string) string { - // Fast path: sessionID is the directory name (no-meta fallback). - if messagesPath := filepath.Join(root, sessionID, "messages.jsonl"); isVibeMessagesFile(messagesPath) { - return messagesPath - } - - // Otherwise sessionID is a meta.json session_id; scan session - // directories and match on their recorded session_id. - entries, err := os.ReadDir(root) - if err != nil { - return "" - } - for _, entry := range entries { - if !isDirOrSymlink(entry, root) || !strings.HasPrefix(entry.Name(), "session_") { - continue - } - messagesPath := filepath.Join(root, entry.Name(), "messages.jsonl") - if !isVibeMessagesFile(messagesPath) { - continue - } - metaPath := filepath.Join(root, entry.Name(), "meta.json") - if meta, err := parseVibeMetadata(metaPath); err == nil && meta.SessionID == sessionID { - return messagesPath - } - } - return "" -} - -// isVibeMessagesFile reports whether path is an existing regular file. -func isVibeMessagesFile(path string) bool { - info, err := os.Stat(path) - if err != nil || info == nil { - return false - } - return !info.IsDir() -} diff --git a/internal/parser/discovery_test.go b/internal/parser/discovery_test.go index 61019154a..9acad18e2 100644 --- a/internal/parser/discovery_test.go +++ b/internal/parser/discovery_test.go @@ -1424,7 +1424,7 @@ func TestIsPiSessionFile(t *testing.T) { func TestDiscoverVibeSessionsIntegration(t *testing.T) { // Test discovery with testdata - files := DiscoverVibeSessions("testdata/vibe") + files := discoverVibeTestSessions(t, "testdata/vibe") // Should find all session directories with messages.jsonl require.NotEmpty(t, files) @@ -1442,7 +1442,7 @@ func TestDiscoverVibeSessionsIntegration(t *testing.T) { func TestFindVibeSourceFileIntegration(t *testing.T) { // Test with actual testdata sessionID := "session_basic" - result := FindVibeSourceFile("testdata/vibe", sessionID) + result := findVibeTestSourceFile(t, "testdata/vibe", sessionID) expected := filepath.Join("testdata", "vibe", sessionID, "messages.jsonl") assert.Equal(t, expected, result) diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 12e500d98..362d5fc0a 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -375,6 +375,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newQwenProviderFactory(def) case AgentQwenPaw: return newQwenPawProviderFactory(def) + case AgentVibe: + return newVibeProviderFactory(def) case AgentWorkBuddy: return newWorkBuddyProviderFactory(def) case AgentZencoder: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 2b5797002..53629eafc 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -52,7 +52,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentPositron: ProviderMigrationLegacyOnly, AgentAntigravity: ProviderMigrationLegacyOnly, AgentAntigravityCLI: ProviderMigrationLegacyOnly, - AgentVibe: ProviderMigrationLegacyOnly, + AgentVibe: ProviderMigrationProviderAuthoritative, AgentZed: ProviderMigrationLegacyOnly, AgentQwenPaw: ProviderMigrationProviderAuthoritative, AgentGptme: ProviderMigrationProviderAuthoritative, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index 226cdc567..43d193c2a 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -61,7 +61,6 @@ var pendingShimProviderFiles = map[string]bool{ "opencode_provider.go": true, "positron_provider.go": true, "shelley_provider.go": true, - "vibe_provider.go": true, "visualstudio_copilot_provider.go": true, "vscode_copilot_provider.go": true, "zed_provider.go": true, diff --git a/internal/parser/types.go b/internal/parser/types.go index 4c91fcbe3..71eddc398 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -577,15 +577,13 @@ var Registry = []AgentDef{ FindSourceFunc: FindShelleySourceFile, }, { - Type: AgentVibe, - DisplayName: "Mistral Vibe", - EnvVar: "VIBE_SESSIONS_DIR", - ConfigKey: "vibe_session_dirs", - DefaultDirs: []string{".vibe/logs/session"}, - IDPrefix: "vibe:", - FileBased: true, - DiscoverFunc: DiscoverVibeSessions, - FindSourceFunc: FindVibeSourceFile, + Type: AgentVibe, + DisplayName: "Mistral Vibe", + EnvVar: "VIBE_SESSIONS_DIR", + ConfigKey: "vibe_session_dirs", + DefaultDirs: []string{".vibe/logs/session"}, + IDPrefix: "vibe:", + FileBased: true, }, { // Aider has no central session store. It writes one Markdown diff --git a/internal/parser/vibe.go b/internal/parser/vibe.go index 49aad1018..4f16f325a 100644 --- a/internal/parser/vibe.go +++ b/internal/parser/vibe.go @@ -67,8 +67,10 @@ type VibeStats struct { LastTurnTotalTokens int `json:"last_turn_total_tokens"` } -// ParseVibeSession parses a Mistral Vibe messages.jsonl file -func ParseVibeSession(path string, fileInfo FileInfo) (ParseResult, error) { +// parseVibeResult parses a Mistral Vibe messages.jsonl file into a ParseResult. +// It owns the on-disk shape (messages.jsonl plus the sibling meta.json) for the +// Vibe provider; the package-level entrypoint was folded onto the provider. +func parseVibeResultFile(path string, fileInfo FileInfo) (ParseResult, error) { result := ParseResult{ Session: ParsedSession{ Agent: AgentVibe, @@ -386,11 +388,11 @@ func vibeToolArguments(args json.RawMessage) string { return string(args) } -// ParseVibeSessionWrapper wraps ParseVibeSession and returns the session, -// messages, and usage events in the shape the sync engine consumes: -// (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, error). It stats the -// file to build FileInfo and optionally overrides the project and machine. -func ParseVibeSessionWrapper(path, project, machine string) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, error) { +// parseSession parses a Vibe session at path and returns the session, messages, +// and usage events in the shape the provider consumes: (*ParsedSession, +// []ParsedMessage, []ParsedUsageEvent, error). It stats the file to build +// FileInfo and optionally overrides the project and machine. +func parseVibeSession(path, project, machine string) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, error) { info, err := os.Stat(path) if err != nil { return nil, nil, nil, fmt.Errorf("stat %s: %w", path, err) @@ -402,7 +404,7 @@ func ParseVibeSessionWrapper(path, project, machine string) (*ParsedSession, []P Mtime: info.ModTime().UnixNano(), } - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeResultFile(path, fileInfo) if err != nil { return nil, nil, nil, err } diff --git a/internal/parser/vibe_provider.go b/internal/parser/vibe_provider.go new file mode 100644 index 000000000..639e8ff0a --- /dev/null +++ b/internal/parser/vibe_provider.go @@ -0,0 +1,304 @@ +package parser + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Vibe stores each session in /session___/, with a +// messages.jsonl transcript and a sibling meta.json. It is a single-file +// provider: one transcript parses into one session, with a composite fingerprint +// folding in meta.json and a fallback-ID exclusion when meta.json later supplies +// a different session_id. All behavior is wired into the shared single-file base +// via options. +func newVibeProviderFactory(def AgentDef) ProviderFactory { + return newSingleFileProviderFactory( + def, + vibeProviderCapabilities(), + func(cfg ProviderConfig) singleFileSourceSet { + return newSingleFileSourceSet( + AgentVibe, + cfg.Roots, + withFileDiscovery(vibeDiscoverFiles), + withFileWatchRoots(vibeWatchRoots), + withFileChangedPathClassifier(vibeClassifyPath), + withFileLookup(vibeFindFile), + withFileFingerprint(vibeFingerprintSource), + withFileParse(vibeParseFile), + ) + }, + ) +} + +func vibeDiscoverFiles(root string) []singleFileMatch { + var out []singleFileMatch + for _, path := range discoverVibeSessionPaths(root) { + if match, ok := vibeStrictMatch(root, path); ok { + out = append(out, match) + } + } + return out +} + +// discoverVibeSessionPaths finds all Vibe messages.jsonl paths under root. +// Symlinked session directories are followed (matching the watcher), but only +// session_-prefixed directories that hold a regular messages.jsonl qualify. +func discoverVibeSessionPaths(root string) []string { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var paths []string + for _, entry := range entries { + if !isDirOrSymlink(entry, root) { + continue + } + if !isVibeSessionDirName(entry.Name()) { + continue + } + messagesPath := filepath.Join(root, entry.Name(), "messages.jsonl") + if isVibeMessagesFile(messagesPath) { + paths = append(paths, messagesPath) + } + } + return paths +} + +func vibeWatchRoots(roots []string) []WatchRoot { + out := make([]WatchRoot, 0, len(roots)) + for _, root := range roots { + out = append(out, WatchRoot{ + Path: root, + Recursive: true, + IncludeGlobs: []string{"messages.jsonl", "meta.json"}, + DebounceKey: string(AgentVibe) + ":sessions:" + root, + }) + } + return out +} + +// vibeClassifyPath maps a messages.jsonl or meta.json event path to its session +// transcript. Under allowMissing a transcript that does not (yet) exist still +// classifies via the session directory name, so a metadata-only event or a +// deletion still resolves. +func vibeClassifyPath( + root, path string, allowMissing bool, +) (singleFileMatch, bool) { + rel, ok := vibeRelPath(root, path) + if !ok { + return singleFileMatch{}, false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 2 || !isVibeSessionDirName(parts[0]) { + return singleFileMatch{}, false + } + messagesPath := filepath.Join(filepath.Clean(root), parts[0], "messages.jsonl") + switch parts[1] { + case "messages.jsonl": + if allowMissing { + return vibeMatchFromSessionDir(parts[0], messagesPath) + } + return vibeStrictMatch(root, messagesPath) + case "meta.json": + if allowMissing && !isVibeMessagesFile(messagesPath) { + return vibeMatchFromSessionDir(parts[0], messagesPath) + } + return vibeStrictMatch(root, messagesPath) + default: + return singleFileMatch{}, false + } +} + +// vibeStrictMatch requires the messages.jsonl to exist as a regular file under a +// session directory before classifying it. +func vibeStrictMatch(root, path string) (singleFileMatch, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + if !isVibeMessagesFile(path) { + return singleFileMatch{}, false + } + rel, ok := vibeRelPath(root, path) + if !ok { + return singleFileMatch{}, false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 2 || !isVibeSessionDirName(parts[0]) || + parts[1] != "messages.jsonl" { + return singleFileMatch{}, false + } + return vibeMatchFromSessionDir(parts[0], path) +} + +func vibeMatchFromSessionDir(sessionDir, path string) (singleFileMatch, bool) { + if !isVibeSessionDirName(sessionDir) { + return singleFileMatch{}, false + } + return singleFileMatch{Path: path, ProjectHint: sessionDir}, true +} + +func vibeFindFile(root, rawID string) (singleFileMatch, bool) { + path := findVibeSourceFile(root, rawID) + if path == "" { + return singleFileMatch{}, false + } + return vibeStrictMatch(root, path) +} + +// findVibeSourceFile locates a Vibe session by ID under root. The ID is the +// session_id from meta.json (a uuid), which usually differs from the session +// directory name, so a direct directory-name path is tried before scanning +// meta.json files. +func findVibeSourceFile(root, sessionID string) string { + if messagesPath := filepath.Join( + root, sessionID, "messages.jsonl", + ); isVibeMessagesFile(messagesPath) { + return messagesPath + } + entries, err := os.ReadDir(root) + if err != nil { + return "" + } + for _, entry := range entries { + if !isDirOrSymlink(entry, root) || + !strings.HasPrefix(entry.Name(), "session_") { + continue + } + messagesPath := filepath.Join(root, entry.Name(), "messages.jsonl") + if !isVibeMessagesFile(messagesPath) { + continue + } + metaPath := filepath.Join(root, entry.Name(), "meta.json") + if meta, err := parseVibeMetadata(metaPath); err == nil && + meta.SessionID == sessionID { + return messagesPath + } + } + return "" +} + +// isVibeMessagesFile reports whether path is an existing regular file. +func isVibeMessagesFile(path string) bool { + info, err := os.Stat(path) + if err != nil || info == nil { + return false + } + return !info.IsDir() +} + +func vibeFingerprintSource(src singleFileSource) (SourceFingerprint, error) { + info, err := os.Stat(src.Path) + if err != nil { + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", src.Path, err) + } + if info.IsDir() { + return SourceFingerprint{}, fmt.Errorf( + "stat %s: source is a directory", src.Path, + ) + } + size := info.Size() + mtime := info.ModTime().UnixNano() + metaPath := vibeMetaPath(src.Path) + if metaInfo, err := os.Stat(metaPath); err == nil { + size += metaInfo.Size() + if metaMTime := metaInfo.ModTime().UnixNano(); metaMTime > mtime { + mtime = metaMTime + } + } + hash, err := hashJSONLSourceFile(src.Path) + if err != nil { + return SourceFingerprint{}, err + } + return SourceFingerprint{ + Size: size, + MTimeNS: mtime, + Hash: hash, + }, nil +} + +func vibeParseFile( + src singleFileSource, req ParseRequest, +) ([]ParseResult, []string, error) { + sess, msgs, usageEvents, err := parseVibeSession(src.Path, "", req.Machine) + if err != nil { + return nil, nil, err + } + if sess == nil { + return nil, nil, nil + } + if req.Fingerprint.Size > 0 { + sess.File.Size = req.Fingerprint.Size + } + if req.Fingerprint.MTimeNS > 0 { + sess.File.Mtime = req.Fingerprint.MTimeNS + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + } + excluded := vibeProviderExcludedSessionIDs(src.Path, sess.ID) + return []ParseResult{{ + Session: *sess, + Messages: msgs, + UsageEvents: usageEvents, + }}, excluded, nil +} + +func vibeRelPath(root, path string) (string, bool) { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil || rel == "." || rel == "" { + return "", false + } + if strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return "", false + } + for part := range strings.SplitSeq(rel, string(filepath.Separator)) { + if part == "" || part == "." || part == ".." { + return "", false + } + } + return rel, true +} + +func isVibeSessionDirName(name string) bool { + return strings.HasPrefix(name, "session_") && strings.Contains(name, "_") +} + +func vibeMetaPath(messagesPath string) string { + return filepath.Join(filepath.Dir(messagesPath), "meta.json") +} + +func vibeProviderExcludedSessionIDs(path, currentID string) []string { + fallbackID := string(AgentVibe) + ":" + filepath.Base(filepath.Dir(path)) + if currentID == "" || currentID == fallbackID { + return nil + } + return []string{fallbackID} +} + +func vibeProviderCapabilities() Capabilities { + return Capabilities{ + Source: SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + MultiSessionSource: CapabilityNotApplicable, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilitySupported, + ForceReplaceOnParse: CapabilityNotApplicable, + }, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + SessionName: CapabilitySupported, + Cwd: CapabilitySupported, + GitBranch: CapabilitySupported, + Relationships: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + AggregateUsageEvents: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/vibe_provider_test.go b/internal/parser/vibe_provider_test.go new file mode 100644 index 000000000..8dbae35f2 --- /dev/null +++ b/internal/parser/vibe_provider_test.go @@ -0,0 +1,297 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVibeProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentVibe) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentVibe, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestVibeProviderSourceMethods(t *testing.T) { + root := t.TempDir() + sessionDir := "session_20260613_123456_abc123def" + messagesPath := filepath.Join(root, sessionDir, "messages.jsonl") + metaPath := filepath.Join(root, sessionDir, "meta.json") + writeSourceFile(t, messagesPath, vibeProviderMessagesFixture("provider question")) + writeSourceFile(t, metaPath, vibeProviderMetaFixture("uuid-1234", "Provider title")) + writeSourceFile(t, filepath.Join(root, "scratch", "messages.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "session_missing_messages", "meta.json"), "{}\n") + nestedPath := filepath.Join(root, "nested", "session_20260613_123456_nested", "messages.jsonl") + writeSourceFile(t, nestedPath, vibeProviderMessagesFixture("nested")) + + provider, ok := NewProvider(AgentVibe, 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, 1) + assert.Equal(t, root, plan.Roots[0].Path) + assert.True(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"messages.jsonl", "meta.json"}, plan.Roots[0].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + source := discovered[0] + assert.Equal(t, AgentVibe, source.Provider) + assert.Equal(t, messagesPath, source.DisplayPath) + assert.Equal(t, messagesPath, source.FingerprintKey) + assert.Equal(t, sessionDir, source.ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "remote~vibe:uuid-1234", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, messagesPath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: sessionDir, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, messagesPath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: messagesPath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, messagesPath, found.DisplayPath) + + messageInfo, err := os.Stat(messagesPath) + require.NoError(t, err) + metaInfo, err := os.Stat(metaPath) + require.NoError(t, err) + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, messagesPath, fingerprint.Key) + assert.Equal(t, messageInfo.Size()+metaInfo.Size(), fingerprint.Size) + assert.Equal( + t, + max(messageInfo.ModTime().UnixNano(), metaInfo.ModTime().UnixNano()), + fingerprint.MTimeNS, + ) + assert.NotEmpty(t, fingerprint.Hash) + + for _, tc := range []struct { + name string + path string + want string + }{ + {name: "messages", path: messagesPath, want: messagesPath}, + {name: "meta sidecar", path: metaPath, want: messagesPath}, + } { + t.Run(tc.name, func(t *testing.T) { + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: tc.path, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, tc.want, changed[0].DisplayPath) + }) + } + + require.NoError(t, os.Remove(metaPath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: metaPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, messagesPath, changed[0].DisplayPath) + + ignored, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "scratch", "messages.jsonl"), + EventKind: "write", + WatchRoot: root, + }, + ) + require.NoError(t, err) + assert.Empty(t, ignored) + + nested, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: nestedPath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + assert.Empty(t, nested) + + require.NoError(t, os.Remove(messagesPath)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: messagesPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, messagesPath, changed[0].DisplayPath) + assert.Equal(t, sessionDir, changed[0].ProjectHint) + + wrongRoot, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: messagesPath, + EventKind: "write", + WatchRoot: filepath.Join(root, "..", "other-root"), + }, + ) + require.NoError(t, err) + assert.Empty(t, wrongRoot) +} + +func TestVibeProviderDiscoversSymlinkedSessionDirectory(t *testing.T) { + root := t.TempDir() + targetRoot := t.TempDir() + sessionDir := "session_20260613_123456_symlinked" + targetDir := filepath.Join(targetRoot, sessionDir) + sourceDir := filepath.Join(root, sessionDir) + sourcePath := filepath.Join(sourceDir, "messages.jsonl") + writeSourceFile( + t, + filepath.Join(targetDir, "messages.jsonl"), + vibeProviderMessagesFixture("from symlink"), + ) + if err := os.Symlink(targetDir, sourceDir); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentVibe, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: sessionDir, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) +} + +func TestVibeProviderParse(t *testing.T) { + root := t.TempDir() + sessionDir := "session_20260613_123456_abc123def" + messagesPath := filepath.Join(root, sessionDir, "messages.jsonl") + metaPath := filepath.Join(root, sessionDir, "meta.json") + writeSourceFile(t, messagesPath, vibeProviderMessagesFixture("parse question")) + writeSourceFile(t, metaPath, vibeProviderMetaFixture("uuid-1234", "Provider title")) + + provider, ok := NewProvider(AgentVibe, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + fingerprint, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.False(t, outcome.ForceReplace) + require.Len(t, outcome.Results, 1) + result := outcome.Results[0] + assert.Equal(t, DataVersionCurrent, result.DataVersion) + assert.Equal(t, "vibe:uuid-1234", result.Result.Session.ID) + assert.Equal(t, AgentVibe, result.Result.Session.Agent) + assert.Equal(t, "vibe", result.Result.Session.Project) + assert.Equal(t, "devbox", result.Result.Session.Machine) + assert.Equal(t, messagesPath, result.Result.Session.File.Path) + assert.Equal(t, fingerprint.Size, result.Result.Session.File.Size) + assert.Equal(t, fingerprint.MTimeNS, result.Result.Session.File.Mtime) + assert.Equal(t, fingerprint.Hash, result.Result.Session.File.Hash) + assert.Equal(t, "Provider title", result.Result.Session.SessionName) + assert.Equal(t, "parse question", result.Result.Session.FirstMessage) + assert.Contains(t, outcome.ExcludedSessionIDs, "vibe:"+sessionDir) + assert.Len(t, result.Result.Messages, 2) +} + +// TestVibeProviderParseEmitsUsageEvents locks in the usage-event and +// excluded-ID behavior the deleted shadow-baseline test asserted: when +// meta.json carries a model and token stats, Parse must surface a single +// session-level usage event and exclude the directory-name fallback ID. +func TestVibeProviderParseEmitsUsageEvents(t *testing.T) { + root := t.TempDir() + sessionDir := "session_20260616_083518_abc123" + sessionID := "uuid-1234" + messagesPath := filepath.Join(root, sessionDir, "messages.jsonl") + metaPath := filepath.Join(root, sessionDir, "meta.json") + writeSourceFile(t, messagesPath, vibeProviderMessagesFixture("provider question")) + writeSourceFile(t, metaPath, vibeProviderMetaWithStatsFixture(sessionID, "Provider title")) + + provider, ok := NewProvider(AgentVibe, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + fingerprint, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.Len(t, outcome.Results, 1) + result := outcome.Results[0] + assert.Equal(t, "vibe:"+sessionID, result.Result.Session.ID) + assert.Equal(t, []string{"vibe:" + sessionDir}, outcome.ExcludedSessionIDs) + + require.Len(t, result.Result.UsageEvents, 1) + usageEvent := result.Result.UsageEvents[0] + assert.Equal(t, "vibe:"+sessionID, usageEvent.SessionID) + assert.Equal(t, "mistral-medium-3.5", usageEvent.Model) + assert.Equal(t, 100, usageEvent.InputTokens) + assert.Equal(t, 40, usageEvent.OutputTokens) +} + +func vibeProviderMessagesFixture(firstMessage string) string { + return `{"role":"user","content":"` + firstMessage + `"}` + "\n" + + `{"role":"assistant","content":"Done."}` + "\n" +} + +func vibeProviderMetaFixture(sessionID, title string) string { + return `{"session_id":"` + sessionID + `","title":"` + title + `"}` +} + +func vibeProviderMetaWithStatsFixture(sessionID, title string) string { + return `{"session_id":"` + sessionID + `","title":"` + title + `",` + + `"config":{"active_model":"mistral-medium-3.5"},` + + `"stats":{"session_prompt_tokens":100,"session_completion_tokens":40}}` +} diff --git a/internal/parser/vibe_test.go b/internal/parser/vibe_test.go index 7d7af4630..89b3b6085 100644 --- a/internal/parser/vibe_test.go +++ b/internal/parser/vibe_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "encoding/json" "os" "path/filepath" @@ -11,6 +12,55 @@ import ( "github.com/stretchr/testify/require" ) +// newVibeTestProvider builds a Vibe provider for the given roots so package +// tests can exercise discovery through the Provider interface. +func newVibeTestProvider(t *testing.T, roots ...string) Provider { + t.Helper() + provider, ok := NewProvider(AgentVibe, ProviderConfig{ + Roots: roots, + Machine: "local", + }) + require.True(t, ok) + return provider +} + +// parseVibeTestSession parses a Vibe messages.jsonl file at path into a +// ParseResult through the folded free function, replacing the removed +// package-level ParseVibeSession entrypoint. +func parseVibeTestSession(t *testing.T, path string, fileInfo FileInfo) (ParseResult, error) { + t.Helper() + return parseVibeResultFile(path, fileInfo) +} + +// discoverVibeTestSessions discovers Vibe sessions under root through the +// provider, returning the legacy DiscoveredFile shape (path + project) the +// tests assert against. +func discoverVibeTestSessions(t *testing.T, root string) []DiscoveredFile { + t.Helper() + provider := newVibeTestProvider(t, root) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + if len(sources) == 0 { + return nil + } + files := make([]DiscoveredFile, 0, len(sources)) + for _, source := range sources { + files = append(files, DiscoveredFile{ + Path: source.DisplayPath, + Project: source.ProjectHint, + Agent: AgentVibe, + }) + } + return files +} + +// findVibeTestSourceFile resolves a Vibe session ID to a messages.jsonl path, +// replacing the removed FindVibeSourceFile. +func findVibeTestSourceFile(t *testing.T, root, sessionID string) string { + t.Helper() + return findVibeSourceFile(root, sessionID) +} + func TestDiscoverVibeSessions(t *testing.T) { tmpDir := t.TempDir() @@ -29,7 +79,7 @@ func TestDiscoverVibeSessions(t *testing.T) { require.NoError(t, os.MkdirAll(otherDir, 0755)) // Run discovery - discovered := DiscoverVibeSessions(tmpDir) + discovered := discoverVibeTestSessions(t, tmpDir) // Verify results require.Len(t, discovered, 1) @@ -53,7 +103,7 @@ func TestDiscoverVibeSessionsMultiple(t *testing.T) { require.NoError(t, os.MkdirAll(invalidDir, 0755)) // Run discovery - discovered := DiscoverVibeSessions(tmpDir) + discovered := discoverVibeTestSessions(t, tmpDir) // Verify results - should find only 3 valid sessions require.Len(t, discovered, 3) @@ -69,7 +119,7 @@ func TestDiscoverVibeSessionsEmptyDir(t *testing.T) { tmpDir := t.TempDir() // Run discovery on empty directory - files := DiscoverVibeSessions(tmpDir) + files := discoverVibeTestSessions(t, tmpDir) // Should return empty slice assert.Len(t, files, 0) @@ -77,7 +127,7 @@ func TestDiscoverVibeSessionsEmptyDir(t *testing.T) { func TestDiscoverVibeSessionsNonExistentDir(t *testing.T) { // Run discovery on non-existent directory - files := DiscoverVibeSessions("/nonexistent/path") + files := discoverVibeTestSessions(t, "/nonexistent/path") // Should return empty slice without error assert.Len(t, files, 0) @@ -92,7 +142,7 @@ func TestFindVibeSourceFile(t *testing.T) { // When the ID matches the directory name (no meta.json), the file is // resolved directly. - result := FindVibeSourceFile(root, sessionID) + result := findVibeTestSourceFile(t, root, sessionID) expected := filepath.Join(root, sessionID, "messages.jsonl") assert.Equal(t, expected, result) } @@ -104,7 +154,7 @@ func TestFindVibeSourceFileWithSpecialChars(t *testing.T) { filepath.Join(sessionID, "messages.jsonl"): "test", }) - result := FindVibeSourceFile(root, sessionID) + result := findVibeTestSourceFile(t, root, sessionID) expected := filepath.Join(root, sessionID, "messages.jsonl") assert.Equal(t, expected, result) } @@ -119,12 +169,12 @@ func TestFindVibeSourceFileByMetaSessionID(t *testing.T) { // The canonical ID is the meta.json session_id, which differs from the // directory name; the lookup must scan meta.json to resolve it. - result := FindVibeSourceFile(root, "uuid-1234") + result := findVibeTestSourceFile(t, root, "uuid-1234") expected := filepath.Join(root, dirName, "messages.jsonl") assert.Equal(t, expected, result) // An unknown ID resolves to nothing. - assert.Empty(t, FindVibeSourceFile(root, "does-not-exist")) + assert.Empty(t, findVibeTestSourceFile(t, root, "does-not-exist")) } func TestParseVibeSession(t *testing.T) { @@ -134,7 +184,7 @@ func TestParseVibeSession(t *testing.T) { Mtime: time.Now().UnixNano(), } - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) // Verify session metadata @@ -192,7 +242,7 @@ func TestParseVibeSessionWithTools(t *testing.T) { Mtime: time.Now().UnixNano(), } - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) // Verify messages @@ -254,7 +304,7 @@ func TestParseVibeSessionEmpty(t *testing.T) { Mtime: time.Now().UnixNano(), } - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) // Empty file should have no messages @@ -281,7 +331,7 @@ func TestParseVibeSessionMalformedLines(t *testing.T) { Mtime: time.Now().UnixNano(), } - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) // Should have parsed 2 valid messages and counted 1 malformed line @@ -307,7 +357,7 @@ func TestParseVibeSessionWithoutMeta(t *testing.T) { Mtime: time.Now().UnixNano(), } - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) // Should have parsed messages but no metadata from meta.json. The ID @@ -358,7 +408,7 @@ func TestParseVibeSessionEmptyStats(t *testing.T) { Mtime: time.Now().UnixNano(), } - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) // Should have parsed messages and metadata but no usage events due to empty stats @@ -406,7 +456,7 @@ func TestParseVibeSessionModelFromMessages(t *testing.T) { Mtime: time.Now().UnixNano(), } - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) // Should have parsed messages and metadata @@ -460,7 +510,7 @@ func TestParseVibeSessionModelFromConfig(t *testing.T) { path := filepath.Join(tmpDir, "session_test", "messages.jsonl") fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()} - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) require.Len(t, result.UsageEvents, 1) @@ -488,7 +538,7 @@ func TestParseVibeSessionInjectedUserExcluded(t *testing.T) { path := filepath.Join(tmpDir, "session_test", "messages.jsonl") fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()} - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) require.Len(t, result.Messages, 3) @@ -507,7 +557,7 @@ func TestParseVibeSessionToolResultNotCountedAsUser(t *testing.T) { path := "testdata/vibe/session_with_tools/messages.jsonl" fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()} - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) assert.Equal(t, 1, result.Session.UserMessageCount) @@ -533,7 +583,7 @@ func TestParseVibeSessionMalformedMetaRecoversID(t *testing.T) { path := filepath.Join(tmpDir, "session_dir", "messages.jsonl") fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()} - result, err := ParseVibeSession(path, fileInfo) + result, err := parseVibeTestSession(t, path, fileInfo) require.NoError(t, err) assert.Equal(t, "vibe:uuid-canonical-1", result.Session.ID) @@ -560,7 +610,7 @@ func TestParseVibeSessionCorruptMetaReturnsError(t *testing.T) { path := filepath.Join(tmpDir, "session_dir", "messages.jsonl") fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()} - _, err := ParseVibeSession(path, fileInfo) + _, err := parseVibeTestSession(t, path, fileInfo) require.Error(t, err) assert.Contains(t, err.Error(), "meta.json") } @@ -575,8 +625,10 @@ func TestVibeAgentByType(t *testing.T) { assert.Equal(t, "vibe_session_dirs", def.ConfigKey) assert.Equal(t, "vibe:", def.IDPrefix) assert.True(t, def.FileBased) - assert.NotNil(t, def.DiscoverFunc) - assert.NotNil(t, def.FindSourceFunc) + // Vibe is provider-authoritative: discovery and source lookup live on the + // vibeProvider, not on legacy AgentDef hooks. + assert.Nil(t, def.DiscoverFunc) + assert.Nil(t, def.FindSourceFunc) } func TestVibeAgentByPrefix(t *testing.T) { @@ -642,7 +694,7 @@ func TestParseRealVibeSession(t *testing.T) { Mtime: time.Now().UnixNano(), } - result, err := ParseVibeSession(messagesPath, fileInfo) + result, err := parseVibeTestSession(t, messagesPath, fileInfo) require.NoError(t, err) // Verify basic session metadata diff --git a/internal/sync/classify_vibe_test.go b/internal/sync/classify_vibe_test.go deleted file mode 100644 index 5b5cb7602..000000000 --- a/internal/sync/classify_vibe_test.go +++ /dev/null @@ -1,92 +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_Vibe(t *testing.T) { - dir := t.TempDir() - sessionDir := "session_20260616_083518_0107f266" - - // Vibe layout: /session__/messages.jsonl. - msgPath := filepath.Join(dir, sessionDir, "messages.jsonl") - require.NoError(t, os.MkdirAll(filepath.Dir(msgPath), 0o755)) - require.NoError(t, os.WriteFile(msgPath, []byte("{}\n"), 0o644)) - - // A real meta.json sits beside messages.jsonl. Changes to it should - // route back to the sibling messages.jsonl, since title/model/usage - // stats are sourced from meta.json. - metaPath := filepath.Join(dir, sessionDir, "meta.json") - require.NoError(t, os.WriteFile(metaPath, []byte("{}\n"), 0o644)) - - deletedMetaDir := "session_20260616_083519_deleted" - deletedMetaMsgPath := filepath.Join(dir, deletedMetaDir, "messages.jsonl") - require.NoError(t, os.MkdirAll(filepath.Dir(deletedMetaMsgPath), 0o755)) - require.NoError(t, os.WriteFile(deletedMetaMsgPath, []byte("{}\n"), 0o644)) - deletedMetaPath := filepath.Join(dir, deletedMetaDir, "meta.json") - - // A non-session directory must not classify. - otherPath := filepath.Join(dir, "scratch", "messages.jsonl") - require.NoError(t, os.MkdirAll(filepath.Dir(otherPath), 0o755)) - require.NoError(t, os.WriteFile(otherPath, []byte("{}\n"), 0o644)) - - eng := &Engine{ - agentDirs: map[parser.AgentType][]string{ - parser.AgentVibe: {dir}, - }, - } - geminiMap := make(map[string]map[string]string) - - tests := []struct { - name string - path string - want bool - wantPath string - wantProject string - }{ - { - name: "messages.jsonl under session dir classifies", - path: msgPath, - want: true, - wantPath: msgPath, - wantProject: sessionDir, - }, - { - name: "messages.jsonl outside session dir ignored", - path: otherPath, - want: false, - }, - { - name: "meta.json routes to sibling messages.jsonl", - path: metaPath, - want: true, - wantPath: msgPath, - wantProject: sessionDir, - }, - { - name: "deleted meta.json routes to sibling messages.jsonl", - path: deletedMetaPath, - want: true, - wantPath: deletedMetaMsgPath, - wantProject: deletedMetaDir, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := eng.classifyOnePath(tt.path, geminiMap) - assert.Equal(t, tt.want, ok) - if ok { - assert.Equal(t, parser.AgentVibe, got.Agent) - assert.Equal(t, tt.wantPath, got.Path) - assert.Equal(t, tt.wantProject, got.Project) - } - }) - } -} diff --git a/internal/sync/engine.go b/internal/sync/engine.go index b74eb28af..f3b2a809b 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -887,7 +887,7 @@ func isUnder(dir, path string) (string, bool) { // classifyContainerPath runs the container- and SQLite-style classifiers that // resolve a path whether or not it currently exists on disk (OpenCode-format -// stores, Kiro, Zed, Shelley, and Vibe). Split out of classifyOnePath to keep +// stores, Kiro, Zed, and Shelley). Split out of classifyOnePath to keep // that function within NilAway's per-function CFG-block limit. func (e *Engine) classifyContainerPath( path string, pathExists bool, @@ -916,9 +916,6 @@ func (e *Engine) classifyContainerPath( if df, ok := e.classifyShelleySQLitePath(path); ok { return df, true } - if df, ok := e.classifyVibePath(path); ok { - return df, true - } return parser.DiscoveredFile{}, false } @@ -1334,55 +1331,6 @@ func (e *Engine) classifyAiderPath( return parser.DiscoveredFile{}, false } -// classifyVibePath handles Vibe's session directory layout: -// -// /session__/messages.jsonl -// /session__/meta.json -// -// meta.json changes route back to messages.jsonl because title, model, -// timestamps, and usage stats are sourced from the sidecar metadata file. -func (e *Engine) classifyVibePath( - path string, -) (parser.DiscoveredFile, bool) { - sep := string(filepath.Separator) - for _, vibeDir := range e.agentDirs[parser.AgentVibe] { - if vibeDir == "" { - continue - } - rel, ok := isUnder(vibeDir, path) - if !ok { - continue - } - parts := strings.Split(rel, sep) - if len(parts) != 2 || !strings.HasPrefix(parts[0], "session_") { - continue - } - switch parts[1] { - case "messages.jsonl": - if _, err := os.Stat(path); err != nil { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: parts[0], - Agent: parser.AgentVibe, - }, true - case "meta.json": - messagesPath := filepath.Join( - vibeDir, parts[0], "messages.jsonl", - ) - if _, err := os.Stat(messagesPath); err == nil { - return parser.DiscoveredFile{ - Path: messagesPath, - Project: parts[0], - Agent: parser.AgentVibe, - }, true - } - } - } - 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 @@ -4233,8 +4181,6 @@ func (e *Engine) processFile( res = e.processKiroIDE(file, info) case parser.AgentHermes: res = e.processHermes(file, info) - case parser.AgentVibe: - res = e.processVibe(file, info) case parser.AgentPositron: res = e.processPositron(file, info) case parser.AgentZed: @@ -4396,9 +4342,126 @@ func (e *Engine) processProviderFile( }) } } + e.applyProviderFilePathPolicies(provider, file.Agent, &res) return res, true } +// applyProviderFilePathPolicies reproduces the DB-aware, file-path-scoped +// session bookkeeping that a provider cannot do on its own (it has no database +// handle). It runs only for single-session-per-file providers whose canonical +// ID can change while the source path is unchanged (e.g. Vibe, whose ID flips +// between the meta.json session_id and the directory-name fallback as meta.json +// appears or is removed). Multi-session sources are skipped, where several +// distinct sessions legitimately share one path; for stable-ID providers it is +// a no-op because the stored ID always matches the freshly parsed one. +// +// Two policies are applied per result, keyed by the (path-rewritten) file_path: +// +// 1. Resurrection guard: if the user removed the session occupying this path — +// a trashed row at the same path, or an alternate identity for the path +// (the provider's excluded fallback ID, or a stale stored ID) that is now +// trashed or permanently excluded — the freshly parsed row must not be +// written under its new ID. The result is dropped and its ID is excluded. +// 2. Stale-row cleanup: any other live stored ID at the same path that the +// current parse no longer emits is added to the exclusion list so the +// superseded row is deleted. +func (e *Engine) applyProviderFilePathPolicies( + provider parser.Provider, + agent parser.AgentType, + res *processResult, +) { + if provider.Capabilities().Source.MultiSessionSource == parser.CapabilitySupported { + return + } + if len(res.results) == 0 { + return + } + + excluded := make(map[string]struct{}, len(res.excludedSessionIDs)) + for _, id := range e.applyIDPrefixToSessionIDs(res.excludedSessionIDs) { + excluded[id] = struct{}{} + } + addExclusion := func(id string) { + if id == "" { + return + } + if _, ok := excluded[id]; ok { + return + } + excluded[id] = struct{}{} + res.excludedSessionIDs = append(res.excludedSessionIDs, id) + } + + kept := res.results[:0] + for _, result := range res.results { + path := result.Session.File.Path + if path == "" { + kept = append(kept, result) + continue + } + lookupPath := path + if e.pathRewriter != nil { + lookupPath = e.pathRewriter(path) + } + currentID := result.Session.ID + currentPrefixedID := e.idPrefix + result.Session.ID + + existingIDs, err := e.db.ListSessionIDsByFilePath(lookupPath, string(agent)) + if err != nil { + log.Printf("list session IDs by file path: %v", err) + kept = append(kept, result) + continue + } + + // Resurrection guard. The path's identity is removed when a trashed row + // shares it, or when any alternate identity for the path (the + // provider's excluded fallback IDs or a stale stored ID) is trashed or + // permanently excluded. In that case the new row must not be written. + suppress := e.db.HasTrashedSessionByFilePath(lookupPath, string(agent)) + if !suppress { + for id := range excluded { + if id == currentID || id == currentPrefixedID { + continue + } + if e.db.IsSessionExcluded(id) || e.db.IsSessionTrashed(id) { + suppress = true + break + } + } + } + if !suppress { + for _, id := range existingIDs { + if id == currentID || id == currentPrefixedID { + continue + } + if e.db.IsSessionExcluded(id) || e.db.IsSessionTrashed(id) { + suppress = true + break + } + } + } + if suppress { + // Keep a trashed current ID trashed rather than converting it to a + // parser deletion; the upsert's trash guard already hides it. + if (currentPrefixedID == "" || !e.db.IsSessionTrashed(currentPrefixedID)) && + !e.db.IsSessionTrashed(currentID) { + addExclusion(currentID) + } + continue + } + + // Stale-row cleanup for live siblings the current parse supersedes. + for _, id := range existingIDs { + if id == currentID || id == currentPrefixedID { + continue + } + addExclusion(id) + } + kept = append(kept, result) + } + res.results = kept +} + func providerOutcomeAllowsCleanSkipCache(outcome parser.ParseOutcome) bool { if !outcome.ResultSetComplete { return false @@ -6187,100 +6250,6 @@ func (e *Engine) processHermes( } } -func (e *Engine) processVibe( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - // Title/model/usage stats come from the sibling meta.json, so the - // skip check and stored file info must account for it too, or a - // meta.json-only update never refreshes those fields. - effectiveInfo := vibeEffectiveInfo(file.Path, info) - if e.shouldSkipByPath(file.Path, effectiveInfo) { - return processResult{skip: true} - } - - // Pass an empty project so the parser-derived project (from the - // session's working directory) is kept. file.Project holds the - // cryptic session directory name, which must not become the project. - sess, msgs, usageEvents, err := parser.ParseVibeSessionWrapper( - file.Path, "", 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 - } - - var excludedIDs []string - lookupPath := file.Path - if e.pathRewriter != nil { - lookupPath = e.pathRewriter(file.Path) - } - existingIDs, err := e.db.ListSessionIDsByFilePath( - lookupPath, string(parser.AgentVibe), - ) - if err != nil { - return processResult{err: err} - } - currentID := sess.ID - currentPrefixedID := e.idPrefix + sess.ID - fallbackID := "vibe:" + filepath.Base(filepath.Dir(file.Path)) - for _, id := range existingIDs { - if id != currentID && id != currentPrefixedID { - excludedIDs = append(excludedIDs, id) - } - } - - currentFallbackTrashed := sess.ID == fallbackID && e.isSessionTrashed(fallbackID) - if e.isSessionBlocked(fallbackID) || - (sess.ID == fallbackID && - e.db.HasTrashedSessionByFilePath(lookupPath, string(parser.AgentVibe))) { - if !currentFallbackTrashed && !slices.Contains(excludedIDs, sess.ID) { - excludedIDs = append(excludedIDs, sess.ID) - } - return processResult{excludedSessionIDs: excludedIDs} - } - - // Sessions parsed before meta.json existed (or was parseable) are stored - // under the directory-name fallback ID. Keep excluding that legacy row even - // if it predates file_path metadata and did not appear in the path lookup. - if sess.ID != fallbackID && !slices.Contains(excludedIDs, fallbackID) { - excludedIDs = append(excludedIDs, fallbackID) - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs, UsageEvents: usageEvents}, - }, - excludedSessionIDs: excludedIDs, - } -} - -func (e *Engine) isSessionBlocked(id string) bool { - if e.idPrefix != "" && !strings.HasPrefix(id, e.idPrefix) { - prefixed := e.idPrefix + id - return e.db.IsSessionExcluded(prefixed) || e.db.IsSessionTrashed(prefixed) - } - if e.db.IsSessionExcluded(id) || e.db.IsSessionTrashed(id) { - return true - } - return false -} - -func (e *Engine) isSessionTrashed(id string) bool { - if e.idPrefix != "" && !strings.HasPrefix(id, e.idPrefix) { - return e.db.IsSessionTrashed(e.idPrefix + id) - } - return e.db.IsSessionTrashed(id) -} - // vibeEffectiveInfo returns size/mtime for a Vibe session that account // for the sibling meta.json file: size is the sum of both files, and // mtime is the larger of the two. Returns info unchanged when meta.json diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index f92ba9e6c..30fe5c830 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -1225,10 +1225,15 @@ func TestProcessAntigravityWALOnlyUpdateNotSkipped(t *testing.T) { func TestProcessVibeMetaOnlyUpdateNotSkipped(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.AgentVibe: {root}, + }, + }) + sessionDir := filepath.Join(root, "session_20260616_083518_0107f266") require.NoError(t, os.MkdirAll(sessionDir, 0o755)) @@ -1246,32 +1251,19 @@ func TestProcessVibeMetaOnlyUpdateNotSkipped(t *testing.T) { 0o644, )) - file := parser.DiscoveredFile{ - Agent: parser.AgentVibe, - Path: msgPath, - } - - res := e.processFile(ctx, file) - require.NoError(t, res.err) - require.False(t, res.skip) - require.Len(t, res.results, 1) - require.Equal(t, "Original title", res.results[0].Session.SessionName) + canonicalID := "vibe:abc" - pw := pendingWrite{ - sess: res.results[0].Session, - msgs: res.results[0].Messages, - } - written, _, failed := e.writeBatch( - []pendingWrite{pw}, syncWriteDefault, false, - ) - require.Equal(t, 0, failed) - require.Equal(t, 1, written) - - res = e.processFile(ctx, file) - require.True(t, res.skip, "unchanged session should skip") + e.SyncPaths([]string{msgPath}) + sess, err := database.GetSession(ctx, canonicalID) + require.NoError(t, err) + require.NotNil(t, sess) + require.NotNil(t, sess.DisplayName) + assert.Equal(t, "Original title", *sess.DisplayName) // meta.json-only update: messages.jsonl is untouched, but the title - // (sourced from meta.json) changes. + // (sourced from meta.json) changes. The Vibe provider's composite + // fingerprint folds the sibling meta.json mtime in, so the change busts + // the skip cache and triggers a reparse rather than a skip. info, err := os.Stat(msgPath) require.NoError(t, err) metaTime := info.ModTime().Add(5 * time.Second) @@ -1282,10 +1274,12 @@ func TestProcessVibeMetaOnlyUpdateNotSkipped(t *testing.T) { )) require.NoError(t, os.Chtimes(metaPath, metaTime, metaTime)) - res = e.processFile(ctx, file) - require.False(t, res.skip, "meta.json-only update must trigger a reparse") - require.Len(t, res.results, 1) - assert.Equal(t, "Renamed title", res.results[0].Session.SessionName) + e.SyncPaths([]string{msgPath}) + sess, err = database.GetSession(ctx, canonicalID) + require.NoError(t, err) + require.NotNil(t, sess) + require.NotNil(t, sess.DisplayName) + assert.Equal(t, "Renamed title", *sess.DisplayName) } func TestProcessAntigravityBrainOnlyUpdateNotSkipped(t *testing.T) {