From c7c67909f0ea432572690934f88a86dff450e09e Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 24 Jun 2026 20:59:03 -0400 Subject: [PATCH] feat(parser): migrate kiro providers Kiro has two source families that were still coupled to the legacy sync adapter: CLI JSONL plus current-store SQLite for Kiro, and old .chat plus workspace-session JSON for Kiro IDE. Moving them behind concrete providers keeps those source shapes explicit at the facade boundary. The Kiro provider preserves current-store fan-out, per-session SQLite virtual lookup, legacy JSONL shadowing, source hashing, changed-path classification, force-replace SQLite parses, per-session source errors, and Kiro IDE old/new session parsing through the existing parsers. fix(parser): prefer kiro sqlite lookup Kiro sessions can migrate from legacy JSONL files into the current-store SQLite database while the persisted row still points at the old source path. Source lookup needs to treat the session ID as authoritative in that case, otherwise explicit resyncs can resolve the shadowed JSONL file and skip the current SQLite session. fix(parser): align kiro provider shadowing The legacy Kiro sync path treated current-store SQLite sessions as globally shadowing legacy JSONL files across all configured roots. The provider needs the same behavior so multi-root setups do not parse the same logical session from both source families. Deleted SQLite DBs and per-session rows also need to fingerprint as tombstones so the provider caller can still reach parse and produce the force-replace SkipNoSession outcome used for archive cleanup. fix(parser): shadow kiro providers The Kiro provider branch had concrete Kiro and Kiro IDE providers, but the migration manifest still held both agents on legacy-only mode. That prevented the provider bridge from exercising their changed-path behavior during the dual-run phase. Move both Kiro agents into shadow compare on their migration branch so the stack remains a runtime migration instead of an additive provider implementation. Validation: go test -tags "fts5" ./internal/parser -run 'Test(KiroProvider|KiroIDEProvider|ProviderMigrationModes)' -count=1; go test -tags "fts5" ./internal/sync -run 'Test.*Kiro' -count=1; go test -tags "fts5" ./internal/parser -count=1; go test -tags "fts5" ./internal/sync -count=1; go vet ./...; git diff --check test(sync): compare kiro family shadow parity Kiro and Kiro IDE are shadow-compared on this branch, so the migration should prove provider observation still matches the legacy parsers that currently feed sync writes. Cover Kiro SQLite database sources and Kiro IDE workspace-session JSON sources through ObserveProviderSource, including force-replace intent and data-version planning. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatches(KiroSQLite|KiroIDE)LegacyParser|TestKiroProvider|TestKiroIDEProvider' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/...; git diff --check test(parser): cover kiro stored source hints Kiro's SQLite provider already supports tombstone parsing for missing database rows and deleted databases, but fresh stored-source lookup still accepted those stale hints. During the dual-run migration, callers use RequireFreshSource to distinguish explicit fresh lookup from changed-path cleanup, so the provider needs to honor that contract before legacy dispatch can be removed. Fresh stored SQLite paths now require the physical DB or virtual row to exist, while non-fresh lookup still preserves source identity for SkipNoSession tombstones. The tests also reject malformed and stale SQLite virtual paths under the Kiro root. Validation: go test -tags "fts5" ./internal/parser -run 'TestKiro' -count=1; go test -tags "fts5" ./internal/sync -run 'TestObserveProviderSourceMatchesKiro(SQLite|IDE)LegacyParser|TestProcessFileProviderAuthoritativeSourceErrorsOnlyForceParse' -count=1 -v; go fmt ./...; go vet ./...; GOMAXPROCS=1 GOGC=5 GOMEMLIMIT=128MiB ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser; git diff --check Generated with Codex Co-authored-by: Codex refactor(parser): fold kiro providers Kiro and Kiro IDE were still dual-running: concrete providers existed but the migration manifest held both on shadow-compare, so the legacy package-level entrypoints and a large legacy sync dispatch still owned writes. Promote both agents to provider-authoritative and delete that legacy surface so the providers are the single source of truth. The eight legacy free functions are removed: DiscoverKiroSessions, FindKiroSourceFile, ParseKiroSession, FindKiroSQLiteDBPath, ParseKiroSQLiteVirtualPath, ParseKiroSQLiteSession (Kiro) and FindKiroIDESourceFile, ParseKiroIDESession (Kiro IDE). Discovery, legacy-JSONL source lookup, and both parse paths move onto the concrete providers; the orphaned DiscoverKiroIDESessions helper goes with them. SQLite virtual-path handling is preserved through the provider-neutral resolver. The Kiro provider continues to give each conversation row a stable identity via KiroSQLiteVirtualPath/VirtualSourcePath and resolves a "#" path back through ParseVirtualSourcePathForBase (now via the unexported kiroSQLiteVirtualPathParts in the parser and a sync-package equivalent). Current-store fan-out, per-session virtual lookup, cross-root legacy shadowing, source hashing, force-replace SQLite parses, and per-session source errors all keep their existing behavior. The engine loses its kiro legacy dispatch: the bulk syncKiroSQLite phase, classifyKiroSQLitePath plus the legacy-JSONL classifyOnePath block, the processKiro/processKiroIDE arms and methods, syncSingleKiroSQLite, and the now-redundant per-session count/shadow helpers. Provider discovery now emits the data.sqlite3 source and processProviderFile fans it out, so the DB is counted once via normal file sync instead of the separate DB-backed accounting. The cross-root legacy shadow filter stays in the engine because a scoped sync configures the provider with only the in-scope roots and cannot otherwise see a current-store DB in an out-of-scope root. providerChangedPathEventKind now resolves a virtual source path to its physical container before the existence check so a per-session SQLite resync via SyncPaths is treated as a write rather than a phantom remove. Parse-diff discovers kiro through the provider facade (parseDiffProviderDiscover) now that it carries no DiscoverFunc hook. The two shadow-baseline assertions that encoded the old bulk-sync idempotency (a no-op resync counting zero) are updated for the authoritative model, where the database is rediscovered and re-parsed every full sync; archive preservation on a malformed update is unchanged. The shadow parity test is replaced with provider-API coverage, a parser guard asserts the legacy entrypoints stay gone, and both provider files leave the pending-shim scan list. fix(parser): thread ctx through kiro_ide source lookups --- internal/parser/kiro.go | 14 +- internal/parser/kiro_ide.go | 125 +--- internal/parser/kiro_ide_provider.go | 151 +++++ internal/parser/kiro_provider.go | 630 ++++++++++++++++++ internal/parser/kiro_provider_test.go | 517 ++++++++++++++ internal/parser/kiro_sqlite.go | 33 +- internal/parser/kiro_sqlite_test.go | 10 +- internal/parser/provider.go | 4 + internal/parser/provider_migration.go | 4 +- internal/parser/provider_shim_scan_test.go | 2 - internal/parser/types.go | 22 +- internal/sync/engine.go | 573 +++------------- internal/sync/engine_integration_test.go | 12 +- internal/sync/parsediff.go | 13 +- .../sync/provider_shadow_kiro_family_test.go | 111 +++ 15 files changed, 1542 insertions(+), 679 deletions(-) create mode 100644 internal/parser/kiro_ide_provider.go create mode 100644 internal/parser/kiro_provider.go create mode 100644 internal/parser/kiro_provider_test.go create mode 100644 internal/sync/provider_shadow_kiro_family_test.go 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)) +}