Skip to content

Commit c7c6790

Browse files
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 <codex@openai.com> 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 "<db>#<sessionID>" 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
1 parent 347a284 commit c7c6790

15 files changed

Lines changed: 1542 additions & 679 deletions

internal/parser/kiro.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ type kiroMeta struct {
2828
UpdatedAt string `json:"updated_at"`
2929
}
3030

31-
// DiscoverKiroSessions finds all .jsonl session files under the
32-
// Kiro CLI sessions directory. Layout:
31+
// discoverLegacyJSONL finds all .jsonl session files under a Kiro
32+
// CLI sessions directory. Layout:
3333
// <sessionsDir>/<uuid>.jsonl (with companion <uuid>.json)
34-
func DiscoverKiroSessions(sessionsDir string) []DiscoveredFile {
34+
func (s kiroSourceSet) discoverLegacyJSONL(sessionsDir string) []DiscoveredFile {
3535
entries, err := os.ReadDir(sessionsDir)
3636
if err != nil {
3737
return nil
@@ -58,9 +58,9 @@ func DiscoverKiroSessions(sessionsDir string) []DiscoveredFile {
5858
return files
5959
}
6060

61-
// FindKiroSourceFile locates a Kiro session file by its raw
61+
// legacySourceFile locates a legacy Kiro JSONL session file by its raw
6262
// session ID (without the "kiro:" prefix).
63-
func FindKiroSourceFile(sessionsDir, rawID string) string {
63+
func (s kiroSourceSet) legacySourceFile(sessionsDir, rawID string) string {
6464
if sessionsDir == "" || !IsValidSessionID(rawID) {
6565
return ""
6666
}
@@ -100,10 +100,10 @@ func loadKiroMeta(jsonlPath string) *kiroMeta {
100100
return &m
101101
}
102102

103-
// ParseKiroSession parses a Kiro CLI session from its JSONL file.
103+
// parseLegacySession parses a Kiro CLI session from its JSONL file.
104104
// Returns (nil, nil, nil) if the file doesn't exist or contains
105105
// no user/assistant messages.
106-
func ParseKiroSession(
106+
func (p *kiroProvider) parseLegacySession(
107107
path, machine string,
108108
) (*ParsedSession, []ParsedMessage, error) {
109109
info, err := os.Stat(path)

internal/parser/kiro_ide.go

Lines changed: 2 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9-
"sort"
109
"strings"
1110
"time"
1211

@@ -98,126 +97,6 @@ type kiroIDEActionOutput struct {
9897
Message string `json:"message"`
9998
}
10099

101-
// DiscoverKiroIDESessions finds all session files under the
102-
// Kiro IDE globalStorage directory. It scans both:
103-
// - <dir>/<workspace-hash>/<execution-hash>.chat (old format)
104-
// - <dir>/workspace-sessions/<b64-path>/<uuid>.json (new format)
105-
func DiscoverKiroIDESessions(dir string) []DiscoveredFile {
106-
entries, err := os.ReadDir(dir)
107-
if err != nil {
108-
return nil
109-
}
110-
111-
var files []DiscoveredFile
112-
113-
for _, wsEntry := range entries {
114-
if !wsEntry.IsDir() {
115-
continue
116-
}
117-
name := wsEntry.Name()
118-
if name == "default" || name == "dev_data" ||
119-
name == "index" || name == "workspace-sessions" ||
120-
strings.HasPrefix(name, ".") {
121-
continue
122-
}
123-
124-
wsDir := filepath.Join(dir, name)
125-
chatFiles, err := os.ReadDir(wsDir)
126-
if err != nil {
127-
continue
128-
}
129-
for _, cf := range chatFiles {
130-
if cf.IsDir() ||
131-
!strings.HasSuffix(cf.Name(), ".chat") {
132-
continue
133-
}
134-
files = append(files, DiscoveredFile{
135-
Path: filepath.Join(wsDir, cf.Name()),
136-
Agent: AgentKiroIDE,
137-
})
138-
}
139-
}
140-
141-
// Scan workspace-sessions for new-format session JSONs.
142-
wsSessionsDir := filepath.Join(dir, "workspace-sessions")
143-
wsDirs, err := os.ReadDir(wsSessionsDir)
144-
if err == nil {
145-
for _, wsEntry := range wsDirs {
146-
if !wsEntry.IsDir() {
147-
continue
148-
}
149-
wsDir := filepath.Join(wsSessionsDir, wsEntry.Name())
150-
jsonFiles, err := os.ReadDir(wsDir)
151-
if err != nil {
152-
continue
153-
}
154-
for _, jf := range jsonFiles {
155-
name := jf.Name()
156-
if name == "sessions.json" ||
157-
!strings.HasSuffix(name, ".json") {
158-
continue
159-
}
160-
files = append(files, DiscoveredFile{
161-
Path: filepath.Join(wsDir, name),
162-
Agent: AgentKiroIDE,
163-
})
164-
}
165-
}
166-
}
167-
168-
sort.Slice(files, func(i, j int) bool {
169-
return files[i].Path < files[j].Path
170-
})
171-
return files
172-
}
173-
174-
// FindKiroIDESourceFile locates a Kiro IDE session file by
175-
// raw session ID. Supports both formats:
176-
// - Old: "<workspace-hash>:<filename-hash>" → .chat file
177-
// - New: "<uuid>" → workspace-sessions/*/<uuid>.json
178-
func FindKiroIDESourceFile(dir, rawID string) string {
179-
cleanDir := filepath.Clean(dir)
180-
181-
// Old format: <workspace-hash>:<filename-hash>
182-
wsHash, fileHash, ok := strings.Cut(rawID, ":")
183-
if ok && IsValidSessionID(wsHash) && IsValidSessionID(fileHash) {
184-
candidate := filepath.Join(dir, wsHash, fileHash+".chat")
185-
if abs, err := filepath.Abs(candidate); err == nil &&
186-
strings.HasPrefix(abs, cleanDir) {
187-
if _, err := os.Stat(candidate); err == nil {
188-
return candidate
189-
}
190-
}
191-
}
192-
193-
// New format: rawID is a UUID, file is at
194-
// workspace-sessions/<b64-path>/<uuid>.json
195-
if !IsValidSessionID(rawID) {
196-
return ""
197-
}
198-
wsSessionsDir := filepath.Join(dir, "workspace-sessions")
199-
wsDirs, err := os.ReadDir(wsSessionsDir)
200-
if err != nil {
201-
return ""
202-
}
203-
for _, wsEntry := range wsDirs {
204-
if !wsEntry.IsDir() {
205-
continue
206-
}
207-
candidate := filepath.Join(
208-
wsSessionsDir, wsEntry.Name(), rawID+".json",
209-
)
210-
abs, err := filepath.Abs(candidate)
211-
if err != nil || !strings.HasPrefix(abs, cleanDir) {
212-
continue
213-
}
214-
if _, err := os.Stat(candidate); err == nil {
215-
return candidate
216-
}
217-
}
218-
return ""
219-
}
220-
221100
// isKiroIDESystemMessage returns true for system prompt and
222101
// rules messages that should not be shown as user content.
223102
func isKiroIDESystemMessage(content string) bool {
@@ -228,11 +107,11 @@ func isKiroIDESystemMessage(content string) bool {
228107
strings.HasPrefix(content, "You are operating in a workspace")
229108
}
230109

231-
// ParseKiroIDESession parses a Kiro IDE session file.
110+
// parseSession parses a Kiro IDE session file.
232111
// Supports both old (.chat) and new (.json) formats.
233112
// Returns (nil, nil, nil) if the file doesn't exist or
234113
// contains no meaningful messages.
235-
func ParseKiroIDESession(
114+
func parseKiroIDESession(
236115
path, machine string,
237116
) (*ParsedSession, []ParsedMessage, error) {
238117
if strings.HasSuffix(path, ".json") {
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package parser
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// Kiro IDE stores sessions in two on-disk layouts: an old format keyed by a
11+
// "<workspace-hash>:<filename-hash>" pair pointing at a <ws>/<file>.chat file,
12+
// and a new format where a UUID names a workspace-sessions/<ws>/<uuid>.json
13+
// file. It is a directory-of-files provider: discovery, watching, change
14+
// classification, and fingerprinting come from JSONLSourceSet. The ParseFile
15+
// option makes that source set a full SourceSet so it rides the generic
16+
// factory; RawSessionIDSourceFiles reconstructs the file path for the old
17+
// colon-joined IDs, which the filename-stem discovery scan cannot match.
18+
func newKiroIDEProviderFactory(def AgentDef) ProviderFactory {
19+
return newSourceSetFactory(
20+
def,
21+
kiroIDEProviderCapabilities(),
22+
func(cfg ProviderConfig) SourceSet { return newKiroIDESourceSet(cfg.Roots) },
23+
)
24+
}
25+
26+
func newKiroIDESourceSet(roots []string) JSONLSourceSet {
27+
return newJSONLSourceSet(AgentKiroIDE, roots,
28+
withRecursive(),
29+
withExtensions(".chat", ".json"),
30+
withContentHashing(),
31+
withIncludePath(isKiroIDESourcePath),
32+
withSessionIDFromPath(kiroIDESessionIDFromPath),
33+
withRawSessionIDSourceFiles(kiroIDERawSessionIDSourceFiles),
34+
withParseFile(kiroIDEParseFile),
35+
)
36+
}
37+
38+
func kiroIDEParseFile(
39+
_ context.Context, path string, req ParseRequest,
40+
) ([]ParseResult, []string, error) {
41+
sess, msgs, err := parseKiroIDESession(path, req.Machine)
42+
if err != nil {
43+
return nil, nil, err
44+
}
45+
if sess == nil {
46+
return nil, nil, nil
47+
}
48+
if req.Fingerprint.Hash != "" {
49+
sess.File.Hash = req.Fingerprint.Hash
50+
}
51+
return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil
52+
}
53+
54+
// kiroIDERawSessionIDSourceFiles reconstructs candidate file paths from a raw
55+
// session ID for both Kiro IDE layouts. The old format
56+
// "<workspace-hash>:<filename-hash>" maps to <root>/<ws>/<file>.chat. The new
57+
// format is a UUID whose file lives at workspace-sessions/<ws>/<uuid>.json, so
58+
// every workspace-sessions subdirectory under each root yields a candidate.
59+
// FindSource gates each candidate on existence via the shared path lookup.
60+
func kiroIDERawSessionIDSourceFiles(roots []string, rawID string) []string {
61+
wsHash, fileHash, hasColon := strings.Cut(rawID, ":")
62+
oldFormat := hasColon && IsValidSessionID(wsHash) && IsValidSessionID(fileHash)
63+
newFormat := IsValidSessionID(rawID)
64+
if !oldFormat && !newFormat {
65+
return nil
66+
}
67+
var candidates []string
68+
for _, root := range roots {
69+
if root == "" {
70+
continue
71+
}
72+
if oldFormat {
73+
candidates = append(
74+
candidates,
75+
filepath.Join(root, wsHash, fileHash+".chat"),
76+
)
77+
}
78+
if newFormat {
79+
wsSessionsDir := filepath.Join(root, "workspace-sessions")
80+
entries, err := os.ReadDir(wsSessionsDir)
81+
if err != nil {
82+
continue
83+
}
84+
for _, entry := range entries {
85+
if !entry.IsDir() {
86+
continue
87+
}
88+
candidates = append(candidates, filepath.Join(
89+
wsSessionsDir, entry.Name(), rawID+".json",
90+
))
91+
}
92+
}
93+
}
94+
return candidates
95+
}
96+
97+
func isKiroIDESourcePath(root, path string) bool {
98+
rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path))
99+
if !ok {
100+
return false
101+
}
102+
parts := strings.Split(rel, string(filepath.Separator))
103+
for _, part := range parts {
104+
if part == "" || part == "." || part == ".." {
105+
return false
106+
}
107+
}
108+
if len(parts) == 2 {
109+
if parts[0] == "default" || parts[0] == "dev_data" ||
110+
parts[0] == "index" || parts[0] == "workspace-sessions" ||
111+
strings.HasPrefix(parts[0], ".") {
112+
return false
113+
}
114+
return strings.HasSuffix(parts[1], ".chat")
115+
}
116+
return len(parts) == 3 &&
117+
parts[0] == "workspace-sessions" &&
118+
!strings.HasPrefix(parts[1], ".") &&
119+
parts[2] != "sessions.json" &&
120+
strings.HasSuffix(parts[2], ".json")
121+
}
122+
123+
func kiroIDESessionIDFromPath(root, path string) string {
124+
if !isKiroIDESourcePath(root, path) {
125+
return ""
126+
}
127+
rel, ok := relUnder(filepath.Clean(root), filepath.Clean(path))
128+
if !ok {
129+
return ""
130+
}
131+
parts := strings.Split(rel, string(filepath.Separator))
132+
if len(parts) == 2 {
133+
return parts[0] + ":" + strings.TrimSuffix(parts[1], ".chat")
134+
}
135+
if len(parts) == 3 {
136+
return strings.TrimSuffix(parts[2], ".json")
137+
}
138+
return ""
139+
}
140+
141+
func kiroIDEProviderCapabilities() Capabilities {
142+
return Capabilities{
143+
Source: jsonlFileProviderSourceCapabilities(),
144+
Content: ContentCapabilities{
145+
FirstMessage: CapabilitySupported,
146+
SessionName: CapabilitySupported,
147+
ToolCalls: CapabilitySupported,
148+
Model: CapabilitySupported,
149+
},
150+
}
151+
}

0 commit comments

Comments
 (0)