Skip to content

Commit a6aefdf

Browse files
authored
Merge pull request #1868 from entireio/feat/enable-import-progress
feat(enable): show progress while importing existing sessions
2 parents 11fbdc6 + 36d5a13 commit a6aefdf

8 files changed

Lines changed: 681 additions & 11 deletions

File tree

cmd/entire/cli/agentimport/agentimport.go

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ type Options struct {
113113
// whose transcript records a resolvable commit that is an ancestor of this
114114
// fallback anchors to that real commit instead (see turnAnchorResolver).
115115
LinkCommitSHA string
116+
117+
// Progress, when non-nil, receives session/turn progress notifications
118+
// as Run executes. Nil (the default) reports nothing.
119+
Progress *Progress
116120
}
117121

118122
// Result summarizes an import run.
@@ -122,6 +126,54 @@ type Result struct {
122126
TurnsSkipped int
123127
}
124128

129+
// Progress reports observable events as Run walks sessions and writes turns.
130+
// It is UI-agnostic: Run never prints or logs on its behalf, so rendering
131+
// (a progress bar, log lines, a TUI) is entirely up to the caller. Every
132+
// field is optional, and a nil *Progress (the default) is a no-op — Run's
133+
// behavior is byte-identical whether or not one is supplied.
134+
//
135+
// Invariant: for every turn Run processes, exactly one of TurnWritten or
136+
// TurnSkipped fires — so summing both callbacks' calls across one session
137+
// always equals that session's turnCount (as reported by SessionStart).
138+
type Progress struct {
139+
// SessionStart fires once per session, after its transcript has been
140+
// split into turns and before any of them are written. sessionIndex is
141+
// 0-based against sessionTotal, the number of sessions Discover
142+
// returned; turnCount is the number of turns split from this session.
143+
SessionStart func(sessionIndex, sessionTotal int, agentName, sessionID string, turnCount int)
144+
// TurnWritten fires once per turn Run actually writes to the checkpoint
145+
// store — never for a turn skipped as already-imported, nor under
146+
// DryRun (see TurnSkipped for those). turnIndex is 0-based against
147+
// turnCount, matching the turnCount reported by this turn's
148+
// SessionStart call.
149+
TurnWritten func(sessionIndex, turnIndex, turnCount int)
150+
// TurnSkipped fires once per turn Run processes without writing: a turn
151+
// already imported (idempotent re-run) or, under DryRun, every turn
152+
// (dry runs never write). Index semantics match TurnWritten exactly.
153+
TurnSkipped func(sessionIndex, turnIndex, turnCount int)
154+
}
155+
156+
func (p *Progress) sessionStart(sessionIndex, sessionTotal int, agentName, sessionID string, turnCount int) {
157+
if p == nil || p.SessionStart == nil {
158+
return
159+
}
160+
p.SessionStart(sessionIndex, sessionTotal, agentName, sessionID, turnCount)
161+
}
162+
163+
func (p *Progress) turnWritten(sessionIndex, turnIndex, turnCount int) {
164+
if p == nil || p.TurnWritten == nil {
165+
return
166+
}
167+
p.TurnWritten(sessionIndex, turnIndex, turnCount)
168+
}
169+
170+
func (p *Progress) turnSkipped(sessionIndex, turnIndex, turnCount int) {
171+
if p == nil || p.TurnSkipped == nil {
172+
return
173+
}
174+
p.TurnSkipped(sessionIndex, turnIndex, turnCount)
175+
}
176+
125177
// DeriveCheckpointID produces a stable 12-hex checkpoint ID for an imported
126178
// turn. Re-importing the same (sessionID, turnUUID) yields the same ID, which
127179
// is how import stays idempotent.
@@ -162,7 +214,7 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
162214
// identity for imported sessions, leaving them unattributed.
163215
authorName, authorEmail := cp.GetGitAuthorFromRepo(repo)
164216

