From f1862c05c1b8f4b6a720e20f2b4bbb1005362961 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 22 Jun 2026 22:10:38 -0400 Subject: [PATCH] feat(parser): migrate vibe provider Vibe stores transcript content in messages.jsonl while canonical session identity, title, timestamps, model, and usage can live in a sibling meta.json. Moving it behind a concrete provider keeps that companion relationship explicit at the provider boundary.\n\nThe provider preserves recursive session discovery, symlinked session directories, raw and full ID lookup through meta.json, meta-sidecar changed-path classification, effective size and mtime freshness, transcript hashing, fallback-ID exclusion, and parser output normalization through the existing Vibe parser wrapper. fix(parser): classify removed vibe transcripts Vibe source events need to keep working after the primary messages.jsonl has already disappeared. Routing deletion and rename-style events through the existing file check meant the watcher could ignore the exact event that should refresh or remove the stored session. Synthesize source refs only for missing-path removal semantics, keep ordinary lookups existence-checked, and pin the intentionally shallow session directory layout in provider tests. This lets the Vibe provider enter shadow comparison as a real migration step. Validation: go test -tags "fts5" ./internal/parser -run 'Test(VibeProvider|ProviderMigrationModes)' -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check test(sync): compare vibe shadow parity Vibe is shadow-compared on this branch, so add source-level migration coverage that compares provider observation with ParseVibeSessionWrapper. The test includes meta.json canonical ID promotion, provider-adjusted fingerprint metadata, usage events, and excluded fallback IDs so reviewers can see the migration preserves the composite source behavior. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesVibeLegacyParser|TestVibeProvider|TestParseVibe|TestClassifyOnePath_Vibe|TestSyncVibe|TestSourceMtimeVibe|TestProcessVibe' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; git diff --check; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/... test(sync): cover vibe provider usage parity Roborev job 2711 caught that the Vibe shadow parity fixture compared empty usage slices, so it could not detect regressions in aggregate usage emission. Seed the fixture with real Vibe metadata fields for active model and nonzero stats, then assert both legacy and provider paths emit usage before comparing them. Validation: go test -tags "fts5" ./internal/sync -run TestObserveProviderSourceMatchesVibeLegacyParser -count=1; go fmt ./...; go vet ./...; git diff --check refactor(parser): fold vibe into provider Move Vibe source discovery, lookup, and parse ownership onto the concrete vibeProvider and delete the package-level DiscoverVibeSessions, FindVibeSourceFile, and ParseVibeSessionWrapper free functions. Discovery and find-source bodies now live as provider-owned helpers (discoverSessionPaths, findSourceFile) on the vibe source set, the isVibeMessagesFile guard moves to the provider file, and the messages.jsonl parser becomes the provider parseVibeResult/parseSession methods. Make Vibe provider-authoritative and drop its legacy sync dispatch: the classifyContainerPath classifyVibePath call and method, the processFile case arm, the processVibe method, and its now-orphaned isSessionBlocked and isSessionTrashed helpers. vibeEffectiveInfo stays as a shared composite-mtime helper used by the skip-cache and fingerprint paths. Because a provider has no database handle, the engine reproduces Vibe's DB-aware, file-path-scoped bookkeeping in applyProviderFilePathPolicies for single-session-per-file providers: stale stored IDs at the same source path are excluded, and a freshly parsed row is suppressed when the user already removed (trashed or deleted) the session occupying that path, so a canonical ID flipping between the meta.json session_id and the directory-name fallback no longer resurrects a hidden session. This is a no-op for stable-ID providers and skipped for multi-session sources. Drop the Vibe AgentDef DiscoverFunc/FindSourceFunc hooks, remove it from the pending shim scan list, replace the shadow-baseline test with provider API coverage plus a guard that the legacy entrypoints stay gone, and route the package and engine tests through the provider methods. The obsolete classifyOnePath Vibe test is removed; the provider's SourcesForChangedPath coverage replaces it. --- internal/parser/discovery.go | 78 ------ internal/parser/discovery_test.go | 4 +- internal/parser/provider.go | 2 + internal/parser/provider_migration.go | 2 +- internal/parser/provider_shim_scan_test.go | 1 - internal/parser/types.go | 16 +- internal/parser/vibe.go | 18 +- internal/parser/vibe_provider.go | 304 +++++++++++++++++++++ internal/parser/vibe_provider_test.go | 297 ++++++++++++++++++++ internal/parser/vibe_test.go | 98 +++++-- internal/sync/classify_vibe_test.go | 92 ------- internal/sync/engine.go | 267 ++++++++---------- internal/sync/engine_test.go | 50 ++-- 13 files changed, 838 insertions(+), 391 deletions(-) create mode 100644 internal/parser/vibe_provider.go create mode 100644 internal/parser/vibe_provider_test.go delete mode 100644 internal/sync/classify_vibe_test.go 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) {