diff --git a/internal/parser/commandcode.go b/internal/parser/commandcode.go index 314d13f0b..a952fc937 100644 --- a/internal/parser/commandcode.go +++ b/internal/parser/commandcode.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strconv" "strings" "time" @@ -20,83 +19,8 @@ type commandCodeMeta struct { Cwd string `json:"cwd"` } -// DiscoverCommandCodeSessions finds Command Code transcripts under -// ~/.commandcode/projects//.jsonl. -func DiscoverCommandCodeSessions(projectsDir string) []DiscoveredFile { - if projectsDir == "" { - return nil - } - - projectEntries, err := os.ReadDir(projectsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, entry := range projectEntries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - - projectDir := filepath.Join(projectsDir, entry.Name()) - sessionEntries, err := os.ReadDir(projectDir) - if err != nil { - continue - } - - for _, sessionEntry := range sessionEntries { - if sessionEntry.IsDir() { - continue - } - name := sessionEntry.Name() - if !strings.HasSuffix(name, ".jsonl") || - strings.HasSuffix(name, ".checkpoints.jsonl") || - strings.HasSuffix(name, ".prompts.jsonl") { - continue - } - id := strings.TrimSuffix(name, ".jsonl") - if !IsValidSessionID(id) { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(projectDir, name), - Agent: AgentCommandCode, - }) - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindCommandCodeSourceFile locates a Command Code transcript by -// its raw session ID (without the "commandcode:" prefix). -func FindCommandCodeSourceFile(projectsDir, rawID string) string { - if projectsDir == "" || !IsValidSessionID(rawID) { - return "" - } - - projectEntries, err := os.ReadDir(projectsDir) - if err != nil { - return "" - } - for _, entry := range projectEntries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - - candidate := filepath.Join(projectsDir, entry.Name(), rawID+".jsonl") - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate - } - } - return "" -} - -// ParseCommandCodeSession parses a Command Code JSONL transcript. -func ParseCommandCodeSession( +// parseSession parses a Command Code JSONL transcript. +func (p *commandCodeProvider) parseSession( path, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/commandcode_provider.go b/internal/parser/commandcode_provider.go new file mode 100644 index 000000000..2125a5ed7 --- /dev/null +++ b/internal/parser/commandcode_provider.go @@ -0,0 +1,321 @@ +package parser + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" +) + +var _ Provider = (*commandCodeProvider)(nil) + +type commandCodeProviderFactory struct { + def AgentDef +} + +func newCommandCodeProviderFactory(def AgentDef) ProviderFactory { + return commandCodeProviderFactory{def: cloneAgentDef(def)} +} + +func (f commandCodeProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f commandCodeProviderFactory) Capabilities() Capabilities { + return commandCodeProviderCapabilities() +} + +func (f commandCodeProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &commandCodeProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: commandCodeProviderCapabilities(), + Config: cfg, + }, + sources: newCommandCodeSourceSet(cfg.Roots), + } +} + +type commandCodeProvider struct { + ProviderBase + sources DirectoryJSONLSourceSet +} + +func (p *commandCodeProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *commandCodeProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + plan, err := p.sources.WatchPlan(ctx) + if err != nil { + return WatchPlan{}, err + } + for i := range plan.Roots { + plan.Roots[i].IncludeGlobs = append( + plan.Roots[i].IncludeGlobs, + "*.meta.json", + ) + } + return plan, nil +} + +func (p *commandCodeProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + sources, err := p.sources.SourcesForChangedPath(ctx, req) + if err != nil || len(sources) > 0 { + return sources, err + } + if source, ok, err := p.sourceForMetaCompanion(ctx, req); err != nil { + return nil, err + } else if ok { + return []SourceRef{source}, nil + } + return nil, nil +} + +func (p *commandCodeProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + return p.sources.FindSource(ctx, providerFindRequestWithRawSessionID(p.Def, req)) +} + +func (p *commandCodeProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + if err := ctx.Err(); err != nil { + return SourceFingerprint{}, err + } + path, ok, err := p.sources.pathFromSource(ctx, source) + if err != nil { + return SourceFingerprint{}, err + } + if !ok { + return SourceFingerprint{}, fmt.Errorf("commandcode source path unavailable") + } + info, err := os.Stat(path) + if err != nil { + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", path, err) + } + if info.IsDir() { + return SourceFingerprint{}, fmt.Errorf("stat %s: source is a directory", path) + } + fingerprint := SourceFingerprint{ + Key: firstNonEmptyJSONLString( + source.FingerprintKey, + source.Key, + path, + ), + Size: info.Size(), + MTimeNS: info.ModTime().UnixNano(), + } + + h := sha256.New() + if err := addCommandCodeFingerprintPart(h, "transcript", path, info); err != nil { + return SourceFingerprint{}, err + } + metaPath := commandCodeMetaCompanionPath(path) + if metaInfo, ok, err := commandCodeCompanionInfo(metaPath); err != nil { + return SourceFingerprint{}, err + } else if ok && metaInfo != nil { + fingerprint.Size += metaInfo.Size() + if mtime := metaInfo.ModTime().UnixNano(); mtime > fingerprint.MTimeNS { + fingerprint.MTimeNS = mtime + } + if err := addCommandCodeFingerprintPart(h, "meta", metaPath, metaInfo); err != nil { + return SourceFingerprint{}, err + } + } + fingerprint.Hash = fmt.Sprintf("%x", h.Sum(nil)) + return fingerprint, nil +} + +func (p *commandCodeProvider) Parse( + ctx context.Context, + req ParseRequest, +) (ParseOutcome, error) { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + path, ok, err := p.sources.pathFromSource(ctx, req.Source) + if err != nil { + return ParseOutcome{}, err + } + if !ok { + return ParseOutcome{}, fmt.Errorf("commandcode source path unavailable") + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + sess, msgs, err := p.parseSession(path, machine) + if err != nil { + return ParseOutcome{}, err + } + if sess == nil { + return ParseOutcome{ + ResultSetComplete: true, + SkipReason: SkipNoSession, + }, nil + } + if hash, err := hashJSONLSourceFile(path); err == nil { + sess.File.Hash = hash + } + // Mirror the legacy effective-info behavior: the transcript's + // freshness identity (size and mtime) includes the .meta.json + // companion so a title-only rename triggers a reparse. + if size, mtime, ok := commandCodeEffectiveFileInfo(path); ok { + sess.File.Size = size + sess.File.Mtime = mtime + } + return ParseOutcome{ + Results: []ParseResultOutcome{{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + }, + DataVersion: DataVersionCurrent, + }}, + ResultSetComplete: true, + }, nil +} + +func newCommandCodeSourceSet(roots []string) DirectoryJSONLSourceSet { + return newDirectoryJSONLSourceSet(AgentCommandCode, roots, + withSymlinkFollowing(), + withIncludePath(isCommandCodeSourcePath), + withProjectHint(func(root, path string) string { return "" }), + withSessionIDFromPath(commandCodeSessionIDFromPath), + ) +} + +func (p *commandCodeProvider) sourceForMetaCompanion( + ctx context.Context, + req ChangedPathRequest, +) (SourceRef, bool, error) { + if req.Path == "" { + return SourceRef{}, false, nil + } + path := filepath.Clean(req.Path) + stem, ok := strings.CutSuffix(filepath.Base(path), ".meta.json") + if !ok || !IsValidSessionID(stem) { + return SourceRef{}, false, nil + } + transcriptPath := filepath.Join(filepath.Dir(path), stem+".jsonl") + if _, err := os.Stat(transcriptPath); err != nil { + return SourceRef{}, false, nil + } + source, ok, err := p.sources.sourceForPath(ctx, transcriptPath) + if err != nil { + return SourceRef{}, false, err + } + if !ok { + return SourceRef{}, false, nil + } + if req.WatchRoot != "" { + root := filepath.Clean(req.WatchRoot) + src := source.Opaque.(JSONLSource) + if !samePath(root, src.Root) { + return SourceRef{}, false, nil + } + } + return source, true, nil +} + +func isCommandCodeSourcePath(root, path string) bool { + name := filepath.Base(path) + if !strings.HasSuffix(name, ".jsonl") || + strings.HasSuffix(name, ".checkpoints.jsonl") || + strings.HasSuffix(name, ".prompts.jsonl") { + return false + } + return IsValidSessionID(strings.TrimSuffix(name, ".jsonl")) +} + +func commandCodeSessionIDFromPath(root, path string) string { + name := filepath.Base(path) + if !isCommandCodeSourcePath(root, path) { + return "" + } + return strings.TrimSuffix(name, ".jsonl") +} + +func commandCodeMetaCompanionPath(path string) string { + return strings.TrimSuffix(path, ".jsonl") + ".meta.json" +} + +// commandCodeEffectiveFileInfo returns the combined size and mtime of the +// transcript and its optional .meta.json companion. The bool is false only +// when the transcript itself cannot be stat'd. +func commandCodeEffectiveFileInfo(path string) (int64, int64, bool) { + info, err := os.Stat(path) + if err != nil { + return 0, 0, false + } + size := info.Size() + mtime := info.ModTime().UnixNano() + if metaInfo, ok, err := commandCodeCompanionInfo( + commandCodeMetaCompanionPath(path), + ); err == nil && ok && metaInfo != nil { + size += metaInfo.Size() + if metaMtime := metaInfo.ModTime().UnixNano(); metaMtime > mtime { + mtime = metaMtime + } + } + return size, mtime, true +} + +func commandCodeCompanionInfo(path string) (os.FileInfo, bool, error) { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("stat %s: %w", path, err) + } + if info.IsDir() { + return nil, false, nil + } + return info, true, nil +} + +func addCommandCodeFingerprintPart( + h interface{ Write([]byte) (int, error) }, + label string, + path string, + info os.FileInfo, +) error { + hash, err := hashJSONLSourceFile(path) + if err != nil { + return err + } + _, _ = fmt.Fprintf( + h, + "%s:%s:%d:%d:%s\n", + label, + filepath.Base(path), + info.Size(), + info.ModTime().UnixNano(), + hash, + ) + return nil +} + +func commandCodeProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + SessionName: CapabilitySupported, + Cwd: CapabilitySupported, + GitBranch: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + MalformedLineCount: CapabilitySupported, + }, + } +} diff --git a/internal/parser/commandcode_provider_test.go b/internal/parser/commandcode_provider_test.go new file mode 100644 index 000000000..465699ad7 --- /dev/null +++ b/internal/parser/commandcode_provider_test.go @@ -0,0 +1,171 @@ +package parser + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommandCodeProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentCommandCode) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestCommandCodeProviderSourceMethods(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "users-alice-code-sample-project") + sourcePath := filepath.Join(projectDir, "sess_123.jsonl") + writeSourceFile(t, sourcePath, commandCodeProviderFixture()) + writeSourceFile(t, filepath.Join(projectDir, "sess_123.checkpoints.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(projectDir, "sess_123.prompts.jsonl"), "{}\n") + + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, AgentCommandCode, discovered[0].Provider) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + assert.Empty(t, discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~commandcode:sess_123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + FingerprintKey: sourcePath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + require.NoError(t, os.Remove(sourcePath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: sourcePath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, sourcePath, changed[0].DisplayPath) +} + +func TestCommandCodeProviderDiscoversSymlinkedProjectDirectory(t *testing.T) { + root := t.TempDir() + realProjectDir := filepath.Join(t.TempDir(), "real-project") + linkProjectDir := filepath.Join(root, "linked-project") + if err := os.Symlink(realProjectDir, linkProjectDir); err != nil { + t.Skipf("symlink not supported: %v", err) + } + sourcePath := filepath.Join(linkProjectDir, "sess_123.jsonl") + writeSourceFile(t, filepath.Join(realProjectDir, "sess_123.jsonl"), commandCodeProviderFixture()) + + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sess_123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) +} + +func TestCommandCodeProviderParse(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "project", "sess_123.jsonl") + transcript := commandCodeProviderFixture() + writeSourceFile(t, sourcePath, transcript) + + provider, ok := NewProvider(AgentCommandCode, 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) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: SourceFingerprint{ + Key: sourcePath, + }, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion) + assert.Equal(t, "commandcode:sess_123", outcome.Results[0].Result.Session.ID) + assert.Equal(t, "devbox", outcome.Results[0].Result.Session.Machine) + assert.Equal(t, + fmt.Sprintf("%x", sha256.Sum256([]byte(transcript))), + outcome.Results[0].Result.Session.File.Hash, + ) + assert.Len(t, outcome.Results[0].Result.Messages, 2) +} + +func TestCommandCodeProviderParsePreservesTranscriptFileHash(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "project", "sess_123.jsonl") + transcript := commandCodeProviderFixture() + writeSourceFile(t, sourcePath, transcript) + writeSourceFile(t, commandCodeMetaCompanionPath(sourcePath), `{"title":"Renamed"}`) + + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + fingerprint, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + transcriptHash := fmt.Sprintf("%x", sha256.Sum256([]byte(transcript))) + require.NotEqual(t, transcriptHash, fingerprint.Hash, + "fixture must prove metadata participates in freshness separately") + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + assert.Equal(t, transcriptHash, outcome.Results[0].Result.Session.File.Hash) +} + +func commandCodeProviderFixture() string { + return `{"id":"m1","timestamp":"2026-06-01T10:00:00Z","sessionId":"sess_123","role":"user","content":[{"type":"text","text":"Inspect server logs"}],"gitBranch":"feature/command-code","metadata":{"version":2,"cwd":"/Users/alice/code/sample-project"}} +{"id":"m2","timestamp":"2026-06-01T10:00:03Z","sessionId":"sess_123","role":"assistant","content":[{"type":"text","text":"The error is in the startup path."}],"gitBranch":"feature/command-code","metadata":{"version":2}}` +} diff --git a/internal/parser/commandcode_test.go b/internal/parser/commandcode_test.go index f3aa5d0e6..1fce65f91 100644 --- a/internal/parser/commandcode_test.go +++ b/internal/parser/commandcode_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "strings" @@ -10,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestDiscoverCommandCodeSessions(t *testing.T) { +func TestCommandCodeProviderDiscoversSessions(t *testing.T) { t.Parallel() root := t.TempDir() @@ -22,13 +23,17 @@ func TestDiscoverCommandCodeSessions(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(projectDir, "sess_a.prompts.jsonl"), []byte("{}\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(projectDir, "notes.txt"), []byte("ignore"), 0o644)) - files := DiscoverCommandCodeSessions(root) - require.Len(t, files, 1) - assert.Equal(t, AgentCommandCode, files[0].Agent) - assert.Equal(t, filepath.Join(projectDir, "sess_a.jsonl"), files[0].Path) + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, AgentCommandCode, sources[0].Provider) + assert.Equal(t, filepath.Join(projectDir, "sess_a.jsonl"), sources[0].DisplayPath) } -func TestFindCommandCodeSourceFile(t *testing.T) { +func TestCommandCodeProviderFindsSourceFile(t *testing.T) { t.Parallel() root := t.TempDir() @@ -37,11 +42,24 @@ func TestFindCommandCodeSourceFile(t *testing.T) { path := filepath.Join(projectDir, "sess_123.jsonl") require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) - assert.Equal(t, path, FindCommandCodeSourceFile(root, "sess_123")) - assert.Empty(t, FindCommandCodeSourceFile(root, "sess_missing")) + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sess_123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, path, found.DisplayPath) + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sess_missing", + }) + require.NoError(t, err) + assert.False(t, ok) } -func TestParseCommandCodeSession(t *testing.T) { +func TestCommandCodeProviderParsesSession(t *testing.T) { t.Parallel() content := `{"id":"m1","timestamp":"2026-06-01T10:00:00Z","sessionId":"sess_123","role":"user","content":[{"type":"text","text":"Inspect server logs"}],"gitBranch":"feature/command-code","metadata":{"version":2,"cwd":"/Users/alice/code/sample-project"}} @@ -49,13 +67,31 @@ func TestParseCommandCodeSession(t *testing.T) { {"id":"m3","timestamp":"2026-06-01T10:00:02Z","sessionId":"sess_123","role":"tool","content":[{"type":"tool-result","toolCallId":"tc1","toolName":"Read","output":{"type":"text","value":"error: boom"}}],"gitBranch":"feature/command-code","metadata":{"version":2}} {"id":"m4","timestamp":"2026-06-01T10:00:03Z","sessionId":"sess_123","role":"assistant","content":[{"type":"text","text":"The error is in the startup path."}],"gitBranch":"feature/command-code","metadata":{"version":2}}` - path := createTestFile(t, "commandcode.jsonl", content) + root := t.TempDir() + path := filepath.Join(root, "project", "sess_123.jsonl") + writeSourceFile(t, path, content) metaPath := strings.TrimSuffix(path, ".jsonl") + ".meta.json" require.NoError(t, os.WriteFile(metaPath, []byte(`{"title":"Startup investigation"}`), 0o644)) - sess, msgs, err := ParseCommandCodeSession(path, "local") + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{ + Roots: []string{root}, + Machine: "local", + }) + require.True(t, ok) + source, found, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sess_123", + }) require.NoError(t, err) - require.NotNil(t, sess) + require.True(t, found) + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Machine: "local", + }) + require.NoError(t, err) + require.Len(t, outcome.Results, 1) + + sess := outcome.Results[0].Result.Session + msgs := outcome.Results[0].Result.Messages require.Len(t, msgs, 4) assert.Equal(t, "commandcode:sess_123", sess.ID) diff --git a/internal/parser/discovery.go b/internal/parser/discovery.go index 15b83aed1..0ed066181 100644 --- a/internal/parser/discovery.go +++ b/internal/parser/discovery.go @@ -2309,49 +2309,6 @@ func FindQClawSourceFile(agentsDir, rawID string) string { return "" } -// DiscoverIflowProjects finds all project directories under the -// iFlow projects dir and returns their JSONL session files. -// iFlow stores sessions in .iflow/projects//session-.jsonl -func DiscoverIflowProjects(projectsDir string) []DiscoveredFile { - entries, err := os.ReadDir(projectsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, entry := range entries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - - projDir := filepath.Join(projectsDir, entry.Name()) - sessionFiles, err := os.ReadDir(projDir) - if err != nil { - continue - } - - for _, sf := range sessionFiles { - if sf.IsDir() { - continue - } - name := sf.Name() - if !strings.HasPrefix(name, "session-") || !strings.HasSuffix(name, ".jsonl") { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(projDir, name), - Project: entry.Name(), - Agent: AgentIflow, - }) - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - // extractIflowBaseSessionID extracts the base session ID from an iFlow // session ID. Fork IDs are formatted as -, so we // remove the child UUID suffix to get the base session ID for file lookup. @@ -2379,39 +2336,6 @@ func extractIflowBaseSessionID(sessionID string) string { return sessionID } -// FindIflowSourceFile finds the original JSONL file for an iFlow -// session ID by searching all project directories. -func FindIflowSourceFile( - projectsDir, sessionID string, -) string { - if !IsValidSessionID(sessionID) { - return "" - } - - // For fork IDs, extract the base session ID to find the source file - baseID := extractIflowBaseSessionID(sessionID) - - entries, err := os.ReadDir(projectsDir) - if err != nil { - return "" - } - - target := "session-" + strings.TrimPrefix(baseID, "iflow:") + ".jsonl" - for _, entry := range entries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - candidate := filepath.Join( - projectsDir, entry.Name(), target, - ) - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - - return "" -} - // DiscoverVibeSessions finds all Vibe session files under the given root directory. // Vibe stores sessions in: ~/.vibe/logs/session/session_YYYYMMDD_HHMMSS_uuid/ // Each session directory contains messages.jsonl diff --git a/internal/parser/iflow.go b/internal/parser/iflow.go index eb502871a..f9c08e5cf 100644 --- a/internal/parser/iflow.go +++ b/internal/parser/iflow.go @@ -24,12 +24,12 @@ type dagEntryIflow struct { timestamp time.Time } -// ParseIflowSession parses an iFlow JSONL session file. +// parseSession parses an iFlow JSONL session file. // Returns a single ParseResult. Unlike Claude, iFlow's // uuid/parentUuid DAG represents streaming incremental updates // (sliding-window snapshots), not conversation forks, so fork // splitting is intentionally not applied. -func ParseIflowSession( +func parseIflowSession( path, project, machine string, ) ([]ParseResult, error) { info, err := os.Stat(path) diff --git a/internal/parser/iflow_parser_test.go b/internal/parser/iflow_parser_test.go index 8cf23170e..f78d504d2 100644 --- a/internal/parser/iflow_parser_test.go +++ b/internal/parser/iflow_parser_test.go @@ -10,6 +10,16 @@ import ( "github.com/stretchr/testify/require" ) +// parseIflowSessionForTest exercises the iFlow provider's transcript parsing +// in isolation, passing the project directly so tests can assert parse +// behavior without project-resolution enrichment. +func parseIflowSessionForTest( + t *testing.T, path, project, machine string, +) ([]ParseResult, error) { + t.Helper() + return parseIflowSession(path, project, machine) +} + func TestParseIflowSession(t *testing.T) { tests := []struct { name string @@ -29,7 +39,8 @@ func TestParseIflowSession(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, tt.filename, "test-project", "local", @@ -68,7 +79,8 @@ func TestExtractIflowProjectHints(t *testing.T) { } func TestIflowSystemMessageFiltering(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl", "test-project", "local", @@ -90,7 +102,8 @@ func TestIflowSystemMessageFiltering(t *testing.T) { } func TestIflowToolCallParsing(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl", "test-project", "local", @@ -121,7 +134,8 @@ func TestIflowToolCallParsing(t *testing.T) { } func TestIflowBurstMerge(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl", "test-project", "local", @@ -283,7 +297,8 @@ func TestIflowBurstBoundary(t *testing.T) { } func TestIflowTimestampParsing(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl", "test-project", "local", diff --git a/internal/parser/iflow_provider.go b/internal/parser/iflow_provider.go new file mode 100644 index 000000000..7a28b7ef7 --- /dev/null +++ b/internal/parser/iflow_provider.go @@ -0,0 +1,105 @@ +package parser + +import ( + "context" + "path/filepath" + "strings" +) + +// iFlow stores each chat as a JSONL transcript named session-.jsonl in a +// per-project directory. It is a directory-of-files provider: discovery, +// watching, change classification, lookup, and fingerprinting come from +// DirectoryJSONLSourceSet. The ParseFile option makes that source set a full +// SourceSet so it rides the generic factory; RawSessionIDForLookup strips the +// subagent suffix from stored IDs so FindSource still matches the base file. +func newIflowProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + iflowProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newIflowSourceSet(cfg.Roots) }, + ) +} + +func newIflowSourceSet(roots []string) DirectoryJSONLSourceSet { + return newDirectoryJSONLSourceSet(AgentIflow, roots, + withContentHashing(), + withSymlinkFollowing(), + withIncludePath(isIflowSourcePath), + withSessionIDFromPath(iflowSessionIDFromPath), + withRawSessionIDForLookup(extractIflowBaseSessionID), + withParseFile(iflowParseFile), + ) +} + +func iflowParseFile( + ctx context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + project := iflowResolveProject(ctx, req.Source, path) + results, err := parseIflowSession(path, project, req.Machine) + if err != nil { + return nil, nil, err + } + if len(results) == 0 { + return nil, nil, nil + } + // Mirror the legacy sync path: derive continuation/subagent + // relationship types from parent linkage before emitting. + InferRelationshipTypes(results) + if req.Fingerprint.Hash != "" { + for i := range results { + results[i].Session.File.Hash = req.Fingerprint.Hash + } + } + return results, nil, nil +} + +// iflowResolveProject mirrors the legacy sync project resolution for iFlow: +// start from the project directory name, then prefer a canonical project +// derived from the session's recorded cwd and git branch when available. +func iflowResolveProject( + ctx context.Context, + source SourceRef, + path string, +) string { + dirName := firstNonEmptyJSONLString( + source.ProjectHint, + directoryJSONLProjectFromPath(path), + ) + project := GetProjectName(dirName) + + cwd, gitBranch := ExtractIflowProjectHints(path) + if cwd != "" { + if p := ExtractProjectFromCwdWithBranchContext( + ctx, cwd, gitBranch, + ); p != "" { + project = p + } + } + return project +} + +func isIflowSourcePath(root, path string) bool { + name := filepath.Base(path) + return strings.HasPrefix(name, "session-") && + strings.HasSuffix(name, ".jsonl") +} + +func iflowSessionIDFromPath(root, path string) string { + if !isIflowSourcePath(root, path) { + return "" + } + stem := strings.TrimSuffix(filepath.Base(path), ".jsonl") + return strings.TrimPrefix(stem, "session-") +} + +func iflowProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + }, + } +} diff --git a/internal/parser/iflow_provider_test.go b/internal/parser/iflow_provider_test.go new file mode 100644 index 000000000..6bb17fb06 --- /dev/null +++ b/internal/parser/iflow_provider_test.go @@ -0,0 +1,147 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIflowProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentIflow) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentIflow, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestIflowProviderSourceMethods(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "test-project") + rawID := "5de701fc-7454-4858-a249-95cac4fd3b51" + sourcePath := filepath.Join(projectDir, "session-"+rawID+".jsonl") + copyFixtureFile(t, "testdata/iflow/session-"+rawID+".jsonl", sourcePath) + writeSourceFile(t, filepath.Join(projectDir, rawID+".jsonl"), "{}\n") + + provider, ok := NewProvider(AgentIflow, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, AgentIflow, discovered[0].Provider) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + assert.Equal(t, "test-project", discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~iflow:" + rawID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + forkID := rawID + "-6f5d8718-7a95-4bb8-965f-faa23246c82d" + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: forkID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + require.NoError(t, os.Remove(sourcePath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: sourcePath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, sourcePath, changed[0].DisplayPath) +} + +func TestIflowProviderDiscoversSymlinkedProjectDirectory(t *testing.T) { + root := t.TempDir() + realProjectDir := filepath.Join(t.TempDir(), "real-project") + linkProjectDir := filepath.Join(root, "linked-project") + if err := os.Symlink(realProjectDir, linkProjectDir); err != nil { + t.Skipf("symlink not supported: %v", err) + } + rawID := "5de701fc-7454-4858-a249-95cac4fd3b51" + sourcePath := filepath.Join(linkProjectDir, "session-"+rawID+".jsonl") + copyFixtureFile( + t, + "testdata/iflow/session-"+rawID+".jsonl", + filepath.Join(realProjectDir, "session-"+rawID+".jsonl"), + ) + + provider, ok := NewProvider(AgentIflow, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + assert.Equal(t, "linked-project", discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: rawID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) +} + +func TestIflowProviderParse(t *testing.T) { + root := t.TempDir() + project := "test-project" + rawID := "5de701fc-7454-4858-a249-95cac4fd3b51" + sourcePath := filepath.Join(root, project, "session-"+rawID+".jsonl") + copyFixtureFile(t, "testdata/iflow/session-"+rawID+".jsonl", sourcePath) + + provider, ok := NewProvider(AgentIflow, 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) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: SourceFingerprint{Key: sourcePath, Hash: "abc123"}, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion) + assert.Equal(t, "iflow:"+rawID, outcome.Results[0].Result.Session.ID) + // The provider mirrors the legacy sync project resolution, deriving the + // canonical project from the session's recorded cwd rather than the raw + // project directory name. + assert.Equal(t, "docker_image_retagger", outcome.Results[0].Result.Session.Project) + assert.Equal(t, "devbox", outcome.Results[0].Result.Session.Machine) + assert.Equal(t, "abc123", outcome.Results[0].Result.Session.File.Hash) + assert.Len(t, outcome.Results[0].Result.Messages, 11) +} + +func copyFixtureFile(t *testing.T, src, dst string) { + t.Helper() + + data, err := os.ReadFile(src) + require.NoError(t, err) + writeSourceFile(t, dst, string(data)) +} diff --git a/internal/parser/provider.go b/internal/parser/provider.go index f71a5c242..64745e779 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -339,13 +339,23 @@ func (p *legacyProvider) Parse(context.Context, ParseRequest) (ParseOutcome, err func ProviderFactories() []ProviderFactory { factories := make([]ProviderFactory, 0, len(Registry)) for _, def := range Registry { - factories = append(factories, legacyProviderFactory{ - def: cloneAgentDef(def), - }) + factories = append(factories, providerFactoryForDef(def)) } return factories } +func providerFactoryForDef(def AgentDef) ProviderFactory { + def = cloneAgentDef(def) + switch def.Type { + case AgentCommandCode: + return newCommandCodeProviderFactory(def) + case AgentIflow: + return newIflowProviderFactory(def) + default: + return legacyProviderFactory{def: def} + } +} + // ProviderFactoryByType returns the factory for an agent type. func ProviderFactoryByType(t AgentType) (ProviderFactory, bool) { for _, factory := range ProviderFactories() { diff --git a/internal/parser/provider_capabilities.go b/internal/parser/provider_capabilities.go new file mode 100644 index 000000000..f95270b4f --- /dev/null +++ b/internal/parser/provider_capabilities.go @@ -0,0 +1,15 @@ +package parser + +func jsonlFileProviderSourceCapabilities() SourceCapabilities { + return SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + MultiSessionSource: CapabilityNotApplicable, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilityNotApplicable, + } +} diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index e213cbf00..439ec5334 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -27,14 +27,14 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentKilo: ProviderMigrationLegacyOnly, AgentOpenHands: ProviderMigrationLegacyOnly, AgentCursor: ProviderMigrationLegacyOnly, - AgentIflow: ProviderMigrationLegacyOnly, + AgentIflow: ProviderMigrationProviderAuthoritative, AgentAmp: ProviderMigrationLegacyOnly, AgentZencoder: ProviderMigrationLegacyOnly, AgentVSCodeCopilot: ProviderMigrationLegacyOnly, AgentVSCopilot: ProviderMigrationLegacyOnly, AgentPi: ProviderMigrationLegacyOnly, AgentQwen: ProviderMigrationLegacyOnly, - AgentCommandCode: ProviderMigrationLegacyOnly, + AgentCommandCode: ProviderMigrationProviderAuthoritative, AgentDeepSeekTUI: ProviderMigrationLegacyOnly, AgentOpenClaw: ProviderMigrationLegacyOnly, AgentQClaw: ProviderMigrationLegacyOnly, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index b0231d1b3..19023bf20 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -46,14 +46,12 @@ var pendingShimProviderFiles = map[string]bool{ "antigravity_provider.go": true, "claude_provider.go": true, "codex_provider.go": true, - "commandcode_provider.go": true, "copilot_provider.go": true, "cowork_provider.go": true, "cursor_provider.go": true, "db_backed_provider.go": true, "gemini_provider.go": true, "hermes_provider.go": true, - "iflow_provider.go": true, "kiro_ide_provider.go": true, "kiro_provider.go": true, "opencode_provider.go": true, diff --git a/internal/parser/types.go b/internal/parser/types.go index 348c1b5d5..59e3010ad 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -254,15 +254,13 @@ var Registry = []AgentDef{ FindSourceFunc: FindZencoderSourceFile, }, { - Type: AgentIflow, - DisplayName: "iFlow", - EnvVar: "IFLOW_DIR", - ConfigKey: "iflow_dirs", - DefaultDirs: []string{".iflow/projects"}, - IDPrefix: "iflow:", - FileBased: true, - DiscoverFunc: DiscoverIflowProjects, - FindSourceFunc: FindIflowSourceFile, + Type: AgentIflow, + DisplayName: "iFlow", + EnvVar: "IFLOW_DIR", + ConfigKey: "iflow_dirs", + DefaultDirs: []string{".iflow/projects"}, + IDPrefix: "iflow:", + FileBased: true, }, { Type: AgentVSCodeCopilot, @@ -347,15 +345,13 @@ var Registry = []AgentDef{ FindSourceFunc: FindQwenSourceFile, }, { - Type: AgentCommandCode, - DisplayName: "Command Code", - EnvVar: "COMMANDCODE_PROJECTS_DIR", - ConfigKey: "commandcode_project_dirs", - DefaultDirs: []string{".commandcode/projects"}, - IDPrefix: "commandcode:", - FileBased: true, - DiscoverFunc: DiscoverCommandCodeSessions, - FindSourceFunc: FindCommandCodeSourceFile, + Type: AgentCommandCode, + DisplayName: "Command Code", + EnvVar: "COMMANDCODE_PROJECTS_DIR", + ConfigKey: "commandcode_project_dirs", + DefaultDirs: []string{".commandcode/projects"}, + IDPrefix: "commandcode:", + FileBased: true, }, { Type: AgentDeepSeekTUI, diff --git a/internal/parser/types_test.go b/internal/parser/types_test.go index d62e602c3..3861c296a 100644 --- a/internal/parser/types_test.go +++ b/internal/parser/types_test.go @@ -558,8 +558,11 @@ func TestCommandCodeRegistryEntry(t *testing.T) { def, ok := AgentByType(AgentCommandCode) require.True(t, ok, "AgentCommandCode missing from Registry") require.True(t, def.FileBased, "Command Code FileBased") - require.NotNil(t, def.DiscoverFunc, "Command Code DiscoverFunc") - require.NotNil(t, def.FindSourceFunc, "Command Code FindSourceFunc") + // Command Code 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, "Command Code DiscoverFunc") + require.Nil(t, def.FindSourceFunc, "Command Code FindSourceFunc") assert.Equal(t, []string{".commandcode/projects"}, def.DefaultDirs) assert.Equal(t, "commandcode:", def.IDPrefix) } diff --git a/internal/sync/engine.go b/internal/sync/engine.go index eb43cc389..41d009f0f 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1188,27 +1188,6 @@ func (e *Engine) classifyOnePath( } } - // iFlow: //session-.jsonl - for _, iflowDir := range e.agentDirs[parser.AgentIflow] { - if iflowDir == "" { - continue - } - if rel, ok := isUnder(iflowDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) != 2 { - continue - } - if !strings.HasPrefix(parts[1], "session-") || !strings.HasSuffix(parts[1], ".jsonl") { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: parts[0], - Agent: parser.AgentIflow, - }, true - } - } - // Kimi: ///wire.jsonl (legacy) // or ///agents//wire.jsonl (.kimi-code) // Components that cannot round-trip through the ':'-delimited @@ -1492,46 +1471,6 @@ func (e *Engine) classifyOnePath( return df, true } - // Command Code: //.jsonl - for _, commandCodeDir := range e.agentDirs[parser.AgentCommandCode] { - if commandCodeDir == "" { - continue - } - if rel, ok := isUnder(commandCodeDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) != 2 { - continue - } - if sessionID, ok := strings.CutSuffix(parts[1], ".meta.json"); ok { - if !parser.IsValidSessionID(sessionID) { - continue - } - jsonlPath := filepath.Join(commandCodeDir, parts[0], sessionID+".jsonl") - if _, err := os.Stat(jsonlPath); err != nil { - continue - } - return parser.DiscoveredFile{ - Path: jsonlPath, - Project: parser.NormalizeName(parts[0]), - Agent: parser.AgentCommandCode, - }, true - } - if strings.HasSuffix(parts[1], ".checkpoints.jsonl") || - strings.HasSuffix(parts[1], ".prompts.jsonl") { - continue - } - sessionID, ok := strings.CutSuffix(parts[1], ".jsonl") - if !ok || !parser.IsValidSessionID(sessionID) { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: parser.NormalizeName(parts[0]), - Agent: parser.AgentCommandCode, - }, true - } - } - // OpenClaw: //sessions/.jsonl // or: //sessions/.jsonl. for _, ocDir := range e.agentDirs[parser.AgentOpenClaw] { @@ -4660,8 +4599,6 @@ func (e *Engine) processFile( res = e.processOpenHands(file, info) case parser.AgentCursor: res = e.processCursor(file, info) - case parser.AgentIflow: - res = e.processIflow(ctx, file, info) case parser.AgentAmp: res = e.processAmp(file, info) case parser.AgentDeepSeekTUI: @@ -4676,8 +4613,6 @@ func (e *Engine) processFile( res = e.processPi(file, info) case parser.AgentQwen: res = e.processQwen(file, info) - case parser.AgentCommandCode: - res = e.processCommandCode(file, info) case parser.AgentOpenClaw: res = e.processOpenClaw(file, info) case parser.AgentQClaw: @@ -7435,39 +7370,6 @@ func (e *Engine) processQwen( } } -func (e *Engine) processCommandCode( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - effectiveInfo := commandCodeEffectiveInfo(file.Path, info) - if e.shouldSkipByPath(file.Path, effectiveInfo) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseCommandCodeSession( - file.Path, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - sess.File.Size = effectiveInfo.Size() - sess.File.Mtime = effectiveInfo.ModTime().UnixNano() - - return processResult{ - results: []parser.ParseResult{{ - Session: *sess, - Messages: msgs, - }}, - } -} - func commandCodeEffectiveInfo(path string, info os.FileInfo) os.FileInfo { size := info.Size() mtime := info.ModTime().UnixNano() @@ -7506,56 +7408,6 @@ func validateCursorContainment( return nil } -func (e *Engine) processIflow( - ctx context.Context, - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - // Extract session ID from filename: session-.jsonl - sessionID := "iflow:" + strings.TrimPrefix(strings.TrimSuffix(info.Name(), ".jsonl"), "session-") - - if e.shouldSkipFile(sessionID, info) { - sess, _ := e.db.GetSession( - ctx, e.idPrefix+sessionID, - ) - if sess != nil && - sess.Project != "" && - !parser.NeedsProjectReparse(sess.Project) { - return processResult{skip: true} - } - } - - // Determine project name from cwd if possible - project := parser.GetProjectName(file.Project) - cwd, gitBranch := parser.ExtractIflowProjectHints( - file.Path, - ) - if cwd != "" { - if p := parser.ExtractProjectFromCwdWithBranchContext( - ctx, cwd, gitBranch, - ); p != "" { - project = p - } - } - - results, err := parser.ParseIflowSession( - file.Path, project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - for i := range results { - results[i].Session.File.Hash = hash - } - } - - parser.InferRelationshipTypes(results) - - return processResult{results: results} -} - // computeFinalStreak counts trailing consecutive failures // from the end of the tool call list. func computeFinalStreak(calls []signals.ToolCallRow) int { diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index 5bc90c310..edfb00892 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -3144,7 +3144,11 @@ func TestEngine_ClassifyPathsCommandCodeSession(t *testing.T) { require.Len(t, files, 1, "len(files) = %d, want 1 (%v)", len(files), files) assert.Equal(t, sessionPath, files[0].Path) assert.Equal(t, parser.AgentCommandCode, files[0].Agent) - assert.Equal(t, "users_alice_code_sample_project", files[0].Project) + // Command Code is provider-authoritative: classification attaches a + // provider source and recomputes the project during parse, so the + // classification carries no informational project hint. + assert.Empty(t, files[0].Project) + require.NotNil(t, files[0].ProviderSource) bogus := []string{ filepath.Join(commandCodeDir, "stray.jsonl"), diff --git a/internal/sync/iflow_discovery_test.go b/internal/sync/iflow_discovery_test.go index eb7749d5a..5bf654956 100644 --- a/internal/sync/iflow_discovery_test.go +++ b/internal/sync/iflow_discovery_test.go @@ -1,6 +1,7 @@ package sync import ( + "context" "os" "path/filepath" "testing" @@ -10,7 +11,7 @@ import ( "go.kenn.io/agentsview/internal/parser" ) -func TestDiscoverIflowProjects(t *testing.T) { +func TestIflowProviderDiscoversProjects(t *testing.T) { // Create a temporary directory structure for testing tmpDir := t.TempDir() @@ -40,16 +41,19 @@ func TestDiscoverIflowProjects(t *testing.T) { subDir := filepath.Join(proj1, "subdir") require.NoError(t, os.MkdirAll(subDir, 0o755)) - // Run discovery - files := parser.DiscoverIflowProjects(tmpDir) + provider, ok := parser.NewProvider(parser.AgentIflow, parser.ProviderConfig{ + Roots: []string{tmpDir}, + }) + require.True(t, ok) - // Verify results - assert.Len(t, files, 3) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + assert.Len(t, sources, 3) - // Verify file paths + // Verify source paths paths := make(map[string]bool) - for _, f := range files { - paths[f.Path] = true + for _, s := range sources { + paths[s.DisplayPath] = true } assert.True(t, paths[session1], "session1 not found in results") @@ -57,22 +61,22 @@ func TestDiscoverIflowProjects(t *testing.T) { assert.True(t, paths[session3], "session3 not found in results") assert.False(t, paths[otherFile], "other.txt should not be in results") - // Verify project names + // Verify project hints projects := make(map[string]bool) - for _, f := range files { - projects[f.Project] = true + for _, s := range sources { + projects[s.ProjectHint] = true } assert.True(t, projects["project1"], "project1 not found in projects") assert.True(t, projects["project2"], "project2 not found in projects") - // Verify agent type - for _, f := range files { - assert.Equal(t, parser.AgentType("iflow"), f.Agent) + // Verify provider type + for _, s := range sources { + assert.Equal(t, parser.AgentIflow, s.Provider) } } -func TestFindIflowSourceFile(t *testing.T) { +func TestIflowProviderFindsSourceFile(t *testing.T) { tmpDir := t.TempDir() // Create a project directory @@ -84,23 +88,39 @@ func TestFindIflowSourceFile(t *testing.T) { sessionFile := filepath.Join(proj, "session-"+sessionID+".jsonl") require.NoError(t, os.WriteFile(sessionFile, []byte(`{"test":"data"}`), 0o644)) + provider, ok := parser.NewProvider(parser.AgentIflow, parser.ProviderConfig{ + Roots: []string{tmpDir}, + }) + require.True(t, ok) + // Test finding the file - found := parser.FindIflowSourceFile(tmpDir, sessionID) - assert.Equal(t, sessionFile, found) + found, ok, err := provider.FindSource(context.Background(), parser.FindSourceRequest{ + RawSessionID: sessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sessionFile, found.DisplayPath) // Test finding a non-existent file - notFound := parser.FindIflowSourceFile(tmpDir, "nonexistent") - assert.Empty(t, notFound) + _, ok, err = provider.FindSource(context.Background(), parser.FindSourceRequest{ + RawSessionID: "nonexistent", + }) + require.NoError(t, err) + assert.False(t, ok) // Test finding a fork ID (should extract base session ID) // Fork IDs have format: - - // The file lookup should use only the base UUID + // The source lookup should use only the base UUID baseSessionID := "96e6d875-92eb-40b9-b193-a9ba99f0f709" forkSessionID := baseSessionID + "-12345678-1234-5678-9abc-def012345678" forkSessionFile := filepath.Join(proj, "session-"+baseSessionID+".jsonl") require.NoError(t, os.WriteFile(forkSessionFile, []byte(`{"test":"fork"}`), 0o644)) // Test finding the fork session - should find the base file - foundFork := parser.FindIflowSourceFile(tmpDir, forkSessionID) - assert.Equal(t, forkSessionFile, foundFork, "for fork ID %s", forkSessionID) + foundFork, ok, err := provider.FindSource(context.Background(), parser.FindSourceRequest{ + RawSessionID: forkSessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, forkSessionFile, foundFork.DisplayPath, "for fork ID %s", forkSessionID) }