165-
for _, sf := range files {
217+
for sessionIndex, sf := range files {
166218
res.SessionsScanned++
167219
full, readErr := os.ReadFile(sf.Path)
168220
if readErr != nil {
@@ -172,20 +224,24 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
172224
if splitErr != nil {
173225
return res, fmt.Errorf("split %s session %s: %w", imp.Name(), sf.SessionID, splitErr)
174226
}
227+
opts.Progress.sessionStart(sessionIndex, len(files), string(imp.AgentType()), sf.SessionID, len(turns))
228+
175229
// Redact the session transcript once and reuse it for every turn's
176230
// checkpoint (each turn stores the full session transcript with its own
177231
// CheckpointTranscriptStart). Redacting per turn would be O(turns).
178232
// Computed lazily so a fully-skipped or dry-run file pays nothing.
179233
var red redact.RedactedBytes
180234
redacted := false
181-
for _, turn := range turns {
235+
for turnIndex, turn := range turns {
182236
cid := DeriveCheckpointID(sf.SessionID, turn.UUID)
183237
if existing[cid.String()] {
184238
res.TurnsSkipped++
239+
opts.Progress.turnSkipped(sessionIndex, turnIndex, len(turns))
185240
continue
186241
}
187242
if opts.DryRun {
188243
res.TurnsImported++ // counts what would import
244+
opts.Progress.turnSkipped(sessionIndex, turnIndex, len(turns))
189245
continue
190246
}
191247
if !redacted {
@@ -206,6 +262,7 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
206262
}
207263
existing[cid.String()] = true
208264
res.TurnsImported++
265+
opts.Progress.turnWritten(sessionIndex, turnIndex, len(turns))
209266
}
210267

211268
// Track A: surface this session in `entire session list`. Best-effort —
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package agentimport
2+
3+
import (
4+
"context"
5+
"reflect"
6+
"testing"
7+
"time"
8+
)
9+
10+
// progressSessionEvent and progressTurnEvent capture one Progress callback
11+
// invocation each, in call order, for assertion.
12+
type progressSessionEvent struct {
13+
sessionIndex, sessionTotal int
14+
agentName, sessionID string
15+
turnCount int
16+
}
17+
18+
type progressTurnEvent struct {
19+
sessionIndex, turnIndex, turnCount int
20+
}
21+
22+
// TestRun_ReportsProgress proves SessionStart fires exactly once per session
23+
// with correct totals, and TurnWritten fires exactly turnCount times per
24+
// session, in order, on a fixture with 2 sessions x 2 turns each.
25+
func TestRun_ReportsProgress(t *testing.T) {
26+
t.Parallel()
27+
repo, repoDir := initRepoWithCommit(t)
28+
claudeDir := t.TempDir()
29+
writeFixtureSession(t, claudeDir, "sess1.jsonl")
30+
writeFixtureSession(t, claudeDir, "sess2.jsonl")
31+
32+
var sessionEvents []progressSessionEvent
33+
var turnEvents []progressTurnEvent
34+
progress := &Progress{
35+
SessionStart: func(sessionIndex, sessionTotal int, agentName, sessionID string, turnCount int) {
36+
sessionEvents = append(sessionEvents, progressSessionEvent{sessionIndex, sessionTotal, agentName, sessionID, turnCount})
37+
},
38+
TurnWritten: func(sessionIndex, turnIndex, turnCount int) {
39+
turnEvents = append(turnEvents, progressTurnEvent{sessionIndex, turnIndex, turnCount})
40+
},
41+
}
42+
43+
imp := claudeImporter{}
44+
res, err := Run(context.Background(), repo, imp, Options{
45+
RepoRoot: repoDir, OverridePath: claudeDir,
46+
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
47+
Progress: progress,
48+
})
49+
if err != nil {
50+
t.Fatal(err)
51+
}
52+
if res.TurnsImported != 4 {
53+
t.Fatalf("want 4 imported, got %+v", res)
54+
}
55+
56+
wantAgentName := string(imp.AgentType())
57+
wantSessions := []progressSessionEvent{
58+
{sessionIndex: 0, sessionTotal: 2, agentName: wantAgentName, sessionID: "sess1", turnCount: 2},
59+
{sessionIndex: 1, sessionTotal: 2, agentName: wantAgentName, sessionID: "sess2", turnCount: 2},
60+
}
61+
if !reflect.DeepEqual(sessionEvents, wantSessions) {
62+
t.Fatalf("session events = %+v, want %+v", sessionEvents, wantSessions)
63+
}
64+
65+
wantTurns := []progressTurnEvent{
66+
{sessionIndex: 0, turnIndex: 0, turnCount: 2},
67+
{sessionIndex: 0, turnIndex: 1, turnCount: 2},
68+
{sessionIndex: 1, turnIndex: 0, turnCount: 2},
69+
{sessionIndex: 1, turnIndex: 1, turnCount: 2},
70+
}
71+
if !reflect.DeepEqual(turnEvents, wantTurns) {
72+
t.Fatalf("turn events = %+v, want %+v", turnEvents, wantTurns)
73+
}
74+
}
75+
76+
// TestRun_NilProgressDoesNotPanic proves a nil Progress (the zero value of
77+
// Options.Progress) behaves identically to a reporter-enabled run: no panic,
78+
// same turn count imported.
79+
func TestRun_NilProgressDoesNotPanic(t *testing.T) {
80+
t.Parallel()
81+
claudeDir := t.TempDir()
82+
writeFixtureSession(t, claudeDir, "sess1.jsonl")
83+
writeFixtureSession(t, claudeDir, "sess2.jsonl")
84+
now := time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)
85+
86+
repoNil, repoNilDir := initRepoWithCommit(t)
87+
resNil, err := Run(context.Background(), repoNil, claudeImporter{}, Options{
88+
RepoRoot: repoNilDir, OverridePath: claudeDir, Now: now,
89+
})
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
94+
repoWith, repoWithDir := initRepoWithCommit(t)
95+
resWith, err := Run(context.Background(), repoWith, claudeImporter{}, Options{
96+
RepoRoot: repoWithDir, OverridePath: claudeDir, Now: now,
97+
Progress: &Progress{
98+
SessionStart: func(int, int, string, string, int) {},
99+
TurnWritten: func(int, int, int) {},
100+
},
101+
})
102+
if err != nil {
103+
t.Fatal(err)
104+
}
105+
106+
if resNil.TurnsImported != resWith.TurnsImported {
107+
t.Fatalf("nil progress imported %d turns, reporter-enabled imported %d", resNil.TurnsImported, resWith.TurnsImported)
108+
}
109+
if resNil.TurnsImported != 4 {
110+
t.Fatalf("want 4 imported, got %+v", resNil)
111+
}
112+
}
113+
114+
// progressRecorder collects Progress callback invocations for assertion.
115+
type progressRecorder struct {
116+
written []progressTurnEvent
117+
skipped []progressTurnEvent
118+
}
119+
120+
func (r *progressRecorder) progress() *Progress {
121+
return &Progress{
122+
TurnWritten: func(sessionIndex, turnIndex, turnCount int) {
123+
r.written = append(r.written, progressTurnEvent{sessionIndex, turnIndex, turnCount})
124+
},
125+
TurnSkipped: func(sessionIndex, turnIndex, turnCount int) {
126+
r.skipped = append(r.skipped, progressTurnEvent{sessionIndex, turnIndex, turnCount})
127+
},
128+
}
129+
}
130+
131+
// TestRun_ReimportFiresTurnSkippedNotTurnWritten proves a re-import over an
132+
// already-imported corpus (the idempotent-skip path) reports every turn via
133+
// TurnSkipped, in order, and never via TurnWritten — the P2 Codex's pre-push
134+
// review caught: without this, a TTY progress reporter driven only by
135+
// TurnWritten freezes at "turn 0/M" on a fully-skipped session.
136+
func TestRun_ReimportFiresTurnSkippedNotTurnWritten(t *testing.T) {
137+
t.Parallel()
138+
repo, repoDir := initRepoWithCommit(t)
139+
claudeDir := t.TempDir()
140+
writeFixtureSession(t, claudeDir, "sess1.jsonl")
141+
writeFixtureSession(t, claudeDir, "sess2.jsonl")
142+
opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)}
143+
144+
// First run: no progress, just to populate the store so the second run
145+
// hits the idempotent-skip path for every turn.
146+
if _, err := Run(context.Background(), repo, claudeImporter{}, opts); err != nil {
147+
t.Fatal(err)
148+
}
149+
150+
rec := &progressRecorder{}
151+
opts.Progress = rec.progress()
152+
res, err := Run(context.Background(), repo, claudeImporter{}, opts)
153+
if err != nil {
154+
t.Fatal(err)
155+
}
156+
if res.TurnsImported != 0 || res.TurnsSkipped != 4 {
157+
t.Fatalf("want 0 imported / 4 skipped on re-import, got %+v", res)
158+
}
159+
160+
if len(rec.written) != 0 {
161+
t.Errorf("TurnWritten fired %d times on a fully-skipped re-import, want 0: %+v", len(rec.written), rec.written)
162+
}
163+
wantSkipped := []progressTurnEvent{
164+
{sessionIndex: 0, turnIndex: 0, turnCount: 2},
165+
{sessionIndex: 0, turnIndex: 1, turnCount: 2},
166+
{sessionIndex: 1, turnIndex: 0, turnCount: 2},
167+
{sessionIndex: 1, turnIndex: 1, turnCount: 2},
168+
}
169+
if !reflect.DeepEqual(rec.skipped, wantSkipped) {
170+
t.Fatalf("TurnSkipped events = %+v, want %+v", rec.skipped, wantSkipped)
171+
}
172+
}
173+
174+
// TestRun_DryRunFiresTurnSkippedForEveryTurn proves DryRun — which never
175+
// writes — reports every turn via TurnSkipped and never via TurnWritten.
176+
func TestRun_DryRunFiresTurnSkippedForEveryTurn(t *testing.T) {
177+
t.Parallel()
178+
repo, repoDir := initRepoWithCommit(t)
179+
claudeDir := t.TempDir()
180+
writeFixtureSession(t, claudeDir, "sess1.jsonl")
181+
writeFixtureSession(t, claudeDir, "sess2.jsonl")
182+
183+
rec := &progressRecorder{}
184+
res, err := Run(context.Background(), repo, claudeImporter{}, Options{
185+
RepoRoot: repoDir, OverridePath: claudeDir, DryRun: true,
186+
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
187+
Progress: rec.progress(),
188+
})
189+
if err != nil {
190+
t.Fatal(err)
191+
}
192+
if res.TurnsImported != 4 {
193+
// DryRun's Result bookkeeping is unchanged: TurnsImported still means
194+
// "would import" (see Run's DryRun branch). TurnSkipped is a separate,
195+
// additive signal that nothing was actually written.
196+
t.Fatalf("want 4 (would-import), got %+v", res)
197+
}
198+
199+
if len(rec.written) != 0 {
200+
t.Errorf("TurnWritten fired %d times under DryRun, want 0: %+v", len(rec.written), rec.written)
201+
}
202+
if len(rec.skipped) != 4 {
203+
t.Fatalf("TurnSkipped fired %d times under DryRun, want 4: %+v", len(rec.skipped), rec.skipped)
204+
}
205+
}
206+
207+
// TestRun_MixedSkipAndWriteSatisfiesInvariant proves the documented
208+
// invariant — for every turn, exactly one of TurnWritten/TurnSkipped fires,
209+
// so per-session written+skipped == turnCount — holds when a run mixes
210+
// already-imported sessions with a brand-new one in a single call.
211+
func TestRun_MixedSkipAndWriteSatisfiesInvariant(t *testing.T) {
212+
t.Parallel()
213+
repo, repoDir := initRepoWithCommit(t)
214+
claudeDir := t.TempDir()
215+
writeFixtureSession(t, claudeDir, "sess1.jsonl")
216+
writeFixtureSession(t, claudeDir, "sess2.jsonl")
217+
opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)}
218+
219+
// Import sess1 and sess2 first, so a second run finds them already
220+
// imported while a newly-added sess3 is still fresh.
221+
if _, err := Run(context.Background(), repo, claudeImporter{}, opts); err != nil {
222+
t.Fatal(err)
223+
}
224+
writeFixtureSession(t, claudeDir, "sess3.jsonl")
225+
226+
rec := &progressRecorder{}
227+
opts.Progress = rec.progress()
228+
res, err := Run(context.Background(), repo, claudeImporter{}, opts)
229+
if err != nil {
230+
t.Fatal(err)
231+
}
232+
if res.TurnsImported != 2 || res.TurnsSkipped != 4 {
233+
t.Fatalf("want 2 imported (sess3) / 4 skipped (sess1+sess2), got %+v", res)
234+
}
235+
236+
counts := map[int]struct{ written, skipped int }{}
237+
for _, ev := range rec.written {
238+
c := counts[ev.sessionIndex]
239+
c.written++
240+
counts[ev.sessionIndex] = c
241+
}
242+
for _, ev := range rec.skipped {
243+
c := counts[ev.sessionIndex]
244+
c.skipped++
245+
counts[ev.sessionIndex] = c
246+
}
247+
248+
// Discovery is sorted by path, so sess1=0, sess2=1, sess3=2 (each has 2
249+
// turns per writeFixtureSession).
250+
wantBySession := map[int]struct{ written, skipped int }{
251+
0: {written: 0, skipped: 2}, // sess1: already imported
252+
1: {written: 0, skipped: 2}, // sess2: already imported
253+
2: {written: 2, skipped: 0}, // sess3: brand new
254+
}
255+
if len(counts) != len(wantBySession) {
256+
t.Fatalf("saw events for %d sessions, want %d: %+v", len(counts), len(wantBySession), counts)
257+
}
258+
for sessionIndex, want := range wantBySession {
259+
got := counts[sessionIndex]
260+
if got != want {
261+
t.Errorf("session %d: written=%d skipped=%d, want written=%d skipped=%d",
262+
sessionIndex, got.written, got.skipped, want.written, want.skipped)
263+
}
264+
if got.written+got.skipped != 2 {
265+
t.Errorf("session %d: written+skipped = %d, want turnCount 2 (invariant violated)",
266+
sessionIndex, got.written+got.skipped)
267+
}
268+
}
269+
}

cmd/entire/cli/import_cmd.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,14 @@ fails even with --dry-run.`, imp.AgentType()),
8080
linkCommitSHA := resolveImportLinkCommitSHA(repo)
8181
logging.Debug(ctx, "import: resolved link commit", "commit_sha", linkCommitSHA)
8282

83+
progress, stopProgress := newImportProgressReporter(c.OutOrStdout(), string(imp.AgentType()))
8384
res, err := agentimport.Run(ctx, repo, imp, agentimport.Options{
8485
RepoRoot: repoRoot, OverridePath: pathFlag, SessionFilter: sessions,
8586
Now: time.Now(), DryRun: dryRun,
8687
LinkCommitSHA: linkCommitSHA,
88+
Progress: progress,
8789
})
90+
stopProgress(err == nil)
8891
if err != nil {
8992
return fmt.Errorf("import %s: %w", imp.Name(), err)
9093
}

0 commit comments

Comments
 (0)