diff --git a/internal/parser/kiro.go b/internal/parser/kiro.go index ec465a741..ae5799b81 100644 --- a/internal/parser/kiro.go +++ b/internal/parser/kiro.go @@ -28,10 +28,10 @@ type kiroMeta struct { UpdatedAt string `json:"updated_at"` } -// DiscoverKiroSessions finds all .jsonl session files under the -// Kiro CLI sessions directory. Layout: +// discoverLegacyJSONL finds all .jsonl session files under a Kiro +// CLI sessions directory. Layout: // /.jsonl (with companion .json) -func DiscoverKiroSessions(sessionsDir string) []DiscoveredFile { +func (s kiroSourceSet) discoverLegacyJSONL(sessionsDir string) []DiscoveredFile { entries, err := os.ReadDir(sessionsDir) if err != nil { return nil @@ -58,9 +58,9 @@ func DiscoverKiroSessions(sessionsDir string) []DiscoveredFile { return files } -// FindKiroSourceFile locates a Kiro session file by its raw +// legacySourceFile locates a legacy Kiro JSONL session file by its raw // session ID (without the "kiro:" prefix). -func FindKiroSourceFile(sessionsDir, rawID string) string { +func (s kiroSourceSet) legacySourceFile(sessionsDir, rawID string) string { if sessionsDir == "" || !IsValidSessionID(rawID) { return "" } @@ -100,10 +100,10 @@ func loadKiroMeta(jsonlPath string) *kiroMeta { return &m } -// ParseKiroSession parses a Kiro CLI session from its JSONL file. +// parseLegacySession parses a Kiro CLI session from its JSONL file. // Returns (nil, nil, nil) if the file doesn't exist or contains // no user/assistant messages. -func ParseKiroSession( +func (p *kiroProvider) parseLegacySession( path, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/kiro_ide.go b/internal/parser/kiro_ide.go index 270bc21cb..385bf49a0 100644 --- a/internal/parser/kiro_ide.go +++ b/internal/parser/kiro_ide.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "time" @@ -98,126 +97,6 @@ type kiroIDEActionOutput struct { Message string `json:"message"` } -// DiscoverKiroIDESessions finds all session files under the -// Kiro IDE globalStorage directory. It scans both: -// - //.chat (old format) -// - /workspace-sessions//.json (new format) -func DiscoverKiroIDESessions(dir string) []DiscoveredFile { - entries, err := os.ReadDir(dir) - if err != nil { - return nil - } - - var files []DiscoveredFile - - for _, wsEntry := range entries { - if !wsEntry.IsDir() { - continue - } - name := wsEntry.Name() - if name == "default" || name == "dev_data" || - name == "index" || name == "workspace-sessions" || - strings.HasPrefix(name, ".") { - continue - } - - wsDir := filepath.Join(dir, name) - chatFiles, err := os.ReadDir(wsDir) - if err != nil { - continue - } - for _, cf := range chatFiles { - if cf.IsDir() || - !strings.HasSuffix(cf.Name(), ".chat") { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(wsDir, cf.Name()), - Agent: AgentKiroIDE, - }) - } - } - - // Scan workspace-sessions for new-format session JSONs. - wsSessionsDir := filepath.Join(dir, "workspace-sessions") - wsDirs, err := os.ReadDir(wsSessionsDir) - if err == nil { - for _, wsEntry := range wsDirs { - if !wsEntry.IsDir() { - continue - } - wsDir := filepath.Join(wsSessionsDir, wsEntry.Name()) - jsonFiles, err := os.ReadDir(wsDir) - if err != nil { - continue - } - for _, jf := range jsonFiles { - name := jf.Name() - if name == "sessions.json" || - !strings.HasSuffix(name, ".json") { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(wsDir, name), - Agent: AgentKiroIDE, - }) - } - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindKiroIDESourceFile locates a Kiro IDE session file by -// raw session ID. Supports both formats: -// - Old: ":" → .chat file -// - New: "" → workspace-sessions/*/.json -func FindKiroIDESourceFile(dir, rawID string) string { - cleanDir := filepath.Clean(dir) - - // Old format: : - wsHash, fileHash, ok := strings.Cut(rawID, ":") - if ok && IsValidSessionID(wsHash) && IsValidSessionID(fileHash) { - candidate := filepath.Join(dir, wsHash, fileHash+".chat") - if abs, err := filepath.Abs(candidate); err == nil && - strings.HasPrefix(abs, cleanDir) { - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - } - - // New format: rawID is a UUID, file is at - // workspace-sessions//.json - if !IsValidSessionID(rawID) { - return "" - } - wsSessionsDir := filepath.Join(dir, "workspace-sessions") - wsDirs, err := os.ReadDir(wsSessionsDir) - if err != nil { - return "" - } - for _, wsEntry := range wsDirs { - if !wsEntry.IsDir() { - continue - } - candidate := filepath.Join( - wsSessionsDir, wsEntry.Name(), rawID+".json", - ) - abs, err := filepath.Abs(candidate) - if err != nil || !strings.HasPrefix(abs, cleanDir) { - continue - } - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - return "" -} - // isKiroIDESystemMessage returns true for system prompt and // rules messages that should not be shown as user content. func isKiroIDESystemMessage(content string) bool { @@ -228,11 +107,11 @@ func isKiroIDESystemMessage(content string) bool { strings.HasPrefix(content, "You are operating in a workspace") } -// ParseKiroIDESession parses a Kiro IDE session file. +// parseSession parses a Kiro IDE session file. // Supports both old (.chat) and new (.json) formats. // Returns (nil, nil, nil) if the file doesn't exist or // contains no meaningful messages. -func ParseKiroIDESession( +func parseKiroIDESession( path, machine string, ) (*ParsedSession, []ParsedMessage, error) { if strings.HasSuffix(path, ".json") { diff --git a/internal/parser/kiro_ide_provider.go b/internal/parser/kiro_ide_provider.go new file mode 100644 index 000000000..1c6b82a2b --- /dev/null +++ b/internal/parser/kiro_ide_provider.go @@ -0,0 +1,151 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "strings" +) + +// Kiro IDE stores sessions in two on-disk layouts: an old format keyed by a +// ":" pair pointing at a /.chat file, +// and a new format where a UUID names a workspace-sessions//.json +// file. It is a directory-of-files provider: discovery, watching, change +// classification, and fingerprinting come from JSONLSourceSet. The ParseFile +// option makes that source set a full SourceSet so it rides the generic +// factory; RawSessionIDSourceFiles reconstructs the file path for the old +// colon-joined IDs, which the filename-stem discovery scan cannot match. +func newKiroIDEProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + kiroIDEProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newKiroIDESourceSet(cfg.Roots) }, + ) +} + +func newKiroIDESourceSet(roots []string) JSONLSourceSet { + return newJSONLSourceSet(AgentKiroIDE, roots, + withRecursive(), + withExtensions(".chat", ".json"), + withContentHashing(), + withIncludePath(isKiroIDESourcePath), + withSessionIDFromPath(kiroIDESessionIDFromPath), + withRawSessionIDSourceFiles(kiroIDERawSessionIDSourceFiles), + withParseFile(kiroIDEParseFile), + ) +} + +func kiroIDEParseFile( + _ context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + sess, msgs, err := parseKiroIDESession(path, req.Machine) + if err != nil { + return nil, nil, err + } + if sess == nil { + return nil, nil, nil + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + } + return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil +} + +// kiroIDERawSessionIDSourceFiles reconstructs candidate file paths from a raw +// session ID for both Kiro IDE layouts. The old format +// ":" maps to //.chat. The new +// format is a UUID whose file lives at workspace-sessions//.json, so +// every workspace-sessions subdirectory under each root yields a candidate. +// FindSource gates each candidate on existence via the shared path lookup. +func kiroIDERawSessionIDSourceFiles(roots []string, rawID string) []string { + wsHash, fileHash, hasColon := strings.Cut(rawID, ":") + oldFormat := hasColon && IsValidSessionID(wsHash) && IsValidSessionID(fileHash) + newFormat := IsValidSessionID(rawID) + if !oldFormat && !newFormat { + return nil + } + var candidates []string + for _, root := range roots { + if root == "" { + continue + } + if oldFormat { + candidates = append( + candidates, + filepath.Join(root, wsHash, fileHash+".chat"), + ) + } + if newFormat { + wsSessionsDir := filepath.Join(root, "workspace-sessions") + entries, err := os.ReadDir(wsSessionsDir) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + candidates = append(candidates, filepath.Join( + wsSessionsDir, entry.Name(), rawID+".json", + )) + } + } + } + return candidates +} + +func isKiroIDESourcePath(root, path string) bool { + rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path)) + if !ok { + return false + } + parts := strings.Split(rel, string(filepath.Separator)) + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return false + } + } + if len(parts) == 2 { + if parts[0] == "default" || parts[0] == "dev_data" || + parts[0] == "index" || parts[0] == "workspace-sessions" || + strings.HasPrefix(parts[0], ".") { + return false + } + return strings.HasSuffix(parts[1], ".chat") + } + return len(parts) == 3 && + parts[0] == "workspace-sessions" && + !strings.HasPrefix(parts[1], ".") && + parts[2] != "sessions.json" && + strings.HasSuffix(parts[2], ".json") +} + +func kiroIDESessionIDFromPath(root, path string) string { + if !isKiroIDESourcePath(root, path) { + return "" + } + rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path)) + if !ok { + return "" + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) == 2 { + return parts[0] + ":" + strings.TrimSuffix(parts[1], ".chat") + } + if len(parts) == 3 { + return strings.TrimSuffix(parts[2], ".json") + } + return "" +} + +func kiroIDEProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + SessionName: CapabilitySupported, + ToolCalls: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/kiro_provider.go b/internal/parser/kiro_provider.go new file mode 100644 index 000000000..51a82d1d1 --- /dev/null +++ b/internal/parser/kiro_provider.go @@ -0,0 +1,630 @@ +package parser + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +var _ Provider = (*kiroProvider)(nil) + +type kiroProviderFactory struct { + def AgentDef +} + +func newKiroProviderFactory(def AgentDef) ProviderFactory { + return kiroProviderFactory{def: cloneAgentDef(def)} +} + +func (f kiroProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f kiroProviderFactory) Capabilities() Capabilities { + return kiroProviderCapabilities() +} + +func (f kiroProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &kiroProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: kiroProviderCapabilities(), + Config: cfg, + }, + sources: newKiroSourceSet(cfg.Roots), + } +} + +type kiroProvider struct { + ProviderBase + sources kiroSourceSet +} + +func (p *kiroProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *kiroProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *kiroProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *kiroProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = providerFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *kiroProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *kiroProvider) Parse( + ctx context.Context, + req ParseRequest, +) (ParseOutcome, error) { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + src, ok := p.sources.sourceFromRef(req.Source) + if !ok { + return ParseOutcome{}, fmt.Errorf("kiro source path unavailable") + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + switch src.Kind { + case kiroSourceSQLiteDB: + return p.parseSQLiteDB(ctx, src, machine) + case kiroSourceSQLiteSession: + return p.parseSQLiteSession(src, machine, req.Fingerprint) + default: + return p.parseLegacyJSONL(src, machine, req.Fingerprint) + } +} + +func (p *kiroProvider) parseSQLiteDB( + ctx context.Context, + src kiroSource, + machine string, +) (ParseOutcome, error) { + if _, err := os.Stat(src.DBPath); err != nil { + if os.IsNotExist(err) { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + return ParseOutcome{}, fmt.Errorf("stat %s: %w", src.DBPath, err) + } + store, err := OpenKiroSQLiteStore(src.DBPath) + if err != nil { + return ParseOutcome{}, err + } + defer store.Close() + metas, err := store.ListSessionMeta() + if err != nil { + return ParseOutcome{}, err + } + results := make([]ParseResultOutcome, 0, len(metas)) + var sourceErrs []SourceError + for _, meta := range metas { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + sess, msgs, err := store.ParseSession(meta.SessionID, machine) + if err != nil { + sourceErrs = append(sourceErrs, SourceError{ + SourceKey: meta.VirtualPath, + DisplayPath: meta.VirtualPath, + SessionID: "kiro:" + meta.SessionID, + Err: err, + Retryable: true, + }) + continue + } + if sess == nil { + continue + } + results = append(results, ParseResultOutcome{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + }, + DataVersion: DataVersionCurrent, + }) + } + if len(results) == 0 && len(sourceErrs) == 0 { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + return ParseOutcome{ + Results: results, + SourceErrors: sourceErrs, + ResultSetComplete: true, + ForceReplace: true, + }, nil +} + +func (p *kiroProvider) parseSQLiteSession( + src kiroSource, + machine string, + fingerprint SourceFingerprint, +) (ParseOutcome, error) { + if _, err := os.Stat(src.DBPath); err != nil { + if os.IsNotExist(err) { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + return ParseOutcome{}, fmt.Errorf("stat %s: %w", src.DBPath, err) + } + sess, msgs, err := parseKiroSQLiteSession(src.DBPath, src.SessionID, machine) + if errors.Is(err, sql.ErrNoRows) { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + if err != nil { + return ParseOutcome{}, err + } + if sess == nil { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + if fingerprint.Hash != "" { + sess.File.Hash = fingerprint.Hash + } + return ParseOutcome{ + Results: []ParseResultOutcome{{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + }, + DataVersion: DataVersionCurrent, + }}, + ResultSetComplete: true, + ForceReplace: true, + }, nil +} + +func (p *kiroProvider) parseLegacyJSONL( + src kiroSource, + machine string, + fingerprint SourceFingerprint, +) (ParseOutcome, error) { + if p.sources.legacyPathShadowed(src.Path) { + return ParseOutcome{ + ResultSetComplete: true, + SkipReason: SkipNoSession, + }, nil + } + sess, msgs, err := p.parseLegacySession(src.Path, machine) + if err != nil { + return ParseOutcome{}, err + } + if sess == nil { + return ParseOutcome{ + ResultSetComplete: true, + SkipReason: SkipNoSession, + }, nil + } + if fingerprint.Hash != "" { + sess.File.Hash = fingerprint.Hash + } + return ParseOutcome{ + Results: []ParseResultOutcome{{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + }, + DataVersion: DataVersionCurrent, + }}, + ResultSetComplete: true, + }, nil +} + +type kiroSourceKind uint8 + +const ( + kiroSourceLegacyJSONL kiroSourceKind = iota + kiroSourceSQLiteDB + kiroSourceSQLiteSession +) + +type kiroSource struct { + Root string + Path string + DBPath string + SessionID string + Kind kiroSourceKind +} + +type kiroSourceSet struct { + roots []string +} + +func newKiroSourceSet(roots []string) kiroSourceSet { + return kiroSourceSet{roots: cleanJSONLRoots(roots)} +} + +func (s kiroSourceSet) Discover(ctx context.Context) ([]SourceRef, error) { + var sources []SourceRef + seen := make(map[string]struct{}) + currentIDs := s.currentSessionIDs() + for _, root := range s.roots { + if err := ctx.Err(); err != nil { + return nil, err + } + if dbPath := kiroSQLiteDBPath(root); dbPath != "" { + addJSONLSource(s.newSourceRef(root, dbPath, dbPath, "", kiroSourceSQLiteDB), &sources, seen) + } + for _, file := range s.discoverLegacyJSONL(root) { + if _, shadowed := currentIDs[KiroSessionIDFromPath(file.Path)]; shadowed { + continue + } + source, ok := s.sourceRef(root, file.Path, false) + if ok { + addJSONLSource(source, &sources, seen) + } + } + } + sortJSONLSources(sources) + return sources, nil +} + +func (s kiroSourceSet) WatchPlan(context.Context) (WatchPlan, error) { + roots := make([]WatchRoot, 0, len(s.roots)) + for _, root := range s.roots { + roots = append(roots, WatchRoot{ + Path: root, + Recursive: false, + IncludeGlobs: []string{"*.jsonl", kiroSQLiteDBName, kiroSQLiteDBName + "-*"}, + DebounceKey: string(AgentKiro) + ":root:" + root, + }) + } + return WatchPlan{Roots: roots}, nil +} + +func (s kiroSourceSet) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, root := range s.roots { + if req.WatchRoot != "" && !samePath(req.WatchRoot, root) { + continue + } + source, ok := s.sourceRefForChangedPath(root, req.Path) + if ok { + return []SourceRef{source}, nil + } + } + return nil, nil +} + +func (s kiroSourceSet) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + if err := ctx.Err(); err != nil { + return SourceRef{}, false, err + } + if req.RawSessionID != "" { + for _, root := range s.roots { + dbPath := kiroSQLiteDBPath(root) + if dbPath != "" && KiroSQLiteSessionExists(dbPath, req.RawSessionID) { + return s.newSourceRef( + root, + KiroSQLiteVirtualPath(dbPath, req.RawSessionID), + dbPath, + req.RawSessionID, + kiroSourceSQLiteSession, + ), true, nil + } + } + } + for _, path := range []string{req.StoredFilePath, req.FingerprintKey} { + if path == "" { + continue + } + for _, root := range s.roots { + if source, ok := s.sourceRef(root, path, true); ok { + if req.RequireFreshSource && !kiroSourceExists(source) { + continue + } + return source, true, nil + } + } + } + if req.RawSessionID == "" { + return SourceRef{}, false, nil + } + for _, root := range s.roots { + path := s.legacySourceFile(root, req.RawSessionID) + if path != "" { + if source, ok := s.sourceRef(root, path, false); ok { + return source, true, nil + } + } + } + sources, err := s.Discover(ctx) + if err != nil { + return SourceRef{}, false, err + } + for _, source := range sources { + src := source.Opaque.(kiroSource) + if src.Kind == kiroSourceLegacyJSONL && + KiroSessionIDFromPath(src.Path) == req.RawSessionID { + return source, true, nil + } + } + return SourceRef{}, false, nil +} + +func kiroSourceExists(source SourceRef) bool { + src, ok := source.Opaque.(kiroSource) + if !ok { + ptr, ok := source.Opaque.(*kiroSource) + if !ok || ptr == nil { + return false + } + src = *ptr + } + switch src.Kind { + case kiroSourceSQLiteSession: + return KiroSQLiteSessionExists(src.DBPath, src.SessionID) + case kiroSourceSQLiteDB: + return IsRegularFile(src.DBPath) + default: + return IsRegularFile(src.Path) + } +} + +func (s kiroSourceSet) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + if err := ctx.Err(); err != nil { + return SourceFingerprint{}, err + } + src, ok := s.sourceFromRef(source) + if !ok { + return SourceFingerprint{}, fmt.Errorf("kiro source path unavailable") + } + key := firstNonEmptyJSONLString(source.FingerprintKey, source.Key, src.Path) + if src.Kind == kiroSourceSQLiteSession { + if _, err := os.Stat(src.DBPath); err != nil { + if os.IsNotExist(err) { + return SourceFingerprint{Key: key}, nil + } + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", src.DBPath, err) + } + row, err := loadKiroSQLiteRow(src.DBPath, src.SessionID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return SourceFingerprint{Key: key}, nil + } + return SourceFingerprint{}, err + } + return SourceFingerprint{ + Key: key, + Size: int64(len(row.value)), + MTimeNS: row.updatedAt * 1_000_000, + }, nil + } + info, err := os.Stat(src.Path) + if err != nil { + if os.IsNotExist(err) && src.Kind == kiroSourceSQLiteDB { + return SourceFingerprint{Key: key}, nil + } + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", src.Path, err) + } + if info.IsDir() { + return SourceFingerprint{}, fmt.Errorf("stat %s: source is a directory", src.Path) + } + fingerprint := SourceFingerprint{ + Key: key, + Size: info.Size(), + MTimeNS: info.ModTime().UnixNano(), + } + if src.Kind == kiroSourceSQLiteDB { + if compositeMtime, err := sqliteDBCompositeMtime(src.DBPath); err == nil { + fingerprint.MTimeNS = compositeMtime + } + return fingerprint, nil + } + hash, err := hashJSONLSourceFile(src.Path) + if err != nil { + return SourceFingerprint{}, err + } + fingerprint.Hash = hash + return fingerprint, nil +} + +func (s kiroSourceSet) sourceFromRef(source SourceRef) (kiroSource, bool) { + switch src := source.Opaque.(type) { + case kiroSource: + return src, src.Path != "" + case *kiroSource: + if src != nil && src.Path != "" { + return *src, true + } + } + for _, candidate := range []string{source.DisplayPath, source.FingerprintKey, source.Key} { + for _, root := range s.roots { + if ref, ok := s.sourceRef(root, candidate, true); ok { + src := ref.Opaque.(kiroSource) + return src, true + } + } + } + return kiroSource{}, false +} + +func (s kiroSourceSet) sourceRef( + root, path string, + allowMissing bool, +) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + if dbPath, sessionID, ok := kiroSQLiteVirtualPathParts(path); ok { + if !kiroDBUnderRoot(root, dbPath, !allowMissing) { + return SourceRef{}, false + } + return s.newSourceRef(root, path, dbPath, sessionID, kiroSourceSQLiteSession), true + } + if kiroDBUnderRoot(root, path, !allowMissing) { + return s.newSourceRef(root, path, path, "", kiroSourceSQLiteDB), true + } + if !kiroLegacyPathUnderRoot(root, path) { + return SourceRef{}, false + } + if !allowMissing && !IsRegularFile(path) { + return SourceRef{}, false + } + return s.newSourceRef(root, path, "", "", kiroSourceLegacyJSONL), true +} + +func (s kiroSourceSet) sourceRefForChangedPath(root, path string) (SourceRef, bool) { + if source, ok := s.sourceRef(root, path, false); ok { + return source, true + } + root = filepath.Clean(root) + path = filepath.Clean(path) + if dbPath, sessionID, ok := kiroSQLiteVirtualPathParts(path); ok { + if !kiroDBUnderRoot(root, dbPath, false) { + return SourceRef{}, false + } + return s.newSourceRef(root, path, dbPath, sessionID, kiroSourceSQLiteSession), true + } + if dbPath, ok := kiroDBPathForEvent(root, path); ok { + return s.newSourceRef(root, dbPath, dbPath, "", kiroSourceSQLiteDB), true + } + if kiroLegacyPathUnderRoot(root, path) { + return s.newSourceRef(root, path, "", "", kiroSourceLegacyJSONL), true + } + return SourceRef{}, false +} + +func (s kiroSourceSet) currentSessionIDs() map[string]struct{} { + ids := make(map[string]struct{}) + for _, root := range s.roots { + for id := range KiroSQLiteSessionIDs(root) { + ids[id] = struct{}{} + } + } + return ids +} + +func (s kiroSourceSet) legacyPathShadowed(path string) bool { + legacyID := KiroSessionIDFromPath(path) + if legacyID == "" { + return false + } + for _, root := range s.roots { + dbPath := kiroSQLiteDBPath(root) + if dbPath != "" && KiroSQLiteSessionExists(dbPath, legacyID) { + return true + } + } + return false +} + +func (s kiroSourceSet) newSourceRef( + root, path, dbPath, sessionID string, + kind kiroSourceKind, +) SourceRef { + return SourceRef{ + Provider: AgentKiro, + Key: path, + DisplayPath: path, + FingerprintKey: path, + Opaque: kiroSource{ + Root: root, + Path: path, + DBPath: dbPath, + SessionID: sessionID, + Kind: kind, + }, + } +} + +func kiroDBUnderRoot(root, dbPath string, requireRegular bool) bool { + root = filepath.Clean(root) + dbPath = filepath.Clean(dbPath) + rel, ok := relUnder(root, dbPath) + if !ok || filepath.ToSlash(rel) != kiroSQLiteDBName { + return false + } + return !requireRegular || IsRegularFile(dbPath) +} + +func kiroDBPathForEvent(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) == kiroSQLiteDBName || + (filepath.Dir(rel) == "." && + strings.HasPrefix(filepath.Base(rel), kiroSQLiteDBName+"-")) { + return filepath.Join(root, kiroSQLiteDBName), true + } + return "", false +} + +func kiroLegacyPathUnderRoot(root, path string) bool { + rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path)) + if !ok { + return false + } + if strings.Contains(rel, string(filepath.Separator)) { + return false + } + return strings.HasSuffix(rel, ".jsonl") +} + +func kiroProviderCapabilities() Capabilities { + source := jsonlFileProviderSourceCapabilities() + source.MultiSessionSource = CapabilitySupported + source.PerSessionErrors = CapabilitySupported + source.ForceReplaceOnParse = CapabilitySupported + return Capabilities{ + Source: source, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Cwd: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + }, + } +} diff --git a/internal/parser/kiro_provider_test.go b/internal/parser/kiro_provider_test.go new file mode 100644 index 000000000..b80d15c4c --- /dev/null +++ b/internal/parser/kiro_provider_test.go @@ -0,0 +1,517 @@ +package parser + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKiroProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentKiro) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentKiro, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestKiroProviderSourceMethods(t *testing.T) { + root := t.TempDir() + dbPath, db := newKiroProviderSQLiteDBAt(t, root) + seedKiroSQLiteSession( + t, db, "/home/user/code/kiro-app", "sqlite-session", + readKiroFixture(t, "standard_payload.json"), + 1779012000000, 1779012030000, + ) + seedKiroSQLiteSession( + t, db, "/home/user/code/shadowed", "shadowed-session", + readKiroFixture(t, "standard_payload.json"), + 1779012000000, 1779012040000, + ) + legacyPath := filepath.Join(root, "legacy-session.jsonl") + writeSourceFile(t, legacyPath, kiroProviderJSONLFixture("Legacy question")) + writeSourceFile(t, filepath.Join(root, "legacy-session.json"), + kiroProviderMetaFixture("legacy-session", "/home/user/code/legacy")) + shadowedPath := filepath.Join(root, "shadowed-session.jsonl") + writeSourceFile(t, shadowedPath, kiroProviderJSONLFixture("Shadowed question")) + writeSourceFile(t, filepath.Join(root, "notes", "nested.jsonl"), "{}\n") + + provider, ok := NewProvider(AgentKiro, 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.Contains(t, plan.Roots[0].IncludeGlobs, "*.jsonl") + assert.Contains(t, plan.Roots[0].IncludeGlobs, kiroSQLiteDBName) + assert.Contains(t, plan.Roots[0].IncludeGlobs, kiroSQLiteDBName+"-*") + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.Equal(t, dbPath, discovered[0].DisplayPath) + assert.Equal(t, legacyPath, discovered[1].DisplayPath) + + foundSQLite, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~kiro:sqlite-session", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, KiroSQLiteVirtualPath(dbPath, "sqlite-session"), foundSQLite.DisplayPath) + assert.Equal(t, foundSQLite.DisplayPath, foundSQLite.FingerprintKey) + + foundLegacy, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "legacy-session", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, legacyPath, foundLegacy.DisplayPath) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: dbPath + "-wal", EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, dbPath, changed[0].DisplayPath) +} + +func TestKiroProviderParsePhysicalVirtualAndLegacySources(t *testing.T) { + root := t.TempDir() + dbPath, db := newKiroProviderSQLiteDBAt(t, root) + seedKiroSQLiteSession( + t, db, "/home/user/code/kiro-app", "sqlite-session", + readKiroFixture(t, "standard_payload.json"), + 1779012000000, 1779012030000, + ) + legacyPath := filepath.Join(root, "legacy-session.jsonl") + writeSourceFile(t, legacyPath, kiroProviderJSONLFixture("Legacy question")) + + provider, ok := NewProvider(AgentKiro, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 2) + + 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, 1) + assert.Equal(t, "kiro:sqlite-session", allOutcome.Results[0].Result.Session.ID) + + virtualSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sqlite-session", + }) + 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, "devbox", oneOutcome.Results[0].Result.Session.Machine) + + legacySource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: legacyPath, + }) + require.NoError(t, err) + require.True(t, ok) + legacyOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: legacySource, + Fingerprint: SourceFingerprint{Hash: "legacy-hash"}, + }) + require.NoError(t, err) + require.True(t, legacyOutcome.ResultSetComplete) + require.False(t, legacyOutcome.ForceReplace) + require.Len(t, legacyOutcome.Results, 1) + assert.Equal(t, "kiro:legacy-session", legacyOutcome.Results[0].Result.Session.ID) + assert.Equal(t, "legacy-hash", legacyOutcome.Results[0].Result.Session.File.Hash) + + require.NoError(t, os.Remove(dbPath)) + missingOutcome, err := provider.Parse(context.Background(), ParseRequest{Source: sources[0]}) + require.NoError(t, err) + assert.True(t, missingOutcome.ResultSetComplete) + assert.True(t, missingOutcome.ForceReplace) + assert.Equal(t, SkipNoSession, missingOutcome.SkipReason) +} + +func TestKiroProviderSkipsShadowedLegacySource(t *testing.T) { + root := t.TempDir() + dbPath, db := newKiroProviderSQLiteDBAt(t, root) + seedKiroSQLiteSession( + t, db, "/home/user/code/shadowed", "shadowed-session", + readKiroFixture(t, "standard_payload.json"), + 1779012000000, 1779012030000, + ) + shadowedPath := filepath.Join(root, "shadowed-session.jsonl") + writeSourceFile(t, shadowedPath, kiroProviderJSONLFixture("Shadowed question")) + + provider, ok := NewProvider(AgentKiro, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: shadowedPath, + }) + require.NoError(t, err) + require.True(t, ok) + + outcome, err := provider.Parse(context.Background(), ParseRequest{Source: source}) + require.NoError(t, err) + assert.True(t, outcome.ResultSetComplete) + assert.Equal(t, SkipNoSession, outcome.SkipReason) + assert.Empty(t, outcome.Results) + + source, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~kiro:shadowed-session", + StoredFilePath: shadowedPath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, KiroSQLiteVirtualPath(dbPath, "shadowed-session"), source.DisplayPath) +} + +func TestKiroProviderShadowsLegacyAcrossAllRoots(t *testing.T) { + sqliteRoot := t.TempDir() + legacyRoot := t.TempDir() + dbPath, db := newKiroProviderSQLiteDBAt(t, sqliteRoot) + seedKiroSQLiteSession( + t, db, "/home/user/code/current", "shared-session", + readKiroFixture(t, "standard_payload.json"), + 1779012000000, 1779012030000, + ) + legacyPath := filepath.Join(legacyRoot, "legacy-storage.jsonl") + writeSourceFile(t, legacyPath, kiroProviderJSONLFixture("Legacy question")) + writeSourceFile(t, filepath.Join(legacyRoot, "legacy-storage.json"), + kiroProviderMetaFixture("shared-session", "/home/user/code/legacy")) + + provider, ok := NewProvider(AgentKiro, ProviderConfig{ + Roots: []string{sqliteRoot, legacyRoot}, + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, dbPath, discovered[0].DisplayPath) + + legacySource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: legacyPath, + }) + require.NoError(t, err) + require.True(t, ok) + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: legacySource, + }) + require.NoError(t, err) + assert.True(t, outcome.ResultSetComplete) + assert.Equal(t, SkipNoSession, outcome.SkipReason) + assert.Empty(t, outcome.Results) +} + +func TestKiroProviderFingerprintsSQLiteAndLegacySources(t *testing.T) { + root := t.TempDir() + payload := readKiroFixture(t, "standard_payload.json") + dbPath, db := newKiroProviderSQLiteDBAt(t, root) + seedKiroSQLiteSession( + t, db, "/home/user/code/kiro-app", "sqlite-session", + payload, + 1779012000000, 1779012030000, + ) + legacyPath := filepath.Join(root, "legacy-session.jsonl") + writeSourceFile(t, legacyPath, kiroProviderJSONLFixture("Legacy question")) + + provider, ok := NewProvider(AgentKiro, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + virtualSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sqlite-session", + }) + require.NoError(t, err) + require.True(t, ok) + virtualFingerprint, err := provider.Fingerprint(context.Background(), virtualSource) + require.NoError(t, err) + assert.Equal(t, KiroSQLiteVirtualPath(dbPath, "sqlite-session"), virtualFingerprint.Key) + assert.Equal(t, int64(len(payload)), virtualFingerprint.Size) + assert.Equal(t, int64(1779012030000)*1_000_000, virtualFingerprint.MTimeNS) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, sources) + sqliteSource := sources[0] + require.Equal(t, dbPath, sqliteSource.DisplayPath) + beforePhysical, err := provider.Fingerprint(context.Background(), sqliteSource) + require.NoError(t, err) + walPath := dbPath + "-wal" + writeSourceFile(t, walPath, "wal") + walTime := time.Unix(0, beforePhysical.MTimeNS+int64(time.Second)) + require.NoError(t, os.Chtimes(walPath, walTime, walTime)) + afterPhysical, err := provider.Fingerprint(context.Background(), sqliteSource) + require.NoError(t, err) + assert.Greater(t, afterPhysical.MTimeNS, beforePhysical.MTimeNS) + + legacySource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: legacyPath, + }) + require.NoError(t, err) + require.True(t, ok) + legacyFingerprint, err := provider.Fingerprint(context.Background(), legacySource) + require.NoError(t, err) + assert.Equal(t, legacyPath, legacyFingerprint.Key) + assert.NotEmpty(t, legacyFingerprint.Hash) +} + +func TestKiroProviderMissingSQLiteSourcesCanReachParse(t *testing.T) { + root := t.TempDir() + dbPath, db := newKiroProviderSQLiteDBAt(t, root) + seedKiroSQLiteSession( + t, db, "/home/user/code/kiro-app", "sqlite-session", + readKiroFixture(t, "standard_payload.json"), + 1779012000000, 1779012030000, + ) + + provider, ok := NewProvider(AgentKiro, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + physicalSource := sources[0] + virtualSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sqlite-session", + }) + require.NoError(t, err) + require.True(t, ok) + + _, err = db.Exec(`DELETE FROM conversations_v2 WHERE conversation_id = ?`, "sqlite-session") + require.NoError(t, err) + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: virtualSource.DisplayPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "fresh lookup must reject a deleted SQLite row") + staleVirtualSource, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: virtualSource.DisplayPath, + }) + require.NoError(t, err) + require.True(t, ok, "non-fresh lookup keeps virtual tombstone identity") + assert.Equal(t, virtualSource.DisplayPath, staleVirtualSource.DisplayPath) + virtualFingerprint, err := provider.Fingerprint(context.Background(), virtualSource) + require.NoError(t, err) + assert.Equal(t, virtualSource.FingerprintKey, virtualFingerprint.Key) + virtualOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: virtualSource, + Fingerprint: virtualFingerprint, + }) + require.NoError(t, err) + assert.True(t, virtualOutcome.ResultSetComplete) + assert.True(t, virtualOutcome.ForceReplace) + assert.Equal(t, SkipNoSession, virtualOutcome.SkipReason) + + require.NoError(t, db.Close()) + require.NoError(t, os.Remove(dbPath)) + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: physicalSource.DisplayPath, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "fresh lookup must reject a deleted SQLite DB") + physicalFingerprint, err := provider.Fingerprint(context.Background(), physicalSource) + require.NoError(t, err) + assert.Equal(t, physicalSource.FingerprintKey, physicalFingerprint.Key) + physicalOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: physicalSource, + Fingerprint: physicalFingerprint, + }) + require.NoError(t, err) + assert.True(t, physicalOutcome.ResultSetComplete) + assert.True(t, physicalOutcome.ForceReplace) + assert.Equal(t, SkipNoSession, physicalOutcome.SkipReason) +} + +func TestKiroProviderRejectsInvalidStoredSQLitePaths(t *testing.T) { + root := t.TempDir() + dbPath, db := newKiroProviderSQLiteDBAt(t, root) + seedKiroSQLiteSession( + t, db, "/home/user/code/kiro-app", "sqlite-session", + readKiroFixture(t, "standard_payload.json"), + 1779012000000, 1779012030000, + ) + provider, ok := NewProvider(AgentKiro, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + for _, path := range []string{ + dbPath + "#", + filepath.Join(root, "data-copy.sqlite3") + "#sqlite-session", + filepath.Join(root, "nested", kiroSQLiteDBName) + "#sqlite-session", + } { + _, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: path, + RequireFreshSource: true, + }) + require.NoError(t, err) + assert.False(t, ok, "stored path %q", path) + } +} + +func TestKiroIDEProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentKiroIDE) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentKiroIDE, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestKiroIDEProviderSourceMethods(t *testing.T) { + root := t.TempDir() + oldWSHash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + oldFileHash := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + oldPath := filepath.Join(root, oldWSHash, oldFileHash+".chat") + writeSourceFile(t, oldPath, kiroIDEProviderOldFixture("Old IDE question")) + newPath := filepath.Join(root, "workspace-sessions", "encoded-workspace", "new-session.json") + writeSourceFile(t, newPath, kiroIDEProviderNewFixture("New IDE question")) + writeSourceFile(t, filepath.Join(root, "workspace-sessions", "encoded-workspace", "sessions.json"), "[]\n") + writeSourceFile(t, filepath.Join(root, "default", "ignored.chat"), kiroIDEProviderOldFixture("Ignored")) + + provider, ok := NewProvider(AgentKiroIDE, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 1) + assert.Equal(t, root, plan.Roots[0].Path) + assert.True(t, plan.Roots[0].Recursive) + assert.Contains(t, plan.Roots[0].IncludeGlobs, "*.chat") + assert.Contains(t, plan.Roots[0].IncludeGlobs, "*.json") + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.Equal(t, oldPath, discovered[0].DisplayPath) + assert.Equal(t, newPath, discovered[1].DisplayPath) + + foundOld, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: oldWSHash + ":" + oldFileHash, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, oldPath, foundOld.DisplayPath) + + foundNew, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~kiro-ide:new-session", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, newPath, foundNew.DisplayPath) +} + +func TestKiroIDEProviderParsesOldAndNewSources(t *testing.T) { + root := t.TempDir() + oldWSHash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + oldFileHash := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + oldPath := filepath.Join(root, oldWSHash, oldFileHash+".chat") + writeSourceFile(t, oldPath, kiroIDEProviderOldFixture("Old IDE question")) + newPath := filepath.Join(root, "workspace-sessions", "encoded-workspace", "new-session.json") + writeSourceFile(t, newPath, kiroIDEProviderNewFixture("New IDE question")) + + provider, ok := NewProvider(AgentKiroIDE, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 2) + + oldOutcome, err := provider.Parse(context.Background(), ParseRequest{Source: sources[0]}) + require.NoError(t, err) + require.True(t, oldOutcome.ResultSetComplete) + require.Len(t, oldOutcome.Results, 1) + assert.Equal(t, "kiro-ide:"+oldWSHash+":"+oldFileHash, oldOutcome.Results[0].Result.Session.ID) + assert.Equal(t, "devbox", oldOutcome.Results[0].Result.Session.Machine) + + newOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[1], + Fingerprint: SourceFingerprint{Hash: "new-hash"}, + }) + require.NoError(t, err) + require.True(t, newOutcome.ResultSetComplete) + require.Len(t, newOutcome.Results, 1) + assert.Equal(t, "kiro-ide:new-session", newOutcome.Results[0].Result.Session.ID) + assert.Equal(t, "new-hash", newOutcome.Results[0].Result.Session.File.Hash) +} + +func TestKiroIDEProviderFingerprintsSessionContent(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "workspace-sessions", "encoded-workspace", "new-session.json") + writeSourceFile(t, path, kiroIDEProviderNewFixture("New IDE question")) + + provider, ok := NewProvider(AgentKiroIDE, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "new-session", + }) + require.NoError(t, err) + require.True(t, ok) + before, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + + writeSourceFile(t, path, kiroIDEProviderNewFixture("Changed IDE question")) + after, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.NotEqual(t, before.Hash, after.Hash) +} + +func newKiroProviderSQLiteDBAt(t *testing.T, root string) (string, *sql.DB) { + t.Helper() + dbPath := filepath.Join(root, kiroSQLiteDBName) + db, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err, "open kiro provider sqlite db") + t.Cleanup(func() { _ = db.Close() }) + _, err = db.Exec(kiroSQLiteSchema) + require.NoError(t, err, "create kiro sqlite schema") + return dbPath, db +} + +func kiroProviderJSONLFixture(question string) string { + return `{"kind":"Prompt","data":{"content":[{"kind":"text","data":"` + question + `"}]}}` + "\n" + + `{"kind":"AssistantMessage","data":{"content":[{"kind":"text","data":"Kiro answer"}]}}` + "\n" +} + +func kiroProviderMetaFixture(sessionID, cwd string) string { + return `{"session_id":"` + sessionID + `","cwd":"` + cwd + `","title":"` + sessionID + `","created_at":"2026-06-01T10:00:00Z","updated_at":"2026-06-01T10:01:00Z"}` + "\n" +} + +func kiroIDEProviderOldFixture(question string) string { + return `{"executionId":"exec-old","actionId":"act-old","chat":[{"role":"human","content":"` + question + `"},{"role":"bot","content":"Old IDE answer"}],"metadata":{"modelId":"claude-sonnet-4-6","startTime":1779012000000,"endTime":1779012030000}}` + "\n" +} + +func kiroIDEProviderNewFixture(question string) string { + return `{"sessionId":"new-session","title":"New title","workspaceDirectory":"/home/user/dev/new-app","history":[{"message":{"role":"user","content":"` + question + `","id":"m1"}},{"message":{"role":"assistant","content":"New IDE answer","id":"m2"}}]}` + "\n" +} diff --git a/internal/parser/kiro_sqlite.go b/internal/parser/kiro_sqlite.go index 03cb97842..828a5591b 100644 --- a/internal/parser/kiro_sqlite.go +++ b/internal/parser/kiro_sqlite.go @@ -58,9 +58,9 @@ func (s *KiroSQLiteStore) Close() error { return s.db.Close() } -// FindKiroSQLiteDBPath returns the current-store Kiro SQLite DB -// when the configured root contains one. -func FindKiroSQLiteDBPath(dir string) string { +// kiroSQLiteDBPath returns the current-store Kiro SQLite DB when the +// configured root contains one. +func kiroSQLiteDBPath(dir string) string { if dir == "" { return "" } @@ -75,22 +75,13 @@ func FindKiroSQLiteDBPath(dir string) string { // KiroSQLiteVirtualPath gives each conversation inside the shared // Kiro DB a stable source identity for the AgentsView archive. func KiroSQLiteVirtualPath(dbPath, sessionID string) string { - return dbPath + "#" + sessionID + return VirtualSourcePath(dbPath, sessionID) } -// ParseKiroSQLiteVirtualPath splits a virtual Kiro SQLite source +// kiroSQLiteVirtualPathParts splits a virtual Kiro SQLite source // path back into its database path and raw session ID. -func ParseKiroSQLiteVirtualPath(path string) (string, string, bool) { - idx := strings.LastIndex(path, "#") - if idx < 0 { - return "", "", false - } - dbPath, sessionID := path[:idx], path[idx+1:] - if filepath.Base(dbPath) != kiroSQLiteDBName || - sessionID == "" { - return "", "", false - } - return dbPath, sessionID, true +func kiroSQLiteVirtualPathParts(path string) (string, string, bool) { + return ParseVirtualSourcePathForBase(path, kiroSQLiteDBName) } // KiroSQLiteSessionExists reports whether the current Kiro DB has @@ -189,7 +180,7 @@ func (s *KiroSQLiteStore) ListSessionMeta() ([]KiroSQLiteSessionMeta, error) { // KiroSQLiteSessionIDs returns the set of current-store logical // conversation IDs under dir. func KiroSQLiteSessionIDs(dir string) map[string]struct{} { - dbPath := FindKiroSQLiteDBPath(dir) + dbPath := kiroSQLiteDBPath(dir) if dbPath == "" { return nil } @@ -207,7 +198,7 @@ func KiroSQLiteSessionIDs(dir string) map[string]struct{} { // KiroSQLiteSourceMtime resolves the canonical per-session // updated_at timestamp for a virtual SQLite source path. func KiroSQLiteSourceMtime(path string) (int64, error) { - dbPath, sessionID, ok := ParseKiroSQLiteVirtualPath(path) + dbPath, sessionID, ok := kiroSQLiteVirtualPathParts(path) if !ok { return 0, fmt.Errorf("not a kiro sqlite virtual path: %s", path) } @@ -218,9 +209,9 @@ func KiroSQLiteSourceMtime(path string) (int64, error) { return row.updatedAt * 1_000_000, nil } -// ParseKiroSQLiteSession parses one current-store Kiro CLI -// conversation into normal AgentsView session/message records. -func ParseKiroSQLiteSession( +// parseKiroSQLiteSession parses one current-store Kiro CLI conversation +// into normal AgentsView session/message records. +func parseKiroSQLiteSession( dbPath, sessionID, machine string, ) (*ParsedSession, []ParsedMessage, error) { store, err := OpenKiroSQLiteStore(dbPath) diff --git a/internal/parser/kiro_sqlite_test.go b/internal/parser/kiro_sqlite_test.go index ba01a9d74..2ad08d534 100644 --- a/internal/parser/kiro_sqlite_test.go +++ b/internal/parser/kiro_sqlite_test.go @@ -64,10 +64,10 @@ func TestParseKiroSQLiteSession(t *testing.T) { 1779012000000, 1779012030000, ) - sess, msgs, err := ParseKiroSQLiteSession( + sess, msgs, err := parseKiroSQLiteSession( dbPath, "sqlite-session", "test-machine", ) - require.NoError(t, err, "ParseKiroSQLiteSession") + require.NoError(t, err, "parseKiroSQLiteSession") require.NotNil(t, sess, "expected session") assert.Equal(t, "kiro:sqlite-session", sess.ID, "ID") assert.Equal(t, AgentKiro, sess.Agent, "Agent") @@ -122,7 +122,7 @@ func TestKiroSQLiteSourceMtime(t *testing.T) { assert.Equal(t, int64(7_000_000), mtime, "mtime") } -func TestParseKiroSQLiteVirtualPath(t *testing.T) { +func TestKiroSQLiteVirtualPathParts(t *testing.T) { tests := []struct { name string dbPath string @@ -142,7 +142,7 @@ func TestParseKiroSQLiteVirtualPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotDB, gotID, ok := ParseKiroSQLiteVirtualPath( + gotDB, gotID, ok := kiroSQLiteVirtualPathParts( KiroSQLiteVirtualPath(tt.dbPath, tt.sessionID), ) require.True(t, ok, "expected virtual path to parse") @@ -159,7 +159,7 @@ func TestParseKiroSQLiteSessionRejectsMalformedPayload(t *testing.T) { readKiroFixture(t, "malformed_payload.txt"), 1, 2, ) - _, _, err := ParseKiroSQLiteSession( + _, _, err := parseKiroSQLiteSession( dbPath, "broken-session", "test-machine", ) require.Error(t, err, "expected malformed payload error") diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 635afa55b..0c5dea9e3 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -380,6 +380,10 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newGeminiProviderFactory(def) case AgentKimi: return newKimiProviderFactory(def) + case AgentKiro: + return newKiroProviderFactory(def) + case AgentKiroIDE: + return newKiroIDEProviderFactory(def) case AgentKilo: return newKiloProviderFactory(def) case AgentMiMoCode: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 933a542c9..40b597295 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -41,8 +41,8 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentKimi: ProviderMigrationProviderAuthoritative, AgentClaudeAI: ProviderMigrationLegacyOnly, AgentChatGPT: ProviderMigrationLegacyOnly, - AgentKiro: ProviderMigrationLegacyOnly, - AgentKiroIDE: ProviderMigrationLegacyOnly, + AgentKiro: ProviderMigrationProviderAuthoritative, + AgentKiroIDE: ProviderMigrationProviderAuthoritative, AgentCortex: ProviderMigrationProviderAuthoritative, AgentHermes: ProviderMigrationProviderAuthoritative, AgentWorkBuddy: ProviderMigrationProviderAuthoritative, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index 3f945361d..2ae6ca068 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -56,8 +56,6 @@ var pendingShimProviderFiles = map[string]bool{ "db_backed_provider.go": true, "gemini_provider.go": true, "hermes_provider.go": true, - "kiro_ide_provider.go": true, - "kiro_provider.go": true, "opencode_provider.go": true, "positron_provider.go": true, "vibe_provider.go": true, diff --git a/internal/parser/types.go b/internal/parser/types.go index ba2a0f6f5..2c62d805c 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -385,21 +385,17 @@ var Registry = []AgentDef{ ".kiro/sessions/cli", ".local/share/kiro-cli", }, - IDPrefix: "kiro:", - FileBased: true, - DiscoverFunc: DiscoverKiroSessions, - FindSourceFunc: FindKiroSourceFile, + IDPrefix: "kiro:", + FileBased: true, }, { - Type: AgentKiroIDE, - DisplayName: "Kiro IDE", - EnvVar: "KIRO_IDE_DIR", - ConfigKey: "kiro_ide_dirs", - DefaultDirs: kiroIDEDefaultDirs(), - IDPrefix: "kiro-ide:", - FileBased: true, - DiscoverFunc: DiscoverKiroIDESessions, - FindSourceFunc: FindKiroIDESourceFile, + Type: AgentKiroIDE, + DisplayName: "Kiro IDE", + EnvVar: "KIRO_IDE_DIR", + ConfigKey: "kiro_ide_dirs", + DefaultDirs: kiroIDEDefaultDirs(), + IDPrefix: "kiro-ide:", + FileBased: true, }, { Type: AgentCortex, diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 607389871..bd619af0b 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -736,7 +736,15 @@ func providerChangedPathEventKind(path string) string { if path == "" { return "" } - if _, err := os.Lstat(path); err != nil && os.IsNotExist(err) { + // A virtual source path (e.g. a SQLite per-session path + // "#") is never a real file. Resolve it to its physical + // container so an existence check reflects whether the backing store is + // present rather than always reporting the synthetic path as removed. + statPath := path + if container, _, ok := parser.ParseVirtualSourcePath(path); ok { + statPath = container + } + if _, err := os.Lstat(statPath); err != nil && os.IsNotExist(err) { return "remove" } return "write" @@ -926,16 +934,14 @@ func isUnder(dir, path string) (string, bool) { return rel, true } -// classifyContainerPath runs the container- and SQLite-style classifiers that -// 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. +// classifyContainerPath previously ran the container- and SQLite-style +// classifiers that resolve a path whether or not it currently exists on disk. +// Every such provider (OpenCode-format stores, Kiro, Zed, Shelley, Vibe) is now +// provider-authoritative and classifies through the provider facade, so no +// legacy classifier remains here. func (e *Engine) classifyContainerPath( path string, pathExists bool, ) (parser.DiscoveredFile, bool) { - if df, ok := e.classifyKiroSQLitePath(path); ok { - return df, true - } return parser.DiscoveredFile{}, false } @@ -979,22 +985,6 @@ func (e *Engine) classifyOnePath( return df, true } - // Kiro CLI legacy: /.jsonl - for _, kiroDir := range e.agentDirs[parser.AgentKiro] { - if kiroDir == "" { - continue - } - if rel, ok := isUnder(kiroDir, path); ok { - if strings.Count(rel, sep) == 0 && - strings.HasSuffix(rel, ".jsonl") { - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentKiro, - }, true - } - } - } - // Antigravity IDE: /conversations/.db (+ -wal, -shm). // annotations/.pbtxt and brain//* sidecar events are // handled in classifyPaths via classifyAntigravitySidecarPath, @@ -1211,42 +1201,6 @@ func (e *Engine) classifyAntigravityCLIBrainPath( return nil } -func (e *Engine) classifyKiroSQLitePath( - path string, -) (parser.DiscoveredFile, bool) { - if dbPath, _, ok := parser.ParseKiroSQLiteVirtualPath(path); ok { - for _, kiroDir := range e.agentDirs[parser.AgentKiro] { - if _, under := isUnder(kiroDir, dbPath); under { - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentKiro, - }, true - } - } - } - for _, kiroDir := range e.agentDirs[parser.AgentKiro] { - if kiroDir == "" { - continue - } - rel, ok := isUnder(kiroDir, path) - if !ok { - continue - } - base := filepath.Base(rel) - if rel != "data.sqlite3" && - !strings.HasPrefix(base, "data.sqlite3-") { - continue - } - if dbPath := parser.FindKiroSQLiteDBPath(kiroDir); dbPath != "" { - return parser.DiscoveredFile{ - Path: dbPath, - Agent: parser.AgentKiro, - }, 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 @@ -1324,7 +1278,6 @@ func (e *Engine) resyncAllLocked( oldFileSessions -= e.countRootOpenCodeFormatSessions( origDB, parser.AgentOpenCode, ) - oldFileSessions -= e.countRootKiroSQLiteSessions(origDB) oldFileSessions -= e.countRootOpenCodeFormatSessions( origDB, parser.AgentKilo, ) @@ -1788,24 +1741,6 @@ func (e *Engine) countRootOpenCodeFormatSessions( return count } -func (e *Engine) countRootKiroSQLiteSessions( - database *db.DB, -) int { - var count int - err := database.Reader().QueryRow(` - SELECT COUNT(*) FROM sessions - WHERE agent = ? - AND file_path LIKE ? - AND message_count > 0 - AND relationship_type NOT IN ('subagent', 'fork') - AND deleted_at IS NULL - `, string(parser.AgentKiro), "%data.sqlite3#%").Scan(&count) - if err != nil { - log.Printf("count root kiro sqlite sessions: %v", err) - } - return count -} - // Sync state keys persisted in pg_sync_state. const ( syncStateStartedAt = "last_sync_started_at" @@ -2176,62 +2111,6 @@ func (e *Engine) syncAllLocked( e.reportProgress(onProgress, dbProgress) } - // Sync current Kiro CLI sessions (SQLite-backed). - tKiro := time.Now() - var kiroPending []pendingWrite - if scope.includesAny(e.agentDirs[parser.AgentKiro]) { - kiroPending = e.syncKiroSQLite(ctx, scope) - } - if len(kiroPending) > 0 { - stats.TotalSessions += len(kiroPending) - tWrite := time.Now() - var kiroWritten int - if writeMode == syncWriteBulk { - var failedWrites int - kiroWritten, _, failedWrites = e.writeBatch( - kiroPending, writeMode, true, - ) - for range failedWrites { - stats.RecordFailed() - } - } else { - resolveWorktreeProject := e.loadWorktreeProjectResolver() - for _, pw := range kiroPending { - if ctx.Err() != nil { - break - } - switch err := e.writeSessionFullWithResolver( - pw, resolveWorktreeProject, - ); { - case err == nil: - kiroWritten++ - case isIntentionalSessionSkip(err), - errors.Is(err, errSessionPreserved): - default: - stats.RecordFailed() - } - } - } - stats.RecordSynced(kiroWritten) - if verbose { - log.Printf( - "kiro sqlite write: %d sessions in %s", - len(kiroPending), - time.Since(tWrite).Round(time.Millisecond), - ) - } - } - if verbose { - log.Printf( - "kiro sqlite sync: %s", - time.Since(tKiro).Round(time.Millisecond), - ) - } - advanceDBProgress( - e.countDBBackedProgressTotal(parser.AgentKiro, scope), - kiroPending, - ) - if ctx.Err() != nil { stats.Aborted = true return stats @@ -2765,7 +2644,7 @@ func discoveredFileMtime( file parser.DiscoveredFile, ) (int64, error) { if file.Agent == parser.AgentKiro { - if _, _, ok := parser.ParseKiroSQLiteVirtualPath(file.Path); ok { + if _, _, ok := parseKiroSQLiteVirtualPath(file.Path); ok { return parser.KiroSQLiteSourceMtime(file.Path) } } @@ -3060,8 +2939,6 @@ func (e *Engine) countDBBackedProgressTotal( continue } switch agent { - case parser.AgentKiro: - total += e.countOneKiroSQLiteSessions(dir) case parser.AgentWarp: total += e.countOneWarpSessions(dir) case parser.AgentForge: @@ -3081,7 +2958,6 @@ func (e *Engine) countDBBackedSessions( } total := 0 for _, agent := range []parser.AgentType{ - parser.AgentKiro, parser.AgentWarp, parser.AgentForge, parser.AgentPiebald, @@ -3091,166 +2967,6 @@ func (e *Engine) countDBBackedSessions( return total } -func (e *Engine) filterShadowedLegacyKiroFiles( - files []parser.DiscoveredFile, -) []parser.DiscoveredFile { - if !hasLegacyKiroCandidates(files) { - return files - } - - currentIDs := make(map[string]struct{}) - for _, dir := range e.agentDirs[parser.AgentKiro] { - for id := range parser.KiroSQLiteSessionIDs(dir) { - currentIDs[id] = struct{}{} - } - } - if len(currentIDs) == 0 { - return files - } - - out := files[:0] - for _, file := range files { - if file.Agent != parser.AgentKiro || - filepath.Base(file.Path) == "data.sqlite3" { - out = append(out, file) - continue - } - legacyID := parser.KiroSessionIDFromPath(file.Path) - if _, shadowed := currentIDs[legacyID]; shadowed { - continue - } - out = append(out, file) - } - return out -} - -func hasLegacyKiroCandidates(files []parser.DiscoveredFile) bool { - for _, file := range files { - if file.Agent == parser.AgentKiro && - filepath.Base(file.Path) != "data.sqlite3" { - return true - } - } - return false -} - -func (e *Engine) isShadowedLegacyKiroPath(path string) bool { - if filepath.Base(path) == "data.sqlite3" { - return false - } - legacyID := parser.KiroSessionIDFromPath(path) - if legacyID == "" { - return false - } - for _, dir := range e.agentDirs[parser.AgentKiro] { - dbPath := parser.FindKiroSQLiteDBPath(dir) - if dbPath != "" && - parser.KiroSQLiteSessionExists(dbPath, legacyID) { - return true - } - } - return false -} - -func (e *Engine) kiroSQLitePendingSessionIDs( - metas []parser.KiroSQLiteSessionMeta, -) []string { - var changed []string - for _, meta := range metas { - _, storedMtime, ok := e.db.GetFileInfoByPath(meta.VirtualPath) - if ok && storedMtime == meta.FileMtime && - e.db.GetDataVersionByPath(meta.VirtualPath) >= - db.CurrentDataVersion() { - continue - } - changed = append(changed, meta.SessionID) - } - return changed -} - -func (e *Engine) countOneKiroSQLiteSessions(dir string) int { - dbPath := parser.FindKiroSQLiteDBPath(dir) - if dbPath == "" { - return 0 - } - store, err := parser.OpenKiroSQLiteStore(dbPath) - if err != nil { - log.Printf("sync kiro sqlite: %v", err) - return 0 - } - defer store.Close() - metas, err := store.ListSessionMeta() - if err != nil { - log.Printf("sync kiro sqlite: %v", err) - return 0 - } - return len(metas) -} - -func (e *Engine) syncKiroSQLite( - ctx context.Context, scope *rootSyncScope, -) []pendingWrite { - var allPending []pendingWrite - for _, dir := range e.agentDirs[parser.AgentKiro] { - if ctx.Err() != nil { - break - } - if dir == "" || !scope.includes(dir) { - continue - } - allPending = append( - allPending, e.syncOneKiroSQLite(ctx, dir)..., - ) - } - return allPending -} - -func (e *Engine) syncOneKiroSQLite( - ctx context.Context, dir string, -) []pendingWrite { - dbPath := parser.FindKiroSQLiteDBPath(dir) - if dbPath == "" { - return nil - } - store, err := parser.OpenKiroSQLiteStore(dbPath) - if err != nil { - log.Printf("sync kiro sqlite: %v", err) - return nil - } - defer store.Close() - metas, err := store.ListSessionMeta() - if err != nil { - log.Printf("sync kiro sqlite: %v", err) - return nil - } - changed := e.kiroSQLitePendingSessionIDs(metas) - if len(changed) == 0 { - return nil - } - - var pending []pendingWrite - for _, sid := range changed { - if ctx.Err() != nil { - break - } - sess, msgs, err := store.ParseSession( - sid, e.machine, - ) - if err != nil { - log.Printf("kiro sqlite session %s: %v", sid, err) - continue - } - if sess == nil { - continue - } - pending = append(pending, pendingWrite{ - sess: *sess, - msgs: msgs, - }) - } - return pending -} - // startWorkers fans out file processing across a worker pool // and returns a channel of results. When ctx is cancelled, // workers skip remaining jobs with a context error instead @@ -3557,7 +3273,7 @@ func (e *Engine) processFile( info, err = parser.AntigravityFileInfo(file.Path) default: statPath := file.Path - if dbPath, _, ok := parser.ParseKiroSQLiteVirtualPath(file.Path); ok { + if dbPath, _, ok := parseKiroSQLiteVirtualPath(file.Path); ok { statPath = dbPath } else if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(file.Path, "threads.db"); ok { statPath = dbPath @@ -3630,10 +3346,6 @@ func (e *Engine) processFile( switch file.Agent { case parser.AgentReasonix: res = e.processReasonix(file, info) - case parser.AgentKiro: - res = e.processKiro(file, info) - case parser.AgentKiroIDE: - res = e.processKiroIDE(file, info) case parser.AgentAntigravity: res = e.processAntigravity(file, info) case parser.AgentAntigravityCLI: @@ -4300,10 +4012,10 @@ func (e *Engine) shouldCacheSkip( file parser.DiscoveredFile, ) bool { if file.Agent == parser.AgentKiro { - if filepath.Base(file.Path) == "data.sqlite3" { + if filepath.Base(file.Path) == kiroSQLiteDBName { return false } - if _, _, ok := parser.ParseKiroSQLiteVirtualPath(file.Path); ok { + if _, _, ok := parseKiroSQLiteVirtualPath(file.Path); ok { return false } } @@ -5390,138 +5102,6 @@ func reasonixEffectiveInfo(path string, info os.FileInfo) os.FileInfo { return fakeSnapshotInfo{fSize: size, fMtime: mtime} } -func (e *Engine) processKiro( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if dbPath, sessionID, ok := parser.ParseKiroSQLiteVirtualPath(file.Path); ok { - sess, msgs, err := parser.ParseKiroSQLiteSession( - dbPath, sessionID, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - forceReplace: true, - } - } - if filepath.Base(file.Path) == "data.sqlite3" { - store, err := parser.OpenKiroSQLiteStore(file.Path) - if err != nil { - return processResult{err: err} - } - defer store.Close() - metas, err := store.ListSessionMeta() - if err != nil { - return processResult{err: err} - } - 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 - } - sess, msgs, err := store.ParseSession( - meta.SessionID, e.machine, - ) - if err != nil { - if e.forceParse { - sessionErrs = append(sessionErrs, sessionParseError{ - sessionID: meta.SessionID, - virtualPath: meta.VirtualPath, - err: err, - }) - } else { - log.Printf( - "kiro sqlite watch session %s: %v", - meta.SessionID, err, - ) - } - continue - } - if sess == nil { - continue - } - results = append(results, parser.ParseResult{ - Session: *sess, - Messages: msgs, - }) - } - return processResult{ - results: results, - sessionErrs: sessionErrs, - forceReplace: true, - } - } - if e.isShadowedLegacyKiroPath(file.Path) { - return processResult{skip: true} - } - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseKiroSession( - 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 - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - -func (e *Engine) processKiroIDE( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseKiroIDESession( - 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 - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - // vibeEffectiveInfo returns size/mtime for a Vibe session that account // for the sibling meta.json file: size is the sum of both files, and // mtime is the larger of the two. Returns info unchanged when meta.json @@ -7528,7 +7108,7 @@ func (e *Engine) FindSourceFile(sessionID string) string { } if def.Type == parser.AgentKiro { for _, dir := range e.agentDirs[parser.AgentKiro] { - dbPath := parser.FindKiroSQLiteDBPath(dir) + dbPath := kiroSQLiteDBPath(dir) if dbPath == "" || !parser.KiroSQLiteSessionExists( dbPath, rawSessionID, @@ -7714,7 +7294,7 @@ func (e *Engine) SourceMtime(sessionID string) int64 { return mtime } if def.Type == parser.AgentKiro { - if _, _, ok := parser.ParseKiroSQLiteVirtualPath(path); ok { + if _, _, ok := parseKiroSQLiteVirtualPath(path); ok { mtime, err := parser.KiroSQLiteSourceMtime(path) if err != nil { return 0 @@ -7868,15 +7448,7 @@ func (e *Engine) SyncSingleSessionContext( // provider-authoritative: their SQLite virtual paths and storage // sessions resync through the generic processFile path below, which // routes to the provider facade. - if def.Type == parser.AgentKiro && - isKiroSQLiteVirtualPath(path) { - err = e.syncSingleKiroSQLite(sessionID) - if errors.Is(err, errSessionPreserved) { - preserved = true - return nil - } - return err - } + agent := def.Type // Clear skip cache so explicit re-sync always processes @@ -8089,63 +7661,80 @@ func (e *Engine) applyWorktreeMappingToSingleSession( return nil } -func (e *Engine) syncSingleKiroSQLite( - sessionID string, -) error { - rawID := strings.TrimPrefix(sessionID, "kiro:") +// filterShadowedLegacyKiroFiles drops discovered legacy Kiro JSONL sources +// whose logical session ID already exists in a current-store SQLite database +// under any configured Kiro root. The Kiro provider performs the same +// shadowing during its own Discover, but only across the roots it is +// configured with; a scoped sync (e.g. SyncRootsSince over a single root) +// configures the provider with that scope only, so the engine reapplies the +// cross-root shadow here using every configured Kiro root. This keeps a legacy +// file from being imported when its session lives in the SQLite store of a +// different, out-of-scope root. +func (e *Engine) filterShadowedLegacyKiroFiles( + files []parser.DiscoveredFile, +) []parser.DiscoveredFile { + if !hasLegacyKiroCandidates(files) { + return files + } - var lastErr error + currentIDs := make(map[string]struct{}) for _, dir := range e.agentDirs[parser.AgentKiro] { - dbPath := parser.FindKiroSQLiteDBPath(dir) - if dbPath == "" { - continue - } - store, err := parser.OpenKiroSQLiteStore(dbPath) - if err != nil { - lastErr = err - continue - } - sess, msgs, err := store.ParseSession( - rawID, e.machine, - ) - if closeErr := store.Close(); closeErr != nil && err == nil { - lastErr = closeErr - continue + for id := range parser.KiroSQLiteSessionIDs(dir) { + currentIDs[id] = struct{}{} } - if err != nil { - lastErr = err + } + if len(currentIDs) == 0 { + return files + } + + out := files[:0] + for _, file := range files { + if file.Agent != parser.AgentKiro || + filepath.Base(file.Path) == kiroSQLiteDBName { + out = append(out, file) continue } - if sess == nil { + legacyID := parser.KiroSessionIDFromPath(file.Path) + if _, shadowed := currentIDs[legacyID]; shadowed { continue } - if err := e.writeSessionFull( - pendingWrite{sess: *sess, msgs: msgs}, - ); err != nil && - !isIntentionalSessionSkip(err) && - !errors.Is(err, errSessionPreserved) { - return fmt.Errorf("write session %s: %w", - sess.ID, err) - } else if errors.Is(err, errSessionPreserved) { - return err + out = append(out, file) + } + return out +} + +func hasLegacyKiroCandidates(files []parser.DiscoveredFile) bool { + for _, file := range files { + if file.Agent == parser.AgentKiro && + filepath.Base(file.Path) != kiroSQLiteDBName { + return true } - return nil } + return false +} + +// kiroSQLiteDBName is the filename of the current-store Kiro SQLite DB. +const kiroSQLiteDBName = "data.sqlite3" - if len(e.agentDirs[parser.AgentKiro]) == 0 { - return fmt.Errorf("kiro dir not configured") +// kiroSQLiteDBPath returns the current-store Kiro SQLite DB path when the +// configured root contains one, or "" otherwise. +func kiroSQLiteDBPath(dir string) string { + if dir == "" { + return "" } - if lastErr != nil { - return fmt.Errorf( - "kiro sqlite session %s: %w", sessionID, lastErr, - ) + path := filepath.Join(dir, kiroSQLiteDBName) + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return "" } - return fmt.Errorf("kiro sqlite session %s not found", sessionID) + return path } -func isKiroSQLiteVirtualPath(path string) bool { - _, _, ok := parser.ParseKiroSQLiteVirtualPath(path) - return ok +// parseKiroSQLiteVirtualPath splits a virtual Kiro SQLite source path back +// into its database path and raw session ID using the provider-neutral +// virtual-source-path resolver. +func parseKiroSQLiteVirtualPath(path string) (string, string, bool) { + return parser.ParseVirtualSourcePathForBase(path, kiroSQLiteDBName) } func (e *Engine) warpPendingSessionIDs(dir string) []string { diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index 2064e3567..5fa1a7389 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -316,8 +316,12 @@ func TestSyncEngineKiroSQLiteCurrentStoreShadowsLegacy(t *testing.T) { }) assertSessionProject(t, env.db, "kiro:overlap-session", "current_kiro") + // Kiro is provider-authoritative: the current-store database is + // rediscovered and re-parsed on every full sync (force-replace), so an + // idempotent resync re-counts and re-writes the SQLite-backed session + // rather than skipping it. The legacy file stays shadowed throughout. runSyncAndAssert(t, env.engine, sync.SyncStats{ - TotalSessions: 0, Synced: 0, Skipped: 0, + TotalSessions: 1, Synced: 1, Skipped: 0, }) sess, err := env.db.GetSessionFull( context.Background(), "kiro:overlap-session", @@ -392,8 +396,12 @@ func TestSyncEngineKiroSQLiteMalformedUpdatePreservesArchive(t *testing.T) { readKiroSQLiteFixture(t, "malformed_payload.txt"), 1779012040000, ) + // Kiro is provider-authoritative: the database is rediscovered and + // re-parsed (TotalSessions counts the source), but the malformed payload + // yields no parseable session, so nothing is written and the previously + // archived session is preserved. runSyncAndAssert(t, env.engine, sync.SyncStats{ - TotalSessions: 0, Synced: 0, Skipped: 0, + TotalSessions: 1, Synced: 0, Skipped: 0, }) assertSessionMessageCount(t, env.db, "kiro:sqlite-session", 4) } diff --git a/internal/sync/parsediff.go b/internal/sync/parsediff.go index b95fa7b9e..ab9548773 100644 --- a/internal/sync/parsediff.go +++ b/internal/sync/parsediff.go @@ -356,17 +356,6 @@ func (e *Engine) parseDiffDatabaseSources( continue } switch def.Type { - case parser.AgentKiro: - for _, dir := range e.agentDirs[def.Type] { - if dir == "" { - continue - } - if dbPath := parser.FindKiroSQLiteDBPath(dir); dbPath != "" { - extra = append(extra, parser.DiscoveredFile{ - Path: dbPath, Agent: parser.AgentKiro, - }) - } - } case parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode: for _, dir := range e.agentDirs[def.Type] { if dir == "" { @@ -451,7 +440,7 @@ func stripVirtualSourceSuffix(path string) string { if historyPath, _, ok := parser.ParseAiderVirtualPath(path); ok { return historyPath } - if dbPath, _, ok := parser.ParseKiroSQLiteVirtualPath(path); ok { + if dbPath, _, ok := parseKiroSQLiteVirtualPath(path); ok { return dbPath } if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(path, "threads.db"); ok { diff --git a/internal/sync/provider_shadow_kiro_family_test.go b/internal/sync/provider_shadow_kiro_family_test.go new file mode 100644 index 000000000..f75ce007c --- /dev/null +++ b/internal/sync/provider_shadow_kiro_family_test.go @@ -0,0 +1,111 @@ +package sync_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/parser" + "go.kenn.io/agentsview/internal/sync" +) + +// TestKiroProviderAuthoritativeParsesSQLiteSource exercises the Kiro +// provider end to end now that Kiro is provider-authoritative and the legacy +// package-level entrypoints have been folded away. The provider discovers the +// current-store data.sqlite3 source, fans it out per conversation, and emits a +// force-replace result set so the archive cleanup semantics are preserved. +func TestKiroProviderAuthoritativeParsesSQLiteSource(t *testing.T) { + root := t.TempDir() + store := createKiroSQLiteDB(t, root) + sessionID := "sqlite-session" + store.addSession( + t, + "/home/user/code/kiro-app", + sessionID, + readKiroSQLiteFixture(t, "standard_payload.json"), + 1779012000000, + 1779012030000, + ) + + provider, ok := parser.NewProvider(parser.AgentKiro, parser.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) + + observation, err := sync.ObserveProviderSource(context.Background(), provider, sync.ProviderObserveRequest{ + Source: sources[0], + Machine: "devbox", + }) + require.NoError(t, err) + require.Len(t, observation.Results, 1) + + assert.True(t, observation.ForceReplace) + session := observation.Results[0].Session + assert.Equal(t, "kiro:"+sessionID, session.ID) + assert.Equal(t, parser.AgentKiro, session.Agent) + assert.Equal(t, "devbox", session.Machine) + assert.Equal(t, store.path+"#"+sessionID, session.File.Path) + assert.NotEmpty(t, observation.Results[0].Messages) + assert.Equal(t, []string{session.ID}, observation.Planned.DataVersionSessionIDs()) + assert.Empty(t, observation.Planned.Diagnostics) +} + +// TestKiroIDEProviderAuthoritativeParsesWorkspaceSession exercises the Kiro +// IDE provider end to end after the fold: discovery and parse own the +// workspace-sessions JSON source without any legacy package-level entrypoint. +func TestKiroIDEProviderAuthoritativeParsesWorkspaceSession(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join( + root, + "workspace-sessions", + "encoded-workspace", + "new-session.json", + ) + writeKiroIDEProviderSource(t, sourcePath, "New IDE question") + + provider, ok := parser.NewProvider(parser.AgentKiroIDE, parser.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) + + observation, err := sync.ObserveProviderSource(context.Background(), provider, sync.ProviderObserveRequest{ + Source: sources[0], + Machine: "devbox", + }) + require.NoError(t, err) + require.Len(t, observation.Results, 1) + + session := observation.Results[0].Session + assert.Equal(t, parser.AgentKiroIDE, session.Agent) + assert.Equal(t, "devbox", session.Machine) + assert.Equal(t, observation.Fingerprint.Hash, session.File.Hash) + assert.NotEmpty(t, observation.Results[0].Messages) + assert.Equal(t, []string{session.ID}, observation.Planned.DataVersionSessionIDs()) + assert.Empty(t, observation.Planned.Diagnostics) +} + +func writeKiroIDEProviderSource(t *testing.T, path, question string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte( + `{"sessionId":"new-session",`+ + `"title":"New title",`+ + `"workspaceDirectory":"/home/user/dev/new-app",`+ + `"history":[`+ + `{"message":{"role":"user","content":"`+question+`","id":"m1"}},`+ + `{"message":{"role":"assistant","content":"New IDE answer","id":"m2"}}`+ + `]}`+"\n", + ), 0o644)) +}