diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 5591e96f5f..3ff1af0153 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -62,7 +62,7 @@ func (opts attachOptions) committedRefs(ctx context.Context) cpkg.CommittedRefs // openAttachStore opens the committed store for the resolved topology. refs is // passed explicitly so attach preserves PrimaryAsRead() pinning. -func openAttachStore(ctx context.Context, repo *git.Repository, refs cpkg.CommittedRefs) (*cpkg.GitStore, error) { +func openAttachStore(ctx context.Context, repo *git.Repository, refs cpkg.CommittedRefs) (cpkg.CommittedStore, error) { //nolint:ireturn // committed store capability preserves attach's read-ref override stores, err := cpkg.Open(ctx, repo, cpkg.OpenOptions{Refs: &refs}) if err != nil { return nil, fmt.Errorf("open checkpoint store: %w", err) @@ -337,7 +337,7 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ writeOpts.HasReview = true } - if err := store.WriteCommitted(ctx, writeOpts); err != nil { + if err := store.WriteSession(ctx, cpkg.SessionIDRef(checkpointID, sessionID), writeOpts); err != nil { return fmt.Errorf("failed to write checkpoint: %w", err) } @@ -370,19 +370,20 @@ func checkpointHasSessionMetadata(ctx context.Context, repo *git.Repository, ref if err != nil { return false, err } - summary, err := store.ReadCommitted(ctx, checkpointID) + summary, err := store.ReadCheckpoint(ctx, checkpointID) if err != nil { + if errors.Is(err, cpkg.ErrCheckpointNotFound) { + return false, nil + } return false, fmt.Errorf("read checkpoint summary: %w", err) } - if summary == nil { - return false, nil - } for i := range summary.Sessions { - metadata, err := store.ReadSessionMetadata(ctx, checkpointID, i) + content, err := store.ReadSession(ctx, cpkg.SessionIndexRef(checkpointID, i), cpkg.WithSessionMetadataOnly()) if err != nil { return false, fmt.Errorf("read session %d metadata: %w", i, err) } - if metadata != nil && metadata.SessionID == sessionID { + metadata := content.Metadata + if metadata.SessionID == sessionID { return true, nil } } @@ -473,8 +474,11 @@ func checkpointPresentLocally(ctx context.Context, repo *git.Repository, refs cp if err != nil { return false, err } - summary, err := store.ReadCommitted(ctx, checkpointID) + summary, err := store.ReadCheckpoint(ctx, checkpointID) if err != nil { + if errors.Is(err, cpkg.ErrCheckpointNotFound) { + return false, nil + } return false, err //nolint:wrapcheck // Caller wraps with checkpoint ID context } return summary != nil, nil diff --git a/cmd/entire/cli/attribution.go b/cmd/entire/cli/attribution.go index f15909c764..598f4541c6 100644 --- a/cmd/entire/cli/attribution.go +++ b/cmd/entire/cli/attribution.go @@ -117,7 +117,7 @@ type attributionSummary struct { type attributionResolver struct { ctx context.Context repo *git.Repository - store *checkpoint.GitStore + store committedCheckpointReader fetchOnMiss bool commitCache map[string]*object.Commit @@ -549,7 +549,7 @@ type checkpointSessionForFile struct { } func (r *attributionResolver) readSessionForCheckpoint(cpID id.CheckpointID, index int) (checkpointSessionForFile, error) { - content, err := r.store.ReadSessionMetadataAndPrompts(r.ctx, cpID, index) + content, err := r.store.ReadSession(r.ctx, checkpoint.SessionIndexRef(cpID, index), checkpoint.WithSessionMetadataAndPrompts()) if err != nil { return checkpointSessionForFile{}, err //nolint:wrapcheck // caller skips partial metadata } diff --git a/cmd/entire/cli/attribution_test.go b/cmd/entire/cli/attribution_test.go index ad8a9ce548..55ed0ef755 100644 --- a/cmd/entire/cli/attribution_test.go +++ b/cmd/entire/cli/attribution_test.go @@ -353,6 +353,55 @@ func TestAttributionBlameMixedUsesFileMatchingCheckpoint(t *testing.T) { require.Equal(t, 1, payload.Summary.AILines) } +func TestAttributionResolverUsesCheckpointReader(t *testing.T) { + t.Parallel() + + cpID := checkpointid.MustCheckpointID("d9b2c3d4e5f6") + reader := &attributionCheckpointReaderStub{ + summary: &checkpoint.CheckpointSummary{ + FilesTouched: []string{"auth.py"}, + Sessions: []checkpoint.SessionFilePaths{{Metadata: "metadata.json"}}, + }, + content: &checkpoint.SessionContent{ + Metadata: checkpoint.CommittedMetadata{ + SessionID: "session-ai", + FilesTouched: []string{"auth.py"}, + Agent: agent.AgentTypeClaudeCode, + Model: "claude-test", + }, + Prompts: "Explain the authentication change.", + }, + } + resolver := &attributionResolver{ + ctx: context.Background(), + store: reader, + checkpointCache: make(map[string]attributionCheckpointContext), + } + + ctx := resolver.readCheckpointContext(cpID, "auth.py") + require.Equal(t, "session-ai", ctx.SessionID) + require.Equal(t, "Claude Code", ctx.Agent) + require.Equal(t, "claude-test", ctx.Model) + require.Equal(t, "Explain the authentication change.", ctx.Prompt) +} + +type attributionCheckpointReaderStub struct { + summary *checkpoint.CheckpointSummary + content *checkpoint.SessionContent +} + +func (s *attributionCheckpointReaderStub) ListCheckpoints(context.Context) ([]checkpoint.CommittedInfo, error) { + return nil, nil +} + +func (s *attributionCheckpointReaderStub) ReadCheckpoint(context.Context, checkpointid.CheckpointID) (*checkpoint.CheckpointSummary, error) { + return s.summary, nil +} + +func (s *attributionCheckpointReaderStub) ReadSession(context.Context, checkpoint.SessionRef, ...checkpoint.ReadOption) (*checkpoint.SessionContent, error) { + return s.content, nil +} + func TestAttributionBlameScopesMixedToSessionNotCheckpoint(t *testing.T) { repoRoot := newAttributionRepo(t) writeAttributionCheckpoint(t, repoRoot, "a9b2c3d4e5f6", checkpoint.WriteCommittedOptions{ diff --git a/cmd/entire/cli/benchutil/benchutil.go b/cmd/entire/cli/benchutil/benchutil.go index d05f5959b4..20853aebd0 100644 --- a/cmd/entire/cli/benchutil/benchutil.go +++ b/cmd/entire/cli/benchutil/benchutil.go @@ -398,7 +398,7 @@ func (br *BenchRepo) SeedMetadataBranch(b *testing.B, checkpointCount int) { files = append(files, fmt.Sprintf("src/file_%03d.go", (i*5+j)%100)) } - err = br.Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ + err = br.Store.WriteSession(context.Background(), checkpoint.SessionIDRef(cpID, sessionID), checkpoint.Session{ CheckpointID: cpID, SessionID: sessionID, Strategy: br.Strategy, diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 2fb564135c..802ba5ed11 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -60,56 +60,16 @@ const ( Committed ) -// Store provides low-level primitives for reading and writing checkpoints. -// This is used by strategies to implement their storage approach. -// -// The interface matches the GitStore implementation signatures directly: -// - WriteTemporary takes WriteTemporaryOptions and returns a result with commit hash and skip status -// - ReadTemporary takes baseCommit (not sessionID) since shadow branches are keyed by commit -// - List methods return implementation-specific info types for richer data -type Store interface { - // WriteTemporary writes a temporary checkpoint (full state) to a shadow branch. - // Shadow branches are named entire/. - // Returns a result containing the commit hash and whether the checkpoint was skipped. - // Checkpoints are skipped (deduplicated) when the tree hash matches the previous checkpoint. +// TemporaryStore provides the production shadow-branch checkpoint surface. +type TemporaryStore interface { WriteTemporary(ctx context.Context, opts WriteTemporaryOptions) (WriteTemporaryResult, error) - - // ReadTemporary reads the latest checkpoint from a shadow branch. - // baseCommit is the commit hash the session is based on. - // worktreeID is the internal git worktree identifier (empty for main worktree). - // Returns nil, nil if the shadow branch doesn't exist. - ReadTemporary(ctx context.Context, baseCommit, worktreeID string) (*ReadTemporaryResult, error) - - // ListTemporary lists all shadow branches with their checkpoint info. + WriteTemporaryTask(ctx context.Context, opts WriteTemporaryTaskOptions) (plumbing.Hash, error) ListTemporary(ctx context.Context) ([]TemporaryInfo, error) - - // WriteCommitted writes a committed checkpoint to the entire/checkpoints/v1 branch. - // Checkpoints are stored at sharded paths: // - WriteCommitted(ctx context.Context, opts WriteCommittedOptions) error - - // ReadCommitted reads a committed checkpoint's summary by ID. - // Returns only the CheckpointSummary (paths + aggregated stats), not actual content. - // Use ReadSessionContent to read actual transcript/prompts. - // Returns nil, nil if the checkpoint does not exist. - ReadCommitted(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) - - // ReadSessionContent reads the actual content for a specific session within a checkpoint. - // sessionIndex is 0-based (0 for first session, 1 for second, etc.). - // Returns the session's metadata, transcript, and prompts. - ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) - - // ReadSessionContentByID reads a session's content by its session ID. - // Useful when you have the session ID but don't know its index within the checkpoint. - ReadSessionContentByID(ctx context.Context, checkpointID id.CheckpointID, sessionID string) (*SessionContent, error) - - // ListCommitted lists all committed checkpoints. - ListCommitted(ctx context.Context) ([]CommittedInfo, error) - - // UpdateCommitted replaces the transcript and prompts for an existing - // committed checkpoint. Used at stop time to finalize checkpoints with the full - // session transcript (prompt to stop event). - // Returns ErrCheckpointNotFound if the checkpoint doesn't exist. - UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error + ListTemporaryCheckpoints(ctx context.Context, baseCommit, worktreeID, sessionID string, limit int) ([]TemporaryCheckpointInfo, error) + ListCheckpointsForBranch(ctx context.Context, branchName, sessionID string, limit int) ([]TemporaryCheckpointInfo, error) + ListAllTemporaryCheckpoints(ctx context.Context, sessionID string, limit int) ([]TemporaryCheckpointInfo, error) + GetTranscriptFromCommit(ctx context.Context, commitHash plumbing.Hash, metadataDir string, agentType types.AgentType) ([]byte, error) + ShadowBranchExists(baseCommit, worktreeID string) bool } // WriteTemporaryResult contains the result of writing a temporary checkpoint. diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 56069d95dc..b102d3e174 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -1327,7 +1327,7 @@ func (s *GitStore) GetSessionLog(ctx context.Context, cpID id.CheckpointID) ([]b // LookupSessionLog is a convenience function that opens the repository and retrieves // a session log by checkpoint ID. This is the primary entry point for callers that -// don't already have a GitStore instance. +// do not already have a committed store instance. // Returns ErrCheckpointNotFound if the checkpoint doesn't exist. // Returns ErrNoTranscript if the checkpoint exists but has no transcript. func LookupSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string, error) { @@ -1340,93 +1340,17 @@ func LookupSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string if err != nil { return nil, "", fmt.Errorf("open checkpoint store: %w", err) } - return stores.Primary.GetSessionLog(ctx, cpID) + content, err := stores.Primary.ReadSession(ctx, LatestSessionRef(cpID)) + if err != nil { + return nil, "", err //nolint:wrapcheck // Checkpoint store errors are already caller-facing sentinel errors. + } + return content.Transcript, content.Metadata.SessionID, nil } // UpdateSummary updates the summary field in the latest session's metadata. // Returns ErrCheckpointNotFound if the checkpoint doesn't exist. func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.CheckpointID, summary *Summary) error { - if err := ctx.Err(); err != nil { - return err //nolint:wrapcheck // Propagating context cancellation - } - - // Ensure sessions branch exists - if err := s.ensureSessionsBranch(ctx); err != nil { - return fmt.Errorf("failed to ensure sessions branch: %w", err) - } - - // Get branch ref and root tree hash (O(1), no flatten) - parentHash, rootTreeHash, err := s.getSessionsBranchRef() - if err != nil { - return err - } - - // Flatten only the checkpoint subtree - basePath := checkpointID.Path() + "/" - checkpointPath := checkpointID.Path() - entries, err := s.flattenCheckpointEntries(rootTreeHash, checkpointPath) - if err != nil { - return err - } - - // Read root CheckpointSummary to find the latest session - rootMetadataPath := basePath + paths.MetadataFileName - entry, exists := entries[rootMetadataPath] - if !exists { - return ErrCheckpointNotFound - } - - checkpointSummary, err := s.readSummaryFromBlob(entry.Hash) - if err != nil { - return fmt.Errorf("failed to read checkpoint summary: %w", err) - } - - // Find the latest session's metadata path (0-based indexing) - latestIndex := len(checkpointSummary.Sessions) - 1 - sessionMetadataPath := fmt.Sprintf("%s%d/%s", basePath, latestIndex, paths.MetadataFileName) - sessionEntry, exists := entries[sessionMetadataPath] - if !exists { - return fmt.Errorf("session metadata not found at %s", sessionMetadataPath) - } - - // Read and update session metadata - existingMetadata, err := s.readMetadataFromBlob(sessionEntry.Hash) - if err != nil { - return fmt.Errorf("failed to read session metadata: %w", err) - } - - // Update the summary - existingMetadata.Summary = redactSummary(summary) - - // Write updated session metadata - metadataJSON, err := jsonutil.MarshalIndentWithNewline(existingMetadata, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal metadata: %w", err) - } - metadataHash, err := CreateBlobFromContent(s.repo, metadataJSON) - if err != nil { - return fmt.Errorf("failed to create metadata blob: %w", err) - } - entries[sessionMetadataPath] = object.TreeEntry{ - Name: sessionMetadataPath, - Mode: filemode.Regular, - Hash: metadataHash, - } - - // Build checkpoint subtree and splice into root (O(depth) tree surgery) - newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, checkpointID, basePath, entries) - if err != nil { - return err - } - - authorName, authorEmail := GetGitAuthorFromRepo(s.repo) - commitMsg := fmt.Sprintf("Update summary for checkpoint %s (session: %s)", checkpointID, existingMetadata.SessionID) - newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, authorName, authorEmail) - if err != nil { - return err - } - - return s.setPrimaryRef(newCommitHash) + return s.UpdateSession(ctx, LatestSessionRef(checkpointID), WithSummary(summary)) } // UpdateCommitted replaces the transcript, prompts, and context for an existing diff --git a/cmd/entire/cli/checkpoint/committed_domain.go b/cmd/entire/cli/checkpoint/committed_domain.go new file mode 100644 index 0000000000..59b2feae9b --- /dev/null +++ b/cmd/entire/cli/checkpoint/committed_domain.go @@ -0,0 +1,484 @@ +package checkpoint + +import ( + "context" + "errors" + "fmt" + + "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/id" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/redact" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/filemode" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// Session is the committed session document written by SessionWriter. +type Session = WriteCommittedOptions + +type sessionRefMode int + +const ( + sessionRefLatest sessionRefMode = iota + sessionRefIndex + sessionRefID +) + +// SessionRef identifies a session within today's embedded checkpoint layout. +type SessionRef struct { + checkpointID id.CheckpointID + sessionID string + sessionIndex int + sessionRefMode sessionRefMode +} + +// LatestSessionRef targets the latest session in a checkpoint. +func LatestSessionRef(checkpointID id.CheckpointID) SessionRef { + return SessionRef{checkpointID: checkpointID, sessionIndex: -1, sessionRefMode: sessionRefLatest} +} + +// SessionIndexRef targets a session by its checkpoint-local index. +func SessionIndexRef(checkpointID id.CheckpointID, sessionIndex int) SessionRef { + return SessionRef{checkpointID: checkpointID, sessionIndex: sessionIndex, sessionRefMode: sessionRefIndex} +} + +// SessionIDRef targets a session by its session ID. +func SessionIDRef(checkpointID id.CheckpointID, sessionID string) SessionRef { + return SessionRef{checkpointID: checkpointID, sessionID: sessionID, sessionIndex: -1, sessionRefMode: sessionRefID} +} + +// CheckpointID returns the checkpoint that contains the session. +func (r SessionRef) CheckpointID() id.CheckpointID { return r.checkpointID } + +// SessionID returns the target session ID when the ref was created by SessionIDRef. +func (r SessionRef) SessionID() string { return r.sessionID } + +// SessionIndex returns the target index when the ref was created by SessionIndexRef. +func (r SessionRef) SessionIndex() (int, bool) { + if r.sessionRefMode != sessionRefIndex { + return 0, false + } + return r.sessionIndex, true +} + +type sessionReadMode int + +const ( + sessionReadFull sessionReadMode = iota + sessionReadMetadataOnly + sessionReadMetadataAndPrompts +) + +type readOptions struct { + mode sessionReadMode +} + +// ReadOption customizes a session read without expanding the reader interface. +type ReadOption func(*readOptions) + +// WithSessionMetadataOnly reads session metadata without transcript or prompts. +func WithSessionMetadataOnly() ReadOption { + return func(opts *readOptions) { + opts.mode = sessionReadMetadataOnly + } +} + +// WithSessionMetadataAndPrompts reads metadata and prompts without transcript. +func WithSessionMetadataAndPrompts() ReadOption { + return func(opts *readOptions) { + opts.mode = sessionReadMetadataAndPrompts + } +} + +type writeOptions struct { + transcript redact.RedactedBytes + transcriptSet bool + prompts []string + promptsSet bool + agent types.AgentType + skillEvents []agent.SkillEvent + skillEventsSet bool + precomputedBlobs *PrecomputedTranscriptBlobs + summary *Summary + summarySet bool + attribution *InitialAttribution + attributionSet bool +} + +// WriteOption customizes session or checkpoint updates. +type WriteOption func(*writeOptions) + +// WithTranscript replaces a session transcript. +func WithTranscript(transcript redact.RedactedBytes, agentType types.AgentType) WriteOption { + return func(opts *writeOptions) { + opts.transcript = transcript + opts.transcriptSet = true + opts.agent = agentType + } +} + +// WithPrompts replaces a session's prompt content. +func WithPrompts(prompts []string) WriteOption { + return func(opts *writeOptions) { + opts.prompts = prompts + opts.promptsSet = true + } +} + +// WithSkillEvents replaces a session's recorded skill events. +func WithSkillEvents(events []agent.SkillEvent) WriteOption { + return func(opts *writeOptions) { + opts.skillEvents = events + opts.skillEventsSet = true + } +} + +// WithPrecomputedTranscriptBlobs reuses already-written transcript blobs. +func WithPrecomputedTranscriptBlobs(blobs *PrecomputedTranscriptBlobs) WriteOption { + return func(opts *writeOptions) { + opts.precomputedBlobs = blobs + } +} + +// WithSummary updates a session summary. +func WithSummary(summary *Summary) WriteOption { + return func(opts *writeOptions) { + opts.summary = summary + opts.summarySet = true + } +} + +// WithAttribution updates checkpoint-level attribution. +func WithAttribution(attribution *InitialAttribution) WriteOption { + return func(opts *writeOptions) { + opts.attribution = attribution + opts.attributionSet = true + } +} + +// SessionReader reads committed session documents. +type SessionReader interface { + ReadSession(ctx context.Context, ref SessionRef, opts ...ReadOption) (*SessionContent, error) +} + +// SessionWriter writes and backfills committed session documents. +type SessionWriter interface { + WriteSession(ctx context.Context, ref SessionRef, session Session) error + UpdateSession(ctx context.Context, ref SessionRef, opts ...WriteOption) error +} + +// SessionStore reads and writes committed sessions. +type SessionStore interface { + SessionReader + SessionWriter +} + +// Reader reads committed checkpoint documents. +type Reader interface { + ListCheckpoints(ctx context.Context) ([]CommittedInfo, error) + ReadCheckpoint(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) +} + +// Writer backfills committed checkpoint documents. +type Writer interface { + UpdateCheckpoint(ctx context.Context, checkpointID id.CheckpointID, opts ...WriteOption) error +} + +// MetadataStore reads and writes committed checkpoint documents. +type MetadataStore interface { + Reader + Writer +} + +func (s *GitStore) ListCheckpoints(ctx context.Context) ([]CommittedInfo, error) { + return s.ListCommitted(ctx) +} + +func (s *GitStore) ReadCheckpoint(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) { + if err := ctx.Err(); err != nil { + return nil, err //nolint:wrapcheck // Propagating context cancellation + } + + summary, err := s.ReadCommitted(ctx, checkpointID) + if err != nil { + return nil, fmt.Errorf("read committed checkpoint: %w", err) + } + if summary == nil { + return nil, ErrCheckpointNotFound + } + return summary, nil +} + +func (s *GitStore) ReadSession(ctx context.Context, ref SessionRef, opts ...ReadOption) (*SessionContent, error) { + if err := ref.validate(); err != nil { + return nil, err + } + + readOpts := applyReadOptions(opts) + sessionIndex, err := s.resolveSessionIndex(ctx, ref) + if err != nil { + return nil, err + } + + switch readOpts.mode { + case sessionReadFull: + return s.ReadSessionContent(ctx, ref.checkpointID, sessionIndex) + case sessionReadMetadataOnly: + metadata, readErr := s.ReadSessionMetadata(ctx, ref.checkpointID, sessionIndex) + if readErr != nil { + return nil, readErr + } + if metadata == nil { + return nil, ErrCheckpointNotFound + } + return &SessionContent{Metadata: *metadata}, nil + case sessionReadMetadataAndPrompts: + return s.ReadSessionMetadataAndPrompts(ctx, ref.checkpointID, sessionIndex) + default: + return nil, errors.New("unknown session read mode") + } +} + +func (s *GitStore) WriteSession(ctx context.Context, ref SessionRef, session Session) error { + if err := ref.validateForWrite(); err != nil { + return err + } + + opts := session + switch { + case opts.CheckpointID.IsEmpty(): + opts.CheckpointID = ref.checkpointID + case opts.CheckpointID != ref.checkpointID: + return fmt.Errorf("session checkpoint ID %s does not match ref %s", opts.CheckpointID, ref.checkpointID) + } + switch { + case opts.SessionID == "": + opts.SessionID = ref.sessionID + case opts.SessionID != ref.sessionID: + return fmt.Errorf("session ID %q does not match ref %q", opts.SessionID, ref.sessionID) + } + return s.WriteCommitted(ctx, opts) +} + +func (s *GitStore) UpdateSession(ctx context.Context, ref SessionRef, opts ...WriteOption) error { + if err := ref.validate(); err != nil { + return err + } + + writeOpts := applyWriteOptions(opts) + if !writeOpts.hasSessionUpdate() { + return errors.New("no session update options") + } + + if writeOpts.hasTranscriptUpdate() { + sessionID, err := s.resolveSessionID(ctx, ref) + if err != nil { + return err + } + if err := s.UpdateCommitted(ctx, UpdateCommittedOptions{ + CheckpointID: ref.checkpointID, + SessionID: sessionID, + Transcript: writeOpts.transcript, + Prompts: writeOpts.prompts, + Agent: writeOpts.agent, + SkillEvents: writeOpts.skillEvents, + PrecomputedBlobs: writeOpts.precomputedBlobs, + }); err != nil { + return err + } + } + + if writeOpts.summarySet { + return s.updateSessionSummary(ctx, ref, writeOpts.summary) + } + return nil +} + +func (s *GitStore) UpdateCheckpoint(ctx context.Context, checkpointID id.CheckpointID, opts ...WriteOption) error { + writeOpts := applyWriteOptions(opts) + if !writeOpts.attributionSet { + return errors.New("no checkpoint update options") + } + return s.UpdateCheckpointSummary(ctx, checkpointID, writeOpts.attribution) +} + +func applyReadOptions(opts []ReadOption) readOptions { + readOpts := readOptions{mode: sessionReadFull} + for _, opt := range opts { + opt(&readOpts) + } + return readOpts +} + +func applyWriteOptions(opts []WriteOption) writeOptions { + var writeOpts writeOptions + for _, opt := range opts { + opt(&writeOpts) + } + return writeOpts +} + +func (opts writeOptions) hasTranscriptUpdate() bool { + return opts.transcriptSet || opts.promptsSet || opts.skillEventsSet || opts.precomputedBlobs != nil +} + +func (opts writeOptions) hasSessionUpdate() bool { + return opts.hasTranscriptUpdate() || opts.summarySet +} + +func (r SessionRef) validate() error { + if r.checkpointID.IsEmpty() { + return errors.New("session ref checkpoint ID is required") + } + if r.sessionRefMode == sessionRefID && r.sessionID == "" { + return errors.New("session ref session ID is required") + } + if r.sessionRefMode == sessionRefIndex && r.sessionIndex < 0 { + return fmt.Errorf("session ref index must be non-negative: %d", r.sessionIndex) + } + return nil +} + +func (r SessionRef) validateForWrite() error { + if err := r.validate(); err != nil { + return err + } + if r.sessionRefMode != sessionRefID { + return errors.New("write session requires a session ID ref") + } + return nil +} + +func (s *GitStore) resolveSessionID(ctx context.Context, ref SessionRef) (string, error) { + if ref.sessionID != "" { + return ref.sessionID, nil + } + content, err := s.ReadSession(ctx, ref, WithSessionMetadataOnly()) + if err != nil { + return "", err + } + return content.Metadata.SessionID, nil +} + +func (s *GitStore) resolveSessionIndex(ctx context.Context, ref SessionRef) (int, error) { + if ref.sessionRefMode == sessionRefIndex { + return ref.sessionIndex, nil + } + + summary, err := s.ReadCheckpoint(ctx, ref.checkpointID) + if err != nil { + return 0, err + } + return s.resolveSessionIndexFromSummary(ctx, ref, summary) +} + +func (s *GitStore) resolveSessionIndexFromSummary(ctx context.Context, ref SessionRef, summary *CheckpointSummary) (int, error) { + if summary == nil || len(summary.Sessions) == 0 { + return 0, ErrCheckpointNotFound + } + + switch ref.sessionRefMode { + case sessionRefLatest: + return len(summary.Sessions) - 1, nil + case sessionRefIndex: + if ref.sessionIndex >= len(summary.Sessions) { + return 0, fmt.Errorf("session index %d out of range for checkpoint %s: %w", ref.sessionIndex, ref.checkpointID, ErrCheckpointNotFound) + } + return ref.sessionIndex, nil + case sessionRefID: + for index := range summary.Sessions { + metadata, err := s.ReadSessionMetadata(ctx, ref.checkpointID, index) + if err != nil { + return 0, err + } + if metadata != nil && metadata.SessionID == ref.sessionID { + return index, nil + } + } + return 0, fmt.Errorf("session %q not found in checkpoint %s: %w", ref.sessionID, ref.checkpointID, ErrCheckpointNotFound) + default: + return 0, errors.New("unknown session ref mode") + } +} + +func (s *GitStore) updateSessionSummary(ctx context.Context, ref SessionRef, summary *Summary) error { + if err := ctx.Err(); err != nil { + return err //nolint:wrapcheck // Propagating context cancellation + } + if err := s.ensureSessionsBranch(ctx); err != nil { + return fmt.Errorf("failed to ensure sessions branch: %w", err) + } + + parentHash, rootTreeHash, err := s.getSessionsBranchRef() + if err != nil { + return err + } + + basePath := ref.checkpointID.Path() + "/" + entries, err := s.flattenCheckpointEntries(rootTreeHash, ref.checkpointID.Path()) + if err != nil { + return err + } + + rootMetadataPath := basePath + paths.MetadataFileName + rootEntry, exists := entries[rootMetadataPath] + if !exists { + return ErrCheckpointNotFound + } + checkpointSummary, err := s.readSummaryFromBlob(rootEntry.Hash) + if err != nil { + return fmt.Errorf("failed to read checkpoint summary: %w", err) + } + sessionIndex, err := s.resolveSessionIndexFromSummary(ctx, ref, checkpointSummary) + if err != nil { + return err + } + + sessionMetadataPath := fmt.Sprintf("%s%d/%s", basePath, sessionIndex, paths.MetadataFileName) + sessionEntry, exists := entries[sessionMetadataPath] + if !exists { + return fmt.Errorf("session metadata not found at %s", sessionMetadataPath) + } + existingMetadata, err := s.readMetadataFromBlob(sessionEntry.Hash) + if err != nil { + return fmt.Errorf("failed to read session metadata: %w", err) + } + + existingMetadata.Summary = redactSummary(summary) + metadataHash, err := createCommittedMetadataBlob(s, existingMetadata) + if err != nil { + return err + } + entries[sessionMetadataPath] = object.TreeEntry{ + Name: sessionMetadataPath, + Mode: filemode.Regular, + Hash: metadataHash, + } + + newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, ref.checkpointID, basePath, entries) + if err != nil { + return err + } + authorName, authorEmail := GetGitAuthorFromRepo(s.repo) + commitMsg := fmt.Sprintf("Update summary for checkpoint %s (session: %s)", ref.checkpointID, existingMetadata.SessionID) + newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, authorName, authorEmail) + if err != nil { + return err + } + return s.setPrimaryRef(newCommitHash) +} + +func createCommittedMetadataBlob(store *GitStore, metadata *CommittedMetadata) (plumbing.Hash, error) { + metadataJSON, err := jsonutil.MarshalIndentWithNewline(metadata, "", " ") + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to marshal metadata: %w", err) + } + metadataHash, err := CreateBlobFromContent(store.repo, metadataJSON) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to create metadata blob: %w", err) + } + return metadataHash, nil +} diff --git a/cmd/entire/cli/checkpoint/committed_reader_resolve.go b/cmd/entire/cli/checkpoint/committed_reader_resolve.go index 3c1657a4be..924201c635 100644 --- a/cmd/entire/cli/checkpoint/committed_reader_resolve.go +++ b/cmd/entire/cli/checkpoint/committed_reader_resolve.go @@ -7,28 +7,25 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" ) -// CommittedReader provides read access to committed checkpoint data. -type CommittedReader interface { - ReadCommitted(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) - ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) +// CommittedStore provides the production committed checkpoint storage surface. +type CommittedStore interface { + SessionStore + MetadataStore } -// CommittedListReader provides read and list access to committed checkpoint data. -type CommittedListReader interface { - CommittedReader - ListCommitted(ctx context.Context) ([]CommittedInfo, error) - ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*CommittedMetadata, error) - ReadSessionPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (string, error) +// AuthorReader provides optional checkpoint author lookup. +type AuthorReader interface { + GetCheckpointAuthor(ctx context.Context, checkpointID id.CheckpointID) (Author, 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) { +func ReadCommittedCheckpoint(ctx context.Context, reader Reader, 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) + summary, err := reader.ReadCheckpoint(ctx, checkpointID) if err != nil { return nil, fmt.Errorf("read committed checkpoint: %w", err) } @@ -40,19 +37,22 @@ func ReadCommittedCheckpoint(ctx context.Context, reader CommittedReader, checkp // 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) { +func ReadLatestSessionContent(ctx context.Context, reader SessionReader, 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) + content, err := reader.ReadSession(ctx, SessionIndexRef(checkpointID, latestIndex)) if err != nil { return nil, fmt.Errorf("read session %d content: %w", latestIndex, err) } return content, nil } -func ReadRawSessionLogForCheckpoint(ctx context.Context, reader CommittedReader, checkpointID id.CheckpointID) ([]byte, string, error) { +func ReadRawSessionLogForCheckpoint(ctx context.Context, reader interface { + Reader + SessionReader +}, checkpointID id.CheckpointID) ([]byte, string, error) { if err := ctx.Err(); err != nil { return nil, "", err //nolint:wrapcheck // Propagating context cancellation } diff --git a/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go b/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go index 175ded068c..5085e3057a 100644 --- a/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go +++ b/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go @@ -32,6 +32,18 @@ func TestReadCommittedCheckpointWrapsReaderError(t *testing.T) { require.ErrorContains(t, err, "read committed checkpoint") } +func TestReadLatestSessionContentEmptySummaryReturnsNotFound(t *testing.T) { + t.Parallel() + + cpID := id.MustCheckpointID("111111111111") + summary := &CheckpointSummary{} + reader := &committedReaderStub{summary: summary} + + content, err := ReadLatestSessionContent(context.Background(), reader, cpID, summary) + require.Nil(t, content) + require.ErrorIs(t, err, ErrCheckpointNotFound) +} + func TestReadRawSessionLogForCheckpointReadsLatestV1Session(t *testing.T) { t.Parallel() @@ -67,18 +79,118 @@ func TestReadRawSessionLogForCheckpointReadsLatestV1Session(t *testing.T) { require.Equal(t, []byte("latest transcript\n"), transcript) } +func TestGitStoreSessionStoreReadsByRef(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + testutil.InitRepo(t, repoDir) + repo, err := git.PlainOpen(repoDir) + require.NoError(t, err) + + store := NewGitStore(repo, DefaultV1Refs()) + ctx := context.Background() + cpID := id.MustCheckpointID("333333333333") + + writeSessionForStoreTest(t, store, cpID, "session-a", "first transcript\n", "first prompt") + writeSessionForStoreTest(t, store, cpID, "session-b", "latest transcript\n", "latest prompt") + + latest, err := store.ReadSession(ctx, LatestSessionRef(cpID)) + require.NoError(t, err) + require.Equal(t, "session-b", latest.Metadata.SessionID) + require.Equal(t, []byte("latest transcript\n"), latest.Transcript) + + metadataOnly, err := store.ReadSession(ctx, SessionIndexRef(cpID, 0), WithSessionMetadataOnly()) + require.NoError(t, err) + require.Equal(t, "session-a", metadataOnly.Metadata.SessionID) + require.Empty(t, metadataOnly.Transcript) + require.Empty(t, metadataOnly.Prompts) + + metadataAndPrompts, err := store.ReadSession(ctx, SessionIDRef(cpID, "session-a"), WithSessionMetadataAndPrompts()) + require.NoError(t, err) + require.Equal(t, "session-a", metadataAndPrompts.Metadata.SessionID) + require.Equal(t, "first prompt", metadataAndPrompts.Prompts) + require.Empty(t, metadataAndPrompts.Transcript) + + infos, err := store.ListCheckpoints(ctx) + require.NoError(t, err) + require.Len(t, infos, 1) + require.Equal(t, cpID, infos[0].CheckpointID) +} + +func TestResolveSessionIndexUsesIndexRefDirectly(t *testing.T) { + t.Parallel() + + store := &GitStore{} + cpID := id.MustCheckpointID("555555555555") + + sessionIndex, err := store.resolveSessionIndex(context.Background(), SessionIndexRef(cpID, 3)) + require.NoError(t, err) + require.Equal(t, 3, sessionIndex) +} + +func TestGitStoreStoresUpdateSpecificSessionAndCheckpoint(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + testutil.InitRepo(t, repoDir) + repo, err := git.PlainOpen(repoDir) + require.NoError(t, err) + + store := NewGitStore(repo, DefaultV1Refs()) + ctx := context.Background() + cpID := id.MustCheckpointID("444444444444") + + writeSessionForStoreTest(t, store, cpID, "session-a", "first transcript\n", "first prompt") + writeSessionForStoreTest(t, store, cpID, "session-b", "latest transcript\n", "latest prompt") + + sessionSummary := &Summary{Intent: "summarize first session"} + require.NoError(t, store.UpdateSession(ctx, SessionIDRef(cpID, "session-a"), WithSummary(sessionSummary))) + + first, err := store.ReadSession(ctx, SessionIDRef(cpID, "session-a"), WithSessionMetadataOnly()) + require.NoError(t, err) + require.Equal(t, sessionSummary.Intent, first.Metadata.Summary.Intent) + + second, err := store.ReadSession(ctx, SessionIDRef(cpID, "session-b"), WithSessionMetadataOnly()) + require.NoError(t, err) + require.Nil(t, second.Metadata.Summary) + + attribution := &InitialAttribution{AgentLines: 7, TotalCommitted: 11} + require.NoError(t, store.UpdateCheckpoint(ctx, cpID, WithAttribution(attribution))) + + checkpointSummary, err := store.ReadCheckpoint(ctx, cpID) + require.NoError(t, err) + require.Equal(t, 7, checkpointSummary.CombinedAttribution.AgentLines) + require.Equal(t, 11, checkpointSummary.CombinedAttribution.TotalCommitted) +} + +func writeSessionForStoreTest(t *testing.T, store *GitStore, cpID id.CheckpointID, sessionID, transcript, prompt string) { + t.Helper() + + require.NoError(t, store.WriteSession(t.Context(), SessionIDRef(cpID, sessionID), Session{ + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted([]byte(transcript)), + Prompts: []string{prompt}, + AuthorName: "Test", + AuthorEmail: "test@example.com", + })) +} + type committedReaderStub struct { summary *CheckpointSummary readErr error } -func (s *committedReaderStub) ReadCommitted(context.Context, id.CheckpointID) (*CheckpointSummary, error) { +func (s *committedReaderStub) ListCheckpoints(context.Context) ([]CommittedInfo, error) { + return nil, nil +} + +func (s *committedReaderStub) ReadCheckpoint(context.Context, id.CheckpointID) (*CheckpointSummary, error) { if s.readErr != nil { return nil, s.readErr } return s.summary, nil } -func (s *committedReaderStub) ReadSessionContent(context.Context, id.CheckpointID, int) (*SessionContent, error) { +func (s *committedReaderStub) ReadSession(context.Context, SessionRef, ...ReadOption) (*SessionContent, error) { return nil, ErrCheckpointNotFound } diff --git a/cmd/entire/cli/checkpoint/open.go b/cmd/entire/cli/checkpoint/open.go index 2c9c2262fd..b9583adbff 100644 --- a/cmd/entire/cli/checkpoint/open.go +++ b/cmd/entire/cli/checkpoint/open.go @@ -23,11 +23,11 @@ type OpenOptions struct { // Stores is the facade returned by Open: the committed store plus the git-only // temporary capability and resolved committed-ref topology. type Stores struct { - // Primary is the committed store — the source of truth that serves all - // committed reads and writes. - Primary *GitStore + // Primary is the committed store that serves committed reads and writes. + Primary CommittedStore - refs CommittedRefs + temporary TemporaryStore + refs CommittedRefs } // Open resolves the checkpoint storage topology and constructs the backing @@ -41,8 +41,9 @@ func Open(ctx context.Context, repo *git.Repository, opts OpenOptions) (*Stores, store.SetBlobFetcher(opts.BlobFetcher) } return &Stores{ - Primary: store, - refs: refs, + Primary: store, + temporary: store, + refs: refs, }, nil } @@ -53,10 +54,8 @@ func resolveOpenRefs(ctx context.Context, opts OpenOptions) CommittedRefs { return ResolveCommittedRefs(ctx) } -// Temporary returns the git-backed temporary (shadow-branch) store. It is the -// same backing store as Primary; the name marks shadow-branch intent at the -// call site. -func (s *Stores) Temporary() *GitStore { return s.Primary } +// Temporary returns the git-backed temporary shadow-branch store. +func (s *Stores) Temporary() TemporaryStore { return s.temporary } //nolint:ireturn // temporary store capability is the abstraction boundary // Refs returns the resolved committed-ref topology. func (s *Stores) Refs() CommittedRefs { return s.refs } diff --git a/cmd/entire/cli/checkpoint/store.go b/cmd/entire/cli/checkpoint/store.go index 8cab02effc..cd817db7fb 100644 --- a/cmd/entire/cli/checkpoint/store.go +++ b/cmd/entire/cli/checkpoint/store.go @@ -7,8 +7,13 @@ import ( "github.com/go-git/go-git/v6/plumbing" ) -// Compile-time check that GitStore implements the Store interface. -var _ Store = (*GitStore)(nil) +var ( + _ CommittedStore = (*GitStore)(nil) + _ SessionStore = (*GitStore)(nil) + _ MetadataStore = (*GitStore)(nil) + _ TemporaryStore = (*GitStore)(nil) + _ AuthorReader = (*GitStore)(nil) +) // GitStore provides operations for both temporary and committed checkpoint // storage. Writes target refs.Primary; committed reads resolve against diff --git a/cmd/entire/cli/checkpoint_reader.go b/cmd/entire/cli/checkpoint_reader.go new file mode 100644 index 0000000000..9815dbc585 --- /dev/null +++ b/cmd/entire/cli/checkpoint_reader.go @@ -0,0 +1,8 @@ +package cli + +import "github.com/entireio/cli/cmd/entire/cli/checkpoint" + +type committedCheckpointReader interface { + checkpoint.Reader + checkpoint.SessionReader +} diff --git a/cmd/entire/cli/dispatch/mode_local.go b/cmd/entire/cli/dispatch/mode_local.go index f2ea825e73..dce2d81ea0 100644 --- a/cmd/entire/cli/dispatch/mode_local.go +++ b/cmd/entire/cli/dispatch/mode_local.go @@ -181,7 +181,7 @@ func enumerateRepoCandidates(ctx context.Context, repoRoot string, opts Options, return nil, fmt.Errorf("open checkpoint store: %w", err) } store := stores.Primary - infos, err := store.ListCommitted(ctx) + infos, err := store.ListCheckpoints(ctx) if err != nil { return nil, fmt.Errorf("list committed checkpoints: %w", err) } @@ -192,14 +192,11 @@ func enumerateRepoCandidates(ctx context.Context, repoRoot string, opts Options, continue } - summary, err := store.ReadCommitted(ctx, info.CheckpointID) + summary, err := store.ReadCheckpoint(ctx, info.CheckpointID) if err != nil { logging.Warn(ctx, "failed to read committed checkpoint for dispatch", "checkpoint_id", info.CheckpointID.String(), "error", err) continue } - if summary == nil { - continue - } if _, onSelectedBranch := branchSet[summary.Branch]; !onSelectedBranch { if !opts.ImplicitCurrentBranch { continue @@ -212,7 +209,9 @@ func enumerateRepoCandidates(ctx context.Context, repoRoot string, opts Options, localSummary := "" if len(summary.Sessions) > 0 { latestIndex := len(summary.Sessions) - 1 - if metadata, err := store.ReadSessionMetadata(ctx, info.CheckpointID, latestIndex); err == nil && metadata != nil && metadata.Summary != nil { + content, err := store.ReadSession(ctx, checkpoint.SessionIndexRef(info.CheckpointID, latestIndex), checkpoint.WithSessionMetadataOnly()) + if err == nil && content != nil && content.Metadata.Summary != nil { + metadata := content.Metadata localSummary = strings.TrimSpace(metadata.Summary.Outcome) if localSummary == "" { localSummary = strings.TrimSpace(metadata.Summary.Intent) diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index e7f5805c69..ad31c0dae7 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -89,7 +89,7 @@ var errCannotGenerateTemporaryCheckpoint = errors.New("cannot generate summary f type explainCheckpointLookup struct { repo *git.Repository - store *checkpoint.GitStore + store checkpoint.CommittedStore committed []checkpoint.CommittedInfo } @@ -697,7 +697,7 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec return fmt.Errorf("open checkpoint store: %w", openErr) } lookup.store = reopened.Primary - content, err = checkpoint.ReadLatestSessionContent(ctx, lookup.store, fullCheckpointID, summary) + content, err = lookup.store.ReadSession(ctx, checkpoint.LatestSessionRef(fullCheckpointID)) if err != nil { stopLoad(false) return fmt.Errorf("failed to reload checkpoint: %w", err) @@ -729,8 +729,8 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec Name: associatedCommits[0].Author, Email: associatedCommits[0].Email, } - } else { - author, _ = lookup.store.GetCheckpointAuthor(ctx, fullCheckpointID) //nolint:errcheck // Author is optional + } else if authorReader, ok := lookup.store.(checkpoint.AuthorReader); ok { + author, _ = authorReader.GetCheckpointAuthor(ctx, fullCheckpointID) //nolint:errcheck // Author is optional } // Format and output. Stop spinner BEFORE any write to w to keep stderr @@ -750,12 +750,12 @@ func loadCheckpointForExplain(ctx context.Context, lookup *explainCheckpointLook prefetchCheckpointBlobs(ctx, lookup.repo, cpID) store := lookup.store - summary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, cpID) + summary, err := store.ReadCheckpoint(ctx, cpID) if err != nil { return nil, nil, fmt.Errorf("failed to read checkpoint: %w", err) } - content, contentErr := checkpoint.ReadLatestSessionContent(ctx, store, cpID, summary) + content, contentErr := store.ReadSession(ctx, checkpoint.LatestSessionRef(cpID)) if contentErr != nil { return nil, nil, fmt.Errorf("failed to read checkpoint content: %w", contentErr) } @@ -879,7 +879,7 @@ func newExplainCheckpointLookup(ctx context.Context) (*explainCheckpointLookup, store: store, } - committed, err := store.ListCommitted(ctx) + committed, err := store.ListCheckpoints(ctx) if err != nil { return nil, fmt.Errorf("failed to list checkpoints: %w", err) } @@ -889,7 +889,7 @@ func newExplainCheckpointLookup(ctx context.Context) (*explainCheckpointLookup, } type checkpointSummaryUpdater interface { - UpdateSummary(ctx context.Context, checkpointID id.CheckpointID, summary *checkpoint.Summary) error + UpdateSession(ctx context.Context, ref checkpoint.SessionRef, opts ...checkpoint.WriteOption) error } // generateCheckpointSummary generates an AI summary for a checkpoint and persists it. @@ -946,7 +946,11 @@ func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, store che } elapsed := time.Since(start) - if err := store.UpdateSummary(ctx, checkpointID, summary); err != nil { + ref := checkpoint.SessionIDRef(checkpointID, content.Metadata.SessionID) + if content.Metadata.SessionID == "" { + ref = checkpoint.LatestSessionRef(checkpointID) + } + if err := store.UpdateSession(ctx, ref, checkpoint.WithSummary(summary)); err != nil { return fmt.Errorf("failed to save summary: %w", err) } @@ -1204,7 +1208,7 @@ func formatSummaryTimeout(d time.Duration) string { // Searches ALL shadow branches, not just the one for current HEAD, to find checkpoints // created from different base commits (e.g., if HEAD advanced since session start). // The writer w is used for raw transcript output to bypass the pager. -func explainTemporaryCheckpoint(ctx context.Context, w, errW io.Writer, repo *git.Repository, store *checkpoint.GitStore, shaPrefix string, verbose, full, rawTranscript bool) (string, bool, error) { +func explainTemporaryCheckpoint(ctx context.Context, w, errW io.Writer, repo *git.Repository, store checkpoint.TemporaryStore, shaPrefix string, verbose, full, rawTranscript bool) (string, bool, error) { // List temporary checkpoints from ALL shadow branches // This ensures we find checkpoints even if HEAD has advanced since the session started tempCheckpoints, err := store.ListAllTemporaryCheckpoints(ctx, "", branchCheckpointsLimit) @@ -2046,7 +2050,7 @@ func getBranchCheckpoints(ctx context.Context, repo *git.Repository, limit int) store := stores.Primary // Get all committed checkpoints for lookup. - committedInfos, err := store.ListCommitted(ctx) + committedInfos, err := store.ListCheckpoints(ctx) if err != nil { committedInfos = nil // Continue without committed checkpoints } @@ -2163,16 +2167,16 @@ func getBranchCheckpoints(ctx context.Context, repo *git.Repository, limit int) return points, nil } -func readLatestCommittedSessionPrompt(ctx context.Context, store checkpoint.CommittedListReader, cpID id.CheckpointID, sessionCount int) string { +func readLatestCommittedSessionPrompt(ctx context.Context, store checkpoint.SessionReader, cpID id.CheckpointID, sessionCount int) string { if sessionCount <= 0 { return "" } for i := sessionCount - 1; i >= 0; i-- { - prompts, err := store.ReadSessionPrompts(ctx, cpID, i) + content, err := store.ReadSession(ctx, checkpoint.SessionIndexRef(cpID, i), checkpoint.WithSessionMetadataAndPrompts()) if err != nil { continue } - if prompt := strategy.ExtractFirstPrompt(prompts); prompt != "" { + if prompt := strategy.ExtractFirstPrompt(content.Prompts); prompt != "" { return prompt } } @@ -2183,7 +2187,7 @@ func readLatestCommittedSessionPrompt(ctx context.Context, store checkpoint.Comm // whose base commit is reachable from the given HEAD hash and that belong to this worktree. // For default branches, all shadow branches for this worktree are included. // For feature branches, only shadow branches whose base commit is in HEAD's history are included. -func getReachableTemporaryCheckpoints(ctx context.Context, repo *git.Repository, store *checkpoint.GitStore, headHash plumbing.Hash, isOnDefault bool, limit int) []strategy.RewindPoint { +func getReachableTemporaryCheckpoints(ctx context.Context, repo *git.Repository, store checkpoint.TemporaryStore, headHash plumbing.Hash, isOnDefault bool, limit int) []strategy.RewindPoint { var points []strategy.RewindPoint // Compute current worktree's hash for filtering shadow branches diff --git a/cmd/entire/cli/explain_export.go b/cmd/entire/cli/explain_export.go index c07b816d75..907e30891e 100644 --- a/cmd/entire/cli/explain_export.go +++ b/cmd/entire/cli/explain_export.go @@ -261,7 +261,7 @@ func runExplainStreamTranscript(ctx context.Context, w, errW io.Writer, opts exp defer lookup.Close() store := lookup.store - summary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, cpID) + summary, err := store.ReadCheckpoint(ctx, cpID) if err != nil { return fmt.Errorf("failed to read checkpoint: %w", err) } @@ -271,7 +271,7 @@ func runExplainStreamTranscript(ctx context.Context, w, errW io.Writer, opts exp return err } - content, readErr := store.ReadSessionContent(ctx, cpID, idx) + content, readErr := store.ReadSession(ctx, checkpoint.SessionIndexRef(cpID, idx)) if readErr != nil { return fmt.Errorf("failed to read session content: %w", readErr) } @@ -355,7 +355,7 @@ func runExplainCheckpointJSON(ctx context.Context, w, errW io.Writer, opts expla defer lookup.Close() store := lookup.store - summary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, cpID) + summary, err := store.ReadCheckpoint(ctx, cpID) if err != nil { return fmt.Errorf("failed to read checkpoint: %w", err) } @@ -385,7 +385,7 @@ func runExplainCheckpointJSON(ctx context.Context, w, errW io.Writer, opts expla // plus the list of session indexes that failed to read; a non-empty failed // list means envelope.Partial is true. Extracted from runExplainCheckpointJSON so // the envelope-building behavior can be tested independently of git storage. -func buildCheckpointJSONEnvelope(ctx context.Context, reader checkpoint.CommittedReader, summary *checkpoint.CheckpointSummary, cpID id.CheckpointID) (checkpointExportJSON, []int) { +func buildCheckpointJSONEnvelope(ctx context.Context, reader checkpoint.SessionReader, summary *checkpoint.CheckpointSummary, cpID id.CheckpointID) (checkpointExportJSON, []int) { envelope := checkpointExportJSON{ CheckpointID: cpID.String(), Strategy: summary.Strategy, @@ -422,22 +422,10 @@ func buildCheckpointJSONEnvelope(ctx context.Context, reader checkpoint.Committe // readSessionMetadataForExport reads only metadata.json for a session — no // transcript or prompt bytes. GitStore exposes a metadata-only reader, so this // never depends on transcript availability. -func readSessionMetadataForExport(ctx context.Context, reader checkpoint.CommittedReader, cpID id.CheckpointID, idx int) (*checkpoint.CommittedMetadata, error) { - 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 session metadata: %w", err) - } - 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) +func readSessionMetadataForExport(ctx context.Context, reader checkpoint.SessionReader, cpID id.CheckpointID, idx int) (*checkpoint.CommittedMetadata, error) { + content, err := reader.ReadSession(ctx, checkpoint.SessionIndexRef(cpID, idx), checkpoint.WithSessionMetadataOnly()) if err != nil { - return nil, fmt.Errorf("read session content: %w", err) + return nil, fmt.Errorf("read session metadata: %w", err) } meta := content.Metadata return &meta, nil diff --git a/cmd/entire/cli/explain_export_test.go b/cmd/entire/cli/explain_export_test.go index 6a7a5c2a42..6cf7f5c3a7 100644 --- a/cmd/entire/cli/explain_export_test.go +++ b/cmd/entire/cli/explain_export_test.go @@ -489,20 +489,17 @@ func TestRunExplainExport_NoModeFlagFailsLoudly(t *testing.T) { require.Empty(t, stdout.String(), "must not emit JSON when no mode is set") } -// stubCommittedReader is a minimal CommittedReader that returns canned -// metadata or errors per session index. Used to exercise the partial-failure -// path in buildCheckpointJSONEnvelope without corrupting a real git tree. type stubCommittedReader struct { summary *checkpoint.CheckpointSummary contents map[int]*checkpoint.SessionContent // idx -> content (nil ⇒ return error) err error // err returned for indexes not in contents } -func (s *stubCommittedReader) ReadCommitted(_ context.Context, _ id.CheckpointID) (*checkpoint.CheckpointSummary, error) { - return s.summary, nil -} - -func (s *stubCommittedReader) ReadSessionContent(_ context.Context, _ id.CheckpointID, idx int) (*checkpoint.SessionContent, error) { +func (s *stubCommittedReader) ReadSession(_ context.Context, ref checkpoint.SessionRef, _ ...checkpoint.ReadOption) (*checkpoint.SessionContent, error) { + idx, ok := ref.SessionIndex() + if !ok { + return nil, errors.New("stub: expected session index ref") + } if c, ok := s.contents[idx]; ok && c != nil { return c, nil } diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index 6e4dee1275..e458e6aec8 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -1011,7 +1011,7 @@ func TestGenerateCheckpointSummary_AdvancesV1Metadata(t *testing.T) { require.NoError(t, err) store := stores.Primary cpID := id.MustCheckpointID("a1b2c3d4e5f6") - require.NoError(t, store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{ + require.NoError(t, store.WriteSession(ctx, checkpoint.SessionIDRef(cpID, "session-001"), checkpoint.Session{ CheckpointID: cpID, SessionID: "session-001", Strategy: "manual-commit", @@ -1021,9 +1021,9 @@ func TestGenerateCheckpointSummary_AdvancesV1Metadata(t *testing.T) { AuthorEmail: "test@test.com", Agent: agent.AgentTypeClaudeCode, })) - cpSummary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, cpID) + cpSummary, err := store.ReadCheckpoint(ctx, cpID) require.NoError(t, err) - content, err := checkpoint.ReadLatestSessionContent(ctx, store, cpID, cpSummary) + content, err := store.ReadSession(ctx, checkpoint.LatestSessionRef(cpID)) require.NoError(t, err) v1Before, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) @@ -2137,7 +2137,7 @@ func TestRunExplainCheckpoint_GenerateV1ModeUsesSelectedStore(t *testing.T) { AuthorEmail: "test@example.com", Agent: agent.AgentTypeClaudeCode, })) - summary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, cpID) + summary, err := store.ReadCheckpoint(ctx, cpID) require.NoError(t, err) require.Len(t, summary.Sessions, 1) diff --git a/cmd/entire/cli/head_checkpoint_flags.go b/cmd/entire/cli/head_checkpoint_flags.go index 8d543dbf56..b8ad516492 100644 --- a/cmd/entire/cli/head_checkpoint_flags.go +++ b/cmd/entire/cli/head_checkpoint_flags.go @@ -57,7 +57,7 @@ func headCheckpointFlags(ctx context.Context) (hasReview, hasInvestigation bool, logging.Debug(ctx, "head checkpoint flags: open store", slog.String("error", err.Error())) return false, false, "" } - summary, err := checkpoint.ReadCommittedCheckpoint(ctx, stores.Primary, cpID) + summary, err := stores.Primary.ReadCheckpoint(ctx, cpID) if err != nil || summary == nil { logging.Debug(ctx, "head checkpoint flags: resolve checkpoint summary", slog.String("checkpoint_id", cpID.String()), diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 4f6fa908df..61ccc3230c 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -329,7 +329,7 @@ func resumeFromCurrentBranch(ctx context.Context, w, errW io.Writer, branchName // resolveLatestCheckpoint reads metadata for each checkpoint ID and returns // the checkpoint with the latest CreatedAt. -func resolveLatestCheckpoint(ctx context.Context, store *checkpoint.GitStore, checkpointIDs []id.CheckpointID) (*strategy.CheckpointInfo, error) { +func resolveLatestCheckpoint(ctx context.Context, store committedCheckpointReader, checkpointIDs []id.CheckpointID) (*strategy.CheckpointInfo, error) { infoMap := make(map[id.CheckpointID]strategy.CheckpointInfo, len(checkpointIDs)) for _, cpID := range checkpointIDs { metadata, readErr := readCheckpointInfoFromStore(ctx, store, cpID) @@ -349,8 +349,8 @@ func resolveLatestCheckpoint(ctx context.Context, store *checkpoint.GitStore, ch return &latest, nil } -func readCheckpointInfoFromStore(ctx context.Context, store checkpoint.CommittedListReader, checkpointID id.CheckpointID) (*strategy.CheckpointInfo, error) { - summary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, checkpointID) +func readCheckpointInfoFromStore(ctx context.Context, store committedCheckpointReader, checkpointID id.CheckpointID) (*strategy.CheckpointInfo, error) { + summary, err := store.ReadCheckpoint(ctx, checkpointID) if err != nil { return nil, fmt.Errorf("read checkpoint: %w", err) } @@ -361,7 +361,7 @@ func readCheckpointInfoFromStore(ctx context.Context, store checkpoint.Committed SessionCount: len(summary.Sessions), } for i := range summary.Sessions { - metadata, metaErr := store.ReadSessionMetadata(ctx, checkpointID, i) + content, metaErr := store.ReadSession(ctx, checkpoint.SessionIndexRef(checkpointID, i), checkpoint.WithSessionMetadataOnly()) if metaErr != nil { logging.Debug(ctx, "read checkpoint metadata: session metadata read failed", slog.String("checkpoint_id", checkpointID.String()), @@ -370,6 +370,7 @@ func readCheckpointInfoFromStore(ctx context.Context, store checkpoint.Committed ) continue } + metadata := content.Metadata info.SessionIDs = append(info.SessionIDs, metadata.SessionID) if metadata.SessionID != "" { info.SessionID = metadata.SessionID @@ -937,7 +938,7 @@ func resumeSingleSession(ctx context.Context, w, _ io.Writer, ag agent.Agent, se if err != nil { return fmt.Errorf("open checkpoint store: %w", err) } - logContent, _, err := checkpoint.ReadRawSessionLogForCheckpoint(ctx, stores.Primary, checkpointID) + content, err := stores.Primary.ReadSession(ctx, checkpoint.SessionIDRef(checkpointID, sessionID)) if err != nil { if errors.Is(err, checkpoint.ErrCheckpointNotFound) || errors.Is(err, checkpoint.ErrNoTranscript) { logging.Debug(ctx, "resume session completed (no metadata)", @@ -956,6 +957,7 @@ func resumeSingleSession(ctx context.Context, w, _ io.Writer, ag agent.Agent, se ) return fmt.Errorf("failed to get session log: %w", err) } + logContent := content.Transcript // By default, never overwrite a session log that already exists locally: the // on-disk transcript is the live session the user is resuming, so we keep it diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index a425a139d9..150b50260d 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -592,6 +592,64 @@ func TestResolveLatestCheckpoint(t *testing.T) { } } +func TestResolveLatestCheckpointUsesCheckpointInfoReader(t *testing.T) { + t.Parallel() + + oldID := id.MustCheckpointID("aaa111bbb222") + newID := id.MustCheckpointID("ccc333ddd444") + reader := &resumeCheckpointInfoReaderStub{ + summaries: map[id.CheckpointID]*checkpoint.CheckpointSummary{ + oldID: {Sessions: []checkpoint.SessionFilePaths{{Metadata: "old"}}}, + newID: {Sessions: []checkpoint.SessionFilePaths{{Metadata: "new"}}}, + }, + metadata: map[id.CheckpointID][]checkpoint.CommittedMetadata{ + oldID: {{ + SessionID: "old-session", + CreatedAt: time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC), + }}, + newID: {{ + SessionID: "new-session", + CreatedAt: time.Date(2025, 1, 1, 11, 0, 0, 0, time.UTC), + }}, + }, + } + + latest, err := resolveLatestCheckpoint(context.Background(), reader, []id.CheckpointID{oldID, newID}) + if err != nil { + t.Fatalf("resolveLatestCheckpoint() error = %v", err) + } + if latest.CheckpointID != newID { + t.Errorf("resolveLatestCheckpoint() = %s, want %s", latest.CheckpointID, newID) + } +} + +type resumeCheckpointInfoReaderStub struct { + summaries map[id.CheckpointID]*checkpoint.CheckpointSummary + metadata map[id.CheckpointID][]checkpoint.CommittedMetadata +} + +func (r *resumeCheckpointInfoReaderStub) ListCheckpoints(context.Context) ([]checkpoint.CommittedInfo, error) { + return nil, nil +} + +func (r *resumeCheckpointInfoReaderStub) ReadCheckpoint(_ context.Context, checkpointID id.CheckpointID) (*checkpoint.CheckpointSummary, error) { + return r.summaries[checkpointID], nil +} + +func (r *resumeCheckpointInfoReaderStub) ReadSession(_ context.Context, ref checkpoint.SessionRef, _ ...checkpoint.ReadOption) (*checkpoint.SessionContent, error) { + sessionIndex, ok := ref.SessionIndex() + if !ok { + return nil, checkpoint.ErrCheckpointNotFound + } + + checkpointID := ref.CheckpointID() + sessions := r.metadata[checkpointID] + if sessionIndex < 0 || sessionIndex >= len(sessions) { + return nil, checkpoint.ErrCheckpointNotFound + } + return &checkpoint.SessionContent{Metadata: sessions[sessionIndex]}, nil +} + func TestReadCheckpointInfoFromStoreUsesLatestSessionMetadata(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 c98888bee6..8cd2d141e0 100644 --- a/cmd/entire/cli/review_context.go +++ b/cmd/entire/cli/review_context.go @@ -28,14 +28,6 @@ const ( reviewContextCommitSeparator = "\x1e" ) -type reviewContextSessionMetadataReader interface { - ReadSessionMetadata(ctx context.Context, checkpointID checkpointid.CheckpointID, sessionIndex int) (*checkpoint.CommittedMetadata, error) -} - -type reviewContextSessionMetadataPromptsReader interface { - ReadSessionMetadataAndPrompts(ctx context.Context, checkpointID checkpointid.CheckpointID, sessionIndex int) (*checkpoint.SessionContent, error) -} - func reviewCheckpointContext(ctx context.Context, worktreeRoot string, scopeBaseRef string) string { committed := reviewCommittedCheckpointContext(ctx, worktreeRoot, scopeBaseRef) inProgress := reviewSessionContextForCurrentHead(ctx, worktreeRoot) @@ -120,7 +112,7 @@ func reviewCommittedCheckpointContext(ctx context.Context, worktreeRoot string, continue } - summary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, cpID) + summary, err := store.ReadCheckpoint(ctx, cpID) if err != nil { lines = append(lines, fmt.Sprintf("- %s: checkpoint metadata unavailable", cpID)) continue @@ -273,7 +265,7 @@ func formatReviewSessionLine(worktreeRoot string, st *session.State) string { func reviewCheckpointDetail( ctx context.Context, - reader checkpoint.CommittedReader, + reader checkpoint.SessionReader, cpID checkpointid.CheckpointID, summary *checkpoint.CheckpointSummary, ) string { @@ -307,14 +299,11 @@ type reviewContextSessionDetail struct { func readReviewContextSessionMetadata( ctx context.Context, - reader checkpoint.CommittedReader, + reader checkpoint.SessionReader, cpID checkpointid.CheckpointID, sessionIndex int, ) (*checkpoint.CommittedMetadata, error) { - if r, ok := reader.(reviewContextSessionMetadataReader); ok { - return r.ReadSessionMetadata(ctx, cpID, sessionIndex) //nolint:wrapcheck // Best-effort prompt context. - } - content, err := reader.ReadSessionContent(ctx, cpID, sessionIndex) + content, err := reader.ReadSession(ctx, checkpoint.SessionIndexRef(cpID, sessionIndex), checkpoint.WithSessionMetadataOnly()) if err != nil { return nil, err //nolint:wrapcheck // Best-effort prompt context. } @@ -326,23 +315,11 @@ func readReviewContextSessionMetadata( func readReviewContextSessionPrompts( ctx context.Context, - reader checkpoint.CommittedReader, + reader checkpoint.SessionReader, cpID checkpointid.CheckpointID, sessionIndex int, ) (string, error) { - if r, ok := reader.(reviewContextSessionMetadataPromptsReader); ok { - content, err := r.ReadSessionMetadataAndPrompts(ctx, cpID, sessionIndex) - if err == nil { - if content == nil { - return "", errors.New("session content is nil") - } - return content.Prompts, nil - } - if !errors.Is(err, checkpoint.ErrCheckpointNotFound) { - return "", err //nolint:wrapcheck // Best-effort prompt context. - } - } - content, err := reader.ReadSessionContent(ctx, cpID, sessionIndex) + content, err := reader.ReadSession(ctx, checkpoint.SessionIndexRef(cpID, sessionIndex), checkpoint.WithSessionMetadataAndPrompts()) if err != nil { return "", err //nolint:wrapcheck // Best-effort prompt context. } diff --git a/cmd/entire/cli/review_context_test.go b/cmd/entire/cli/review_context_test.go index 31411f9f1d..6d4caf3058 100644 --- a/cmd/entire/cli/review_context_test.go +++ b/cmd/entire/cli/review_context_test.go @@ -467,31 +467,15 @@ func (r *countingReviewContextReader) ReadCommitted( return nil, checkpoint.ErrCheckpointNotFound } -func (r *countingReviewContextReader) ReadSessionContent( +func (r *countingReviewContextReader) ReadSession( context.Context, - checkpointid.CheckpointID, - int, -) (*checkpoint.SessionContent, error) { - return &checkpoint.SessionContent{ - Metadata: r.metadata, - Prompts: r.prompts, - }, nil -} - -func (r *countingReviewContextReader) ReadSessionMetadata( - context.Context, - checkpointid.CheckpointID, - int, -) (*checkpoint.CommittedMetadata, error) { - r.metadataCalls++ - return &r.metadata, r.metadataErr -} - -func (r *countingReviewContextReader) ReadSessionMetadataAndPrompts( - context.Context, - checkpointid.CheckpointID, - int, + checkpoint.SessionRef, + ...checkpoint.ReadOption, ) (*checkpoint.SessionContent, error) { + if r.metadataCalls == 0 { + r.metadataCalls++ + return &checkpoint.SessionContent{Metadata: r.metadata}, r.metadataErr + } r.promptCalls++ return &checkpoint.SessionContent{ Metadata: r.metadata, diff --git a/cmd/entire/cli/rewind.go b/cmd/entire/cli/rewind.go index e5bbd6ec78..9f43881fa4 100644 --- a/cmd/entire/cli/rewind.go +++ b/cmd/entire/cli/rewind.go @@ -722,10 +722,12 @@ func restoreSessionTranscriptFromStrategy(ctx context.Context, cpID id.Checkpoin if err != nil { return "", fmt.Errorf("open checkpoint store: %w", err) } - content, returnedSessionID, err := checkpoint.ReadRawSessionLogForCheckpoint(ctx, stores.Primary, cpID) + sessionContent, err := stores.Primary.ReadSession(ctx, checkpoint.LatestSessionRef(cpID)) if err != nil { return "", fmt.Errorf("failed to get session log: %w", err) } + content := sessionContent.Transcript + returnedSessionID := sessionContent.Metadata.SessionID // Use session ID returned from checkpoint if available // Otherwise fall back to the passed-in sessionID diff --git a/cmd/entire/cli/strategy/cleanup.go b/cmd/entire/cli/strategy/cleanup.go index d1568790e3..8ba3509319 100644 --- a/cmd/entire/cli/strategy/cleanup.go +++ b/cmd/entire/cli/strategy/cleanup.go @@ -318,7 +318,7 @@ func ListOrphanedSessionStates(ctx context.Context) ([]CleanupItem, error) { } sessionsWithCheckpoints := make(map[string]bool) - checkpoints, listErr := cpStores.Primary.ListCommitted(ctx) + checkpoints, listErr := cpStores.Primary.ListCheckpoints(ctx) if listErr == nil { for _, cp := range checkpoints { // cp.SessionID is the most-recent session in a multi-session checkpoint; diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 64f3ffbbb1..8e06546caf 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -309,7 +309,7 @@ func ListCheckpoints(ctx context.Context) ([]CheckpointInfo, error) { if err != nil { return nil, fmt.Errorf("open checkpoint store: %w", err) } - committed, err := stores.Primary.ListCommitted(ctx) + committed, err := stores.Primary.ListCheckpoints(ctx) if err != nil { return nil, fmt.Errorf("failed to list committed checkpoints: %w", err) } diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index d1ee1493d5..4f9a877011 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -40,18 +40,36 @@ func (s *ManualCommitStrategy) getStateStore(_ context.Context) (*session.StateS return s.stateStore, s.stateStoreErr } +func (s *ManualCommitStrategy) getCheckpointStores(ctx context.Context, repo *git.Repository) (*checkpoint.Stores, error) { + stores, err := checkpoint.Open(ctx, repo, checkpoint.OpenOptions{BlobFetcher: s.blobFetcher}) + if err != nil { + return nil, fmt.Errorf("open checkpoint store: %w", err) + } + return stores, nil +} + // getCheckpointStore returns a store bound to the resolved committed-metadata // topology. Writes target refs.Primary; reads target refs.Read. The strategy's // blob fetcher is wired in so reads can fetch blobs on demand after a treeless // fetch. -func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (*checkpoint.GitStore, error) { - stores, err := checkpoint.Open(ctx, repo, checkpoint.OpenOptions{BlobFetcher: s.blobFetcher}) +func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { //nolint:ireturn // committed store capability is the abstraction boundary + stores, err := s.getCheckpointStores(ctx, repo) if err != nil { - return nil, fmt.Errorf("open checkpoint store: %w", err) + return nil, err } return stores.Primary, nil } +// getTemporaryStore returns the git-backed shadow-branch store with the +// strategy's blob fetcher wired in. +func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { //nolint:ireturn // temporary store capability is the abstraction boundary + stores, err := s.getCheckpointStores(ctx, repo) + if err != nil { + return nil, err + } + return stores.Temporary(), nil +} + // NewManualCommitStrategy creates a new manual-commit strategy instance. func NewManualCommitStrategy() *ManualCommitStrategy { return &ManualCommitStrategy{} diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 6c3f569b16..6f482f4d6a 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -54,7 +54,7 @@ func (s *ManualCommitStrategy) listCheckpoints(ctx context.Context) ([]Checkpoin return nil, err } - committed, err := store.ListCommitted(ctx) + committed, err := store.ListCheckpoints(ctx) if err != nil { return nil, fmt.Errorf("failed to list committed checkpoints: %w", err) } @@ -76,11 +76,7 @@ func (s *ManualCommitStrategy) getCheckpointLog(ctx context.Context, checkpointI return nil, err } - summary, err := cpkg.ReadCommittedCheckpoint(ctx, store, checkpointID) - if err != nil { - return nil, fmt.Errorf("failed to read checkpoint: %w", err) - } - content, err := cpkg.ReadLatestSessionContent(ctx, store, checkpointID, summary) + content, err := store.ReadSession(ctx, cpkg.LatestSessionRef(checkpointID)) if err != nil { return nil, fmt.Errorf("failed to read checkpoint: %w", err) } @@ -135,7 +131,7 @@ func checkpointStepCount(s *SessionState) int { // CondenseSession condenses a session's shadow branch to permanent storage. // checkpointID is the 12-hex-char value from the Entire-Checkpoint trailer. // Metadata is stored at sharded path: // -// Uses checkpoint.GitStore.WriteCommitted for the git operations. +// Uses checkpoint.CommittedStore.WriteSession for committed storage. // // For mid-session commits (no Stop/SaveStep called yet), the shadow branch may not exist. // In this case, data is extracted from the live transcript instead. @@ -290,7 +286,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re writeV1Start := time.Now() writeCtx, writeCommittedSpan := perf.Start(ctx, "write_committed_v1") - if err := store.WriteCommitted(writeCtx, writeOpts); err != nil { + if err := store.WriteSession(writeCtx, cpkg.SessionIDRef(checkpointID, state.SessionID), writeOpts); err != nil { writeCommittedSpan.RecordError(err) writeCommittedSpan.End() return nil, fmt.Errorf("failed to write checkpoint metadata: %w", err) diff --git a/cmd/entire/cli/strategy/manual_commit_git.go b/cmd/entire/cli/strategy/manual_commit_git.go index a459f3025f..1a74eb9d84 100644 --- a/cmd/entire/cli/strategy/manual_commit_git.go +++ b/cmd/entire/cli/strategy/manual_commit_git.go @@ -21,7 +21,7 @@ import ( ) // SaveStep saves a checkpoint to the shadow branch. -// Uses checkpoint.GitStore.WriteTemporary for git operations. +// Uses checkpoint.TemporaryStore.WriteTemporary for git operations. func (s *ManualCommitStrategy) SaveStep(ctx context.Context, step StepContext) error { _, openRepoSpan := perf.Start(ctx, "open_repository") repo, err := OpenRepository(ctx) @@ -52,7 +52,7 @@ func (s *ManualCommitStrategy) SaveStep(ctx context.Context, step StepContext) e } migrateSpan.End() - store, err := s.getCheckpointStore(ctx, repo) + store, err := s.getTemporaryStore(ctx, repo) if err != nil { return err } @@ -168,7 +168,7 @@ func (s *ManualCommitStrategy) ensureSessionInitialized(ctx context.Context, rep } // SaveTaskStep saves a task step checkpoint to the shadow branch. -// Uses checkpoint.GitStore.WriteTemporaryTask for git operations. +// Uses checkpoint.TemporaryStore.WriteTemporaryTask for git operations. func (s *ManualCommitStrategy) SaveTaskStep(ctx context.Context, step TaskStepContext) error { repo, err := OpenRepository(ctx) if err != nil { @@ -185,7 +185,7 @@ func (s *ManualCommitStrategy) SaveTaskStep(ctx context.Context, step TaskStepCo return fmt.Errorf("failed to check/migrate shadow branch: %w", err) } - store, err := s.getCheckpointStore(ctx, repo) + store, err := s.getTemporaryStore(ctx, repo) if err != nil { return err } diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 92f4547985..acd8f0732f 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1067,7 +1067,7 @@ func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint( } store := stores.Primary - summary, err := store.ReadCommitted(ctx, checkpointID) + summary, err := store.ReadCheckpoint(ctx, checkpointID) if err != nil { return fmt.Errorf("reading checkpoint summary: %w", err) } @@ -1083,10 +1083,11 @@ func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint( // Old metadata lacks SaveStepCount → 0 → conservatively skipped, matching prior behavior. agentFiles := make(map[string]struct{}) for i := range len(summary.Sessions) { - metadata, readErr := store.ReadSessionMetadata(ctx, checkpointID, i) - if readErr != nil || metadata == nil { + content, readErr := store.ReadSession(ctx, checkpoint.SessionIndexRef(checkpointID, i), checkpoint.WithSessionMetadataOnly()) + if readErr != nil || content == nil { continue } + metadata := content.Metadata if metadata.SaveStepCount == 0 { continue // Skip sessions that used the filesTouched fallback } @@ -1158,7 +1159,7 @@ func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint( slog.Float64("agent_percentage", agentPercentage), ) - if err := store.UpdateCheckpointSummary(ctx, checkpointID, combined); err != nil { + if err := store.UpdateCheckpoint(ctx, checkpointID, checkpoint.WithAttribution(combined)); err != nil { return fmt.Errorf("persisting combined attribution: %w", err) } @@ -2820,17 +2821,12 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s continue } - updateOpts := checkpoint.UpdateCommittedOptions{ - CheckpointID: cpID, - SessionID: state.SessionID, - Transcript: redactedTranscript, - Prompts: prompts, - Agent: state.AgentType, - SkillEvents: skillEvents, - PrecomputedBlobs: precomputed, - } - - updateErr := store.UpdateCommitted(ctx, updateOpts) + updateErr := store.UpdateSession(ctx, checkpoint.SessionIDRef(cpID, state.SessionID), + checkpoint.WithTranscript(redactedTranscript, state.AgentType), + checkpoint.WithPrompts(prompts), + checkpoint.WithSkillEvents(skillEvents), + checkpoint.WithPrecomputedTranscriptBlobs(precomputed), + ) if updateErr != nil { logging.Warn(logCtx, "finalize: failed to update checkpoint", slog.String("checkpoint_id", cpIDStr), diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index b13715e4cf..5477098cd6 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -30,7 +30,7 @@ import ( ) // GetRewindPoints returns available rewind points. -// Uses checkpoint.GitStore.ListTemporaryCheckpoints for reading from shadow branches. +// Uses checkpoint.TemporaryStore for reading from shadow branches. func (s *ManualCommitStrategy) GetRewindPoints(ctx context.Context, limit int) ([]RewindPoint, error) { repo, err := OpenRepository(ctx) if err != nil { @@ -38,7 +38,7 @@ func (s *ManualCommitStrategy) GetRewindPoints(ctx context.Context, limit int) ( } defer repo.Close() - store, err := s.getCheckpointStore(ctx, repo) + store, err := s.getTemporaryStore(ctx, repo) if err != nil { return nil, err } @@ -58,7 +58,7 @@ func (s *ManualCommitStrategy) GetRewindPoints(ctx context.Context, limit int) ( var allPoints []RewindPoint - // Collect checkpoint points from active sessions using checkpoint.GitStore + // Collect checkpoint points from active sessions using temporary storage. // Cache session prompts by session ID to avoid re-reading the same prompt file sessionPrompts := make(map[string]string) @@ -647,7 +647,7 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, w, errW io.W return nil, fmt.Errorf("open checkpoint store: %w", err) } store := stores.Primary - summary, err := cpkg.ReadCommittedCheckpoint(ctx, store, point.CheckpointID) + summary, err := store.ReadCheckpoint(ctx, point.CheckpointID) if err != nil { return nil, fmt.Errorf("failed to read checkpoint: %w", err) } @@ -681,7 +681,7 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, w, errW io.W // Restore all sessions (oldest to newest, using 0-based indexing) var restored []RestoredSession for i := range totalSessions { - content, readErr := store.ReadSessionContent(ctx, point.CheckpointID, i) + content, readErr := store.ReadSession(ctx, cpkg.SessionIndexRef(point.CheckpointID, i)) if readErr != nil { if !errors.Is(readErr, cpkg.ErrNoTranscript) { fmt.Fprintf(errW, " Warning: failed to read session %d: %v\n", i, readErr) @@ -860,13 +860,13 @@ 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 cpkg.CommittedReader, checkpointID id.CheckpointID, summary *cpkg.CheckpointSummary) []SessionRestoreInfo { +func (s *ManualCommitStrategy) classifySessionsForRestore(ctx context.Context, repoRoot string, store cpkg.SessionReader, checkpointID id.CheckpointID, summary *cpkg.CheckpointSummary) []SessionRestoreInfo { var sessions []SessionRestoreInfo totalSessions := len(summary.Sessions) // Check all sessions (0-based indexing) for i := range totalSessions { - content, err := store.ReadSessionContent(ctx, checkpointID, i) + content, err := store.ReadSession(ctx, cpkg.SessionIndexRef(checkpointID, i)) if err != nil || content == nil || len(content.Transcript) == 0 { continue } diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index dc40bb8d38..b4fdf00d29 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -4194,8 +4194,7 @@ func TestCondenseSession_RedactionFailure_DropsTranscriptButWritesMetadata(t *te require.NoError(t, err, "redaction failure should not abort condensation") require.NotNil(t, result) - store, err := s.getCheckpointStore(context.Background(), repo) - require.NoError(t, err) + store := checkpoint.NewGitStore(repo, checkpoint.DefaultV1Refs()) committed, err := store.ListCommitted(context.Background()) require.NoError(t, err)