diff --git a/app/cli/internal/trace/state/ailines.go b/app/cli/internal/trace/state/ailines.go index 6c192795d..b976b7a7a 100644 --- a/app/cli/internal/trace/state/ailines.go +++ b/app/cli/internal/trace/state/ailines.go @@ -22,6 +22,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) @@ -30,11 +31,35 @@ import ( type AILineAttribution struct { SessionID string `json:"session_id"` Files map[string][]aicodingsession.LineRange `json:"files"` + // Pending holds each file whose recorded ranges have not been committed + // yet, and is the only set a new commit may be matched against. Files keeps + // every range ever recorded because push-time enrichment needs the full + // history, but matching against it credits a finished session on every + // later commit that happens to touch a file it once edited. + Pending map[string]PendingEdit `json:"-"` } -// newAILineAttribution returns an AILineAttribution with an initialized Files map. +// PendingEdit is an uncommitted edit to one file. +type PendingEdit struct { + // At is when the edit was made. Ownership of a file that two sessions both + // have pending goes to the later edit, which rewrote the earlier one's + // lines. + At time.Time + // Seq is the edit's position in its ledger, counting from one. Retiring an + // edit is keyed off this rather than At because wall-clock times tie on a + // host with a coarse clock, whereas an append-only ledger's positions are + // unique and strictly increasing. Positions from different ledgers are not + // comparable, which is why ownership still goes by At. + Seq int +} + +// newAILineAttribution returns an AILineAttribution with initialized maps. func newAILineAttribution(sessionID string) *AILineAttribution { - return &AILineAttribution{SessionID: sessionID, Files: make(map[string][]aicodingsession.LineRange)} + return &AILineAttribution{ + SessionID: sessionID, + Files: make(map[string][]aicodingsession.LineRange), + Pending: make(map[string]PendingEdit), + } } // aiLinesPath returns the path for a session's AI line attribution file (JSONL). @@ -49,6 +74,22 @@ type aiLineEntry struct { SessionID string `json:"session_id,omitempty"` File string `json:"file"` Ranges []aicodingsession.LineRange `json:"ranges"` + // RecordedAt is the RFC3339 timestamp of the edit, with sub-second + // precision so two sessions editing one file can be ordered. Entries + // written before this field existed have none, and are never treated as + // pending — those ledgers predate consumption tracking, so their files + // would otherwise keep crediting their session forever. + RecordedAt string `json:"recorded_at,omitempty"` + // ConsumedBy is the SHA of the commit that committed the file's pending + // ranges. Set only on marker entries appended by MarkConsumed, which + // clear the file from Pending instead of contributing ranges. + ConsumedBy string `json:"consumed_by,omitempty"` + // ConsumedSeq is the ledger position of the edit a marker retires. It + // bounds the marker to the snapshot MarkConsumed read, so an edit appended + // afterwards — which the commit cannot have contained — keeps its pending + // state. Zero on a marker written before this field existed, which retires + // the file outright. + ConsumedSeq int `json:"consumed_seq,omitempty"` } // LoadAILineAttribution loads the AI line attribution for a session by @@ -63,16 +104,42 @@ func (s *Store) LoadAILineAttribution(sessionID string) *AILineAttribution { } defer func() { _ = f.Close() }() + // Entries are appended in order, so replaying them in order lets a file + // go pending -> consumed -> pending again across successive edits and + // commits. + seq := 0 dec := json.NewDecoder(f) for dec.More() { var entry aiLineEntry if err := dec.Decode(&entry); err != nil { break } + seq++ if entry.SessionID != "" { attr.SessionID = entry.SessionID } + + if entry.ConsumedBy != "" { + // Retire only the edit the commit actually contained. A concurrent + // RecordLineRanges can land between MarkConsumed reading the + // pending set and appending its markers, leaving a marker behind an + // edit it never saw; that edit sits past the marker's snapshot, so + // it keeps its pending state. + cur, pending := attr.Pending[entry.File] + if pending && (entry.ConsumedSeq == 0 || cur.Seq <= entry.ConsumedSeq) { + delete(attr.Pending, entry.File) + } + + continue + } + attr.Files[entry.File] = append(attr.Files[entry.File], entry.Ranges...) + // An entry with no parseable timestamp is never pending: it either + // predates consumption tracking, in which case its session is long + // gone, or it cannot be ordered against a competing session's edit. + if recordedAt, err := time.Parse(time.RFC3339, entry.RecordedAt); err == nil { + attr.Pending[entry.File] = PendingEdit{At: recordedAt, Seq: seq} + } } return attr @@ -81,6 +148,69 @@ func (s *Store) LoadAILineAttribution(sessionID string) *AILineAttribution { // RecordLineRanges appends line ranges for a file to a session's attribution JSONL. // An empty or nil ranges slice still records the file as touched (e.g., deletion-only edits). func (s *Store) RecordLineRanges(sessionID, filePath string, ranges []aicodingsession.LineRange) error { + return s.RecordLineRangesAt(sessionID, filePath, ranges, time.Now().UTC()) +} + +// RecordLineRangesAt records an edit made at a given time. Ownership of a file +// two sessions both edited is decided by which edit came last, so tests that +// exercise that need to set the times rather than hope the wall clock advances +// between two calls — it does not on a host with a coarse clock. +func (s *Store) RecordLineRangesAt(sessionID, filePath string, ranges []aicodingsession.LineRange, at time.Time) error { + return s.appendAILineEntries(sessionID, []aiLineEntry{{ + SessionID: sessionID, + File: filePath, + Ranges: ranges, + RecordedAt: at.UTC().Format(time.RFC3339Nano), + }}) +} + +// MarkConsumed records that the pending ranges of the given files landed in +// commit sha, so they stop crediting the session on later commits. Files the +// session has nothing pending for are ignored, which keeps the ledger from +// growing a marker per commit per file the session never touched. +func (s *Store) MarkConsumed(sessionID string, files []string, sha string) error { + if sha == "" || len(files) == 0 { + return nil + } + + return s.markConsumedAt(s.LoadAILineAttribution(sessionID).Pending, sessionID, files, sha) +} + +// markConsumedAt writes the retirement markers for a pending snapshot. The +// snapshot is a parameter because it is the boundary this has to be correct +// across: an edit recorded after it was taken is not part of the commit, and +// pinning each marker to the position of the edit it retires is what keeps that +// edit pending. +func (s *Store) markConsumedAt(pending map[string]PendingEdit, sessionID string, files []string, sha string) error { + if len(pending) == 0 { + return nil + } + + entries := make([]aiLineEntry, 0, len(files)) + for _, file := range files { + edit, ok := pending[file] + if !ok { + continue + } + entries = append(entries, aiLineEntry{ + SessionID: sessionID, + File: file, + RecordedAt: edit.At.Format(time.RFC3339Nano), + ConsumedBy: sha, + ConsumedSeq: edit.Seq, + }) + } + + return s.appendAILineEntries(sessionID, entries) +} + +// appendAILineEntries appends entries to a session's attribution JSONL, +// creating the file and its directory on first write. +func (s *Store) appendAILineEntries(sessionID string, entries []aiLineEntry) error { + if len(entries) == 0 { + return nil + } + path := s.aiLinesPath(sessionID) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("create ai-lines dir: %w", err) @@ -91,10 +221,15 @@ func (s *Store) RecordLineRanges(sessionID, filePath string, ranges []aicodingse return err } - if err := json.NewEncoder(f).Encode(aiLineEntry{SessionID: sessionID, File: filePath, Ranges: ranges}); err != nil { - _ = f.Close() - return err + enc := json.NewEncoder(f) + for i := range entries { + if err := enc.Encode(entries[i]); err != nil { + _ = f.Close() + + return err + } } + return f.Close() } @@ -106,6 +241,7 @@ func (s *Store) LoadAllAILineAttributions() ([]*AILineAttribution, error) { if errors.Is(err, os.ErrNotExist) { return nil, nil } + return nil, err } diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go index 084c56751..1cb46fdcf 100644 --- a/app/cli/internal/trace/state/state.go +++ b/app/cli/internal/trace/state/state.go @@ -48,6 +48,12 @@ const ( commitRecordExt = ".json" sessionRecordExt = ".json" aiLinesExt = ".jsonl" + + // staleSessionRetention is how long a session's records are kept once + // everything it produced has been attested. Reachability alone never + // expires them: a session referenced by one surviving commit pins itself + // into the repository for good. + staleSessionRetention = 7 * 24 * time.Hour ) // InitTraceDir creates the /chainloop-trace/ directory structure. @@ -89,7 +95,8 @@ func (s *Store) WipeTraceDir() error { // 1. Walk commits/, drop any record whose SHA isn't in liveSHAs (post-rebase // orphan or commit on a deleted branch). // 2. Walk ai-lines/ and sessions/, drop any entry whose session ID is no -// longer referenced by a surviving CommitRecord. +// longer referenced by a surviving CommitRecord, or which is older than +// staleSessionRetention and has nothing left to attest. // // An empty liveSHAs is treated as a pathological signal (no local branches // readable, or the caller fed us garbage) and the GC short-circuits — wiping @@ -107,6 +114,9 @@ func (s *Store) GCOrphans(liveSHAs map[string]bool) error { } liveSessions := make(map[string]struct{}) + // Sessions with a surviving commit that has not been attested yet. Their + // state must survive the age check — the next push still needs it. + unattestedSessions := make(map[string]struct{}) for _, entry := range commitEntries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), commitRecordExt) { continue @@ -124,9 +134,14 @@ func (s *Store) GCOrphans(liveSHAs map[string]bool) error { } for _, sid := range rec.SessionIDs { liveSessions[sanitizeID(sid)] = struct{}{} + if !rec.Tracked { + unattestedSessions[sanitizeID(sid)] = struct{}{} + } } } + stale := s.staleSessionIDs(unattestedSessions) + for _, child := range []struct { sub string ext string @@ -148,7 +163,8 @@ func (s *Store) GCOrphans(liveSHAs map[string]bool) error { continue } id := strings.TrimSuffix(entry.Name(), child.ext) - if _, alive := liveSessions[id]; !alive { + _, alive := liveSessions[id] + if _, expired := stale[id]; !alive || expired { _ = os.Remove(filepath.Join(dir, entry.Name())) } } @@ -157,6 +173,46 @@ func (s *Store) GCOrphans(liveSHAs map[string]bool) error { return nil } +// staleSessionIDs returns the sanitized IDs of session records older than +// staleSessionRetention whose work has all been attested. Records with an +// unparseable or missing StartedAt are never reported stale, so a bad +// timestamp cannot cause data loss. +// +// Liveness is deliberately not consulted: session-end does not always run, so +// a leaked record stays Active forever and would never expire. +// +// Uncommitted attribution is deliberately not spared either, so a week-old +// edit that is committed later is attributed to nobody rather than to the +// session that made it. That is the safe direction: keeping the entry means a +// session nothing has heard from in a week can still claim a file and pull the +// whole commit's diff into its evidence, and by then its transcript is usually +// gone — raw/ is wiped on every push — so the attestation it wins yields +// nothing anyway. Under-crediting beats crediting the wrong session. +func (s *Store) staleSessionIDs(unattested map[string]struct{}) map[string]struct{} { + records, err := s.LoadAllSessionRecords() + if err != nil { + return nil + } + + cutoff := time.Now().UTC().Add(-staleSessionRetention) + stale := make(map[string]struct{}) + for _, rec := range records { + id := sanitizeID(rec.SessionID) + if _, ok := unattested[id]; ok { + continue + } + startedAt, err := time.Parse(time.RFC3339, rec.StartedAt) + if err != nil { + continue + } + if startedAt.Before(cutoff) { + stale[id] = struct{}{} + } + } + + return stale +} + // RemoveTraceDir removes the entire /chainloop-trace/ directory. func (s *Store) RemoveTraceDir() error { return os.RemoveAll(s.traceDirPath()) diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go index 93b4c848b..eca33f226 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -19,12 +19,19 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// Fixture names reused across the pending-tracking and stale-GC tests. +const ( + fileA = "a.go" + sessOld = "sess-old" +) + func TestLogFilePath(t *testing.T) { assert.Equal(t, filepath.Join("/fake/.git", "chainloop-trace", "log.txt"), NewGitStore("/fake/.git").LogFilePath()) } @@ -359,3 +366,248 @@ func TestAILineAttribution(t *testing.T) { assert.Equal(t, rawID, attrs[0].SessionID, "consumers key off the agent-assigned ID, not the filename") }) } + +func TestAILinePendingTracking(t *testing.T) { + t.Run("a recorded file is pending", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) + + assert.Contains(t, store.LoadAILineAttribution("sess-1").Pending, fileA) + }) + + t.Run("consuming clears pending but keeps the ranges", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.MarkConsumed("sess-1", []string{fileA}, "sha-1")) + + attr := store.LoadAILineAttribution("sess-1") + assert.Empty(t, attr.Pending, "a committed file must stop crediting the session") + assert.Len(t, attr.Files[fileA], 1, "push-time enrichment still needs the ranges") + }) + + t.Run("only the committed files are consumed", func(t *testing.T) { + // Staging one file of two must not retire the other: a session that + // commits its work in logical chunks has to keep credit for the rest. + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.RecordLineRanges("sess-1", "b.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.MarkConsumed("sess-1", []string{fileA}, "sha-1")) + + pending := store.LoadAILineAttribution("sess-1").Pending + assert.NotContains(t, pending, fileA) + assert.Contains(t, pending, "b.go") + }) + + t.Run("editing a file again re-arms it", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.MarkConsumed("sess-1", []string{fileA}, "sha-1")) + require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 8, End: 9}})) + + assert.Contains(t, store.LoadAILineAttribution("sess-1").Pending, fileA) + }) + + t.Run("consuming a file the session never touched is a no-op", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.MarkConsumed("sess-1", []string{"untouched.go"}, "sha-1")) + + attr := store.LoadAILineAttribution("sess-1") + assert.Contains(t, attr.Pending, fileA) + assert.NotContains(t, attr.Files, "untouched.go") + }) + + t.Run("pending records the edit time with sub-second precision", func(t *testing.T) { + // Second resolution cannot order two sessions editing one file, so what + // round-trips through the ledger has to keep the fraction. + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + at := time.Date(2026, 4, 28, 10, 0, 0, 123456789, time.UTC) + require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}}, at)) + + assert.Equal(t, at, store.LoadAILineAttribution("sess-1").Pending[fileA].At) + }) + + t.Run("the wall clock supplies a usable edit time", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + before := time.Now().UTC() + require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) + + got := store.LoadAILineAttribution("sess-1").Pending[fileA] + require.False(t, got.At.IsZero(), "an edit with no readable time is never pending") + assert.False(t, got.At.Before(before.Truncate(time.Second))) + }) + + t.Run("re-editing a file advances its pending edit", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + at := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}}, at)) + first := store.LoadAILineAttribution("sess-1").Pending[fileA] + + require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 8, End: 9}}, at.Add(time.Minute))) + + second := store.LoadAILineAttribution("sess-1").Pending[fileA] + assert.Equal(t, at.Add(time.Minute), second.At) + assert.Greater(t, second.Seq, first.Seq, "ledger positions must advance with each append") + }) + + t.Run("a marker does not retire an edit recorded after it was built", func(t *testing.T) { + // MarkConsumed reads the pending set and then appends; an agent editing + // the same file in between leaves a marker sitting behind an edit the + // commit never contained. That edit must stay pending. + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + committed := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}}, committed)) + + pendingBefore := store.LoadAILineAttribution("sess-1").Pending + require.Contains(t, pendingBefore, fileA) + + // The racing edit lands first, then the marker for the older one. + require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 8, End: 9}}, committed.Add(time.Second))) + require.NoError(t, store.markConsumedAt(pendingBefore, "sess-1", []string{fileA}, "sha-1")) + + assert.Contains(t, store.LoadAILineAttribution("sess-1").Pending, fileA, + "the edit made after the snapshot was not in the commit") + }) + + t.Run("a racing edit sharing the snapshot's timestamp still survives", func(t *testing.T) { + // Same as above, but the racing edit lands in the same clock tick as the + // one being retired. Retirement is keyed off the ledger position rather + // than the time precisely so a tie here cannot sweep it up. + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + tick := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}}, tick)) + + pendingBefore := store.LoadAILineAttribution("sess-1").Pending + require.Contains(t, pendingBefore, fileA) + + require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 8, End: 9}}, tick)) + require.NoError(t, store.markConsumedAt(pendingBefore, "sess-1", []string{fileA}, "sha-1")) + + assert.Contains(t, store.LoadAILineAttribution("sess-1").Pending, fileA, + "an identical timestamp must not make the later edit indistinguishable") + }) + + t.Run("a marker with no ledger position retires the file outright", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}}, time.Now().UTC())) + + // A marker shaped like the ones written before consumed_seq existed. + path := store.aiLinesPath("sess-1") + legacy := `{"session_id":"sess-1","file":"` + fileA + `","ranges":null,"consumed_by":"sha-1"}` + "\n" + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600) + require.NoError(t, err) + _, err = f.WriteString(legacy) + require.NoError(t, err) + require.NoError(t, f.Close()) + + assert.NotContains(t, store.LoadAILineAttribution("sess-1").Pending, fileA) + }) + + t.Run("a ledger written before consumption tracking is never pending", func(t *testing.T) { + // Ledgers already on disk have no recorded_at, and their sessions are + // long gone. Treating them as pending would keep stamping those + // sessions onto new commits until the GC catches up. + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + path := store.aiLinesPath("sess-legacy") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + legacy := `{"session_id":"sess-legacy","file":"legacy.go","ranges":[{"start":1,"end":5}]}` + "\n" + require.NoError(t, os.WriteFile(path, []byte(legacy), 0600)) + + attr := store.LoadAILineAttribution("sess-legacy") + assert.Empty(t, attr.Pending) + assert.Len(t, attr.Files["legacy.go"], 1, "the ranges are still readable for enrichment") + }) +} + +func TestGCOrphans_StaleSessions(t *testing.T) { + t.Run("prunes an aged-out session whose commits are all attested", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + old := time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339) + require.NoError(t, store.SaveCommitRecord(&CommitRecord{ + SHA: "sha-1", SessionIDs: []string{sessOld}, Timestamp: old, Tracked: true, + })) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: sessOld, Active: true, StartedAt: old})) + require.NoError(t, store.RecordLineRanges(sessOld, fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) + + require.NoError(t, store.GCOrphans(map[string]bool{"sha-1": true})) + + // Still Active on disk, because session-end is unreliable — age plus + // "nothing left to attest" is what retires it. + assert.False(t, store.SessionRecordExists(sessOld)) + assert.Empty(t, store.LoadAILineAttribution(sessOld).Files) + }) + + t.Run("keeps an aged-out session with an unattested commit", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + old := time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339) + require.NoError(t, store.SaveCommitRecord(&CommitRecord{ + SHA: "sha-1", SessionIDs: []string{sessOld}, Timestamp: old, Tracked: false, + })) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: sessOld, Active: false, StartedAt: old})) + require.NoError(t, store.RecordLineRanges(sessOld, fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) + + require.NoError(t, store.GCOrphans(map[string]bool{"sha-1": true})) + + assert.True(t, store.SessionRecordExists(sessOld), "the next push still has to attest this commit") + assert.NotEmpty(t, store.LoadAILineAttribution(sessOld).Files) + }) + + t.Run("keeps a recent session", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + recent := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339) + require.NoError(t, store.SaveCommitRecord(&CommitRecord{ + SHA: "sha-1", SessionIDs: []string{"sess-new"}, Timestamp: recent, Tracked: true, + })) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-new", Active: true, StartedAt: recent})) + require.NoError(t, store.RecordLineRanges("sess-new", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) + + require.NoError(t, store.GCOrphans(map[string]bool{"sha-1": true})) + + assert.True(t, store.SessionRecordExists("sess-new")) + assert.NotEmpty(t, store.LoadAILineAttribution("sess-new").Files) + }) + + t.Run("an unparseable StartedAt is never treated as stale", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.SaveCommitRecord(&CommitRecord{ + SHA: "sha-1", SessionIDs: []string{"sess-bad"}, Timestamp: "2026-03-28T00:00:00Z", Tracked: true, + })) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-bad", StartedAt: "not-a-timestamp"})) + + require.NoError(t, store.GCOrphans(map[string]bool{"sha-1": true})) + + assert.True(t, store.SessionRecordExists("sess-bad")) + }) +} diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 6ed55a10b..9ae6a4fea 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -52,11 +52,38 @@ func HandleAgentSessionEnd(provider trace.Provider, log zerolog.Logger) error { log.Debug().Err(err).Msg("copy session data failed") } + setSessionActive(store, sessionID, false, log) + log.Debug().Str("session_id", sessionID).Msg("session ended") return nil } +// setSessionActive flips the Active flag on an existing session record. The +// record is otherwise written once and never upserted, so without this a +// session stays marked active for the life of the repository. +// +// Active is not a reliable liveness signal on its own and must not decide +// attribution: session-end is not installed for `trace run`, and never fires +// on SIGKILL or a closed terminal. It only narrows the set of sessions a push +// attests when there are no commits to go on. +func setSessionActive(store *state.Store, sessionID string, active bool, log zerolog.Logger) { + rec, err := store.LoadSessionRecord(sessionID) + if err != nil { + log.Debug().Err(err).Msg("load session record failed") + + return + } + if rec == nil || rec.Active == active { + return + } + + rec.Active = active + if err := store.SaveSessionRecord(rec); err != nil { + log.Debug().Err(err).Bool("active", active).Msg("update session record failed") + } +} + // sessionStartBanner is what the developer sees at the top of a traced // session. Its audience includes someone who cloned the repository and never // ran init, so it says what is happening in plain words and, when we know it, @@ -113,6 +140,14 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error ensureSessionTracked(provider, store, repoRoot, input, log) + // An agent can resume a session after its end hook ran, and the record is + // created once and never upserted, so revive the flag here. Only + // session-start may do this: the tool hooks share ensureSessionTracked, and + // one of those arriving late would resurrect a session that really has + // ended. Revival keeps StartedAt and the metadata captured on the first + // call intact. + setSessionActive(store, input.SessionID, true, log) + // Composing the banner costs a control-plane round trip, so it is only // worth doing for an agent that can put it in front of the user. Cursor // and opencode would discard it, and the developer would have paid the diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go index ea970e791..8c86c61e5 100644 --- a/app/cli/pkg/action/trace_agent_hook_test.go +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -156,6 +156,83 @@ func TestHandleAgentSessionEnd(t *testing.T) { withStdin(t, `{"session_id":"abc-123"}`) require.NoError(t, HandleAgentSessionEnd(provider, zerolog.Nop())) }) + + t.Run("marks the session inactive", func(t *testing.T) { + repoDir := initTempGitRepo(t) + store := state.NewGitStore(filepath.Join(repoDir, ".git")) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: "abc-123", Provider: providerClaudeCode, Active: true, StartedAt: "2026-03-28T00:00:00Z", + })) + + withStdin(t, `{"session_id":"abc-123"}`) + require.NoError(t, HandleAgentSessionEnd(provider, zerolog.Nop())) + + rec, err := store.LoadSessionRecord("abc-123") + require.NoError(t, err) + require.NotNil(t, rec) + assert.False(t, rec.Active) + assert.Equal(t, "2026-03-28T00:00:00Z", rec.StartedAt, "ending a session must not rewrite when it began") + assert.Equal(t, providerClaudeCode, rec.Provider) + }) + + t.Run("a resumed session goes back to active", func(t *testing.T) { + repoDir := initTempGitRepo(t) + store := state.NewGitStore(filepath.Join(repoDir, ".git")) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: "abc-123", + Provider: providerClaudeCode, + AgentVersion: "1.2.3", + Active: false, + StartedAt: "2026-03-28T00:00:00Z", + })) + + withStdin(t, `{"session_id":"abc-123","cwd":"/some/path"}`) + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + + rec, err := store.LoadSessionRecord("abc-123") + require.NoError(t, err) + require.NotNil(t, rec) + assert.True(t, rec.Active, "an agent can resume a session after its end hook ran") + assert.Equal(t, "2026-03-28T00:00:00Z", rec.StartedAt) + assert.Equal(t, "1.2.3", rec.AgentVersion, "metadata captured on the first hook must survive") + }) + + t.Run("a late tool hook does not revive an ended session", func(t *testing.T) { + // Only session-start means "resumed". The tool hooks share + // ensureSessionTracked, so reviving there would let one arriving after + // session-end resurrect a session that really has finished, and the + // next no-commit push would attest it. + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: "abc-123", Provider: providerClaudeCode, Active: false, StartedAt: "2026-03-28T00:00:00Z", + })) + + target := filepath.Join(dir, "late.txt") + require.NoError(t, os.WriteFile(target, []byte("one\n"), 0600)) + + withStdin(t, `{"session_id":"abc-123","tool_name":"Write","tool_input":{"file_path":"`+target+`"}}`) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + rec, err := store.LoadSessionRecord("abc-123") + require.NoError(t, err) + require.NotNil(t, rec) + assert.False(t, rec.Active) + }) } func TestAutoInstallGitHooks(t *testing.T) { diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index b1d7a893b..ef29356fc 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -23,6 +23,7 @@ import ( "os" "sort" "strings" + "time" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" @@ -84,25 +85,66 @@ func handleCommitMsg(msgFilePath string, log zerolog.Logger) error { return appendTrailer(msgFilePath, sessionIDs) } -// matchSessionsToFiles returns session IDs that have AI line attribution for -// any of the given files. The returned slice is sorted. +// fileOwner is the session currently credited with a staged file's pending +// attribution, and when it made the edit. +type fileOwner struct { + sessionID string + editedAt time.Time +} + +// supersededBy reports whether an edit made at editedAt by sessionID takes the +// file over from this owner. Equal timestamps fall to the lower session ID, so +// the trailer stays stable instead of following map iteration order. +func (o fileOwner) supersededBy(sessionID string, editedAt time.Time) bool { + if editedAt.Equal(o.editedAt) { + return sessionID < o.sessionID + } + + return editedAt.After(o.editedAt) +} + +// matchSessionsToFiles returns the session IDs that own uncommitted AI line +// attribution for any of the given files. The returned slice is sorted. +// +// Matching is deliberately against Pending rather than Files: a session's full +// range history outlives its commits, so intersecting it would credit a +// long-finished session on every later commit that touches a file it once +// edited, and that commit's whole diff would then be attributed to it. +// +// Each file is owned by the session that edited it last. When two sessions both +// have it pending, the earlier edit no longer describes the file — the later +// session rewrote those lines — so crediting both would attach a session that +// has moved on to this commit and to the pull request carrying it. func matchSessionsToFiles(attrs []*state.AILineAttribution, files []string) []string { fileSet := make(map[string]struct{}, len(files)) for _, f := range files { fileSet[f] = struct{}{} } - var sessionIDs []string + owners := make(map[string]fileOwner, len(fileSet)) for _, attr := range attrs { - for filePath := range attr.Files { - if _, ok := fileSet[filePath]; ok { - sessionIDs = append(sessionIDs, attr.SessionID) - - break + for filePath, edit := range attr.Pending { + if _, staged := fileSet[filePath]; !staged { + continue } + if cur, claimed := owners[filePath]; claimed && !cur.supersededBy(attr.SessionID, edit.At) { + continue + } + + owners[filePath] = fileOwner{sessionID: attr.SessionID, editedAt: edit.At} } } + seen := make(map[string]struct{}, len(owners)) + var sessionIDs []string + for _, o := range owners { + if _, dup := seen[o.sessionID]; dup { + continue + } + seen[o.sessionID] = struct{}{} + sessionIDs = append(sessionIDs, o.sessionID) + } + sort.Strings(sessionIDs) return sessionIDs @@ -191,20 +233,46 @@ func handlePostCommit(_ context.Context, log zerolog.Logger) error { return fmt.Errorf("save commit record: %w", err) } + // The credited sessions' pending ranges have now landed, so retire them. + // Skipping this is what makes a session accumulate foreign commits: its + // files stay pending and keep matching every later commit that touches + // them. Post-commit errors only ever get logged — the commit already + // exists — so returning here costs nothing but surfaces the failure. + if len(sessionIDs) > 0 { + committed := commitFiles(client, repoRoot, sha) + for _, sid := range sessionIDs { + if err := store.MarkConsumed(sid, committed, sha); err != nil { + return fmt.Errorf("mark attribution consumed for session %s: %w", sid, err) + } + } + } + log.Info().Str("sha", sha).Msg("commit record saved") return nil } // deriveSessionIDsForCommit scans ai-lines data and returns session IDs -// that have modified any file that appears in the given commit. +// that have uncommitted attribution for any file in the given commit. func deriveSessionIDsForCommit(client tracegit.Client, store *state.Store, repoRoot, headSHA string) []string { attrs, err := store.LoadAllAILineAttributions() if err != nil || len(attrs) == 0 { return nil } - changes, err := client.CodeChangesForRange(repoRoot, headSHA, headSHA) + files := commitFiles(client, repoRoot, headSHA) + if len(files) == 0 { + return nil + } + + return matchSessionsToFiles(attrs, files) +} + +// commitFiles returns the paths the commit at sha changed relative to its +// parent. Returns nil when the diff cannot be read, which leaves callers +// treating the commit as touching nothing rather than failing the hook. +func commitFiles(client tracegit.Client, repoRoot, sha string) []string { + changes, err := client.CodeChangesForRange(repoRoot, sha, sha) if err != nil || len(changes.Files) == 0 { return nil } @@ -214,7 +282,7 @@ func deriveSessionIDsForCommit(client tracegit.Client, store *state.Store, repoR files = append(files, f.Path) } - return matchSessionsToFiles(attrs, files) + return files } // filterCurrentBranchCommits returns the subset of records whose SHA is @@ -388,7 +456,13 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts } if len(aiCommits) == 0 { - for sid := range sessionRecords { + for sid, rec := range sessionRecords { + // A session that has ended without contributing a commit to this + // branch has nothing new to say; attesting it on every later push + // republishes the same stale evidence. + if !rec.Active { + continue + } sessionCommits[sid] = nil } if len(sessionCommits) == 0 { diff --git a/app/cli/pkg/action/trace_hook_handler_test.go b/app/cli/pkg/action/trace_hook_handler_test.go index 4e9482f52..157b009a3 100644 --- a/app/cli/pkg/action/trace_hook_handler_test.go +++ b/app/cli/pkg/action/trace_hook_handler_test.go @@ -23,6 +23,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" @@ -34,6 +35,16 @@ import ( "github.com/stretchr/testify/require" ) +// Fixture names reused across the session-matching tests. +const ( + sessionA = "session-a" + sessionB = "session-b" + srcFoo = "src/foo.go" + srcBar = "src/bar.go" + srcBaz = "src/baz.go" + providerClaudeCode = "claude-code" +) + func TestEvidenceName(t *testing.T) { t.Run("truncates long session ID", func(t *testing.T) { name := evidenceName("abcdefghijklmnop") @@ -47,15 +58,28 @@ func TestEvidenceName(t *testing.T) { } func TestMatchSessionsToFiles(t *testing.T) { + edited := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) attrs := []*state.AILineAttribution{ - {SessionID: "session-a", Files: map[string][]aicodingsession.LineRange{"src/foo.go": {{Start: 1, End: 10}}}}, - {SessionID: "session-b", Files: map[string][]aicodingsession.LineRange{"src/bar.go": {{Start: 1, End: 5}}}}, - {SessionID: "session-c", Files: map[string][]aicodingsession.LineRange{"src/baz.go": {{Start: 1, End: 3}}}}, + { + SessionID: sessionA, + Files: map[string][]aicodingsession.LineRange{srcFoo: {{Start: 1, End: 10}}}, + Pending: map[string]state.PendingEdit{srcFoo: {At: edited}}, + }, + { + SessionID: sessionB, + Files: map[string][]aicodingsession.LineRange{srcBar: {{Start: 1, End: 5}}}, + Pending: map[string]state.PendingEdit{srcBar: {At: edited}}, + }, + { + SessionID: "session-c", + Files: map[string][]aicodingsession.LineRange{srcBaz: {{Start: 1, End: 3}}}, + Pending: map[string]state.PendingEdit{srcBaz: {At: edited}}, + }, } t.Run("matches sessions that touched staged files", func(t *testing.T) { - ids := matchSessionsToFiles(attrs, []string{"src/foo.go", "src/bar.go"}) - assert.Equal(t, []string{"session-a", "session-b"}, ids) + ids := matchSessionsToFiles(attrs, []string{srcFoo, srcBar}) + assert.Equal(t, []string{sessionA, sessionB}, ids) }) t.Run("no match returns nil", func(t *testing.T) { @@ -69,13 +93,94 @@ func TestMatchSessionsToFiles(t *testing.T) { }) t.Run("empty attrs returns nil", func(t *testing.T) { - ids := matchSessionsToFiles(nil, []string{"src/foo.go"}) + ids := matchSessionsToFiles(nil, []string{srcFoo}) assert.Nil(t, ids) }) t.Run("result is sorted", func(t *testing.T) { - ids := matchSessionsToFiles(attrs, []string{"src/baz.go", "src/foo.go"}) - assert.Equal(t, []string{"session-a", "session-c"}, ids) + ids := matchSessionsToFiles(attrs, []string{srcBaz, srcFoo}) + assert.Equal(t, []string{sessionA, "session-c"}, ids) + }) + + t.Run("ignores a session whose ranges were already committed", func(t *testing.T) { + // The regression this guards: a finished session keeps its full range + // history in Files, so matching on that would credit it on every later + // commit touching src/foo.go — days or weeks after it ended. + committed := []*state.AILineAttribution{{ + SessionID: sessionA, + Files: map[string][]aicodingsession.LineRange{srcFoo: {{Start: 1, End: 10}}}, + Pending: map[string]state.PendingEdit{}, + }} + + assert.Nil(t, matchSessionsToFiles(committed, []string{srcFoo})) + }) + + t.Run("ignores a legacy attribution with no pending set", func(t *testing.T) { + legacy := []*state.AILineAttribution{{ + SessionID: sessionA, + Files: map[string][]aicodingsession.LineRange{srcFoo: {{Start: 1, End: 10}}}, + }} + + assert.Nil(t, matchSessionsToFiles(legacy, []string{srcFoo})) + }) + + t.Run("the session that edited a file last owns it", func(t *testing.T) { + contested := []*state.AILineAttribution{ + { + SessionID: sessionA, + Files: map[string][]aicodingsession.LineRange{srcFoo: {{Start: 1, End: 10}}}, + Pending: map[string]state.PendingEdit{srcFoo: {At: edited}}, + }, + { + SessionID: sessionB, + Files: map[string][]aicodingsession.LineRange{srcFoo: {{Start: 1, End: 10}}}, + Pending: map[string]state.PendingEdit{srcFoo: {At: edited.Add(time.Minute)}}, + }, + } + + assert.Equal(t, []string{sessionB}, matchSessionsToFiles(contested, []string{srcFoo})) + }) + + t.Run("ownership is per file, not per session", func(t *testing.T) { + // session-b won src/foo.go, but session-a still owns the bar.go edit + // it has pending, so both belong on a commit staging the two files. + split := []*state.AILineAttribution{ + { + SessionID: sessionA, + Files: map[string][]aicodingsession.LineRange{ + srcFoo: {{Start: 1, End: 10}}, + srcBar: {{Start: 1, End: 5}}, + }, + Pending: map[string]state.PendingEdit{srcFoo: {At: edited}, srcBar: {At: edited}}, + }, + { + SessionID: sessionB, + Files: map[string][]aicodingsession.LineRange{srcFoo: {{Start: 1, End: 10}}}, + Pending: map[string]state.PendingEdit{srcFoo: {At: edited.Add(time.Minute)}}, + }, + } + + assert.Equal(t, []string{sessionA, sessionB}, matchSessionsToFiles(split, []string{srcFoo, srcBar})) + }) + + t.Run("a tie breaks deterministically", func(t *testing.T) { + tied := []*state.AILineAttribution{ + { + SessionID: "session-z", + Files: map[string][]aicodingsession.LineRange{srcFoo: {{Start: 1, End: 10}}}, + Pending: map[string]state.PendingEdit{srcFoo: {At: edited}}, + }, + { + SessionID: sessionA, + Files: map[string][]aicodingsession.LineRange{srcFoo: {{Start: 1, End: 10}}}, + Pending: map[string]state.PendingEdit{srcFoo: {At: edited}}, + }, + } + + // Repeated because map iteration order varies between runs. + for range 20 { + assert.Equal(t, []string{sessionA}, matchSessionsToFiles(tied, []string{srcFoo})) + } }) } @@ -373,6 +478,155 @@ func TestHandlePostCommit_UsesTrailerWhenPresent(t *testing.T) { assert.Equal(t, []string{"orig-1", "orig-2"}, commits[0].SessionIDs) } +// A session's attribution must only credit the commit that actually lands it. +// Before consumption tracking, the ai-lines ledger was matched in full on every +// commit, so a session that once edited a file kept being stamped onto later +// commits touching it — the whole foreign diff was then attributed to that +// session, with every file it had no ranges for reported as human-written. +func TestCommitLifecycle_SessionIsNotCreditedTwice(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + shared := filepath.Join(dir, "shared.go") + require.NoError(t, os.WriteFile(shared, []byte("package main\n"), 0600)) + require.NoError(t, store.RecordLineRanges("sess-first", "shared.go", []aicodingsession.LineRange{{Start: 1, End: 1}})) + + // Commit 1: the session edited shared.go, so it earns the trailer. + msg := filepath.Join(dir, "msg1") + require.NoError(t, os.WriteFile(msg, []byte("feat: first\n"), 0600)) + runGit(t, dir, "add", "shared.go") + require.NoError(t, handleCommitMsg(msg, zerolog.Nop())) + + first, err := os.ReadFile(msg) + require.NoError(t, err) + require.Contains(t, string(first), "Chainloop-Trace-Sessions: sess-first") + + runGit(t, dir, "commit", "-F", msg) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + // Commit 2: somebody else touches the same file. The finished session must + // not be credited, even though its ledger still lists shared.go. + require.NoError(t, os.WriteFile(shared, []byte("package main\n\nfunc main() {}\n"), 0600)) + msg2 := filepath.Join(dir, "msg2") + require.NoError(t, os.WriteFile(msg2, []byte("chore: unrelated\n"), 0600)) + runGit(t, dir, "add", "shared.go") + require.NoError(t, handleCommitMsg(msg2, zerolog.Nop())) + + second, err := os.ReadFile(msg2) + require.NoError(t, err) + assert.NotContains(t, string(second), "Chainloop-Trace-Sessions", + "a session whose ranges are already committed must not be credited again") + + runGit(t, dir, "commit", "-F", msg2) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 2) + + credited := map[string][]string{} + for _, c := range commits { + subject := strings.SplitN(c.Message, "\n", 2)[0] + credited[subject] = c.SessionIDs + } + assert.Equal(t, []string{"sess-first"}, credited["feat: first"]) + assert.Empty(t, credited["chore: unrelated"], "the second commit belongs to no session") + + // The ranges themselves survive, because push-time enrichment needs them. + assert.NotEmpty(t, store.LoadAILineAttribution("sess-first").Files["shared.go"]) +} + +// Two sessions in a row over the same file, neither having committed yet: the +// first one records its ranges and ends, then a second one edits the same file. +// Only the session that edited last may be credited — its edit rewrote the +// lines the first one wrote, and crediting both would attach a finished session +// to a commit, and to the pull request holding it, on the strength of work that +// no longer exists. +func TestCommitLifecycle_LaterSessionSupersedesFinishedOne(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + shared := filepath.Join(dir, "shared.go") + + // Explicit edit times: the wall clock does not reliably advance between two + // calls on a host with a coarse clock, and which edit came last is the + // whole point here. + firstEdit := time.Now().UTC().Add(-time.Hour) + + // Session one edits the file, then ends without committing. + require.NoError(t, os.WriteFile(shared, []byte("package main\n"), 0600)) + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: "sess-first", Provider: providerClaudeCode, Active: true, StartedAt: state.NowTimestamp(), + })) + require.NoError(t, store.RecordLineRangesAt("sess-first", "shared.go", []aicodingsession.LineRange{{Start: 1, End: 1}}, firstEdit)) + setSessionActive(store, "sess-first", false, zerolog.Nop()) + + // Session two edits the same file afterwards. + require.NoError(t, os.WriteFile(shared, []byte("package main\n\nfunc main() {}\n"), 0600)) + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: "sess-second", Provider: providerClaudeCode, Active: true, StartedAt: state.NowTimestamp(), + })) + require.NoError(t, store.RecordLineRangesAt("sess-second", "shared.go", []aicodingsession.LineRange{{Start: 3, End: 3}}, firstEdit.Add(time.Minute))) + + msg := filepath.Join(dir, "msg") + require.NoError(t, os.WriteFile(msg, []byte("feat: shared\n"), 0600)) + runGit(t, dir, "add", "shared.go") + require.NoError(t, handleCommitMsg(msg, zerolog.Nop())) + + trailer, err := os.ReadFile(msg) + require.NoError(t, err) + assert.Contains(t, string(trailer), "Chainloop-Trace-Sessions: sess-second") + assert.NotContains(t, string(trailer), "sess-first", "the finished session's edit was overwritten") + + runGit(t, dir, "commit", "-F", msg) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 1) + assert.Equal(t, []string{"sess-second"}, commits[0].SessionIDs) +} + +// A session committing its work in logical chunks has to keep credit for the +// files it has not staged yet. +func TestCommitLifecycle_PartialStagingKeepsCredit(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + for _, name := range []string{"a.go", "b.go"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("package main\n"), 0600)) + require.NoError(t, store.RecordLineRanges("sess-1", name, []aicodingsession.LineRange{{Start: 1, End: 1}})) + } + + commitOne := func(t *testing.T, file, subject string) string { + t.Helper() + msg := filepath.Join(dir, "msg-"+file) + require.NoError(t, os.WriteFile(msg, []byte(subject+"\n"), 0600)) + runGit(t, dir, "add", file) + require.NoError(t, handleCommitMsg(msg, zerolog.Nop())) + content, err := os.ReadFile(msg) + require.NoError(t, err) + runGit(t, dir, "commit", "-F", msg) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + return string(content) + } + + assert.Contains(t, commitOne(t, "a.go", "feat: a"), "Chainloop-Trace-Sessions: sess-1") + assert.Contains(t, commitOne(t, "b.go", "feat: b"), "Chainloop-Trace-Sessions: sess-1", + "committing a.go must not retire the session's pending work on b.go") + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 2) + for _, c := range commits { + assert.Equal(t, []string{"sess-1"}, c.SessionIDs) + } +} + func TestHandlePostCommit_SkipsMergeCommit(t *testing.T) { dir, gitDir := initGitRepo(t) store := state.NewGitStore(gitDir)