diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 08cd92622..635afa55b 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -400,6 +400,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newQwenProviderFactory(def) case AgentQwenPaw: return newQwenPawProviderFactory(def) + case AgentShelley: + return newShelleyProviderFactory(def) case AgentVSCopilot: return newVisualStudioCopilotProviderFactory(def) case AgentVSCodeCopilot: @@ -410,6 +412,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newWorkBuddyProviderFactory(def) case AgentZencoder: return newZencoderProviderFactory(def) + case AgentZed: + return newZedProviderFactory(def) default: return legacyProviderFactory{def: def} } diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index a5de13070..933a542c9 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -53,10 +53,10 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentAntigravity: ProviderMigrationLegacyOnly, AgentAntigravityCLI: ProviderMigrationLegacyOnly, AgentVibe: ProviderMigrationProviderAuthoritative, - AgentZed: ProviderMigrationLegacyOnly, + AgentZed: ProviderMigrationProviderAuthoritative, AgentQwenPaw: ProviderMigrationProviderAuthoritative, AgentGptme: ProviderMigrationProviderAuthoritative, - AgentShelley: ProviderMigrationLegacyOnly, + AgentShelley: ProviderMigrationProviderAuthoritative, AgentAider: ProviderMigrationLegacyOnly, AgentOMP: ProviderMigrationProviderAuthoritative, AgentReasonix: ProviderMigrationLegacyOnly, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index 9eb94c06f..3f945361d 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -59,11 +59,10 @@ var pendingShimProviderFiles = map[string]bool{ "kiro_ide_provider.go": true, "kiro_provider.go": true, "opencode_provider.go": true, - "shelley_provider.go": true, + "positron_provider.go": true, "vibe_provider.go": true, "visualstudio_copilot_provider.go": true, "vscode_copilot_provider.go": true, - "zed_provider.go": true, } // collectLegacyFreeFuncs returns the set of package-level free functions in the diff --git a/internal/parser/shelley.go b/internal/parser/shelley.go index cf77f709f..f1d9efe66 100644 --- a/internal/parser/shelley.go +++ b/internal/parser/shelley.go @@ -8,7 +8,6 @@ import ( "hash" "hash/fnv" "os" - "path/filepath" "strconv" "strings" @@ -102,63 +101,6 @@ func ShelleyVirtualPath(dbPath, conversationID string) string { return dbPath + "#" + conversationID } -// ParseShelleyVirtualPath splits a virtual Shelley source path back into -// its database path and raw conversation ID. -func ParseShelleyVirtualPath(path string) (string, string, bool) { - idx := strings.LastIndex(path, "#") - if idx < 0 { - return "", "", false - } - dbPath, conversationID := path[:idx], path[idx+1:] - if filepath.Base(dbPath) != shelleyDBName || conversationID == "" { - return "", "", false - } - return dbPath, conversationID, true -} - -// FindShelleyDBPath returns the shelley.db under the configured root, or -// "" when the root holds no Shelley database. -func FindShelleyDBPath(dir string) string { - if dir == "" { - return "" - } - path := filepath.Join(dir, shelleyDBName) - if !IsRegularFile(path) { - return "" - } - return path -} - -// DiscoverShelleySessions discovers Shelley's conversation database under -// the configured data directory. Like Zed, it returns a single entry for -// the shared DB; the sync engine fans it out to one session per -// conversation. -func DiscoverShelleySessions(root string) []DiscoveredFile { - dbPath := FindShelleyDBPath(root) - if dbPath == "" { - return nil - } - return []DiscoveredFile{{Path: dbPath, Agent: AgentShelley}} -} - -// FindShelleySourceFile locates Shelley's shared conversation database -// for a raw conversation ID. All conversations live in one SQLite DB, so -// the ID is validated only to reject path-like input and is resolved to -// a virtual path when the conversation exists under this root. -func FindShelleySourceFile(root, rawID string) string { - if root == "" || !IsValidSessionID(rawID) { - return "" - } - dbPath := FindShelleyDBPath(root) - if dbPath == "" { - return "" - } - if ShelleyConversationExists(dbPath, rawID) { - return ShelleyVirtualPath(dbPath, rawID) - } - return "" -} - // ShelleyConversationExists reports whether the Shelley DB has a // conversation row with the given ID. func ShelleyConversationExists(dbPath, conversationID string) bool { @@ -347,7 +289,7 @@ func ListShelleyConversationMetas( // This value is watcher-only and never written to file_mtime or // range-filtered, so the sub-second term is harmless here. func ShelleySourceMtime(path string) (int64, error) { - dbPath, conversationID, ok := ParseShelleyVirtualPath(path) + dbPath, conversationID, ok := parseShelleyVirtualPath(path) if !ok { return 0, fmt.Errorf("not a shelley virtual path: %s", path) } @@ -413,27 +355,10 @@ func openShelleyDB(dbPath string) (*sql.DB, error) { return db, nil } -// ParseShelleyConversationDirect parses a single conversation by ID, -// opening and closing its own connection. dbInfo must be the os.FileInfo -// of the shelley.db file itself. -func ParseShelleyConversationDirect( - dbPath, rawID, machine string, dbInfo os.FileInfo, -) (*ParseResult, error) { - if !IsValidSessionID(rawID) { - return nil, fmt.Errorf("invalid Shelley session ID: %s", rawID) - } - conn, err := openShelleyDB(dbPath) - if err != nil { - return nil, err - } - defer conn.Close() - return ParseShelleyConversationFromDB(conn, dbPath, rawID, machine, dbInfo) -} - -// ParseShelleyConversationFromDB parses one conversation using an +// parseShelleyConversationFromDB parses one conversation using an // already-open connection. Callers parsing multiple conversations should // open the DB once and call this in a loop. -func ParseShelleyConversationFromDB( +func parseShelleyConversationFromDB( conn *sql.DB, dbPath, rawID, machine string, dbInfo os.FileInfo, ) (*ParseResult, error) { conv, err := loadShelleyConversation(conn, rawID) diff --git a/internal/parser/shelley_provider.go b/internal/parser/shelley_provider.go new file mode 100644 index 000000000..14618638d --- /dev/null +++ b/internal/parser/shelley_provider.go @@ -0,0 +1,289 @@ +package parser + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Shelley stores every conversation in one shared SQLite database +// (shelley.db). It is a multi-session container provider: discovery surfaces +// the database as a single source and Parse fans it out into one session per +// conversation, addressed by "::" virtual paths. All +// behavior is wired into the shared multi-session-container base via options. +func newShelleyProviderFactory(def AgentDef) ProviderFactory { + return newMultiSessionProviderFactory( + def, + shelleyProviderCapabilities(), + func(cfg ProviderConfig) multiSessionContainerSourceSet { + return newMultiSessionContainerSourceSet( + AgentShelley, + cfg.Roots, + withContainerDiscovery(shelleyDiscoverContainers), + withWatchRoots(shelleyWatchRoots), + withChangedPathClassifier(shelleyClassifyPath), + withMemberLookup(shelleyFindMember), + withFingerprint(shelleyFingerprintSource), + withContainerParse(shelleyParseContainer), + withMemberParse(shelleyParseMember), + // Special case: confirm a stored conversation still exists for + // RequireFreshSource lookups. + withMemberPresence(shelleyMemberPresent), + ) + }, + ) +} + +func shelleyDiscoverContainers(root string) []string { + if dbPath := shelleyDBPath(root); dbPath != "" { + return []string{dbPath} + } + return nil +} + +func shelleyWatchRoots(roots []string) []WatchRoot { + out := make([]WatchRoot, 0, len(roots)) + for _, root := range roots { + out = append(out, WatchRoot{ + Path: root, + Recursive: false, + IncludeGlobs: []string{shelleyDBName, shelleyDBName + "-*"}, + DebounceKey: string(AgentShelley) + ":db:" + root, + }) + } + return out +} + +// shelleyClassifyPath maps a stored or changed path to its database container +// and conversation. allowMissing relaxes the regular-file requirement so a +// database delete (or its WAL/SHM sibling) still classifies for changed-path +// tombstones, reproducing the legacy strict sourceRef / lenient +// sourceRefForChangedPath split. +func shelleyClassifyPath( + root, path string, allowMissing bool, +) (multiSessionMatch, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + requireRegular := !allowMissing + if dbPath, conversationID, ok := parseShelleyVirtualPath(path); ok { + if !shelleyDBUnderRoot(root, dbPath, requireRegular) { + return multiSessionMatch{}, false + } + return multiSessionMatch{ + Path: path, + Container: dbPath, + MemberID: conversationID, + }, true + } + if shelleyDBUnderRoot(root, path, requireRegular) { + return multiSessionMatch{Path: path, Container: path}, true + } + if allowMissing { + if dbPath, ok := shelleyDBPathForEvent(root, path); ok { + return multiSessionMatch{Path: dbPath, Container: dbPath}, true + } + } + return multiSessionMatch{}, false +} + +// shelleyFindMember resolves a raw conversation ID to its virtual source path +// inside the shared database. The ID is validated only to reject path-like +// input; all conversations live in one DB. +func shelleyFindMember(root, rawID string) (multiSessionMatch, bool) { + if root == "" || !IsValidSessionID(rawID) { + return multiSessionMatch{}, false + } + dbPath := shelleyDBPath(root) + if dbPath == "" || !ShelleyConversationExists(dbPath, rawID) { + return multiSessionMatch{}, false + } + return multiSessionMatch{ + Path: ShelleyVirtualPath(dbPath, rawID), + Container: dbPath, + MemberID: rawID, + }, true +} + +func shelleyFingerprintSource(src multiSessionSource) (SourceFingerprint, error) { + info, err := os.Stat(src.Container) + if err != nil { + if os.IsNotExist(err) { + return SourceFingerprint{}, nil + } + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", src.Container, err) + } + fingerprint := SourceFingerprint{ + Size: info.Size(), + MTimeNS: info.ModTime().UnixNano(), + } + if src.MemberID == "" { + if compositeMtime, err := sqliteDBCompositeMtime(src.Container); err == nil { + fingerprint.MTimeNS = compositeMtime + } + fingerprint.Hash, err = hashJSONLSourceFile(src.Container) + if err != nil { + return SourceFingerprint{}, err + } + return fingerprint, nil + } + + conn, err := OpenShelleyDB(src.Container) + if err != nil { + return SourceFingerprint{}, err + } + defer conn.Close() + metas, err := ListShelleyConversationMetas(conn, src.Container) + if err != nil { + return SourceFingerprint{}, err + } + for _, meta := range metas { + if meta.RawID != src.MemberID { + continue + } + fingerprint.MTimeNS = meta.FileMtime + fingerprint.Hash = meta.Fingerprint + return fingerprint, nil + } + return SourceFingerprint{}, fmt.Errorf( + "shelley conversation not found: %s", src.MemberID, + ) +} + +func shelleyMemberPresent(src multiSessionSource) bool { + if src.MemberID == "" { + return IsRegularFile(src.Container) + } + return ShelleyConversationExists(src.Container, src.MemberID) +} + +func shelleyParseMember( + src multiSessionSource, req ParseRequest, +) (*ParseResult, error) { + dbInfo, err := os.Stat(src.Container) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("stat %s: %w", src.Container, err) + } + if !IsValidSessionID(src.MemberID) { + return nil, fmt.Errorf("invalid Shelley session ID: %s", src.MemberID) + } + conn, err := OpenShelleyDB(src.Container) + if err != nil { + return nil, err + } + defer conn.Close() + return parseShelleyConversationFromDB( + conn, src.Container, src.MemberID, req.Machine, dbInfo, + ) +} + +func shelleyParseContainer( + src multiSessionSource, req ParseRequest, +) ([]ParseResult, error) { + dbInfo, err := os.Stat(src.Container) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("stat %s: %w", src.Container, err) + } + conn, err := OpenShelleyDB(src.Container) + if err != nil { + return nil, err + } + defer conn.Close() + metas, err := ListShelleyConversationMetas(conn, src.Container) + if err != nil { + return nil, err + } + results := make([]ParseResult, 0, len(metas)) + for _, meta := range metas { + result, err := parseShelleyConversationFromDB( + conn, src.Container, meta.RawID, req.Machine, dbInfo, + ) + if err != nil { + return nil, err + } + if result == nil { + continue + } + results = append(results, *result) + } + return results, nil +} + +// shelleyDBPath resolves the shared shelley.db under root, returning "" when +// the root holds no Shelley database. +func shelleyDBPath(root string) string { + if root == "" { + return "" + } + path := filepath.Join(root, shelleyDBName) + if !IsRegularFile(path) { + return "" + } + return path +} + +func shelleyDBUnderRoot(root, dbPath string, requireRegular bool) bool { + root = filepath.Clean(root) + dbPath = filepath.Clean(dbPath) + rel, ok := relUnder(root, dbPath) + if !ok || filepath.ToSlash(rel) != shelleyDBName { + return false + } + return !requireRegular || IsRegularFile(dbPath) +} + +func shelleyDBPathForEvent(root, path string) (string, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + rel, ok := relUnder(root, path) + if !ok { + return "", false + } + if filepath.ToSlash(rel) == shelleyDBName || + (filepath.Dir(rel) == "." && + strings.HasPrefix(filepath.Base(rel), shelleyDBName+"-")) { + return filepath.Join(root, shelleyDBName), true + } + return "", false +} + +// parseShelleyVirtualPath splits a Shelley virtual source path into its +// physical shelley.db path and raw conversation ID. The container basename +// must be shelley.db and the conversation ID must be non-empty. +func parseShelleyVirtualPath(path string) (string, string, bool) { + return ParseVirtualSourcePathForBase(path, shelleyDBName) +} + +func shelleyProviderCapabilities() Capabilities { + return Capabilities{ + Source: SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + IncrementalAppend: CapabilityNotApplicable, + MultiSessionSource: CapabilitySupported, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilitySupported, + }, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + SessionName: CapabilitySupported, + Cwd: CapabilitySupported, + Relationships: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + PerMessageTokenUsage: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/shelley_test.go b/internal/parser/shelley_test.go index 9906df5da..6dc72d04e 100644 --- a/internal/parser/shelley_test.go +++ b/internal/parser/shelley_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "database/sql" "encoding/json" "os" @@ -159,6 +160,19 @@ func seedShelleyMainConversation(t *testing.T, db *sql.DB) { "2026-06-15T10:05:00Z") } +// parseShelleyConversationDirectForTest exercises the Shelley provider's +// single-conversation parse in isolation, mirroring the legacy direct-parse +// entrypoint the fold removed. +func parseShelleyConversationDirectForTest( + t *testing.T, dbPath, rawID, machine string, _ os.FileInfo, +) (*ParseResult, error) { + t.Helper() + return shelleyParseMember( + multiSessionSource{Container: dbPath, MemberID: rawID}, + ParseRequest{Machine: machine}, + ) +} + func TestParseShelleyConversation(t *testing.T) { _, dbPath, db := newShelleyTestDB(t) seedShelleyMainConversation(t, db) @@ -166,10 +180,10 @@ func TestParseShelleyConversation(t *testing.T) { info, err := os.Stat(dbPath) require.NoError(t, err, "stat db") - result, err := ParseShelleyConversationDirect( - dbPath, "cMAIN1", "test-machine", info, + result, err := parseShelleyConversationDirectForTest( + t, dbPath, "cMAIN1", "test-machine", info, ) - require.NoError(t, err, "ParseShelleyConversationDirect") + require.NoError(t, err, "parseConversationDirect") require.NotNil(t, result, "expected result") sess := result.Session @@ -257,8 +271,8 @@ func TestParseShelleySubagentRelationship(t *testing.T) { info, err := os.Stat(dbPath) require.NoError(t, err, "stat db") - result, err := ParseShelleyConversationDirect( - dbPath, "cSUB01", "test-machine", info, + result, err := parseShelleyConversationDirectForTest( + t, dbPath, "cSUB01", "test-machine", info, ) require.NoError(t, err, "parse subagent") require.NotNil(t, result, "expected subagent result") @@ -271,34 +285,60 @@ func TestDiscoverAndFindShelley(t *testing.T) { root, dbPath, db := newShelleyTestDB(t) seedShelleyMainConversation(t, db) - files := DiscoverShelleySessions(root) - require.Len(t, files, 1, "discovered files") - assert.Equal(t, dbPath, files[0].Path, "discovered db path") - assert.Equal(t, AgentShelley, files[0].Agent, "discovered agent") + // Discovery and source lookup are provider-owned after the fold; the + // physical DB still surfaces as a single source and a raw conversation + // ID resolves to its virtual path. + provider, ok := NewProvider(AgentShelley, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1, "discovered sources") + assert.Equal(t, dbPath, sources[0].DisplayPath, "discovered db path") + assert.Equal(t, AgentShelley, sources[0].Provider, "discovered provider") + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "cMAIN1", + }) + require.NoError(t, err) + require.True(t, ok, "find existing") + assert.Equal(t, dbPath+"#cMAIN1", found.DisplayPath, "find existing path") + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "cNOPE0", + }) + require.NoError(t, err) + assert.False(t, ok, "find missing") + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "../escape", + }) + require.NoError(t, err) + assert.False(t, ok, "reject path-like id") - assert.Equal(t, dbPath+"#cMAIN1", - FindShelleySourceFile(root, "cMAIN1"), "find existing") - assert.Empty(t, FindShelleySourceFile(root, "cNOPE0"), "find missing") - assert.Empty(t, FindShelleySourceFile(root, "../escape"), "reject path-like id") assert.True(t, ShelleyConversationExists(dbPath, "cMAIN1"), "exists") assert.False(t, ShelleyConversationExists(dbPath, "cNOPE0"), "not exists") - // Empty root yields no discovery and no source file. - assert.Nil(t, DiscoverShelleySessions(t.TempDir()), "empty dir discovery") + // Empty root yields no discovery. + emptyProvider, ok := NewProvider(AgentShelley, ProviderConfig{Roots: []string{t.TempDir()}}) + require.True(t, ok) + emptySources, err := emptyProvider.Discover(context.Background()) + require.NoError(t, err) + assert.Empty(t, emptySources, "empty dir discovery") } func TestParseShelleyVirtualPath(t *testing.T) { dbPath := "/home/user/.config/shelley/shelley.db" - got, id, ok := ParseShelleyVirtualPath(dbPath + "#cABC123") + got, id, ok := parseShelleyVirtualPath(dbPath + "#cABC123") require.True(t, ok, "valid virtual path") assert.Equal(t, dbPath, got, "db path") assert.Equal(t, "cABC123", id, "conversation id") - _, _, ok = ParseShelleyVirtualPath("/x/other.db#id") + _, _, ok = parseShelleyVirtualPath("/x/other.db#id") assert.False(t, ok, "wrong db name") - _, _, ok = ParseShelleyVirtualPath(dbPath) + _, _, ok = parseShelleyVirtualPath(dbPath) assert.False(t, ok, "no separator") - _, _, ok = ParseShelleyVirtualPath(dbPath + "#") + _, _, ok = parseShelleyVirtualPath(dbPath + "#") assert.False(t, ok, "empty id") } @@ -355,7 +395,7 @@ func TestParseShelleyTimestampFormats(t *testing.T) { info, err := os.Stat(dbPath) require.NoError(t, err, "stat db") - result, err := ParseShelleyConversationDirect(dbPath, "cTIME1", "m", info) + result, err := parseShelleyConversationDirectForTest(t, dbPath, "cTIME1", "m", info) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.Session.StartedAt.IsZero(), "StartedAt parsed") @@ -394,7 +434,7 @@ func TestParseShelleyRobustContent(t *testing.T) { info, err := os.Stat(dbPath) require.NoError(t, err, "stat db") - result, err := ParseShelleyConversationDirect(dbPath, "cROB1", "m", info) + result, err := parseShelleyConversationDirectForTest(t, dbPath, "cROB1", "m", info) require.NoError(t, err, "must not error on robust content") require.NotNil(t, result) @@ -431,7 +471,7 @@ func TestParseShelleyUsageOnlyRows(t *testing.T) { info, err := os.Stat(dbPath) require.NoError(t, err, "stat db") - result, err := ParseShelleyConversationDirect(dbPath, "cUSG1", "m", info) + result, err := parseShelleyConversationDirectForTest(t, dbPath, "cUSG1", "m", info) require.NoError(t, err, "must not error on usage-only row") require.NotNil(t, result) @@ -475,7 +515,7 @@ func TestParseShelleyWebSearchToolResult(t *testing.T) { info, err := os.Stat(dbPath) require.NoError(t, err, "stat db") - result, err := ParseShelleyConversationDirect(dbPath, "cWEB1", "m", info) + result, err := parseShelleyConversationDirectForTest(t, dbPath, "cWEB1", "m", info) require.NoError(t, err, "parse web search conversation") require.NotNil(t, result, "expected result") @@ -526,7 +566,7 @@ func TestShelleySameSecondChangeSignal(t *testing.T) { require.NoError(t, err, "open shelley db") defer conn.Close() - first, err := ParseShelleyConversationDirect(dbPath, "cSEC1", "m", info) + first, err := parseShelleyConversationDirectForTest(t, dbPath, "cSEC1", "m", info) require.NoError(t, err) require.NotNil(t, first) mtime1 := first.Session.File.Mtime @@ -556,7 +596,7 @@ func TestShelleySameSecondChangeSignal(t *testing.T) { `{"Role":1,"Content":[{"Type":2,"Text":"second"}]}`, "", "", "2026-06-15T10:00:00Z") - second, err := ParseShelleyConversationDirect(dbPath, "cSEC1", "m", info) + second, err := parseShelleyConversationDirectForTest(t, dbPath, "cSEC1", "m", info) require.NoError(t, err) require.NotNil(t, second) @@ -597,8 +637,8 @@ func TestShelleyNumericUserInitiatedScans(t *testing.T) { info, err := os.Stat(dbPath) require.NoError(t, err, "stat db") - result, err := ParseShelleyConversationDirect( - dbPath, "cNUM1", "m", info, + result, err := parseShelleyConversationDirectForTest( + t, dbPath, "cNUM1", "m", info, ) require.NoError(t, err) require.NotNil(t, result) diff --git a/internal/parser/types.go b/internal/parser/types.go index c997ebd24..ba2a0f6f5 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -479,16 +479,14 @@ var Registry = []AgentDef{ FileBased: true, }, { - Type: AgentZed, - DisplayName: "Zed", - EnvVar: "ZED_DIR", - ConfigKey: "zed_dirs", - DefaultDirs: zedDefaultDirs(), - IDPrefix: "zed:", - FileBased: true, - WatchSubdirs: []string{"threads"}, - DiscoverFunc: DiscoverZedSessions, - FindSourceFunc: FindZedSourceFile, + Type: AgentZed, + DisplayName: "Zed", + EnvVar: "ZED_DIR", + ConfigKey: "zed_dirs", + DefaultDirs: zedDefaultDirs(), + IDPrefix: "zed:", + FileBased: true, + WatchSubdirs: []string{"threads"}, }, { Type: AgentAntigravity, @@ -544,15 +542,13 @@ var Registry = []AgentDef{ // Shelley (exe.dev) stores all conversations in a single // SQLite DB at ~/.config/shelley/shelley.db. Like Zed, each // conversation is addressed by a virtual path (dbPath#id). - Type: AgentShelley, - DisplayName: "Shelley", - EnvVar: "SHELLEY_DIR", - ConfigKey: "shelley_dirs", - DefaultDirs: []string{".config/shelley"}, - IDPrefix: "shelley:", - FileBased: true, - DiscoverFunc: DiscoverShelleySessions, - FindSourceFunc: FindShelleySourceFile, + Type: AgentShelley, + DisplayName: "Shelley", + EnvVar: "SHELLEY_DIR", + ConfigKey: "shelley_dirs", + DefaultDirs: []string{".config/shelley"}, + IDPrefix: "shelley:", + FileBased: true, }, { Type: AgentVibe, diff --git a/internal/parser/types_test.go b/internal/parser/types_test.go index 0e834d177..d1a99a00d 100644 --- a/internal/parser/types_test.go +++ b/internal/parser/types_test.go @@ -480,24 +480,28 @@ func TestFileBasedAgentsHaveConfigKey(t *testing.T) { func TestZedRegistryEntry(t *testing.T) { def, ok := AgentByType(AgentZed) - if !ok { - t.Fatalf("AgentZed missing from Registry") - } - if !def.FileBased { - t.Fatalf("Zed FileBased = false, want true") - } - if def.EnvVar != "ZED_DIR" { - t.Fatalf("Zed EnvVar = %q", def.EnvVar) - } - if def.ConfigKey != "zed_dirs" { - t.Fatalf("Zed ConfigKey = %q", def.ConfigKey) - } - if def.IDPrefix != "zed:" { - t.Fatalf("Zed IDPrefix = %q", def.IDPrefix) - } - if def.DiscoverFunc == nil || def.FindSourceFunc == nil { - t.Fatalf("Zed discover/source funcs must be set") - } + require.True(t, ok, "AgentZed missing from Registry") + require.True(t, def.FileBased, "Zed FileBased") + assert.Equal(t, "ZED_DIR", def.EnvVar) + assert.Equal(t, "zed_dirs", def.ConfigKey) + assert.Equal(t, "zed:", def.IDPrefix) + // Zed is a migrated, provider-authoritative agent: source discovery and + // lookup live on the concrete provider, not on legacy AgentDef hooks. + require.Nil(t, def.DiscoverFunc, "Zed DiscoverFunc") + require.Nil(t, def.FindSourceFunc, "Zed FindSourceFunc") +} + +func TestShelleyRegistryEntry(t *testing.T) { + def, ok := AgentByType(AgentShelley) + require.True(t, ok, "AgentShelley missing from Registry") + require.True(t, def.FileBased, "Shelley FileBased") + assert.Equal(t, "SHELLEY_DIR", def.EnvVar) + assert.Equal(t, "shelley_dirs", def.ConfigKey) + assert.Equal(t, "shelley:", def.IDPrefix) + // Shelley is a migrated, provider-authoritative agent: source discovery + // and lookup live on the concrete provider, not on legacy AgentDef hooks. + require.Nil(t, def.DiscoverFunc, "Shelley DiscoverFunc") + require.Nil(t, def.FindSourceFunc, "Shelley FindSourceFunc") } func TestOpenCodeRegistryEntry(t *testing.T) { diff --git a/internal/parser/zed.go b/internal/parser/zed.go index 40945d1f4..5105e7b6f 100644 --- a/internal/parser/zed.go +++ b/internal/parser/zed.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "strings" "github.com/klauspost/compress/zstd" @@ -18,33 +17,6 @@ const ( zedThreadsDBRelPath = ZedThreadsDBRelPath ) -// DiscoverZedSessions discovers Zed's thread database under the -// configured data directory. -func DiscoverZedSessions(root string) []DiscoveredFile { - if root == "" { - return nil - } - path := filepath.Join(root, zedThreadsDBRelPath) - if !IsRegularFile(path) { - return nil - } - return []DiscoveredFile{{Path: path, Agent: AgentZed}} -} - -// FindZedSourceFile locates Zed's shared thread database for a raw -// session ID. Zed stores all threads in one SQLite DB, so the ID is -// validated only to reject path-like input. -func FindZedSourceFile(root, rawID string) string { - if root == "" || !IsValidSessionID(rawID) { - return "" - } - path := filepath.Join(root, zedThreadsDBRelPath) - if ZedSQLiteSessionExists(path, rawID) { - return ZedSQLiteVirtualPath(path, rawID) - } - return "" -} - // ZedSQLiteSessionExists reports whether a top-level Zed thread row // with the given ID exists in threads.db. func ZedSQLiteSessionExists(dbPath, sessionID string) bool { @@ -72,67 +44,10 @@ func ZedSQLiteSessionExists(dbPath, sessionID string) bool { return err == nil } -// ParseZedSessions parses all top-level Zed threads from a -// threads.db file. -func ParseZedSessions(dbPath, machine string) ([]ParseResult, error) { - info, err := os.Stat(dbPath) - if err != nil { - return nil, fmt.Errorf("stat %s: %w", dbPath, err) - } - - db, err := openZedDB(dbPath) - if err != nil { - return nil, err - } - defer db.Close() - - rows, err := db.Query(` - SELECT id, - COALESCE(summary, ''), - COALESCE(updated_at, ''), - COALESCE(data_type, ''), - data, - COALESCE(folder_paths, ''), - COALESCE(created_at, '') - FROM threads - WHERE COALESCE(parent_id, '') = '' - ORDER BY updated_at, id - `) - if err != nil { - return nil, fmt.Errorf("listing zed threads: %w", err) - } - defer rows.Close() - - var results []ParseResult - for rows.Next() { - var row zedThreadRow - if err := rows.Scan( - &row.id, - &row.summary, - &row.updatedAt, - &row.dataType, - &row.data, - &row.folderPaths, - &row.createdAt, - ); err != nil { - return nil, fmt.Errorf("scanning zed thread: %w", err) - } - - result, ok := buildZedParseResult(row, dbPath, info, machine) - if ok { - results = append(results, result) - } - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterating zed threads: %w", err) - } - return results, nil -} - // ZedSQLiteSourceMtime resolves the per-thread updated_at timestamp // for a virtual Zed SQLite source path. func ZedSQLiteSourceMtime(path string) (int64, error) { - dbPath, sessionID, ok := ParseZedSQLiteVirtualPath(path) + dbPath, sessionID, ok := parseZedVirtualPath(path) if !ok { return 0, fmt.Errorf("not a zed sqlite virtual path: %s", path) } @@ -166,9 +81,9 @@ type ZedThreadMeta struct { } // ListZedThreadMetas queries thread IDs and updated_at timestamps using an -// already-open connection. Used by the sync engine to check per-session mtimes -// before deciding whether to parse, sharing the same connection as the -// subsequent ParseZedThreadFromDB loop to avoid a second DB open. +// already-open connection. Used by the Zed provider to check per-session +// mtimes before deciding whether to parse, sharing the same connection as the +// subsequent parseZedThreadFromDB loop to avoid a second DB open. func ListZedThreadMetas(conn *sql.DB, dbPath string) ([]ZedThreadMeta, error) { rows, err := conn.Query( `SELECT id, COALESCE(updated_at, '') @@ -199,22 +114,6 @@ func ListZedThreadMetas(conn *sql.DB, dbPath string) ([]ZedThreadMeta, error) { return metas, rows.Err() } -// ParseZedThreadDirect parses a single top-level thread by ID without scanning -// all rows. dbInfo must be the os.FileInfo of the threads.db file itself. -func ParseZedThreadDirect( - dbPath, rawID, machine string, dbInfo os.FileInfo, -) (*ParseResult, error) { - if !IsValidSessionID(rawID) { - return nil, fmt.Errorf("invalid Zed session ID: %s", rawID) - } - conn, err := openZedDB(dbPath) - if err != nil { - return nil, err - } - defer conn.Close() - return parseZedThreadFromDB(conn, dbPath, rawID, machine, dbInfo) -} - // parseZedThreadFromDB queries and parses one thread using an already-open // connection. Callers that parse multiple threads should open the DB once and // call this in a loop to avoid repeated open/close overhead. @@ -263,14 +162,6 @@ func openZedDB(dbPath string) (*sql.DB, error) { return db, nil } -// ParseZedThreadFromDB is the exported variant of parseZedThreadFromDB for use -// by callers that open the DB once and parse multiple threads in a loop. -func ParseZedThreadFromDB( - conn *sql.DB, dbPath, rawID, machine string, dbInfo os.FileInfo, -) (*ParseResult, error) { - return parseZedThreadFromDB(conn, dbPath, rawID, machine, dbInfo) -} - type zedThreadRow struct { id string summary string diff --git a/internal/parser/zed_helpers.go b/internal/parser/zed_helpers.go index d20d43831..00c6e204d 100644 --- a/internal/parser/zed_helpers.go +++ b/internal/parser/zed_helpers.go @@ -2,7 +2,6 @@ package parser import ( "encoding/json" - "path/filepath" "sort" "strings" ) @@ -209,16 +208,3 @@ func zedRelationshipType(parentID string) RelationshipType { func ZedSQLiteVirtualPath(dbPath, sessionID string) string { return dbPath + "#" + sessionID } - -// ParseZedSQLiteVirtualPath splits a Zed virtual source path. -func ParseZedSQLiteVirtualPath(path string) (string, string, bool) { - idx := strings.LastIndex(path, "#") - if idx <= 0 || idx == len(path)-1 { - return "", "", false - } - dbPath, sessionID := path[:idx], path[idx+1:] - if filepath.Base(dbPath) != "threads.db" || !IsValidSessionID(sessionID) { - return "", "", false - } - return dbPath, sessionID, true -} diff --git a/internal/parser/zed_provider.go b/internal/parser/zed_provider.go new file mode 100644 index 000000000..9a7538d06 --- /dev/null +++ b/internal/parser/zed_provider.go @@ -0,0 +1,287 @@ +package parser + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Zed stores every thread in one shared SQLite database +// (threads/threads.db). It is a multi-session container provider: discovery +// surfaces the database as a single source and Parse fans it out into one +// session per thread, addressed by "::" virtual paths. All +// behavior is wired into the shared multi-session-container base via options. +func newZedProviderFactory(def AgentDef) ProviderFactory { + return newMultiSessionProviderFactory( + def, + zedProviderCapabilities(), + func(cfg ProviderConfig) multiSessionContainerSourceSet { + return newMultiSessionContainerSourceSet( + AgentZed, + cfg.Roots, + withContainerDiscovery(zedDiscoverContainers), + withWatchRoots(zedWatchRoots), + withChangedPathClassifier(zedClassifyPath), + withMemberLookup(zedFindMember), + withFingerprint(zedFingerprintSource), + withContainerParse(zedParseContainer), + withMemberParse(zedParseMember), + withMemberPresence(zedMemberPresent), + ) + }, + ) +} + +func zedDiscoverContainers(root string) []string { + if root == "" { + return nil + } + path := filepath.Join(root, zedThreadsDBRelPath) + if !IsRegularFile(path) { + return nil + } + return []string{path} +} + +func zedWatchRoots(roots []string) []WatchRoot { + out := make([]WatchRoot, 0, len(roots)) + for _, root := range roots { + threadsDir := filepath.Join(root, "threads") + out = append(out, WatchRoot{ + Path: threadsDir, + Recursive: false, + IncludeGlobs: []string{"threads.db", "threads.db-*"}, + DebounceKey: string(AgentZed) + ":threads:" + threadsDir, + }) + } + return out +} + +// zedClassifyPath maps a stored or changed path to its database container and +// thread, reproducing the legacy strict sourceRef / lenient +// sourceRefForChangedPath split: allowMissing relaxes the regular-file check so +// a database delete (or its WAL/SHM sibling) still classifies for tombstones. +func zedClassifyPath(root, path string, allowMissing bool) (multiSessionMatch, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + requireRegular := !allowMissing + if dbPath, sessionID, ok := parseZedVirtualPath(path); ok { + if !zedDBUnderRoot(root, dbPath, requireRegular) { + return multiSessionMatch{}, false + } + return multiSessionMatch{ + Path: path, + Container: dbPath, + MemberID: sessionID, + }, true + } + if zedDBUnderRoot(root, path, requireRegular) { + return multiSessionMatch{Path: path, Container: path}, true + } + if allowMissing { + if dbPath, ok := zedDBPathForEvent(root, path); ok { + return multiSessionMatch{Path: dbPath, Container: dbPath}, true + } + } + return multiSessionMatch{}, false +} + +func zedFindMember(root, rawID string) (multiSessionMatch, bool) { + if root == "" || !IsValidSessionID(rawID) { + return multiSessionMatch{}, false + } + path := filepath.Join(root, zedThreadsDBRelPath) + if !ZedSQLiteSessionExists(path, rawID) { + return multiSessionMatch{}, false + } + return multiSessionMatch{ + Path: ZedSQLiteVirtualPath(path, rawID), + Container: path, + MemberID: rawID, + }, true +} + +func zedFingerprintSource(src multiSessionSource) (SourceFingerprint, error) { + info, err := os.Stat(src.Container) + if err != nil { + if os.IsNotExist(err) { + return SourceFingerprint{}, nil + } + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", src.Container, err) + } + mtime := info.ModTime().UnixNano() + if src.MemberID != "" { + if sessionMtime, err := ZedSQLiteSourceMtime(src.Path); err == nil { + mtime = sessionMtime + } + } else if compositeMtime, err := sqliteDBCompositeMtime(src.Container); err == nil { + mtime = compositeMtime + } + // Zed has no cheap per-thread content digest; legacy sync stored the + // physical DB hash on virtual thread rows while per-thread updated_at + // remained the mtime freshness signal. + hash, err := hashJSONLSourceFile(src.Container) + if err != nil { + return SourceFingerprint{}, err + } + return SourceFingerprint{ + Size: info.Size(), + MTimeNS: mtime, + Hash: hash, + }, nil +} + +func zedMemberPresent(src multiSessionSource) bool { + if src.MemberID == "" { + return IsRegularFile(src.Container) + } + return ZedSQLiteSessionExists(src.Container, src.MemberID) +} + +func zedParseMember( + src multiSessionSource, req ParseRequest, +) (*ParseResult, error) { + dbInfo, err := os.Stat(src.Container) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("stat %s: %w", src.Container, err) + } + if !IsValidSessionID(src.MemberID) { + return nil, fmt.Errorf("invalid Zed session ID: %s", src.MemberID) + } + conn, err := OpenZedDB(src.Container) + if err != nil { + return nil, err + } + defer conn.Close() + return parseZedThreadFromDB( + conn, src.Container, src.MemberID, req.Machine, dbInfo, + ) +} + +func zedParseContainer( + src multiSessionSource, req ParseRequest, +) ([]ParseResult, error) { + dbInfo, err := os.Stat(src.Container) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("stat %s: %w", src.Container, err) + } + conn, err := OpenZedDB(src.Container) + if err != nil { + return nil, err + } + defer conn.Close() + metas, err := ListZedThreadMetas(conn, src.Container) + if err != nil { + return nil, err + } + // Zed has no per-thread content digest; stamp the physical DB hash on every + // fanned-out thread row, mirroring the legacy fan-out. Computed here rather + // than via the base's hash stamping because the value is the DB's own hash, + // not the request fingerprint. + dbHash, _ := hashJSONLSourceFile(src.Container) + results := make([]ParseResult, 0, len(metas)) + for _, meta := range metas { + result, err := parseZedThreadFromDB( + conn, src.Container, meta.RawID, req.Machine, dbInfo, + ) + if err != nil { + return nil, err + } + if result == nil { + continue + } + if dbHash != "" { + result.Session.File.Hash = dbHash + } + results = append(results, *result) + } + return results, nil +} + +func zedDBUnderRoot(root, dbPath string, requireRegular bool) bool { + root = filepath.Clean(root) + dbPath = filepath.Clean(dbPath) + rel, ok := relUnder(root, dbPath) + if !ok || filepath.ToSlash(rel) != "threads/threads.db" { + return false + } + return !requireRegular || IsRegularFile(dbPath) +} + +func zedDBPathForEvent(root, path string) (string, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + rel, ok := relUnder(root, path) + if !ok { + return "", false + } + relSlash := filepath.ToSlash(rel) + if relSlash == "threads/threads.db" || + (filepath.ToSlash(filepath.Dir(rel)) == "threads" && + strings.HasPrefix(filepath.Base(rel), "threads.db-")) { + return filepath.Join(root, zedThreadsDBRelPath), true + } + return "", false +} + +func sqliteDBCompositeMtime(dbPath string) (int64, error) { + var maxMtime int64 + for _, suffix := range []string{"", "-wal", "-shm"} { + info, err := os.Stat(dbPath + suffix) + if err != nil { + continue + } + if mtime := info.ModTime().UnixNano(); mtime > maxMtime { + maxMtime = mtime + } + } + if maxMtime == 0 { + return 0, &os.PathError{Op: "stat", Path: dbPath, Err: os.ErrNotExist} + } + return maxMtime, nil +} + +// parseZedVirtualPath splits a Zed virtual source path into its physical +// threads.db path and raw thread ID. The container basename must be threads.db +// and the thread ID must pass IsValidSessionID so path-like input is rejected. +func parseZedVirtualPath(path string) (string, string, bool) { + dbPath, sessionID, ok := ParseVirtualSourcePathForBase(path, "threads.db") + if !ok || !IsValidSessionID(sessionID) { + return "", "", false + } + return dbPath, sessionID, true +} + +func zedProviderCapabilities() Capabilities { + return Capabilities{ + Source: SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + IncrementalAppend: CapabilityNotApplicable, + MultiSessionSource: CapabilitySupported, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilitySupported, + }, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + SessionName: CapabilitySupported, + Cwd: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + AggregateUsageEvents: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/zed_shelley_provider_test.go b/internal/parser/zed_shelley_provider_test.go new file mode 100644 index 000000000..3519e7182 --- /dev/null +++ b/internal/parser/zed_shelley_provider_test.go @@ -0,0 +1,658 @@ +package parser + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestZedShelleyProvidersOwnLegacyEntrypoints guards the fold: the +// provider-specific Discover/Find/Parse free functions for both shared-SQLite +// providers must stay deleted, and neither provider file may reach back into +// them as a shim. Discovery and lookup live on the provider source sets; +// virtual-path resolution flows through the provider-neutral +// ParseVirtualSourcePathForBase helper; parse lives on provider methods. +func TestZedShelleyProvidersOwnLegacyEntrypoints(t *testing.T) { + zedLegacy, err := os.ReadFile("zed.go") + require.NoError(t, err) + zedHelpers, err := os.ReadFile("zed_helpers.go") + require.NoError(t, err) + zedProviderSrc, err := os.ReadFile("zed_provider.go") + require.NoError(t, err) + shelleyLegacy, err := os.ReadFile("shelley.go") + require.NoError(t, err) + shelleyProviderSrc, err := os.ReadFile("shelley_provider.go") + require.NoError(t, err) + + zedLegacyText := string(zedLegacy) + string(zedHelpers) + for _, symbol := range []string{ + "func DiscoverZedSessions", + "func FindZedSourceFile", + "func ParseZedSQLiteVirtualPath", + "func ParseZedThreadDirect", + "func ParseZedThreadFromDB", + } { + assert.NotContains(t, zedLegacyText, symbol) + assert.NotContains(t, string(zedProviderSrc), symbol) + } + for _, call := range []string{ + "DiscoverZedSessions(", + "FindZedSourceFile(", + "ParseZedSQLiteVirtualPath(", + "ParseZedThreadDirect(", + "ParseZedThreadFromDB(", + } { + assert.NotContains(t, string(zedProviderSrc), call) + } + + shelleyLegacyText := string(shelleyLegacy) + for _, symbol := range []string{ + "func DiscoverShelleySessions", + "func FindShelleySourceFile", + "func ParseShelleyConversationDirect", + "func ParseShelleyConversationFromDB", + "func ParseShelleyVirtualPath", + } { + assert.NotContains(t, shelleyLegacyText, symbol) + assert.NotContains(t, string(shelleyProviderSrc), symbol) + } + for _, call := range []string{ + "DiscoverShelleySessions(", + "FindShelleySourceFile(", + "ParseShelleyConversationDirect(", + "ParseShelleyConversationFromDB(", + "ParseShelleyVirtualPath(", + } { + assert.NotContains(t, string(shelleyProviderSrc), call) + } +} + +func TestZedProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentZed) + require.True(t, ok) + require.NotNil(t, factory) + caps := factory.Capabilities() + assert.Equal(t, CapabilityUnsupported, caps.Content.Relationships) + assert.Equal(t, CapabilitySupported, caps.Content.AggregateUsageEvents) + + provider, ok := NewProvider(AgentZed, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestZedProviderSourceMethods(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, zedThreadsDBRelPath) + threadID := "10431c84-c47b-4e6c-b2df-f9f3b9ad025b" + require.NoError(t, os.MkdirAll(filepath.Dir(dbPath), 0o755)) + createZedThreadsDBAt(t, dbPath, []zedTestThread{{ + id: threadID, + summary: "Provider thread", + createdAt: "2026-06-08T09:12:41Z", + updatedAt: "2026-06-08T09:14:10Z", + dataType: "json", + data: []byte(`{"messages":[{"User":{"content":[{"Text":"Hello Zed"}]}}]}`), + }}) + virtualPath := ZedSQLiteVirtualPath(dbPath, threadID) + + provider, ok := NewProvider(AgentZed, 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, filepath.Join(root, "threads"), plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"threads.db", "threads.db-*"}, plan.Roots[0].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, AgentZed, discovered[0].Provider) + assert.Equal(t, dbPath, discovered[0].DisplayPath) + assert.Equal(t, dbPath, discovered[0].FingerprintKey) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~zed:" + threadID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, virtualPath, found.DisplayPath) + assert.Equal(t, virtualPath, found.FingerprintKey) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, virtualPath, fingerprint.Key) + assert.Positive(t, fingerprint.Size) + assert.NotZero(t, fingerprint.MTimeNS) + assert.NotEmpty(t, fingerprint.Hash) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: dbPath + "-wal", EventKind: "write", WatchRoot: filepath.Dir(dbPath)}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, dbPath, changed[0].DisplayPath) +} + +func TestZedProviderParsePhysicalAndVirtualSources(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, zedThreadsDBRelPath) + threadOne := "10431c84-c47b-4e6c-b2df-f9f3b9ad025b" + threadTwo := "20431c84-c47b-4e6c-b2df-f9f3b9ad025b" + require.NoError(t, os.MkdirAll(filepath.Dir(dbPath), 0o755)) + createZedThreadsDBAt(t, dbPath, []zedTestThread{ + { + id: threadOne, + summary: "First thread", + createdAt: "2026-06-08T09:12:41Z", + updatedAt: "2026-06-08T09:14:10Z", + dataType: "json", + data: []byte(`{"messages":[{"User":{"content":[{"Text":"First"}]}}]}`), + }, + { + id: threadTwo, + summary: "Second thread", + createdAt: "2026-06-08T09:15:41Z", + updatedAt: "2026-06-08T09:16:10Z", + dataType: "json", + data: []byte(`{"messages":[{"User":{"content":[{"Text":"Second"}]}}]}`), + }, + }) + + provider, ok := NewProvider(AgentZed, 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) + + allOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + }) + require.NoError(t, err) + require.True(t, allOutcome.ResultSetComplete) + require.True(t, allOutcome.ForceReplace) + require.Len(t, allOutcome.Results, 2) + assert.Equal(t, "zed:"+threadOne, allOutcome.Results[0].Result.Session.ID) + assert.Equal(t, "zed:"+threadTwo, allOutcome.Results[1].Result.Session.ID) + + virtualSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: threadTwo, + }) + require.NoError(t, err) + require.True(t, ok) + oneOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: virtualSource, + }) + require.NoError(t, err) + require.True(t, oneOutcome.ResultSetComplete) + require.True(t, oneOutcome.ForceReplace) + require.Len(t, oneOutcome.Results, 1) + assert.Equal(t, "zed:"+threadTwo, oneOutcome.Results[0].Result.Session.ID) + assert.Equal(t, "devbox", oneOutcome.Results[0].Result.Session.Machine) + assert.Len(t, oneOutcome.Results[0].Result.Messages, 1) +} + +func TestZedProviderFingerprintIncludesWALSiblings(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, zedThreadsDBRelPath) + require.NoError(t, os.MkdirAll(filepath.Dir(dbPath), 0o755)) + createZedThreadsDBAt(t, dbPath, []zedTestThread{{ + id: "10431c84-c47b-4e6c-b2df-f9f3b9ad025b", + summary: "Provider thread", + updatedAt: "2026-06-08T09:14:10Z", + dataType: "json", + data: []byte(`{"messages":[{"User":{"content":[{"Text":"Hello Zed"}]}}]}`), + }}) + + provider, ok := NewProvider(AgentZed, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + before, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + walPath := dbPath + "-wal" + writeSourceFile(t, walPath, "wal") + walTime := time.Unix(0, before.MTimeNS+int64(time.Second)) + require.NoError(t, os.Chtimes(walPath, walTime, walTime)) + after, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + assert.Equal(t, before.Size, after.Size) + assert.Greater(t, after.MTimeNS, before.MTimeNS) +} + +func TestZedProviderClassifiesDeletedPhysicalDB(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, zedThreadsDBRelPath) + require.NoError(t, os.MkdirAll(filepath.Dir(dbPath), 0o755)) + createZedThreadsDBAt(t, dbPath, []zedTestThread{{ + id: "10431c84-c47b-4e6c-b2df-f9f3b9ad025b", + summary: "Provider thread", + updatedAt: "2026-06-08T09:14:10Z", + dataType: "json", + data: []byte(`{"messages":[{"User":{"content":[{"Text":"Hello Zed"}]}}]}`), + }}) + require.NoError(t, os.Remove(dbPath)) + + provider, ok := NewProvider(AgentZed, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: dbPath, EventKind: "remove", WatchRoot: filepath.Dir(dbPath)}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, dbPath, changed[0].DisplayPath) + + outcome, err := provider.Parse(context.Background(), ParseRequest{Source: changed[0]}) + require.NoError(t, err) + assert.True(t, outcome.ResultSetComplete) + assert.True(t, outcome.ForceReplace) + assert.Equal(t, SkipNoSession, outcome.SkipReason) + assert.Empty(t, outcome.Results) +} + +func TestZedProviderStoredVirtualPathFreshness(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, zedThreadsDBRelPath) + threadID := "10431c84-c47b-4e6c-b2df-f9f3b9ad025b" + require.NoError(t, os.MkdirAll(filepath.Dir(dbPath), 0o755)) + createZedThreadsDBAt(t, dbPath, []zedTestThread{{ + id: threadID, + summary: "Provider thread", + updatedAt: "2026-06-08T09:14:10Z", + dataType: "json", + data: []byte(`{"messages":[{"User":{"content":[{"Text":"Hello Zed"}]}}]}`), + }}) + virtualPath := ZedSQLiteVirtualPath(dbPath, threadID) + + provider, ok := NewProvider(AgentZed, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: virtualPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, virtualPath, found.DisplayPath) + + db, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + _, err = db.Exec(`DELETE FROM threads WHERE id = ?`, threadID) + require.NoError(t, err) + require.NoError(t, db.Close()) + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: virtualPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "fresh lookup must reject a deleted virtual row") + + staleSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: virtualPath, + }) + require.NoError(t, err) + require.True(t, ok, "non-fresh lookup keeps tombstone source identity") + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: staleSource, + }) + require.NoError(t, err) + assert.True(t, outcome.ResultSetComplete) + assert.True(t, outcome.ForceReplace) + assert.Equal(t, SkipNoSession, outcome.SkipReason) + assert.Empty(t, outcome.Results) +} + +func TestZedProviderRejectsInvalidStoredVirtualPaths(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, zedThreadsDBRelPath) + threadID := "10431c84-c47b-4e6c-b2df-f9f3b9ad025b" + require.NoError(t, os.MkdirAll(filepath.Dir(dbPath), 0o755)) + createZedThreadsDBAt(t, dbPath, []zedTestThread{{ + id: threadID, + summary: "Provider thread", + updatedAt: "2026-06-08T09:14:10Z", + dataType: "json", + data: []byte(`{"messages":[{"User":{"content":[{"Text":"Hello Zed"}]}}]}`), + }}) + + provider, ok := NewProvider(AgentZed, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + for _, path := range []string{ + dbPath + "#", + filepath.Join(root, "threads", "threads-copy.db") + "#" + threadID, + filepath.Join(root, "debug", "threads.db") + "#" + threadID, + } { + _, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: path, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "stored path %q", path) + } +} + +func TestZedProviderIgnoresUnrelatedSidecarBasename(t *testing.T) { + root := t.TempDir() + provider, ok := NewProvider(AgentZed, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "other", "threads.db-wal"), + EventKind: "remove", + WatchRoot: filepath.Join(root, "other"), + }, + ) + require.NoError(t, err) + assert.Empty(t, changed) +} + +func TestShelleyProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentShelley) + require.True(t, ok) + require.NotNil(t, factory) + caps := factory.Capabilities() + assert.Equal(t, CapabilitySupported, caps.Content.Relationships) + assert.Equal(t, CapabilityUnsupported, caps.Content.AggregateUsageEvents) + + provider, ok := NewProvider(AgentShelley, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestShelleyProviderSourceMethods(t *testing.T) { + root, dbPath, db := newShelleyTestDB(t) + seedShelleyMainConversation(t, db) + virtualPath := ShelleyVirtualPath(dbPath, "cMAIN1") + + provider, ok := NewProvider(AgentShelley, 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.False(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{shelleyDBName, shelleyDBName + "-*"}, plan.Roots[0].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, AgentShelley, discovered[0].Provider) + assert.Equal(t, dbPath, discovered[0].DisplayPath) + assert.Equal(t, dbPath, discovered[0].FingerprintKey) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~shelley:cMAIN1", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, virtualPath, found.DisplayPath) + assert.Equal(t, virtualPath, found.FingerprintKey) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, virtualPath, fingerprint.Key) + assert.Positive(t, fingerprint.Size) + assert.NotZero(t, fingerprint.MTimeNS) + assert.NotEmpty(t, fingerprint.Hash) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: dbPath + "-shm", EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, dbPath, changed[0].DisplayPath) +} + +func TestShelleyProviderParsePhysicalAndVirtualSources(t *testing.T) { + root, dbPath, db := newShelleyTestDB(t) + seedShelleyMainConversation(t, db) + seedShelleyConversation( + t, db, "cAUX1", "Auxiliary", "/home/user/dev/aux", + "claude-sonnet-4-6", "", true, + "2026-06-15T11:00:00Z", "2026-06-15T11:03:00Z", + ) + seedShelleyMessage(t, db, "cAUX1", 1, 1, "user", + `{"Role":0,"Content":[{"Type":2,"Text":"Aux request"}]}`, + "", "", "2026-06-15T11:00:00Z") + + provider, ok := NewProvider(AgentShelley, 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) + + allOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + }) + require.NoError(t, err) + require.True(t, allOutcome.ResultSetComplete) + require.True(t, allOutcome.ForceReplace) + require.Len(t, allOutcome.Results, 2) + assert.Equal(t, "shelley:cAUX1", allOutcome.Results[0].Result.Session.ID) + assert.Equal(t, "shelley:cMAIN1", allOutcome.Results[1].Result.Session.ID) + + virtualSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: ShelleyVirtualPath(dbPath, "cMAIN1"), + }) + require.NoError(t, err) + require.True(t, ok) + oneOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: virtualSource, + }) + require.NoError(t, err) + require.True(t, oneOutcome.ResultSetComplete) + require.True(t, oneOutcome.ForceReplace) + require.Len(t, oneOutcome.Results, 1) + assert.Equal(t, "shelley:cMAIN1", oneOutcome.Results[0].Result.Session.ID) + assert.Equal(t, "devbox", oneOutcome.Results[0].Result.Session.Machine) + assert.Len(t, oneOutcome.Results[0].Result.Messages, 5) +} + +func TestShelleyProviderFingerprintChangesForSameSecondRewrite(t *testing.T) { + root, _, db := newShelleyTestDB(t) + seedShelleyMainConversation(t, db) + + provider, ok := NewProvider(AgentShelley, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "cMAIN1", + }) + require.NoError(t, err) + require.True(t, ok) + + before, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + + _, err = db.Exec( + `UPDATE messages + SET llm_data = ? + WHERE conversation_id = ? AND sequence_id = ?`, + `{"Role":1,"Content":[{"Type":2,"Text":"Changed content."}]}`, + "cMAIN1", + 4, + ) + require.NoError(t, err) + after, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + + assert.Equal(t, before.MTimeNS, after.MTimeNS) + assert.NotEqual(t, before.Hash, after.Hash) +} + +func TestShelleyProviderFingerprintIncludesWALSiblings(t *testing.T) { + root, dbPath, db := newShelleyTestDB(t) + seedShelleyMainConversation(t, db) + + provider, ok := NewProvider(AgentShelley, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + before, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + walPath := dbPath + "-wal" + writeSourceFile(t, walPath, "wal") + walTime := time.Unix(0, before.MTimeNS+int64(time.Second)) + require.NoError(t, os.Chtimes(walPath, walTime, walTime)) + after, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + assert.Equal(t, before.Size, after.Size) + assert.Greater(t, after.MTimeNS, before.MTimeNS) +} + +func TestShelleyProviderClassifiesDeletedVirtualPath(t *testing.T) { + root, dbPath, db := newShelleyTestDB(t) + seedShelleyMainConversation(t, db) + virtualPath := ShelleyVirtualPath(dbPath, "cMAIN1") + require.NoError(t, os.Remove(dbPath)) + + provider, ok := NewProvider(AgentShelley, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: virtualPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, virtualPath, changed[0].DisplayPath) +} + +func TestShelleyProviderClassifiesDeletedPhysicalDB(t *testing.T) { + root, dbPath, db := newShelleyTestDB(t) + seedShelleyMainConversation(t, db) + require.NoError(t, os.Remove(dbPath)) + + provider, ok := NewProvider(AgentShelley, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: dbPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, dbPath, changed[0].DisplayPath) + + outcome, err := provider.Parse(context.Background(), ParseRequest{Source: changed[0]}) + require.NoError(t, err) + assert.True(t, outcome.ResultSetComplete) + assert.True(t, outcome.ForceReplace) + assert.Equal(t, SkipNoSession, outcome.SkipReason) + assert.Empty(t, outcome.Results) +} + +func TestShelleyProviderStoredVirtualPathFreshness(t *testing.T) { + root, dbPath, db := newShelleyTestDB(t) + seedShelleyMainConversation(t, db) + virtualPath := ShelleyVirtualPath(dbPath, "cMAIN1") + + provider, ok := NewProvider(AgentShelley, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: virtualPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, virtualPath, found.DisplayPath) + + _, err = db.Exec(`DELETE FROM messages WHERE conversation_id = ?`, "cMAIN1") + require.NoError(t, err) + _, err = db.Exec(`DELETE FROM conversations WHERE conversation_id = ?`, "cMAIN1") + require.NoError(t, err) + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: virtualPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "fresh lookup must reject a deleted virtual row") + + staleSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: virtualPath, + }) + require.NoError(t, err) + require.True(t, ok, "non-fresh lookup keeps tombstone source identity") + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: staleSource, + }) + require.NoError(t, err) + assert.True(t, outcome.ResultSetComplete) + assert.True(t, outcome.ForceReplace) + assert.Equal(t, SkipNoSession, outcome.SkipReason) + assert.Empty(t, outcome.Results) +} + +func TestShelleyProviderRejectsInvalidStoredVirtualPaths(t *testing.T) { + root, dbPath, db := newShelleyTestDB(t) + seedShelleyMainConversation(t, db) + + provider, ok := NewProvider(AgentShelley, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + for _, path := range []string{ + dbPath + "#", + filepath.Join(root, "shelley-debug.db") + "#cMAIN1", + filepath.Join(root, "nested", shelleyDBName) + "#cMAIN1", + } { + _, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: path, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "stored path %q", path) + } +} + +func TestShelleyProviderIgnoresUnrelatedSidecarBasename(t *testing.T) { + root := t.TempDir() + provider, ok := NewProvider(AgentShelley, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "nested", shelleyDBName+"-wal"), + EventKind: "remove", + WatchRoot: filepath.Join(root, "nested"), + }, + ) + require.NoError(t, err) + assert.Empty(t, changed) +} diff --git a/internal/parser/zed_test.go b/internal/parser/zed_test.go index 0d3e20b0a..99e634442 100644 --- a/internal/parser/zed_test.go +++ b/internal/parser/zed_test.go @@ -2,6 +2,7 @@ package parser import ( "database/sql" + "fmt" "os" "path/filepath" "testing" @@ -10,62 +11,36 @@ import ( _ "github.com/mattn/go-sqlite3" ) -func TestDiscoverZedSessions(t *testing.T) { - root := t.TempDir() - dbPath := filepath.Join(root, zedThreadsDBRelPath) - if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dbPath, []byte("sqlite"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - - files := DiscoverZedSessions(root) - if len(files) != 1 { - t.Fatalf("len(files) = %d, want 1", len(files)) - } - if files[0].Path != dbPath || files[0].Agent != AgentZed { - t.Fatalf("file = %+v, want %s / %s", files[0], dbPath, AgentZed) - } - - if files := DiscoverZedSessions(""); files != nil { - t.Fatalf("DiscoverZedSessions(empty) = %v, want nil", files) - } - if files := DiscoverZedSessions(filepath.Join(root, "missing")); files != nil { - t.Fatalf("DiscoverZedSessions(missing) = %v, want nil", files) +// parseZedAll parses every top-level thread in a Zed threads.db using the same +// per-thread primitives the provider uses (ListZedThreadMetas + +// parseZedThreadFromDB), reproducing the deleted ParseZedSessions +// whole-database free function for the retained parse tests. +func parseZedAll(dbPath, machine string) ([]ParseResult, error) { + info, err := os.Stat(dbPath) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", dbPath, err) } -} - -func TestFindZedSourceFile(t *testing.T) { - root := t.TempDir() - dbPath := filepath.Join(root, zedThreadsDBRelPath) - if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { - t.Fatal(err) + conn, err := OpenZedDB(dbPath) + if err != nil { + return nil, err } - threadID := "10431c84-c47b-4e6c-b2df-f9f3b9ad025b" - createZedThreadsDBAt(t, dbPath, []zedTestThread{{ - id: threadID, - dataType: "json", - data: []byte(`{"messages":[]}`), - }}) + defer conn.Close() - got := FindZedSourceFile(root, threadID) - want := ZedSQLiteVirtualPath(dbPath, threadID) - if got != want { - t.Fatalf("FindZedSourceFile() = %q, want %q", got, want) - } - if got := FindZedSourceFile(root, "../bad"); got != "" { - t.Fatalf("FindZedSourceFile(invalid) = %q, want empty", got) - } - if got := FindZedSourceFile(root, "missing"); got != "" { - t.Fatalf("FindZedSourceFile(missing) = %q, want empty", got) + metas, err := ListZedThreadMetas(conn, dbPath) + if err != nil { + return nil, err } - if got := FindZedSourceFile(filepath.Join(root, "missing"), threadID); got != "" { - t.Fatalf("FindZedSourceFile(missing root) = %q, want empty", got) + var out []ParseResult + for _, m := range metas { + result, err := parseZedThreadFromDB(conn, dbPath, m.RawID, machine, info) + if err != nil { + return nil, err + } + if result != nil { + out = append(out, *result) + } } + return out, nil } func TestParseZedSessions_JSON(t *testing.T) { @@ -93,7 +68,7 @@ func TestParseZedSessions_JSON(t *testing.T) { }`), }}) - results, err := ParseZedSessions(dbPath, "local") + results, err := parseZedAll(dbPath, "local") if err != nil { t.Fatalf("ParseZedSessions: %v", err) } @@ -257,7 +232,7 @@ func TestParseZedSessions_ZstdAndFiltersChildren(t *testing.T) { }, }) - results, err := ParseZedSessions(dbPath, "local") + results, err := parseZedAll(dbPath, "local") if err != nil { t.Fatalf("ParseZedSessions: %v", err) } @@ -275,7 +250,7 @@ func TestParseZedSessions_SkipsUnsupportedDataType(t *testing.T) { dataType: "brotli", data: []byte("x"), }}) - results, err := ParseZedSessions(dbPath, "local") + results, err := parseZedAll(dbPath, "local") if err != nil { t.Fatalf("ParseZedSessions: %v", err) } @@ -284,15 +259,15 @@ func TestParseZedSessions_SkipsUnsupportedDataType(t *testing.T) { } } -func TestParseZedSQLiteVirtualPath(t *testing.T) { +func TestParseZedVirtualPath(t *testing.T) { dbPath := filepath.Join("/tmp", "with#hash", "threads.db") virtual := ZedSQLiteVirtualPath(dbPath, "sess-1") - gotDB, gotID, ok := ParseZedSQLiteVirtualPath(virtual) + gotDB, gotID, ok := parseZedVirtualPath(virtual) if !ok || gotDB != dbPath || gotID != "sess-1" { - t.Fatalf("ParseZedSQLiteVirtualPath = (%q, %q, %v)", gotDB, gotID, ok) + t.Fatalf("parseZedVirtualPath = (%q, %q, %v)", gotDB, gotID, ok) } - if _, _, ok := ParseZedSQLiteVirtualPath("/tmp/not-db#sess-1"); ok { - t.Fatal("ParseZedSQLiteVirtualPath accepted non-threads DB") + if _, _, ok := parseZedVirtualPath("/tmp/not-db#sess-1"); ok { + t.Fatal("parseZedVirtualPath accepted non-threads DB") } } diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 940f6f0db..607389871 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -927,21 +927,15 @@ 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 (Kiro, Zed, -// Shelley, and Vibe). Split out of classifyOnePath to keep that function -// within NilAway's per-function CFG-block limit. +// resolve a path whether or not it currently exists on disk (Kiro and Vibe). +// Split out of classifyOnePath to keep that function within NilAway's +// per-function CFG-block limit. func (e *Engine) classifyContainerPath( path string, pathExists bool, ) (parser.DiscoveredFile, bool) { if df, ok := e.classifyKiroSQLitePath(path); ok { return df, true } - if df, ok := e.classifyZedSQLitePath(path); ok { - return df, true - } - if df, ok := e.classifyShelleySQLitePath(path); ok { - return df, true - } return parser.DiscoveredFile{}, false } @@ -1253,95 +1247,12 @@ func (e *Engine) classifyKiroSQLitePath( return parser.DiscoveredFile{}, false } -func (e *Engine) classifyZedSQLitePath( - path string, -) (parser.DiscoveredFile, bool) { - // Virtual path: threads.db# - if dbPath, _, ok := parser.ParseZedSQLiteVirtualPath(path); ok { - for _, zedDir := range e.agentDirs[parser.AgentZed] { - if _, under := isUnder(zedDir, dbPath); under { - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentZed, - }, true - } - } - } - // Real path: threads/threads.db or WAL/SHM siblings. - // Handled here (before the !pathExists guard) so that delete - // and rename events on threads.db-wal / threads.db-shm are - // not dropped when the sibling no longer exists on disk. - zedDBRel := filepath.Join("threads", "threads.db") - for _, zedDir := range e.agentDirs[parser.AgentZed] { - if zedDir == "" { - continue - } - rel, ok := isUnder(zedDir, path) - if !ok { - continue - } - base := filepath.Base(rel) - if rel != zedDBRel && !strings.HasPrefix(base, "threads.db-") { - continue - } - dbPath := filepath.Join(zedDir, zedDBRel) - if fi, err := os.Stat(dbPath); err == nil && !fi.IsDir() { - return parser.DiscoveredFile{ - Path: dbPath, - Agent: parser.AgentZed, - }, true - } - } - return parser.DiscoveredFile{}, false -} - +// shelleyDBFile is the shared Shelley conversation database basename. Zed and +// Shelley are provider-authoritative, so their changed-path classification and +// parse run through the provider facade; this constant remains for the +// provider-neutral physical-DB deletion and skip-cache checks in the engine. const shelleyDBFile = "shelley.db" -// classifyShelleySQLitePath classifies a Shelley source path. Shelley -// stores every conversation in a single shelley.db under its config -// directory, so paths are either a virtual conversation path -// (shelley.db#) or the real DB file and its WAL/SHM siblings. -func (e *Engine) classifyShelleySQLitePath( - path string, -) (parser.DiscoveredFile, bool) { - // Virtual path: shelley.db# - if dbPath, _, ok := parser.ParseShelleyVirtualPath(path); ok { - for _, dir := range e.agentDirs[parser.AgentShelley] { - if _, under := isUnder(dir, dbPath); under { - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentShelley, - }, true - } - } - } - // Real path: shelley.db or its WAL/SHM siblings. Handled here - // (before the !pathExists guard) so that delete and rename events - // on shelley.db-wal / shelley.db-shm are not dropped when the - // sibling no longer exists on disk. - for _, dir := range e.agentDirs[parser.AgentShelley] { - if dir == "" { - continue - } - rel, ok := isUnder(dir, path) - if !ok { - continue - } - base := filepath.Base(rel) - if rel != shelleyDBFile && !strings.HasPrefix(base, shelleyDBFile+"-") { - continue - } - dbPath := filepath.Join(dir, shelleyDBFile) - if fi, err := os.Stat(dbPath); err == nil && !fi.IsDir() { - return parser.DiscoveredFile{ - Path: dbPath, - Agent: parser.AgentShelley, - }, true - } - } - return parser.DiscoveredFile{}, false -} - // resyncTempSuffix is appended to the original DB path to // form the temp database path during resync. const resyncTempSuffix = "-resync" @@ -2868,14 +2779,14 @@ func discoveredFileMtime( } if file.Agent == parser.AgentZed { dbPath := file.Path - if p, _, ok := parser.ParseZedSQLiteVirtualPath(file.Path); ok { + if p, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, "threads.db"); ok { dbPath = p } return zedDBCompositeMtime(dbPath) } if file.Agent == parser.AgentShelley { dbPath := file.Path - if p, _, ok := parser.ParseShelleyVirtualPath(file.Path); ok { + if p, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, shelleyDBFile); ok { dbPath = p } return shelleyDBCompositeMtime(dbPath) @@ -3648,9 +3559,9 @@ func (e *Engine) processFile( statPath := file.Path if dbPath, _, ok := parser.ParseKiroSQLiteVirtualPath(file.Path); ok { statPath = dbPath - } else if dbPath, _, ok := parser.ParseZedSQLiteVirtualPath(file.Path); ok { + } else if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, "threads.db"); ok { statPath = dbPath - } else if dbPath, _, ok := parser.ParseShelleyVirtualPath(file.Path); ok { + } else if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, shelleyDBFile); ok { statPath = dbPath } else if historyPath, _, ok := parser.ParseAiderVirtualPath(file.Path); ok { // aider stores "#"; stat the physical file @@ -3660,6 +3571,11 @@ func (e *Engine) processFile( info, err = os.Stat(statPath) } if err != nil { + if os.IsNotExist(err) && + file.ForceParse && + providerDeletedPhysicalSQLiteSource(file.Agent, file.Path) { + return processResult{forceReplace: true} + } return processResult{ err: fmt.Errorf("stat %s: %w", file.Path, err), } @@ -3718,10 +3634,6 @@ func (e *Engine) processFile( res = e.processKiro(file, info) case parser.AgentKiroIDE: res = e.processKiroIDE(file, info) - case parser.AgentZed: - res = e.processZed(file, info) - case parser.AgentShelley: - res = e.processShelley(file, info) case parser.AgentAntigravity: res = e.processAntigravity(file, info) case parser.AgentAntigravityCLI: @@ -3800,6 +3712,15 @@ func (e *Engine) processProviderFile( return processResult{err: err}, true } if !found { + // A forced parse on a deleted shared SQLite database (Zed, Shelley) + // resolves to no source because the physical file is gone. Mirror the + // legacy deleted-source handling: complete the source as an empty + // force-replace so the engine retires every session that lived in the + // removed database instead of failing the sync. + if file.ForceParse && + providerDeletedPhysicalSQLiteSource(file.Agent, file.Path) { + return processResult{forceReplace: true}, true + } return processResult{ err: fmt.Errorf( "%s provider source not found for %s", @@ -3841,6 +3762,17 @@ func (e *Engine) processProviderFile( fingerprint, err := provider.Fingerprint(ctx, source) if err != nil { + if file.ForceParse && + providerDeletedPhysicalSQLiteSource(file.Agent, file.Path) && + errors.Is(err, os.ErrNotExist) { + return processResult{ + excludedSessionIDs: e.providerSourceSessionIDsForForceReplace( + file.Agent, + source, + ), + forceReplace: true, + }, true + } return processResult{err: err}, true } cacheKey := providerProcessCacheKey(file, source, fingerprint) @@ -3932,17 +3864,26 @@ func (e *Engine) processProviderFile( } cleanCache := providerOutcomeAllowsCleanSkipCache(outcome) if outcome.SkipReason != parser.SkipNone { + excludedSessionIDs := append([]string(nil), outcome.ExcludedSessionIDs...) + if outcome.ForceReplace && outcome.ResultSetComplete { + excludedSessionIDs = append( + excludedSessionIDs, + e.providerSourceSessionIDsForForceReplace(file.Agent, source)..., + ) + } return processResult{ - skip: true, - mtime: fingerprint.MTimeNS, - cacheSkip: cacheSkip, - cacheKey: cacheKey, - noCacheSkip: !cleanCache, + skip: !outcome.ForceReplace, + excludedSessionIDs: excludedSessionIDs, + mtime: fingerprint.MTimeNS, + cacheSkip: cacheSkip, + cacheKey: cacheKey, + noCacheSkip: !cleanCache, + forceReplace: outcome.ForceReplace, }, true } res := processResult{ - results: parseOutcomeResults(outcome.Results), + results: e.dropUnchangedSharedSQLiteResults(file, parseOutcomeResults(outcome.Results)), excludedSessionIDs: append([]string(nil), outcome.ExcludedSessionIDs...), mtime: fingerprint.MTimeNS, cacheSkip: cacheSkip, @@ -3978,6 +3919,104 @@ func (e *Engine) processProviderFile( return res, true } +// dropUnchangedSharedSQLiteResults reproduces the legacy per-session skip the +// folded processZed/processShelley loops performed. Zed and Shelley keep every +// session in one shared SQLite database, so the provider re-parses every +// session on any database change. Without a per-session filter the engine would +// rewrite and recount unchanged sessions. This drops results whose stored +// file_mtime (and, for Shelley's second-precision timestamps, the content +// fingerprint stored in file_hash) and data_version already match, using the +// path rewriter so remote stored paths resolve. Force-parse runs (parse-diff, +// single-session resync) keep every result so they always re-emit. +func (e *Engine) dropUnchangedSharedSQLiteResults( + file parser.DiscoveredFile, + results []parser.ParseResult, +) []parser.ParseResult { + if e.forceParse || file.ForceParse || len(results) == 0 { + return results + } + compareHash := false + switch file.Agent { + case parser.AgentShelley: + compareHash = true + case parser.AgentZed: + default: + return results + } + + kept := results[:0] + for _, r := range results { + path := r.Session.File.Path + if path == "" { + kept = append(kept, r) + continue + } + lookupPath := path + if e.pathRewriter != nil { + lookupPath = e.pathRewriter(path) + } + _, storedMtime, ok := e.db.GetFileInfoByPath(lookupPath) + if !ok || storedMtime != r.Session.File.Mtime { + kept = append(kept, r) + continue + } + if compareHash { + storedHash, _ := e.db.GetFileHashByPath(lookupPath) + if storedHash != r.Session.File.Hash { + kept = append(kept, r) + continue + } + } + if e.db.GetDataVersionByPath(lookupPath) < db.CurrentDataVersion() { + kept = append(kept, r) + continue + } + // Unchanged: drop so the write batch neither rewrites nor recounts it. + } + return kept +} + +func (e *Engine) providerSourceSessionIDsForForceReplace( + agent parser.AgentType, + source parser.SourceRef, +) []string { + root := "" + for _, candidate := range []string{source.DisplayPath, source.FingerprintKey, source.Key} { + if candidate != "" { + root = candidate + break + } + } + if root == "" { + return nil + } + if e.pathRewriter != nil { + root = e.pathRewriter(root) + } + sourcePaths, err := e.db.ListStoredSourcePathHints(string(agent), []string{root}) + if err != nil { + log.Printf("list provider force-replace source hints: %v", err) + return nil + } + seen := make(map[string]struct{}) + var ids []string + for _, sourcePath := range sourcePaths { + pathIDs, err := e.db.ListSessionIDsByFilePath(sourcePath, string(agent)) + if err != nil { + log.Printf("list provider force-replace sessions: %v", err) + continue + } + for _, id := range pathIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + } + return ids +} + // 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 @@ -4272,7 +4311,7 @@ func (e *Engine) shouldCacheSkip( if filepath.Base(file.Path) == "threads.db" { return false } - if _, _, ok := parser.ParseZedSQLiteVirtualPath(file.Path); ok { + if _, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, "threads.db"); ok { return false } } @@ -4280,7 +4319,7 @@ func (e *Engine) shouldCacheSkip( if filepath.Base(file.Path) == shelleyDBFile { return false } - if _, _, ok := parser.ParseShelleyVirtualPath(file.Path); ok { + if _, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, shelleyDBFile); ok { return false } } @@ -5351,158 +5390,6 @@ func reasonixEffectiveInfo(path string, info os.FileInfo) os.FileInfo { return fakeSnapshotInfo{fSize: size, fMtime: mtime} } -func (e *Engine) processZed( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if dbPath, sessionID, ok := parser.ParseZedSQLiteVirtualPath(file.Path); ok { - result, err := parser.ParseZedThreadDirect( - dbPath, sessionID, e.machine, info, - ) - if err != nil { - return processResult{err: err} - } - if result == nil { - return processResult{} - } - if hash, err := ComputeFileHash(dbPath); err == nil { - result.Session.File.Hash = hash - } - return processResult{ - results: []parser.ParseResult{*result}, - forceReplace: true, - } - } - conn, err := parser.OpenZedDB(file.Path) - if err != nil { - return processResult{err: err} - } - defer conn.Close() - - metas, err := parser.ListZedThreadMetas(conn, file.Path) - if err != nil { - return processResult{err: err} - } - - hash, _ := ComputeFileHash(file.Path) - - var results []parser.ParseResult - var sessionErrs []sessionParseError - for _, meta := range metas { - _, storedMtime, ok := e.db.GetFileInfoByPath(meta.VirtualPath) - // parse-diff: !e.forceParse disables the stored-state skip. - if !e.forceParse && ok && storedMtime == meta.FileMtime && - e.db.GetDataVersionByPath(meta.VirtualPath) >= - db.CurrentDataVersion() { - continue - } - result, err := parser.ParseZedThreadFromDB( - conn, file.Path, meta.RawID, e.machine, info, - ) - if err != nil { - if e.forceParse { - sessionErrs = append(sessionErrs, sessionParseError{ - sessionID: meta.RawID, - virtualPath: meta.VirtualPath, - err: err, - }) - } else { - log.Printf("zed thread %s: %v", meta.RawID, err) - } - continue - } - if result == nil { - continue - } - if hash != "" { - result.Session.File.Hash = hash - } - results = append(results, *result) - } - return processResult{ - results: results, - sessionErrs: sessionErrs, - forceReplace: true, - } -} - -func (e *Engine) processShelley( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if dbPath, sessionID, ok := parser.ParseShelleyVirtualPath(file.Path); ok { - result, err := parser.ParseShelleyConversationDirect( - dbPath, sessionID, e.machine, info, - ) - if err != nil { - return processResult{err: err} - } - if result == nil { - return processResult{} - } - // File.Hash is the parser's per-conversation content fingerprint; - // the whole-db hash would be identical across conversations and is - // not used for Shelley change detection. - return processResult{ - results: []parser.ParseResult{*result}, - forceReplace: true, - } - } - conn, err := parser.OpenShelleyDB(file.Path) - if err != nil { - return processResult{err: err} - } - defer conn.Close() - - metas, err := parser.ListShelleyConversationMetas(conn, file.Path) - if err != nil { - return processResult{err: err} - } - - var results []parser.ParseResult - var sessionErrs []sessionParseError - for _, meta := range metas { - lookupPath := meta.VirtualPath - if e.pathRewriter != nil { - lookupPath = e.pathRewriter(lookupPath) - } - _, storedMtime, ok := e.db.GetFileInfoByPath(lookupPath) - storedHash, _ := e.db.GetFileHashByPath(lookupPath) - // parse-diff: !e.forceParse disables the stored-state skip. - // FileMtime alone has second precision, so the content fingerprint - // (stored in file_hash) catches same-second appends and in-place - // rewrites; see shelleyChangeMtime in the parser. - if !e.forceParse && ok && storedMtime == meta.FileMtime && - storedHash == meta.Fingerprint && - e.db.GetDataVersionByPath(lookupPath) >= - db.CurrentDataVersion() { - continue - } - result, err := parser.ParseShelleyConversationFromDB( - conn, file.Path, meta.RawID, e.machine, info, - ) - if err != nil { - if e.forceParse { - sessionErrs = append(sessionErrs, sessionParseError{ - sessionID: meta.RawID, - virtualPath: meta.VirtualPath, - err: err, - }) - } else { - log.Printf("shelley conversation %s: %v", meta.RawID, err) - } - continue - } - if result == nil { - continue - } - results = append(results, *result) - } - return processResult{ - results: results, - sessionErrs: sessionErrs, - forceReplace: true, - } -} - func (e *Engine) processKiro( file parser.DiscoveredFile, info os.FileInfo, ) processResult { @@ -7836,7 +7723,7 @@ func (e *Engine) SourceMtime(sessionID string) int64 { } } if def.Type == parser.AgentZed { - if _, _, ok := parser.ParseZedSQLiteVirtualPath(path); ok { + if _, _, ok := parser.ParseVirtualSourcePathForBase(path, "threads.db"); ok { mtime, err := parser.ZedSQLiteSourceMtime(path) if err != nil { return 0 @@ -7845,7 +7732,7 @@ func (e *Engine) SourceMtime(sessionID string) int64 { } } if def.Type == parser.AgentShelley { - if _, _, ok := parser.ParseShelleyVirtualPath(path); ok { + if _, _, ok := parser.ParseVirtualSourcePathForBase(path, shelleyDBFile); ok { mtime, err := parser.ShelleySourceMtime(path) if err != nil { return 0 @@ -7971,15 +7858,6 @@ func (e *Engine) SyncSingleSessionContext( } } - if def.Type == parser.AgentZed { - err = e.syncSingleZed(sessionID) - if errors.Is(err, errSessionPreserved) { - preserved = true - return nil - } - return err - } - path := e.FindSourceFile(sessionID) if path == "" { return fmt.Errorf( @@ -8265,56 +8143,6 @@ func (e *Engine) syncSingleKiroSQLite( return fmt.Errorf("kiro sqlite session %s not found", sessionID) } -func (e *Engine) syncSingleZed(sessionID string) error { - rawID := strings.TrimPrefix(sessionID, "zed:") - - var lastErr error - for _, dir := range e.agentDirs[parser.AgentZed] { - dbPath := filepath.Join(dir, parser.ZedThreadsDBRelPath) - if !parser.IsRegularFile(dbPath) { - continue - } - info, err := os.Stat(dbPath) - if err != nil { - lastErr = err - continue - } - result, err := parser.ParseZedThreadDirect(dbPath, rawID, e.machine, info) - if err != nil { - lastErr = err - continue - } - if result == nil { - continue - } - if hash, err := ComputeFileHash(dbPath); err == nil { - result.Session.File.Hash = hash - } - pw := pendingWrite{ - sess: result.Session, - msgs: result.Messages, - usageEvents: result.UsageEvents, - forceReplace: true, - } - if err := e.writeSessionFull(pw); err != nil && - !isIntentionalSessionSkip(err) && - !errors.Is(err, errSessionPreserved) { - return fmt.Errorf("write session %s: %w", result.Session.ID, err) - } else if errors.Is(err, errSessionPreserved) { - return err - } - return nil - } - - if len(e.agentDirs[parser.AgentZed]) == 0 { - return fmt.Errorf("zed dir not configured") - } - if lastErr != nil { - return fmt.Errorf("zed session %s: %w", sessionID, lastErr) - } - return fmt.Errorf("zed session %s not found", sessionID) -} - func isKiroSQLiteVirtualPath(path string) bool { _, _, ok := parser.ParseKiroSQLiteVirtualPath(path) return ok diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index c357bbe96..754de22e2 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -3185,6 +3185,101 @@ func TestEngine_ClassifyPathsOpenCodeFamilyRemovedSessionFile( } } +func TestEngine_ClassifyPathsProviderRemoveKeepsDeletedSQLiteSources( + t *testing.T, +) { + tests := []struct { + name string + agent parser.AgentType + path func(string) string + }{ + { + name: "zed", + agent: parser.AgentZed, + path: func(root string) string { + return filepath.Join(root, "threads", "threads.db") + }, + }, + { + name: "shelley", + agent: parser.AgentShelley, + path: func(root string) string { + return filepath.Join(root, shelleyDBFile) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := openTestDB(t) + root := t.TempDir() + engine := NewEngine(db, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + tt.agent: {root}, + }, + Machine: "local", + }) + dbPath := tt.path(root) + require.NoFileExists(t, dbPath) + + files := engine.classifyPaths([]string{dbPath}) + require.Len(t, files, 1) + assert.Equal(t, dbPath, files[0].Path) + assert.Equal(t, tt.agent, files[0].Agent) + assert.True(t, files[0].ForceParse) + }) + } +} + +func TestEngine_ProcessFileProviderDeletedSQLiteSourcesDoNotFail( + t *testing.T, +) { + tests := []struct { + name string + agent parser.AgentType + path func(string) string + }{ + { + name: "zed", + agent: parser.AgentZed, + path: func(root string) string { + return filepath.Join(root, "threads", "threads.db") + }, + }, + { + name: "shelley", + agent: parser.AgentShelley, + path: func(root string) string { + return filepath.Join(root, shelleyDBFile) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := openTestDB(t) + root := t.TempDir() + engine := NewEngine(db, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + tt.agent: {root}, + }, + Machine: "local", + }) + dbPath := tt.path(root) + require.NoFileExists(t, dbPath) + + res := engine.processFile(context.Background(), parser.DiscoveredFile{ + Path: dbPath, + Agent: tt.agent, + ForceParse: true, + }) + require.NoError(t, res.err) + assert.Empty(t, res.results) + assert.True(t, res.forceReplace) + }) + } +} + func TestEngine_ClassifyPathsOpenCodeRemovedPartDir( t *testing.T, ) { diff --git a/internal/sync/parsediff.go b/internal/sync/parsediff.go index ae73e84d6..b95fa7b9e 100644 --- a/internal/sync/parsediff.go +++ b/internal/sync/parsediff.go @@ -454,7 +454,7 @@ func stripVirtualSourceSuffix(path string) string { if dbPath, _, ok := parser.ParseKiroSQLiteVirtualPath(path); ok { return dbPath } - if dbPath, _, ok := parser.ParseZedSQLiteVirtualPath(path); ok { + if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(path, "threads.db"); ok { return dbPath } if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(path, "opencode.db"); ok { @@ -466,7 +466,7 @@ func stripVirtualSourceSuffix(path string) string { if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(path, "mimocode.db"); ok { return dbPath } - if dbPath, _, ok := parser.ParseShelleyVirtualPath(path); ok { + if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(path, shelleyDBFile); ok { return dbPath } return path diff --git a/internal/sync/shelley_integration_test.go b/internal/sync/shelley_integration_test.go index bb82dd9b6..8e649a436 100644 --- a/internal/sync/shelley_integration_test.go +++ b/internal/sync/shelley_integration_test.go @@ -3,6 +3,7 @@ package sync_test import ( "context" "database/sql" + "os" "path/filepath" "testing" "time" @@ -165,6 +166,52 @@ func TestSyncSingleSessionShelleyUsesVirtualSourcePath(t *testing.T) { database.GetSessionFilePath("shelley:cMAIN1"), "stored file path") } +func TestSyncSingleSessionShelleyForceRewritesUnchangedSession(t *testing.T) { + dir := t.TempDir() + dbPath := createShelleyDB(t, dir) + seedShelleyConvo(t, dbPath, "cMAIN1", "main", "/home/u/dev/app", + "claude-sonnet-4-6", "", true, + "2026-06-15T10:00:00Z", "2026-06-15T10:00:06Z", mainConvoMsgs()) + + engine, database := newShelleyEngine(t, dir) + require.NoError(t, engine.SyncSingleSession("shelley:cMAIN1")) + sess, err := database.GetSession(context.Background(), "shelley:cMAIN1") + require.NoError(t, err) + require.NotNil(t, sess) + require.Equal(t, 2, sess.MessageCount) + + sess.MessageCount = 0 + require.NoError(t, database.UpsertSession(*sess)) + + require.NoError(t, engine.SyncSingleSession("shelley:cMAIN1")) + + sess, err = database.GetSession(context.Background(), "shelley:cMAIN1") + require.NoError(t, err) + require.NotNil(t, sess) + assert.Equal(t, 2, sess.MessageCount) + assert.Equal(t, dbPath+"#cMAIN1", + database.GetSessionFilePath("shelley:cMAIN1"), "stored file path") +} + +func TestSyncPathsShelleyDeletedPhysicalDBRemovesSessions(t *testing.T) { + dir := t.TempDir() + dbPath := createShelleyDB(t, dir) + seedShelleyConvo(t, dbPath, "cMAIN1", "main", "/home/u/dev/app", + "claude-sonnet-4-6", "", true, + "2026-06-15T10:00:00Z", "2026-06-15T10:00:06Z", mainConvoMsgs()) + + engine, database := newShelleyEngine(t, dir) + stats := engine.SyncAll(context.Background(), nil) + require.Equal(t, 1, stats.Synced) + require.NoError(t, os.Remove(dbPath)) + + engine.SyncPaths([]string{dbPath}) + + sess, err := database.GetSession(context.Background(), "shelley:cMAIN1") + require.NoError(t, err) + assert.Nil(t, sess) +} + // TestSourceMtimeShelleyResolvesVirtualPath guards the live per-session // watcher: SourceMtime must resolve a shelley.db# virtual path to the // conversation's updated_at, not fall through to os.Stat (which fails on a diff --git a/internal/sync/zed_integration_test.go b/internal/sync/zed_integration_test.go index 9c696487f..720a09ca9 100644 --- a/internal/sync/zed_integration_test.go +++ b/internal/sync/zed_integration_test.go @@ -59,6 +59,71 @@ func TestSyncSingleSessionZedUsesVirtualSourcePath(t *testing.T) { assert.Nil(t, other) } +func TestSyncSingleSessionZedForceRewritesUnchangedSession(t *testing.T) { + zedDir := t.TempDir() + dbPath := filepath.Join(zedDir, "threads", "threads.db") + createZedThreadsDB(t, dbPath, []zedThreadFixture{{ + id: "exists", + summary: "Existing thread", + updatedAt: "2026-06-09T02:30:00Z", + dataType: "json", + data: []byte(`{"messages":[{"User":{"content":[{"Text":"hello"}]}}]}`), + }}) + + database := dbtest.OpenTestDB(t) + engine := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentZed: {zedDir}, + }, + Machine: "local", + }) + require.NoError(t, engine.SyncSingleSession("zed:exists")) + sess, err := database.GetSession(t.Context(), "zed:exists") + require.NoError(t, err) + require.NotNil(t, sess) + require.Equal(t, 1, sess.MessageCount) + + sess.MessageCount = 0 + require.NoError(t, database.UpsertSession(*sess)) + + require.NoError(t, engine.SyncSingleSession("zed:exists")) + + sess, err = database.GetSession(t.Context(), "zed:exists") + require.NoError(t, err) + require.NotNil(t, sess) + assert.Equal(t, 1, sess.MessageCount) + assert.Equal(t, dbPath+"#exists", database.GetSessionFilePath("zed:exists")) +} + +func TestSyncPathsZedDeletedPhysicalDBRemovesSessions(t *testing.T) { + zedDir := t.TempDir() + dbPath := filepath.Join(zedDir, "threads", "threads.db") + createZedThreadsDB(t, dbPath, []zedThreadFixture{{ + id: "exists", + summary: "Existing thread", + updatedAt: "2026-06-09T02:30:00Z", + dataType: "json", + data: []byte(`{"messages":[{"User":{"content":[{"Text":"hello"}]}}]}`), + }}) + + database := dbtest.OpenTestDB(t) + engine := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentZed: {zedDir}, + }, + Machine: "local", + }) + stats := engine.SyncAll(t.Context(), nil) + require.Equal(t, 1, stats.Synced) + require.NoError(t, os.Remove(dbPath)) + + engine.SyncPaths([]string{dbPath}) + + sess, err := database.GetSession(t.Context(), "zed:exists") + require.NoError(t, err) + assert.Nil(t, sess) +} + func TestSyncSingleSessionZedMissingThreadReturnsNotFound(t *testing.T) { zedDir := t.TempDir() createZedThreadsDB(t, filepath.Join(zedDir, "threads", "threads.db"), []zedThreadFixture{{