diff --git a/cmd/entire/cli/checkpoint/committed_reader_resolve.go b/cmd/entire/cli/checkpoint/committed_reader_resolve.go index 0ac0de526..2b741c493 100644 --- a/cmd/entire/cli/checkpoint/committed_reader_resolve.go +++ b/cmd/entire/cli/checkpoint/committed_reader_resolve.go @@ -3,6 +3,7 @@ package checkpoint import ( "context" "errors" + "fmt" "log/slog" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" @@ -16,92 +17,254 @@ type CommittedReader interface { ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) } -// ResolveCommittedReaderForCheckpoint resolves which committed checkpoint reader -// should be used for a specific checkpoint ID. -// -// Fallback behavior: -// - Try v2 first when preferCheckpointsV2 is true -// - Fall back to v1 for any v2 failure except context cancellation -// - During the v2 migration period, a valid v1 copy should never be blocked -// by a corrupt or unreadable v2 copy -func ResolveCommittedReaderForCheckpoint( - ctx context.Context, - checkpointID id.CheckpointID, - v1Store *GitStore, - v2Store *V2GitStore, - preferCheckpointsV2 bool, -) (CommittedReader, *CheckpointSummary, error) { - if err := ctx.Err(); err != nil { - return nil, nil, err //nolint:wrapcheck // Propagating context cancellation +// CommittedListReader provides read and list access to committed checkpoint data. +// GitStore, V2GitStore, and DualCheckpointReader implement this interface. +type CommittedListReader interface { + CommittedReader + ListCommitted(ctx context.Context) ([]CommittedInfo, error) + ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*CommittedMetadata, error) +} + +type CommittedReadMode int + +const ( + CommittedReadV1 CommittedReadMode = iota + CommittedReadDual + CommittedReadV2 +) + +func CommittedReadModeForOptions(checkpointsV2Enabled bool, checkpointsVersion int) CommittedReadMode { + if checkpointsVersion == 2 { + return CommittedReadV2 + } + if checkpointsV2Enabled { + return CommittedReadDual + } + return CommittedReadV1 +} + +func NewCommittedReader(v1Store *GitStore, v2Store *V2GitStore, mode CommittedReadMode) (CommittedListReader, error) { //nolint:ireturn // Factory selects among v1, v2, and dual reader implementations. + switch mode { + case CommittedReadV2: + if v2Store == nil { + return nil, errors.New("v2 committed checkpoint reader unavailable") + } + return v2Store, nil + case CommittedReadDual: + switch { + case v2Store == nil && v1Store == nil: + return nil, errors.New("committed checkpoint reader unavailable") + case v2Store == nil: + return v1Store, nil + case v1Store == nil: + return v2Store, nil + default: + return &DualCheckpointReader{v2: v2Store, v1: v1Store}, nil + } + case CommittedReadV1: + if v1Store == nil { + return nil, errors.New("v1 committed checkpoint reader unavailable") + } + return v1Store, nil + default: + return nil, fmt.Errorf("unknown committed checkpoint read mode: %d", mode) + } +} + +type DualCheckpointReader struct { + v2 *V2GitStore + v1 *GitStore +} + +func (r *DualCheckpointReader) ReadCommitted(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) { + summary, err := r.v2.ReadCommitted(ctx, checkpointID) + if err == nil && summary != nil { + return summary, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr //nolint:wrapcheck // Propagating context cancellation } + if err != nil { + logV2Fallback(ctx, "v2 ReadCommitted failed, falling back to v1", checkpointID, err) + } + return r.v1.ReadCommitted(ctx, checkpointID) +} + +func (r *DualCheckpointReader) ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) { + content, err := r.v2.ReadSessionContent(ctx, checkpointID, sessionIndex) + if err == nil { + return content, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr //nolint:wrapcheck // Propagating context cancellation + } + if errors.Is(err, ErrNoTranscript) { + fallbackContent, fallbackErr := r.readFallbackSessionContent(ctx, checkpointID, sessionIndex) + if fallbackErr == nil { + return fallbackContent, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr //nolint:wrapcheck // Propagating context cancellation + } + return nil, fallbackReadError(err, "read v1 fallback session content", fallbackErr) + } + return r.readV1SessionContentByIndex(ctx, checkpointID, sessionIndex, err) +} + +func (r *DualCheckpointReader) ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*CommittedMetadata, error) { + metadata, err := r.v2.ReadSessionMetadata(ctx, checkpointID, sessionIndex) + if err == nil { + return metadata, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr //nolint:wrapcheck // Propagating context cancellation + } + return r.readV1SessionMetadataByIndex(ctx, checkpointID, sessionIndex, err) +} + +// ReadSessionMetadataAndPrompts is intentionally v2-only because callers pair +// this metadata with the v2 compact transcript. Returning v1 raw content here +// would bypass the checkpoint transcript offset handling in ReadSessionContent. +func (r *DualCheckpointReader) ReadSessionMetadataAndPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) { + return r.v2.ReadSessionMetadataAndPrompts(ctx, checkpointID, sessionIndex) +} + +func (r *DualCheckpointReader) ReadSessionCompactTranscript(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) ([]byte, error) { + return r.v2.ReadSessionCompactTranscript(ctx, checkpointID, sessionIndex) +} - if preferCheckpointsV2 && v2Store != nil { - summary, err := v2Store.ReadCommitted(ctx, checkpointID) - if err == nil && summary != nil { - return v2Store, summary, nil +func (r *DualCheckpointReader) ListCommitted(ctx context.Context) ([]CommittedInfo, error) { + v2Committed, v2Err := r.v2.ListCommitted(ctx) + v1Committed, v1Err := r.v1.ListCommitted(ctx) + + if v2Err != nil { + logging.Debug(ctx, "v2 ListCommitted failed, using v1 only", + slog.String("error", v2Err.Error()), + ) + if v1Err != nil { + return nil, fmt.Errorf("listing checkpoints: %w", v1Err) } - if err != nil && ctx.Err() != nil { - return nil, nil, ctx.Err() //nolint:wrapcheck // Propagating context cancellation + return v1Committed, nil + } + + if v1Err != nil { + logging.Debug(ctx, "v1 ListCommitted failed, returning v2 only", + slog.String("error", v1Err.Error()), + ) + return v2Committed, nil + } + + seen := make(map[id.CheckpointID]struct{}, len(v2Committed)) + for _, c := range v2Committed { + seen[c.CheckpointID] = struct{}{} + } + committed := make([]CommittedInfo, 0, len(v2Committed)+len(v1Committed)) + committed = append(committed, v2Committed...) + for _, c := range v1Committed { + if _, ok := seen[c.CheckpointID]; !ok { + committed = append(committed, c) } - if err != nil && !errors.Is(err, ErrCheckpointNotFound) && !errors.Is(err, ErrNoTranscript) { - logging.Debug(ctx, "v2 ReadCommitted failed, falling back to v1", - slog.String("checkpoint_id", checkpointID.String()), - slog.String("error", err.Error()), - ) + } + return committed, nil +} + +func (r *DualCheckpointReader) readFallbackSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) { + metadata, err := r.v2.ReadSessionMetadata(ctx, checkpointID, sessionIndex) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr //nolint:wrapcheck // Propagating context cancellation } + return nil, err } + if metadata == nil || metadata.SessionID == "" { + return nil, ErrNoTranscript + } + return r.v1.ReadSessionContentByID(ctx, checkpointID, metadata.SessionID) +} - if v1Store == nil { - return nil, nil, ErrCheckpointNotFound +func (r *DualCheckpointReader) readV1SessionContentByIndex(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int, originalErr error) (*SessionContent, error) { + content, err := r.v1.ReadSessionContent(ctx, checkpointID, sessionIndex) + if err == nil { + return content, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr //nolint:wrapcheck // Propagating context cancellation } + return nil, fallbackReadError(originalErr, "read v1 session content", err) +} - summary, err := v1Store.ReadCommitted(ctx, checkpointID) +func (r *DualCheckpointReader) readV1SessionMetadataByIndex(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int, originalErr error) (*CommittedMetadata, error) { + metadata, err := r.v1.ReadSessionMetadata(ctx, checkpointID, sessionIndex) + if err == nil { + return metadata, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr //nolint:wrapcheck // Propagating context cancellation + } + return nil, fallbackReadError(originalErr, "read v1 session metadata", err) +} + +func fallbackReadError(primaryErr error, fallbackOperation string, fallbackErr error) error { + if fallbackErr == nil { + return primaryErr + } + return errors.Join(primaryErr, fmt.Errorf("%s: %w", fallbackOperation, fallbackErr)) +} + +func logV2Fallback(ctx context.Context, message string, checkpointID id.CheckpointID, err error) { + if errors.Is(err, ErrCheckpointNotFound) || errors.Is(err, ErrNoTranscript) { + return + } + logging.Debug(ctx, message, + slog.String("checkpoint_id", checkpointID.String()), + slog.String("error", err.Error()), + ) +} + +// ReadCommittedCheckpoint reads a committed checkpoint summary and normalizes +// a nil store response into ErrCheckpointNotFound. +func ReadCommittedCheckpoint(ctx context.Context, reader CommittedReader, checkpointID id.CheckpointID) (*CheckpointSummary, error) { + if err := ctx.Err(); err != nil { + return nil, err //nolint:wrapcheck // Propagating context cancellation + } + + summary, err := reader.ReadCommitted(ctx, checkpointID) if err != nil { - return nil, nil, err + return nil, fmt.Errorf("read committed checkpoint: %w", err) } if summary == nil { - return nil, nil, ErrCheckpointNotFound + return nil, ErrCheckpointNotFound } + return summary, nil +} - return v1Store, summary, nil +// ReadLatestSessionContent reads the latest session from an already-resolved +// committed reader and summary. +func ReadLatestSessionContent(ctx context.Context, reader CommittedReader, checkpointID id.CheckpointID, summary *CheckpointSummary) (*SessionContent, error) { + if summary == nil || len(summary.Sessions) == 0 { + return nil, ErrCheckpointNotFound + } + latestIndex := len(summary.Sessions) - 1 + content, err := reader.ReadSessionContent(ctx, checkpointID, latestIndex) + if err != nil { + return nil, fmt.Errorf("read session %d content: %w", latestIndex, err) + } + return content, nil } -// ResolveRawSessionLogForCheckpoint resolves the raw transcript log bytes for a -// checkpoint with v2-first, v1-fallback behavior. -// -// Fallback behavior: -// - Try v2 first when preferCheckpointsV2 is true -// - Fall back to v1 for any v2 failure except context cancellation -func ResolveRawSessionLogForCheckpoint( - ctx context.Context, - checkpointID id.CheckpointID, - v1Store *GitStore, - v2Store *V2GitStore, - preferCheckpointsV2 bool, -) ([]byte, string, error) { +func ReadRawSessionLogForCheckpoint(ctx context.Context, reader CommittedReader, checkpointID id.CheckpointID) ([]byte, string, error) { if err := ctx.Err(); err != nil { return nil, "", err //nolint:wrapcheck // Propagating context cancellation } - if preferCheckpointsV2 && v2Store != nil { - content, sessionID, err := v2Store.GetSessionLog(ctx, checkpointID) - if err == nil && len(content) > 0 { - return content, sessionID, nil - } - if err != nil && ctx.Err() != nil { - return nil, "", ctx.Err() //nolint:wrapcheck // Propagating context cancellation - } - if err != nil && !errors.Is(err, ErrCheckpointNotFound) && !errors.Is(err, ErrNoTranscript) { - logging.Debug(ctx, "v2 GetSessionLog failed, falling back to v1", - slog.String("checkpoint_id", checkpointID.String()), - slog.String("error", err.Error()), - ) - } + summary, err := ReadCommittedCheckpoint(ctx, reader, checkpointID) + if err != nil { + return nil, "", err } - if v1Store == nil { - return nil, "", ErrCheckpointNotFound + content, err := ReadLatestSessionContent(ctx, reader, checkpointID, summary) + if err != nil { + return nil, "", err } - - return v1Store.GetSessionLog(ctx, checkpointID) + return content.Transcript, content.Metadata.SessionID, nil } diff --git a/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go b/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go index 231d4ed37..98a935377 100644 --- a/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go +++ b/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go @@ -16,7 +16,7 @@ import ( "github.com/go-git/go-git/v6/plumbing/object" ) -func TestResolveCommittedReaderForCheckpoint_UsesV2WhenFound(t *testing.T) { +func TestCommittedReader_UsesV2WhenFound(t *testing.T) { t.Parallel() repo := initTestRepo(t) @@ -34,13 +34,166 @@ func TestResolveCommittedReaderForCheckpoint_UsesV2WhenFound(t *testing.T) { AuthorEmail: "test@test.com", })) - reader, summary, err := ResolveCommittedReaderForCheckpoint(ctx, cpID, v1Store, v2Store, true) + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + summary, err := ReadCommittedCheckpoint(ctx, reader, cpID) require.NoError(t, err) require.NotNil(t, summary) + + content, err := reader.ReadSessionContent(ctx, cpID, 0) + require.NoError(t, err) + require.Equal(t, "session-v2", content.Metadata.SessionID) +} + +func TestNewCommittedReader_SelectsMode(t *testing.T) { + t.Parallel() + + repo := initTestRepo(t) + v1Store := NewGitStore(repo) + v2Store := NewV2GitStore(repo, "origin") + + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadV1) + require.NoError(t, err) + require.IsType(t, &GitStore{}, reader) + + reader, err = NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + require.IsType(t, &DualCheckpointReader{}, reader) + + reader, err = NewCommittedReader(v1Store, v2Store, CommittedReadV2) + require.NoError(t, err) require.IsType(t, &V2GitStore{}, reader) } -func TestResolveCommittedReaderForCheckpoint_FallsBackToV1WhenMissingInV2(t *testing.T) { +func TestCommittedReadModeForOptions(t *testing.T) { + t.Parallel() + + require.Equal(t, CommittedReadV1, CommittedReadModeForOptions(false, 1)) + require.Equal(t, CommittedReadDual, CommittedReadModeForOptions(true, 1)) + require.Equal(t, CommittedReadV2, CommittedReadModeForOptions(true, 2)) + require.Equal(t, CommittedReadV2, CommittedReadModeForOptions(false, 2)) +} + +func TestDualCheckpointReader_FallsBackToV1RawTranscriptBySessionID(t *testing.T) { + t.Parallel() + + repo := initTestRepo(t) + v1Store := NewGitStore(repo) + v2Store := NewV2GitStore(repo, "origin") + ctx := context.Background() + cpID := id.MustCheckpointID("121212121212") + + require.NoError(t, v1Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-a", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted([]byte(`{"text":"from-v1-session-a"}` + "\n")), + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + require.NoError(t, v1Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-b", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted([]byte(`{"text":"from-v1-session-b"}` + "\n")), + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + require.NoError(t, v2Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-b", + Strategy: "manual-commit", + CompactTranscript: []byte(`{"text":"compact-session-b"}` + "\n"), + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + summary, err := ReadCommittedCheckpoint(ctx, reader, cpID) + require.NoError(t, err) + require.Len(t, summary.Sessions, 1) + + content, err := reader.ReadSessionContent(ctx, cpID, 0) + require.NoError(t, err) + require.Equal(t, "session-b", content.Metadata.SessionID) + require.Contains(t, string(content.Transcript), "from-v1-session-b") + require.NotContains(t, string(content.Transcript), "from-v1-session-a") +} + +func TestDualCheckpointReader_ReadSessionContentReturnsV2AndFallbackErrors(t *testing.T) { + t.Parallel() + + repo := initTestRepo(t) + v1Store := NewGitStore(repo) + v2Store := NewV2GitStore(repo, "origin") + ctx := context.Background() + cpID := id.MustCheckpointID("565656565656") + + require.NoError(t, v2Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-missing-v1", + Strategy: "manual-commit", + CompactTranscript: []byte(`{"text":"compact-only"}` + "\n"), + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + + content, err := reader.ReadSessionContent(ctx, cpID, 0) + require.Nil(t, content) + require.Error(t, err) + require.ErrorIs(t, err, ErrNoTranscript) + require.ErrorIs(t, err, ErrCheckpointNotFound) + require.Contains(t, err.Error(), "read v1 fallback session content") +} + +func TestReadRawSessionLogForCheckpoint_FallsBackToV1RawTranscriptByV2SessionID(t *testing.T) { + t.Parallel() + + repo := initTestRepo(t) + v1Store := NewGitStore(repo) + v2Store := NewV2GitStore(repo, "origin") + ctx := context.Background() + cpID := id.MustCheckpointID("343434343434") + + require.NoError(t, v1Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-b", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted([]byte(`{"text":"from-v1-session-b"}` + "\n")), + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + require.NoError(t, v1Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-a", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted([]byte(`{"text":"from-v1-session-a"}` + "\n")), + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + require.NoError(t, v2Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-b", + Strategy: "manual-commit", + CompactTranscript: []byte(`{"text":"compact-session-b"}` + "\n"), + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + logContent, sessionID, err := ReadRawSessionLogForCheckpoint(ctx, reader, cpID) + require.NoError(t, err) + require.Equal(t, "session-b", sessionID) + require.Contains(t, string(logContent), "from-v1-session-b") + require.NotContains(t, string(logContent), "from-v1-session-a") +} + +func TestCommittedReader_FallsBackToV1WhenMissingInV2(t *testing.T) { t.Parallel() repo := initTestRepo(t) @@ -58,13 +211,18 @@ func TestResolveCommittedReaderForCheckpoint_FallsBackToV1WhenMissingInV2(t *tes AuthorEmail: "test@test.com", })) - reader, summary, err := ResolveCommittedReaderForCheckpoint(ctx, cpID, v1Store, v2Store, true) + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + summary, err := ReadCommittedCheckpoint(ctx, reader, cpID) require.NoError(t, err) require.NotNil(t, summary) - require.IsType(t, &GitStore{}, reader) + + content, err := reader.ReadSessionContent(ctx, cpID, 0) + require.NoError(t, err) + require.Equal(t, "session-v1", content.Metadata.SessionID) } -func TestResolveCommittedReaderForCheckpoint_PrefersV1WhenV2Disabled(t *testing.T) { +func TestCommittedReader_PrefersV1WhenV2Disabled(t *testing.T) { t.Parallel() repo := initTestRepo(t) @@ -91,13 +249,15 @@ func TestResolveCommittedReaderForCheckpoint_PrefersV1WhenV2Disabled(t *testing. AuthorEmail: "test@test.com", })) - reader, summary, err := ResolveCommittedReaderForCheckpoint(ctx, cpID, v1Store, v2Store, false) + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadV1) + require.NoError(t, err) + summary, err := ReadCommittedCheckpoint(ctx, reader, cpID) require.NoError(t, err) require.NotNil(t, summary) require.IsType(t, &GitStore{}, reader) } -func TestResolveRawSessionLogForCheckpoint_UsesV2WhenFound(t *testing.T) { +func TestReadRawSessionLogForCheckpoint_UsesV2WhenFound(t *testing.T) { t.Parallel() repo := initTestRepo(t) @@ -115,13 +275,92 @@ func TestResolveRawSessionLogForCheckpoint_UsesV2WhenFound(t *testing.T) { AuthorEmail: "test@test.com", })) - logContent, sessionID, err := ResolveRawSessionLogForCheckpoint(ctx, cpID, v1Store, v2Store, true) + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + logContent, sessionID, err := ReadRawSessionLogForCheckpoint(ctx, reader, cpID) require.NoError(t, err) require.Equal(t, "session-v2", sessionID) require.Contains(t, string(logContent), "from-v2") } -func TestResolveRawSessionLogForCheckpoint_FallsBackToV1WhenMissingInV2(t *testing.T) { +func TestDualCheckpointReader_ListCommittedMergesV2AndV1(t *testing.T) { + t.Parallel() + + repo := initTestRepo(t) + v1Store := NewGitStore(repo) + v2Store := NewV2GitStore(repo, "origin") + ctx := context.Background() + transcript := redact.AlreadyRedacted([]byte(`{"text":"hello"}` + "\n")) + + v1OnlyID := id.MustCheckpointID("888888888888") + require.NoError(t, v1Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: v1OnlyID, + SessionID: "session-v1-only", + Strategy: "manual-commit", + Transcript: transcript, + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + + dualID := id.MustCheckpointID("999999999999") + require.NoError(t, v1Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: dualID, + SessionID: "session-dual", + Strategy: "manual-commit", + Transcript: transcript, + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + require.NoError(t, v2Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: dualID, + SessionID: "session-dual", + Strategy: "manual-commit", + Transcript: transcript, + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + + results, err := reader.ListCommitted(ctx) + require.NoError(t, err) + + counts := map[id.CheckpointID]int{} + for _, result := range results { + counts[result.CheckpointID]++ + } + require.Equal(t, 1, counts[v1OnlyID]) + require.Equal(t, 1, counts[dualID]) +} + +func TestCommittedReadV2DoesNotFallBackToV1(t *testing.T) { + t.Parallel() + + repo := initTestRepo(t) + v1Store := NewGitStore(repo) + v2Store := NewV2GitStore(repo, "origin") + ctx := context.Background() + cpID := id.MustCheckpointID("abababababab") + + require.NoError(t, v1Store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-v1", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted([]byte(`{"text":"from-v1"}` + "\n")), + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadV2) + require.NoError(t, err) + + summary, err := reader.ReadCommitted(ctx, cpID) + require.NoError(t, err) + require.Nil(t, summary) +} + +func TestReadRawSessionLogForCheckpoint_FallsBackToV1WhenMissingInV2(t *testing.T) { t.Parallel() repo := initTestRepo(t) @@ -139,13 +378,15 @@ func TestResolveRawSessionLogForCheckpoint_FallsBackToV1WhenMissingInV2(t *testi AuthorEmail: "test@test.com", })) - logContent, sessionID, err := ResolveRawSessionLogForCheckpoint(ctx, cpID, v1Store, v2Store, true) + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + logContent, sessionID, err := ReadRawSessionLogForCheckpoint(ctx, reader, cpID) require.NoError(t, err) require.Equal(t, "session-v1", sessionID) require.Contains(t, string(logContent), "from-v1") } -func TestResolveRawSessionLogForCheckpoint_PrefersV1WhenV2Disabled(t *testing.T) { +func TestReadRawSessionLogForCheckpoint_PrefersV1WhenV2Disabled(t *testing.T) { t.Parallel() repo := initTestRepo(t) @@ -172,13 +413,15 @@ func TestResolveRawSessionLogForCheckpoint_PrefersV1WhenV2Disabled(t *testing.T) AuthorEmail: "test@test.com", })) - logContent, sessionID, err := ResolveRawSessionLogForCheckpoint(ctx, cpID, v1Store, v2Store, false) + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadV1) + require.NoError(t, err) + logContent, sessionID, err := ReadRawSessionLogForCheckpoint(ctx, reader, cpID) require.NoError(t, err) require.Equal(t, "session-v1", sessionID) require.Contains(t, string(logContent), "from-v1") } -func TestResolveCommittedReaderForCheckpoint_FallsBackToV1WhenV2Malformed(t *testing.T) { +func TestCommittedReader_FallsBackToV1WhenV2Malformed(t *testing.T) { t.Parallel() repo := initTestRepo(t) @@ -209,10 +452,14 @@ func TestResolveCommittedReaderForCheckpoint_FallsBackToV1WhenV2Malformed(t *tes corruptV2MainMetadata(t, repo, cpID) // Should fall back to v1 instead of propagating the v2 parse error. - reader, summary, err := ResolveCommittedReaderForCheckpoint(ctx, cpID, v1Store, v2Store, true) + reader, err := NewCommittedReader(v1Store, v2Store, CommittedReadDual) + require.NoError(t, err) + summary, err := ReadCommittedCheckpoint(ctx, reader, cpID) require.NoError(t, err) require.NotNil(t, summary) - require.IsType(t, &GitStore{}, reader) + content, err := reader.ReadSessionContent(ctx, cpID, 0) + require.NoError(t, err) + require.Equal(t, "session-v1", content.Metadata.SessionID) } // corruptV2MainMetadata replaces the v2 /main ref tree with one containing diff --git a/cmd/entire/cli/checkpoint_reader.go b/cmd/entire/cli/checkpoint_reader.go new file mode 100644 index 000000000..eb883f54f --- /dev/null +++ b/cmd/entire/cli/checkpoint_reader.go @@ -0,0 +1,73 @@ +package cli + +import ( + "context" + "log/slog" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/settings" + + git "github.com/go-git/go-git/v6" +) + +type committedCheckpointReaderStores struct { + v1Store *checkpoint.GitStore + v2Store *checkpoint.V2GitStore + reader checkpoint.CommittedListReader + readMode checkpoint.CommittedReadMode +} + +type committedCheckpointReaderOptions struct { + blobFetcher checkpoint.BlobFetchFunc + fetchRemoteLog string +} + +func committedCheckpointReadMode(ctx context.Context) checkpoint.CommittedReadMode { + return checkpoint.CommittedReadModeForOptions( + settings.IsCheckpointsV2Enabled(ctx), + settings.CheckpointsVersion(ctx), + ) +} + +func newCommittedCheckpointReader(ctx context.Context, repo *git.Repository, opts committedCheckpointReaderOptions) (*committedCheckpointReaderStores, error) { + v1Store := checkpoint.NewGitStore(repo) + if opts.blobFetcher != nil { + v1Store.SetBlobFetcher(opts.blobFetcher) + } + + readMode := committedCheckpointReadMode(ctx) + var v2Store *checkpoint.V2GitStore + if committedCheckpointReadUsesV2(readMode) { + v2URL, err := remote.FetchURL(ctx) + if err != nil { + message := opts.fetchRemoteLog + if message == "" { + message = "checkpoint reader: using origin for v2 store fetch remote" + } + logging.Debug(ctx, message, slog.String("error", err.Error())) + v2URL = "" + } + v2Store = checkpoint.NewV2GitStore(repo, v2URL) + if opts.blobFetcher != nil { + v2Store.SetBlobFetcher(opts.blobFetcher) + } + } + + reader, err := checkpoint.NewCommittedReader(v1Store, v2Store, readMode) + if err != nil { + return nil, err //nolint:wrapcheck // Caller adds command-specific context. + } + + return &committedCheckpointReaderStores{ + v1Store: v1Store, + v2Store: v2Store, + reader: reader, + readMode: readMode, + }, nil +} + +func committedCheckpointReadUsesV2(mode checkpoint.CommittedReadMode) bool { + return mode != checkpoint.CommittedReadV1 +} diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 982f099c3..dcebdd68f 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -23,7 +23,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" - "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote" "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -89,11 +88,12 @@ func resolveSummaryTimeout(ctx context.Context, flagSeconds int) time.Duration { var errCannotGenerateTemporaryCheckpoint = errors.New("cannot generate summary for temporary checkpoint") type explainCheckpointLookup struct { - repo *git.Repository - v1Store *checkpoint.GitStore - v2Store *checkpoint.V2GitStore - preferCheckpointsV2 bool - committed []checkpoint.CommittedInfo + repo *git.Repository + v1Store *checkpoint.GitStore + v2Store *checkpoint.V2GitStore + reader checkpoint.CommittedListReader + readMode checkpoint.CommittedReadMode + committed []checkpoint.CommittedInfo } // generateOrRawLabel returns the user-facing verb for the action the user @@ -654,22 +654,17 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec stopLoad(false) return err } - v2Reader, isCheckpointsV2 := resolvedReader.(*checkpoint.V2GitStore) - // Handle summary generation — uses raw transcript. if generate { stopLoad(false) // generation prints its own progress to w/errW if err := generateCheckpointSummary(ctx, w, errW, lookup.v1Store, lookup.v2Store, fullCheckpointID, summary, content, force, summaryTimeoutSeconds); err != nil { return err } - // Reload to get the updated summary. After generation we only need - // /main data for display, so use the /main-only path for v2. + // Reload to get the updated summary. After generation, display can + // prefer v2 /main but must still fall back for v1-only checkpoints in + // dual-read mode. stopLoad = startSpinner(errW, fmt.Sprintf("Reloading checkpoint %s", fullCheckpointID)) - if isCheckpointsV2 { - content, err = readV2ContentFromMain(ctx, v2Reader, fullCheckpointID, summary) - } else { - content, err = readLatestSessionContentForExplain(ctx, resolvedReader, fullCheckpointID, summary) - } + content, err = readCheckpointContentForExplain(ctx, resolvedReader, fullCheckpointID, summary, true) if err != nil { stopLoad(false) return fmt.Errorf("failed to reload checkpoint: %w", err) @@ -679,15 +674,11 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec // Handle raw transcript output if rawTranscript { stopLoad(false) - rawLog, _, rawErr := checkpoint.ResolveRawSessionLogForCheckpoint(ctx, fullCheckpointID, lookup.v1Store, lookup.v2Store, lookup.preferCheckpointsV2) - if rawErr != nil { - return fmt.Errorf("failed to read raw transcript: %w", rawErr) - } - if len(rawLog) == 0 { + if len(content.Transcript) == 0 { return fmt.Errorf("checkpoint %s has no transcript", fullCheckpointID) } // Output raw transcript directly (no pager, no formatting) - if _, err = w.Write(rawLog); err != nil { + if _, err = w.Write(content.Transcript); err != nil { return fmt.Errorf("failed to write transcript: %w", err) } return nil @@ -723,26 +714,16 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec // function stays under maintidx limits. Caller is responsible for the // surrounding spinner. func loadCheckpointForExplain(ctx context.Context, errW io.Writer, lookup *explainCheckpointLookup, cpID id.CheckpointID, full, generate, rawTranscript bool) (checkpoint.CommittedReader, *checkpoint.CheckpointSummary, *checkpoint.SessionContent, error) { - prefetchCheckpointBlobs(ctx, errW, lookup.repo, cpID, lookup.preferCheckpointsV2) + prefetchCheckpointBlobs(ctx, errW, lookup.repo, cpID, committedCheckpointReadUsesV2(lookup.readMode)) - reader, summary, err := checkpoint.ResolveCommittedReaderForCheckpoint(ctx, cpID, lookup.v1Store, lookup.v2Store, lookup.preferCheckpointsV2) + reader := lookup.reader + summary, err := checkpoint.ReadCommittedCheckpoint(ctx, reader, cpID) if err != nil { return nil, nil, nil, fmt.Errorf("failed to read checkpoint: %w", err) } - // Default display modes for v2 checkpoints read only from /main — - // metadata, prompts, and the compact transcript. The raw transcript - // on /full/* refs is never needed for human-readable output and may - // be unavailable (rotated, not fetched). needsRawTranscript := full || generate || rawTranscript - if v2Reader, ok := reader.(*checkpoint.V2GitStore); ok && !needsRawTranscript { - content, contentErr := readV2ContentFromMain(ctx, v2Reader, cpID, summary) - if contentErr != nil { - return nil, nil, nil, fmt.Errorf("failed to read checkpoint content: %w", contentErr) - } - return reader, summary, content, nil - } - content, contentErr := readLatestSessionContentForExplain(ctx, reader, cpID, summary) + content, contentErr := readCheckpointContentForExplain(ctx, reader, cpID, summary, !needsRawTranscript) if contentErr != nil { return nil, nil, nil, fmt.Errorf("failed to read checkpoint content: %w", contentErr) } @@ -860,32 +841,27 @@ func newExplainCheckpointLookup(ctx context.Context) (*explainCheckpointLookup, return nil, fmt.Errorf("not a git repository: %w", err) } - v2URL, err := remote.FetchURL(ctx) - if err != nil { - logging.Debug(ctx, "explain: using origin for v2 store fetch remote", - slog.String("error", err.Error()), - ) - v2URL = "" - } - // FetchBlobsByHash uses `git fetch-pack` for blob SHAs (porcelain // `git fetch` fails against partial-clone repos with "did not send all // necessary objects"). Falls back to a full metadata-branch fetch if // fetch-pack also can't reach the blobs. - v1Store := checkpoint.NewGitStore(repo) - v1Store.SetBlobFetcher(FetchBlobsByHash) - - v2Store := checkpoint.NewV2GitStore(repo, v2URL) - v2Store.SetBlobFetcher(FetchBlobsByHash) + checkpointReader, err := newCommittedCheckpointReader(ctx, repo, committedCheckpointReaderOptions{ + blobFetcher: FetchBlobsByHash, + fetchRemoteLog: "explain: using origin for v2 store fetch remote", + }) + if err != nil { + return nil, fmt.Errorf("prepare checkpoint reader: %w", err) + } lookup := &explainCheckpointLookup{ - repo: repo, - v1Store: v1Store, - v2Store: v2Store, - preferCheckpointsV2: settings.IsCheckpointsV2Enabled(ctx), + repo: repo, + v1Store: checkpointReader.v1Store, + v2Store: checkpointReader.v2Store, + reader: checkpointReader.reader, + readMode: checkpointReader.readMode, } - committed, err := listCommittedForExplain(ctx, lookup.v1Store, lookup.v2Store, lookup.preferCheckpointsV2) + committed, err := checkpointReader.reader.ListCommitted(ctx) if err != nil { return nil, fmt.Errorf("failed to list checkpoints: %w", err) } @@ -893,62 +869,6 @@ func newExplainCheckpointLookup(ctx context.Context) (*explainCheckpointLookup, return lookup, nil } -func listCommittedForExplain(ctx context.Context, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, preferCheckpointsV2 bool) ([]checkpoint.CommittedInfo, error) { - v1Committed, v1Err := v1Store.ListCommitted(ctx) - - if !preferCheckpointsV2 { - if v1Err != nil { - return nil, fmt.Errorf("listing v1 checkpoints: %w", v1Err) - } - return v1Committed, nil - } - - v2Committed, v2Err := v2Store.ListCommitted(ctx) - if v2Err != nil { - logging.Debug(ctx, "v2 ListCommitted failed, using v1 only", - slog.String("error", v2Err.Error()), - ) - if v1Err != nil { - return nil, fmt.Errorf("listing checkpoints: %w", v1Err) - } - return v1Committed, nil - } - - if v1Err != nil { - logging.Debug(ctx, "v1 ListCommitted failed, returning v2 only", - slog.String("error", v1Err.Error()), - ) - return v2Committed, nil - } - - // Merge v2 and v1 results so pre-v2 checkpoints remain visible during transition. - seen := make(map[id.CheckpointID]struct{}, len(v2Committed)) - for _, c := range v2Committed { - seen[c.CheckpointID] = struct{}{} - } - committedCheckpoints := make([]checkpoint.CommittedInfo, 0, len(v2Committed)+len(v1Committed)) - committedCheckpoints = append(committedCheckpoints, v2Committed...) - for _, c := range v1Committed { - if _, ok := seen[c.CheckpointID]; !ok { - committedCheckpoints = append(committedCheckpoints, c) - } - } - return committedCheckpoints, nil -} - -func readLatestSessionContentForExplain(ctx context.Context, reader checkpoint.CommittedReader, checkpointID id.CheckpointID, summary *checkpoint.CheckpointSummary) (*checkpoint.SessionContent, error) { - if summary == nil || len(summary.Sessions) == 0 { - return nil, checkpoint.ErrCheckpointNotFound - } - - latestIndex := len(summary.Sessions) - 1 - content, err := reader.ReadSessionContent(ctx, checkpointID, latestIndex) - if err != nil { - return nil, fmt.Errorf("reading session %d content: %w", latestIndex, err) - } - return content, nil -} - // resolvePromptTree picks the best metadata tree for reading session prompts. // Prefers v2 when enabled (same sharded layout as v1), falls back to v1. func resolvePromptTree(v1Tree, v2Tree *object.Tree, preferV2 bool) *object.Tree { @@ -961,11 +881,41 @@ func resolvePromptTree(v1Tree, v2Tree *object.Tree, preferV2 bool) *object.Tree return v2Tree // Last resort: use v2 even if not preferred } +type v2MainContentReader interface { + checkpoint.CommittedReader + ReadSessionMetadataAndPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*checkpoint.SessionContent, error) +} + +// readCheckpointContentForExplain reads session content for display. When +// preferMain is true (default display modes that don't need the raw +// transcript) it tries the v2 /main ref first — cheaper, and the /full/* +// refs holding the raw transcript may not be fetched locally. It still +// falls back to ReadLatestSessionContent on ErrCheckpointNotFound so +// v1-only checkpoints work in dual-read mode. +func readCheckpointContentForExplain(ctx context.Context, reader checkpoint.CommittedReader, checkpointID id.CheckpointID, summary *checkpoint.CheckpointSummary, preferMain bool) (*checkpoint.SessionContent, error) { + if preferMain { + if mainReader, ok := reader.(v2MainContentReader); ok { + content, err := readV2ContentFromMain(ctx, mainReader, checkpointID, summary) + if err == nil { + return content, nil + } + if !errors.Is(err, checkpoint.ErrCheckpointNotFound) { + return nil, err + } + } + } + content, err := checkpoint.ReadLatestSessionContent(ctx, reader, checkpointID, summary) + if err != nil { + return nil, fmt.Errorf("read latest session content: %w", err) + } + return content, nil +} + // readV2ContentFromMain reads session content from the v2 /main ref only — // metadata, prompts, and the compact transcript (transcript.jsonl). This is the // primary read path for default display modes that don't need the raw transcript // stored on /full/* refs. -func readV2ContentFromMain(ctx context.Context, v2Reader *checkpoint.V2GitStore, checkpointID id.CheckpointID, summary *checkpoint.CheckpointSummary) (*checkpoint.SessionContent, error) { +func readV2ContentFromMain(ctx context.Context, v2Reader v2MainContentReader, checkpointID id.CheckpointID, summary *checkpoint.CheckpointSummary) (*checkpoint.SessionContent, error) { if summary == nil || len(summary.Sessions) == 0 { return nil, checkpoint.ErrCheckpointNotFound } @@ -2150,19 +2100,15 @@ func getBranchCheckpoints(ctx context.Context, repo *git.Repository, limit int) // Warn (once per process) if metadata branches are disconnected strategy.WarnIfMetadataDisconnected() - v1Store := checkpoint.NewGitStore(repo) - v2URL, err := remote.FetchURL(ctx) + checkpointReader, err := newCommittedCheckpointReader(ctx, repo, committedCheckpointReaderOptions{ + fetchRemoteLog: "explain: using origin for branch checkpoint v2 store fetch remote", + }) if err != nil { - logging.Debug(ctx, "explain: using origin for branch checkpoint v2 store fetch remote", - slog.String("error", err.Error()), - ) - v2URL = "" + return nil, fmt.Errorf("prepare checkpoint reader: %w", err) } - v2Store := checkpoint.NewV2GitStore(repo, v2URL) - preferCheckpointsV2 := settings.IsCheckpointsV2Enabled(ctx) // Get all committed checkpoints for lookup (v2-aware with v1 fallback). - committedInfos, err := listCommittedForExplain(ctx, v1Store, v2Store, preferCheckpointsV2) + committedInfos, err := checkpointReader.reader.ListCommitted(ctx) if err != nil { committedInfos = nil // Continue without committed checkpoints } @@ -2191,7 +2137,7 @@ func getBranchCheckpoints(ctx context.Context, repo *git.Repository, limit int) // Try v2 /main first, fall back to v1 metadata branch. v1MetadataTree, _ := strategy.GetMetadataBranchTree(repo) //nolint:errcheck // Best-effort v2MetadataTree, _ := strategy.GetV2MetadataBranchTree(repo) //nolint:errcheck // Best-effort - promptTree := resolvePromptTree(v1MetadataTree, v2MetadataTree, preferCheckpointsV2) + promptTree := resolvePromptTree(v1MetadataTree, v2MetadataTree, committedCheckpointReadUsesV2(checkpointReader.readMode)) var points []strategy.RewindPoint @@ -2272,7 +2218,7 @@ func getBranchCheckpoints(ctx context.Context, repo *git.Repository, limit int) } // Get temporary checkpoints from ALL shadow branches whose base commit is reachable from HEAD. - tempPoints := getReachableTemporaryCheckpoints(ctx, repo, v1Store, head.Hash(), isOnDefault, limit) + tempPoints := getReachableTemporaryCheckpoints(ctx, repo, checkpointReader.v1Store, head.Hash(), isOnDefault, limit) points = append(points, tempPoints...) // Sort by date, most recent first diff --git a/cmd/entire/cli/explain_export.go b/cmd/entire/cli/explain_export.go index d97ddd08a..91a71ca4b 100644 --- a/cmd/entire/cli/explain_export.go +++ b/cmd/entire/cli/explain_export.go @@ -51,6 +51,10 @@ type explainExportOptions struct { listLimit int } +type compactTranscriptReader interface { + ReadSessionCompactTranscript(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) ([]byte, error) +} + // runExplainExport handles --json, --transcript, and --raw-transcript with an // explicit --session-index. JSON is metadata-only (no transcript bytes // embedded); transcript bytes always stream to stdout from a flag, never from @@ -192,7 +196,7 @@ func matchCheckpointPrefixWithRemoteFallback(ctx context.Context, errW io.Writer stop := startSpinner(errW, "Fetching checkpoint metadata from remote") _, _, v1Err := getMetadataTree(ctx) v2OK := false - if lookup.preferCheckpointsV2 { + if committedCheckpointReadUsesV2(lookup.readMode) { if _, _, v2Err := getV2MetadataTree(ctx); v2Err == nil { v2OK = true } @@ -255,12 +259,13 @@ func runExplainStreamTranscript(ctx context.Context, w, errW io.Writer, opts exp return err } - reader, summary, err := checkpoint.ResolveCommittedReaderForCheckpoint(ctx, cpID, lookup.v1Store, lookup.v2Store, lookup.preferCheckpointsV2) + reader := lookup.reader + summary, err := checkpoint.ReadCommittedCheckpoint(ctx, reader, cpID) if err != nil { return fmt.Errorf("failed to read checkpoint: %w", err) } - v2Reader, isV2 := reader.(*checkpoint.V2GitStore) + compactReader, hasCompact := reader.(compactTranscriptReader) wantCompact := !opts.rawTranscript idx, err := resolveSessionIndex(summary, opts.sessionIndex) @@ -270,7 +275,7 @@ func runExplainStreamTranscript(ctx context.Context, w, errW io.Writer, opts exp // Compact transcripts are only stored on v2; transparently fall through // to raw on v1 so consumers don't need to retry. - if wantCompact && !isV2 { + if wantCompact && !hasCompact { fmt.Fprintln(errW, "note: compact transcript unavailable on v1 checkpoint, falling back to raw transcript") wantCompact = false } @@ -286,8 +291,19 @@ func runExplainStreamTranscript(ctx context.Context, w, errW io.Writer, opts exp return nil } - compact, err := v2Reader.ReadSessionCompactTranscript(ctx, cpID, idx) + compact, err := compactReader.ReadSessionCompactTranscript(ctx, cpID, idx) if err != nil { + if errors.Is(err, checkpoint.ErrCheckpointNotFound) || errors.Is(err, checkpoint.ErrNoTranscript) { + fmt.Fprintln(errW, "note: compact transcript unavailable, falling back to raw transcript") + content, readErr := reader.ReadSessionContent(ctx, cpID, idx) + if readErr != nil { + return fmt.Errorf("failed to read session content: %w", readErr) + } + if _, writeErr := w.Write(content.Transcript); writeErr != nil { + return fmt.Errorf("failed to write transcript: %w", writeErr) + } + return nil + } return fmt.Errorf("failed to read compact transcript: %w", err) } if _, err := w.Write(compact); err != nil { @@ -359,7 +375,8 @@ func runExplainCheckpointJSON(ctx context.Context, w, errW io.Writer, opts expla return err } - reader, summary, err := checkpoint.ResolveCommittedReaderForCheckpoint(ctx, cpID, lookup.v1Store, lookup.v2Store, lookup.preferCheckpointsV2) + reader := lookup.reader + summary, err := checkpoint.ReadCommittedCheckpoint(ctx, reader, cpID) if err != nil { return fmt.Errorf("failed to read checkpoint: %w", err) } @@ -430,30 +447,24 @@ func buildCheckpointJSONEnvelope(ctx context.Context, reader checkpoint.Committe // cause an unrelated ErrNoTranscript on v1 checkpoints whose raw transcript // has been pruned). func readSessionMetadataForExport(ctx context.Context, reader checkpoint.CommittedReader, cpID id.CheckpointID, idx int) (*checkpoint.CommittedMetadata, error) { - switch r := reader.(type) { - case *checkpoint.V2GitStore: - meta, err := r.ReadSessionMetadata(ctx, cpID, idx) - if err != nil { - return nil, fmt.Errorf("read v2 session metadata: %w", err) - } - return meta, nil - case *checkpoint.GitStore: + if r, ok := reader.(interface { + ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*checkpoint.CommittedMetadata, error) + }); ok { meta, err := r.ReadSessionMetadata(ctx, cpID, idx) if err != nil { - return nil, fmt.Errorf("read v1 session metadata: %w", err) + return nil, fmt.Errorf("read session metadata: %w", err) } return meta, nil - default: - // CommittedReader doesn't promise a metadata-only method; fall back - // to the heavier ReadSessionContent path. Reachable only if a third - // store implementation is added without updating this switch. - content, err := reader.ReadSessionContent(ctx, cpID, idx) - if err != nil { - return nil, fmt.Errorf("read session content: %w", err) - } - meta := content.Metadata - return &meta, nil } + // CommittedReader doesn't promise a metadata-only method; fall back + // to the heavier ReadSessionContent path. Reachable only if a third + // store implementation is added without exposing metadata reads. + content, err := reader.ReadSessionContent(ctx, cpID, idx) + if err != nil { + return nil, fmt.Errorf("read session content: %w", err) + } + meta := content.Metadata + return &meta, nil } func sessionMetadataToJSON(idx int, meta *checkpoint.CommittedMetadata) checkpointSessionJSON { diff --git a/cmd/entire/cli/explain_export_test.go b/cmd/entire/cli/explain_export_test.go index 72cb1e847..ee8d4eb68 100644 --- a/cmd/entire/cli/explain_export_test.go +++ b/cmd/entire/cli/explain_export_test.go @@ -208,6 +208,35 @@ func TestRunExplainExport_RawTranscriptStreamsRawBytes(t *testing.T) { require.Equal(t, raw, stdout.Bytes()) } +func TestRunExplainExport_RawTranscriptFallsBackToV1WhenV2FullMissing(t *testing.T) { + repo := setupExportRepo(t) + + cpID := id.MustCheckpointID("dddd22223333") + raw := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"v1 raw export fallback"}]}}` + "\n") + v1Store := checkpoint.NewGitStore(repo) + require.NoError(t, v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-export-fallback", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted(raw), + AuthorName: exportTestAuthorName, + AuthorEmail: exportTestAuthorEmail, + })) + writeV2CheckpointForExport(t, repo, cpID, checkpoint.WriteCommittedOptions{ + SessionID: "session-export-fallback", + CompactTranscript: []byte(`{"v":1,"type":"user"}` + "\n"), + }) + + var stdout, stderr bytes.Buffer + err := runExplainExport(context.Background(), &stdout, &stderr, explainExportOptions{ + target: "dddd2222", + rawTranscript: true, + sessionIndex: -1, + }) + require.NoError(t, err) + require.Equal(t, raw, stdout.Bytes()) +} + // TestExplainCmd_RawTranscriptWithSessionIndexRoutesToExportPath guards the // cobra-layer dispatch: --raw-transcript --session-index must reach the // export path (which honors the index). Before the fix, the legacy diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index 45d102e53..1c2d5538c 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -2190,6 +2190,110 @@ func TestRunExplainCheckpoint_V2UsesCompactTranscriptForIntent(t *testing.T) { } } +func TestRunExplainCheckpoint_V2EnabledV1FallbackPreservesTranscriptOffset(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + testutil.InitRepo(t, tmpDir) + repo, err := git.PlainOpen(tmpDir) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, ".entire", "settings.json"), + []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`), + 0o644, + )) + + cpID := id.MustCheckpointID("878787878787") + transcriptBytes := []byte( + `{"type":"user","message":{"content":[{"type":"text","text":"old prompt before checkpoint"}]}}` + "\n" + + `{"type":"user","message":{"content":[{"type":"text","text":"scoped prompt for checkpoint"}]}}` + "\n", + ) + require.NoError(t, checkpoint.NewGitStore(repo).WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-v1", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted(transcriptBytes), + AuthorName: "Test", + AuthorEmail: "test@example.com", + Agent: agent.AgentTypeClaudeCode, + CheckpointTranscriptStart: 1, + })) + + var buf, errBuf bytes.Buffer + err = runExplainCheckpoint(context.Background(), &buf, &errBuf, "878787", true, false, false, false, false, false, false, 0) + require.NoError(t, err) + require.Contains(t, buf.String(), "scoped prompt for checkpoint") + require.NotContains(t, buf.String(), "old prompt before checkpoint") +} + +func TestRunExplainCheckpoint_GenerateV1OnlyDualModeReloadsFromV1(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + testutil.InitRepo(t, tmpDir) + repo, err := git.PlainOpen(tmpDir) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, ".entire", "settings.json"), + []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}, "summary_generation": {"provider": "claude-code"}}`), + 0o644, + )) + + originalGet := getSummaryAgent + originalCLI := isSummaryCLIAvailable + originalDiscover := discoverSummaryProviders + originalGenerate := generateTranscriptSummary + t.Cleanup(func() { + getSummaryAgent = originalGet + isSummaryCLIAvailable = originalCLI + discoverSummaryProviders = originalDiscover + generateTranscriptSummary = originalGenerate + }) + + getSummaryAgent = func(name types.AgentName) (agent.Agent, error) { + return &stubTextAgent{name: name, kind: agent.AgentTypeClaudeCode}, nil + } + isSummaryCLIAvailable = func(types.AgentName) bool { return true } + discoverSummaryProviders = func(context.Context) {} + + var sawV1Transcript bool + generateTranscriptSummary = func( + _ context.Context, + transcript redact.RedactedBytes, + _ []string, + _ types.AgentType, + _ summarize.Generator, + ) (*checkpoint.Summary, error) { + sawV1Transcript = strings.Contains(string(transcript.Bytes()), "v1-only generate prompt") + return &checkpoint.Summary{Intent: "generated intent", Outcome: "generated outcome"}, nil + } + + cpID := id.MustCheckpointID("ab12ab12ab12") + ctx := context.Background() + require.NoError(t, checkpoint.NewGitStore(repo).WriteCommitted(ctx, checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-v1-only-generate", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted([]byte( + `{"type":"user","message":{"content":[{"type":"text","text":"v1-only generate prompt"}]}}` + "\n" + + `{"type":"assistant","message":{"content":"done"}}` + "\n", + )), + AuthorName: "Test", + AuthorEmail: "test@example.com", + Agent: agent.AgentTypeClaudeCode, + })) + + var buf, errBuf bytes.Buffer + err = runExplainCheckpoint(ctx, &buf, &errBuf, "ab12ab", false, false, false, false, true, true, false, 0) + require.NoError(t, err) + require.True(t, sawV1Transcript, "summary generation should use v1 raw transcript") + require.Contains(t, buf.String(), "generated intent") +} + func TestRunExplainCheckpoint_V2PreferredGenerateWritesBothStores(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) @@ -2362,6 +2466,66 @@ func TestRunExplainCheckpoint_V2FallsBackToFullWhenCompactMissing(t *testing.T) "should use raw transcript from /full/current when compact is missing") } +func TestRunExplainCheckpoint_FullFallsBackToV1WhenV2FullMissing(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + testutil.InitRepo(t, tmpDir) + repo, err := git.PlainOpen(tmpDir) + require.NoError(t, err) + + wt, err := repo.Worktree() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644)) + _, err = wt.Add("test.txt") + require.NoError(t, err) + _, err = wt.Commit("initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()}, + }) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, ".entire", "settings.json"), + []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`), + 0o644, + )) + + v1Store := checkpoint.NewGitStore(repo) + v2Store := checkpoint.NewV2GitStore(repo, "origin") + cpID := id.MustCheckpointID("e2e3e4e5e6e7") + ctx := context.Background() + + rawTranscript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"v1 raw fallback prompt"}]}}` + "\n" + + `{"type":"assistant","message":{"content":"v1 raw reply"}}` + "\n") + compactTranscript := []byte(`{"v":1,"type":"user","content":[{"text":"v2 compact prompt"}]}` + "\n") + + require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-v1-fallback", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted(rawTranscript), + AuthorName: "Test", + AuthorEmail: "test@example.com", + })) + require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-v1-fallback", + Strategy: "manual-commit", + CompactTranscript: compactTranscript, + AuthorName: "Test", + AuthorEmail: "test@example.com", + })) + + var buf, errBuf bytes.Buffer + err = runExplainCheckpoint(ctx, &buf, &errBuf, "e2e3e4", false, false, true, false, false, false, false, 0) + require.NoError(t, err) + + output := buf.String() + require.Contains(t, output, "v1 raw fallback prompt") + require.NotContains(t, output, "v2 compact prompt") +} + func TestRunExplainCheckpoint_V2CompactTranscriptNotUsedForGenerate(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) @@ -2478,8 +2642,11 @@ func TestListCommittedForExplain_MergesV1AndV2(t *testing.T) { AuthorEmail: "t@t.com", })) - // With v2 preferred: should return both the dual-write AND the v1-only checkpoint. - results, err := listCommittedForExplain(ctx, v1Store, v2Store, true) + reader, err := checkpoint.NewCommittedReader(v1Store, v2Store, checkpoint.CommittedReadDual) + require.NoError(t, err) + + // In dual-read mode: should return both the dual-write AND the v1-only checkpoint. + results, err := reader.ListCommitted(ctx) require.NoError(t, err) foundIDs := make(map[id.CheckpointID]bool) @@ -2544,7 +2711,10 @@ func TestListCommittedForExplain_V2Disabled_ReturnsV1Only(t *testing.T) { AuthorEmail: "t@t.com", })) - results, err := listCommittedForExplain(ctx, v1Store, v2Store, false) + reader, err := checkpoint.NewCommittedReader(v1Store, v2Store, checkpoint.CommittedReadV1) + require.NoError(t, err) + + results, err := reader.ListCommitted(ctx) require.NoError(t, err) foundIDs := make(map[id.CheckpointID]bool) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index b7fadbded..620d38d08 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -906,30 +906,18 @@ func resumeSingleSession(ctx context.Context, w, errW io.Writer, ag agent.Agent, } var logContent []byte - err = nil // Reset before v2/v1 resolution to avoid stale error from earlier code paths - if settings.IsCheckpointsV2Enabled(ctx) { - repo, repoErr := openRepository(ctx) - if repoErr == nil { - v2URL, fetchRemoteErr := remote.FetchURL(ctx) - if fetchRemoteErr != nil { - logging.Debug(ctx, "resume: using origin for v2 session log fetch remote", - slog.String("error", fetchRemoteErr.Error()), - ) - v2URL = "" - } - v2Store := checkpoint.NewV2GitStore(repo, v2URL) - var v2Err error - logContent, _, v2Err = v2Store.GetSessionLog(ctx, checkpointID) - if v2Err != nil { - logging.Debug(ctx, "v2 GetSessionLog failed, falling back to v1", - slog.String("checkpoint_id", checkpointID.String()), - slog.String("error", v2Err.Error()), - ) - } - } - } - if len(logContent) == 0 { + repo, repoErr := openRepository(ctx) + if repoErr != nil { logContent, _, err = checkpoint.LookupSessionLog(ctx, checkpointID) + } else { + checkpointReader, readerErr := newCommittedCheckpointReader(ctx, repo, committedCheckpointReaderOptions{ + fetchRemoteLog: "resume: using origin for v2 session log fetch remote", + }) + if readerErr != nil { + err = readerErr + } else { + logContent, _, err = checkpoint.ReadRawSessionLogForCheckpoint(ctx, checkpointReader.reader, checkpointID) + } } if err != nil { if errors.Is(err, checkpoint.ErrCheckpointNotFound) || errors.Is(err, checkpoint.ErrNoTranscript) { diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index 525a3de7d..00494ba1a 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -11,9 +11,13 @@ import ( "testing" "time" + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/redact" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" @@ -22,6 +26,46 @@ import ( "github.com/spf13/cobra" ) +type recordingResumeAgent struct { + sessionDir string + writtenSession *agent.AgentSession +} + +var _ agent.Agent = (*recordingResumeAgent)(nil) + +func (a *recordingResumeAgent) Name() types.AgentName { return "recording-resume" } +func (a *recordingResumeAgent) Type() types.AgentType { return "recording-resume" } +func (a *recordingResumeAgent) Description() string { return "recording resume agent" } +func (a *recordingResumeAgent) IsPreview() bool { return false } +func (a *recordingResumeAgent) DetectPresence(_ context.Context) (bool, error) { return true, nil } +func (a *recordingResumeAgent) ProtectedDirs() []string { return nil } +func (a *recordingResumeAgent) ReadTranscript(string) ([]byte, error) { return nil, nil } +func (a *recordingResumeAgent) ChunkTranscript(_ context.Context, content []byte, _ int) ([][]byte, error) { + return [][]byte{content}, nil +} +func (a *recordingResumeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + var out []byte + for _, chunk := range chunks { + out = append(out, chunk...) + } + return out, nil +} +func (a *recordingResumeAgent) GetSessionID(*agent.HookInput) string { return "" } +func (a *recordingResumeAgent) GetSessionDir(string) (string, error) { return a.sessionDir, nil } +func (a *recordingResumeAgent) ResolveSessionFile(sessionDir, sessionID string) string { + return filepath.Join(sessionDir, sessionID+".jsonl") +} +func (a *recordingResumeAgent) ReadSession(*agent.HookInput) (*agent.AgentSession, error) { + return nil, nil //nolint:nilnil // Not used by this test agent. +} +func (a *recordingResumeAgent) WriteSession(_ context.Context, session *agent.AgentSession) error { + a.writtenSession = session + return nil +} +func (a *recordingResumeAgent) FormatResumeCommand(sessionID string) string { + return "recording resume " + sessionID +} + func TestFirstLine(t *testing.T) { tests := []struct { name string @@ -600,6 +644,69 @@ func TestFindBranchCheckpoint_SquashMergeMultipleCheckpoints(t *testing.T) { } } +func TestResumeSingleSession_FallsBackToV1WhenV2FullMissing(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + repo, _, _ := setupResumeTestRepo(t, tmpDir, false) + + if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + t.Fatalf("failed to create settings dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(tmpDir, ".entire", "settings.json"), + []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`), + 0o644, + ); err != nil { + t.Fatalf("failed to write settings: %v", err) + } + + ctx := context.Background() + cpID := id.MustCheckpointID("abc123abc123") + sessionID := "resume-v1-fallback-session" + raw := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"resume v1 fallback"}]}}` + "\n") + + v1Store := checkpoint.NewGitStore(repo) + if err := v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: sessionID, + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted(raw), + AuthorName: "Test", + AuthorEmail: "test@example.com", + }); err != nil { + t.Fatalf("failed to write v1 checkpoint: %v", err) + } + + v2Store := checkpoint.NewV2GitStore(repo, "origin") + if err := v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: sessionID, + Strategy: "manual-commit", + CompactTranscript: []byte(`{"v":1,"type":"user"}` + "\n"), + AuthorName: "Test", + AuthorEmail: "test@example.com", + }); err != nil { + t.Fatalf("failed to write v2 checkpoint: %v", err) + } + + ag := &recordingResumeAgent{sessionDir: filepath.Join(tmpDir, "sessions")} + var stdout, stderr bytes.Buffer + if err := resumeSingleSession(ctx, &stdout, &stderr, ag, sessionID, cpID, tmpDir, true); err != nil { + t.Fatalf("resumeSingleSession() error = %v", err) + } + + if ag.writtenSession == nil { + t.Fatal("resumeSingleSession() did not restore a session") + } + if string(ag.writtenSession.NativeData) != string(raw) { + t.Fatalf("restored transcript = %q, want %q", string(ag.writtenSession.NativeData), string(raw)) + } + if strings.Contains(stdout.String(), "session log not available") { + t.Fatalf("resumeSingleSession() reported missing log: %q", stdout.String()) + } +} + func TestCheckRemoteMetadata_MetadataExistsOnRemote(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) diff --git a/cmd/entire/cli/review_context.go b/cmd/entire/cli/review_context.go index 94f2c84de..c957671f5 100644 --- a/cmd/entire/cli/review_context.go +++ b/cmd/entire/cli/review_context.go @@ -15,11 +15,9 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint" checkpointid "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" - "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" - "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/stringutil" "github.com/entireio/cli/cmd/entire/cli/trailers" ) @@ -99,13 +97,14 @@ func reviewCommittedCheckpointContext(ctx context.Context, worktreeRoot string, logging.Debug(ctx, "review checkpoint context: open repo", slog.String("error", err.Error())) return "" } - v1 := checkpoint.NewGitStore(repo) - v2URL, urlErr := remote.FetchURL(ctx) - if urlErr != nil { - logging.Debug(ctx, "review checkpoint context: no v2 fetch remote", slog.String("error", urlErr.Error())) + checkpointReader, readerErr := newCommittedCheckpointReader(ctx, repo, committedCheckpointReaderOptions{ + fetchRemoteLog: "review checkpoint context: no v2 fetch remote", + }) + if readerErr != nil { + logging.Debug(ctx, "review checkpoint context: checkpoint reader unavailable", slog.String("error", readerErr.Error())) + return "" } - v2 := checkpoint.NewV2GitStore(repo, v2URL) - preferCheckpointsV2 := settings.IsCheckpointsV2Enabled(ctx) + reader := checkpointReader.reader var lines []string seen := map[checkpointid.CheckpointID]bool{} @@ -122,8 +121,8 @@ func reviewCommittedCheckpointContext(ctx context.Context, worktreeRoot string, continue } - reader, summary, err := checkpoint.ResolveCommittedReaderForCheckpoint(ctx, cpID, v1, v2, preferCheckpointsV2) - if err != nil || summary == nil { + summary, err := checkpoint.ReadCommittedCheckpoint(ctx, reader, cpID) + if err != nil { lines = append(lines, fmt.Sprintf("- %s: checkpoint metadata unavailable", cpID)) continue } @@ -334,13 +333,15 @@ func readReviewContextSessionPrompts( ) (string, error) { if r, ok := reader.(reviewContextSessionMetadataPromptsReader); ok { content, err := r.ReadSessionMetadataAndPrompts(ctx, cpID, sessionIndex) - if err != nil { - return "", err //nolint:wrapcheck // Best-effort prompt context. + if err == nil { + if content == nil { + return "", errors.New("session content is nil") + } + return content.Prompts, nil } - if content == nil { - return "", errors.New("session content is nil") + if !errors.Is(err, checkpoint.ErrCheckpointNotFound) { + return "", err //nolint:wrapcheck // Best-effort prompt context. } - return content.Prompts, nil } content, err := reader.ReadSessionContent(ctx, cpID, sessionIndex) if err != nil { diff --git a/cmd/entire/cli/review_helpers.go b/cmd/entire/cli/review_helpers.go index c9ba2fac7..7fb8f1d89 100644 --- a/cmd/entire/cli/review_helpers.go +++ b/cmd/entire/cli/review_helpers.go @@ -25,19 +25,16 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/external" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" - "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" cliReview "github.com/entireio/cli/cmd/entire/cli/review" - "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/trailers" ) // headHasReviewCheckpoint checks whether HEAD's checkpoint metadata includes // a review session. Returns (true, infoString) if HasReview is set. // Single lookup: read the Entire-Checkpoint trailer from HEAD, then resolve -// the CheckpointSummary via ResolveCommittedReaderForCheckpoint so v2-enabled -// repos also work (v1 alone would miss v2-written summaries). +// the CheckpointSummary through the configured committed checkpoint reader. func headHasReviewCheckpoint(ctx context.Context) (bool, string) { repoRoot, err := paths.WorktreeRoot(ctx) if err != nil { @@ -60,15 +57,15 @@ func headHasReviewCheckpoint(ctx context.Context) (bool, string) { logging.Debug(ctx, "head review check: open repository", slog.String("error", err.Error())) return false, "" } - v1Store := checkpoint.NewGitStore(repo) - v2URL, urlErr := remote.FetchURL(ctx) - if urlErr != nil { - logging.Debug(ctx, "head review check: no configured v2 fetch remote", slog.String("error", urlErr.Error())) - v2URL = "" + checkpointReader, readerErr := newCommittedCheckpointReader(ctx, repo, committedCheckpointReaderOptions{ + fetchRemoteLog: "head review check: no configured v2 fetch remote", + }) + if readerErr != nil { + logging.Debug(ctx, "head review check: checkpoint reader unavailable", slog.String("error", readerErr.Error())) + return false, "" } - v2Store := checkpoint.NewV2GitStore(repo, v2URL) - _, summary, err := checkpoint.ResolveCommittedReaderForCheckpoint(ctx, cpID, v1Store, v2Store, settings.IsCheckpointsV2Enabled(ctx)) - if err != nil || summary == nil { + summary, err := checkpoint.ReadCommittedCheckpoint(ctx, checkpointReader.reader, cpID) + if err != nil { logging.Debug(ctx, "head review check: resolve checkpoint summary", slog.String("checkpoint_id", cpID.String()), slog.Any("error", err)) diff --git a/cmd/entire/cli/rewind.go b/cmd/entire/cli/rewind.go index 388665b5d..81112a98c 100644 --- a/cmd/entire/cli/rewind.go +++ b/cmd/entire/cli/rewind.go @@ -686,8 +686,18 @@ func restoreSessionTranscript(ctx context.Context, w io.Writer, transcriptFile, // This is used for strategies that store transcripts in git branches rather than local files. // Returns the session ID that was actually used (may differ from input if checkpoint provides one). func restoreSessionTranscriptFromStrategy(ctx context.Context, cpID id.CheckpointID, sessionID string, agent agentpkg.Agent) (string, error) { - // Get transcript content from checkpoint storage - content, returnedSessionID, err := checkpoint.LookupSessionLog(ctx, cpID) + repo, err := openRepository(ctx) + if err != nil { + return "", fmt.Errorf("failed to open git repository: %w", err) + } + + checkpointReader, err := newCommittedCheckpointReader(ctx, repo, committedCheckpointReaderOptions{ + fetchRemoteLog: "rewind: using origin for v2 session log fetch remote", + }) + if err != nil { + return "", fmt.Errorf("prepare checkpoint reader: %w", err) + } + content, returnedSessionID, err := checkpoint.ReadRawSessionLogForCheckpoint(ctx, checkpointReader.reader, cpID) if err != nil { return "", fmt.Errorf("failed to get session log: %w", err) } diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index f465c187c..11a5bb8e5 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -30,13 +30,6 @@ import ( "github.com/go-git/go-git/v6/plumbing/object" ) -// committedReader provides read access to committed checkpoint data. -// Both checkpoint.GitStore (v1) and checkpoint.V2GitStore implement this interface. -type committedReader interface { - ReadCommitted(ctx context.Context, checkpointID id.CheckpointID) (*cpkg.CheckpointSummary, error) - ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*cpkg.SessionContent, error) -} - // GetRewindPoints returns available rewind points. // Uses checkpoint.GitStore.ListTemporaryCheckpoints for reading from shadow branches. func (s *ManualCommitStrategy) GetRewindPoints(ctx context.Context, limit int) ([]RewindPoint, error) { @@ -632,43 +625,34 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, w, errW io.W return nil, errors.New("missing checkpoint ID") } - // Resolve which store has this checkpoint. Try v2 first when enabled. - // The chosen reader is used for all subsequent reads (summary + session content) - // to avoid mixed v1/v2 reads. No per-session fallback to v1: during dual-write, - // both stores receive the same data, so if v2 has the summary it also has the - // transcripts on /full/* refs. - var reader committedReader - var summary *cpkg.CheckpointSummary - - if settings.IsCheckpointsV2Enabled(ctx) { - v2Store, v2Err := s.getV2CheckpointStore(ctx) - if v2Err == nil { - v2Summary, readErr := v2Store.ReadCommitted(ctx, point.CheckpointID) - if readErr != nil { - logging.Debug(ctx, "v2 ReadCommitted failed, falling back to v1", - slog.String("checkpoint_id", string(point.CheckpointID)), - slog.String("error", readErr.Error()), - ) - } else if v2Summary != nil { - reader = v2Store - summary = v2Summary + v1Store, err := s.getCheckpointStore() + if err != nil { + return nil, fmt.Errorf("failed to get checkpoint store: %w", err) + } + + var v2Store *cpkg.V2GitStore + readMode := cpkg.CommittedReadModeForOptions(settings.IsCheckpointsV2Enabled(ctx), settings.CheckpointsVersion(ctx)) + if readMode != cpkg.CommittedReadV1 { + var v2Err error + v2Store, v2Err = s.getV2CheckpointStore(ctx) + if v2Err != nil { + if readMode == cpkg.CommittedReadV2 { + return nil, fmt.Errorf("failed to get v2 checkpoint store: %w", v2Err) } + logging.Debug(ctx, "failed to get v2 checkpoint store, using v1 only", + slog.String("checkpoint_id", string(point.CheckpointID)), + slog.String("error", v2Err.Error()), + ) } } - if summary == nil { - v1Store, err := s.getCheckpointStore() - if err != nil { - return nil, fmt.Errorf("failed to get checkpoint store: %w", err) - } - summary, err = v1Store.ReadCommitted(ctx, point.CheckpointID) - if err != nil { - return nil, fmt.Errorf("failed to read checkpoint: %w", err) - } - reader = v1Store + reader, err := cpkg.NewCommittedReader(v1Store, v2Store, readMode) + if err != nil { + return nil, fmt.Errorf("failed to prepare checkpoint reader: %w", err) } - if summary == nil { - return nil, fmt.Errorf("checkpoint not found: %s", point.CheckpointID) + summary, err := cpkg.ReadCommittedCheckpoint(ctx, reader, point.CheckpointID) + if err != nil { + return nil, fmt.Errorf("failed to read checkpoint: %w", err) } // Get worktree root for agent session directory lookup @@ -862,7 +846,7 @@ type SessionRestoreInfo struct { // about each session, including whether local logs have newer timestamps. // repoRoot is used to compute per-session agent directories. // Sessions without agent metadata are skipped (cannot determine target directory). -func (s *ManualCommitStrategy) classifySessionsForRestore(ctx context.Context, repoRoot string, store committedReader, checkpointID id.CheckpointID, summary *cpkg.CheckpointSummary) []SessionRestoreInfo { +func (s *ManualCommitStrategy) classifySessionsForRestore(ctx context.Context, repoRoot string, store cpkg.CommittedReader, checkpointID id.CheckpointID, summary *cpkg.CheckpointSummary) []SessionRestoreInfo { var sessions []SessionRestoreInfo totalSessions := len(summary.Sessions)