Skip to content

Commit 4398a44

Browse files
authored
Merge pull request #1630 from entireio/feat/checkpoint-read-routing
Route checkpoint reads by ID kind across git backends
2 parents a978618 + 80c7a4e commit 4398a44

7 files changed

Lines changed: 486 additions & 19 deletions

File tree

cmd/entire/cli/checkpoint/open.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,45 @@ func Open(ctx context.Context, repo *git.Repository, opts OpenOptions) (*Stores,
7878
if err != nil {
7979
return nil, err
8080
}
81+
writer := newFanoutStore(primary, mirrors)
82+
83+
// Read routing: resolve id-keyed reads by the checkpoint's format across both
84+
// git backends (a ULID lives in refs, a hex ID on the branch or a migrated
85+
// ref), so a coexisting / mid-migration repo reads either format without
86+
// reconfiguring. Writes still go through writer (configured primary + mirrors).
87+
branchStore, refsStore, err := buildKindReadStores(ctx, env, primaryType, primary)
88+
if err != nil {
89+
return nil, err
90+
}
8191

8292
return &Stores{
83-
Persistent: newFanoutStore(primary, mirrors),
93+
Persistent: newKindRoutingStore(writer, branchStore, refsStore, primaryType),
8494
ephemeral: newEphemeralStore(repo, refs),
8595
refs: refs,
8696
}, nil
8797
}
8898

99+
// buildKindReadStores returns the git-branch and git-refs read stores used for
100+
// id-kind read routing, reusing the already-built primary for whichever kind it
101+
// is and constructing the sibling. A non-branch/refs git-backed primary (not a
102+
// real configuration today, since buildPrimary only accepts git-backed backends)
103+
// yields both freshly built git stores.
104+
func buildKindReadStores(ctx context.Context, env OpenEnv, primaryType string, primary PersistentStore) (branch, refs PersistentStore, err error) {
105+
switch primaryType {
106+
case BackendTypeGitBranch:
107+
branch = primary
108+
refs, err = build(ctx, env, BackendTypeGitRefs, nil)
109+
case BackendTypeGitRefs:
110+
refs = primary
111+
branch, err = build(ctx, env, BackendTypeGitBranch, nil)
112+
default:
113+
if branch, err = build(ctx, env, BackendTypeGitBranch, nil); err == nil {
114+
refs, err = build(ctx, env, BackendTypeGitRefs, nil)
115+
}
116+
}
117+
return branch, refs, err
118+
}
119+
89120
// resolvePrimaryType returns the configured primary backend type, defaulting to
90121
// the git-branch backend when none is configured.
91122
func resolvePrimaryType(cfg *settings.CheckpointsConfig) string {

cmd/entire/cli/checkpoint/open_config_test.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,13 @@ func TestOpen_DefaultIsGitPrimaryNoMirrors(t *testing.T) {
4343

4444
stores, err := Open(context.Background(), repo, OpenOptions{})
4545
require.NoError(t, err)
46-
_, isGit := stores.Persistent.(*GitStore)
47-
assert.True(t, isGit, "default persistent store should be the raw git store, not a fan-out wrapper")
46+
// Persistent is always the kind-routing store now (it routes id-keyed reads
47+
// across the git backends); with a git-branch primary it preserves the git
48+
// AuthorReader capability.
49+
_, isRouting := stores.Persistent.(*kindRoutingStoreWithAuthor)
50+
assert.True(t, isRouting, "default persistent store should be the kind-routing store")
51+
_, isAuthor := stores.Persistent.(AuthorReader)
52+
assert.True(t, isAuthor, "routing store should preserve the git primary's AuthorReader")
4853
}
4954

5055
func TestOpen_RejectsNonGitBackedPrimary(t *testing.T) {
@@ -101,12 +106,12 @@ func TestOpen_BuildsConfiguredMirror(t *testing.T) {
101106
stores, err := Open(context.Background(), repo, OpenOptions{})
102107
require.NoError(t, err)
103108

104-
// With a mirror configured the persistent store is the fan-out wrapper, not
105-
// the raw git store, and it still exposes AuthorReader (git primary has it).
109+
// The persistent store is the kind-routing store (never the raw git store),
110+
// and it still exposes AuthorReader (git primary has it).
106111
_, isGit := stores.Persistent.(*GitStore)
107-
assert.False(t, isGit, "configured mirror should wrap the primary in a fan-out store")
112+
assert.False(t, isGit, "configured mirror should not expose the raw git store")
108113
_, isAuthor := stores.Persistent.(AuthorReader)
109-
assert.True(t, isAuthor, "fan-out store should preserve the git primary's AuthorReader")
114+
assert.True(t, isAuthor, "routing store should preserve the git primary's AuthorReader")
110115
}
111116

112117
func TestOpen_InvalidCheckpointsBlockErrors(t *testing.T) {
@@ -129,8 +134,10 @@ func TestOpen_ToleratesUnrelatedMalformedSettings(t *testing.T) {
129134

130135
stores, err := Open(context.Background(), repo, OpenOptions{})
131136
require.NoError(t, err)
132-
_, isGit := stores.Persistent.(*GitStore)
133-
assert.True(t, isGit)
137+
// Fail-soft default is the git-branch backend, so the routing store still
138+
// exposes the git AuthorReader capability.
139+
_, isAuthor := stores.Persistent.(AuthorReader)
140+
assert.True(t, isAuthor)
134141
}
135142

136143
func TestOpen_ToleratesWholeFileSyntaxError(t *testing.T) {
@@ -140,6 +147,8 @@ func TestOpen_ToleratesWholeFileSyntaxError(t *testing.T) {
140147

141148
stores, err := Open(context.Background(), repo, OpenOptions{})
142149
require.NoError(t, err)
143-
_, isGit := stores.Persistent.(*GitStore)
144-
assert.True(t, isGit)
150+
// Fail-soft default is the git-branch backend, so the routing store still
151+
// exposes the git AuthorReader capability.
152+
_, isAuthor := stores.Persistent.(AuthorReader)
153+
assert.True(t, isAuthor)
145154
}

cmd/entire/cli/checkpoint/persistent.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,10 +1564,7 @@ func (s *GitStore) List(ctx context.Context) ([]CheckpointInfo, error) {
15641564
return nil
15651565
})
15661566

1567-
// Sort by time (most recent first)
1568-
sort.Slice(checkpoints, func(i, j int) bool {
1569-
return checkpoints[i].CreatedAt.After(checkpoints[j].CreatedAt)
1570-
})
1567+
sortCheckpointInfosByRecency(checkpoints) // most recent first
15711568

15721569
return checkpoints, nil
15731570
}

cmd/entire/cli/checkpoint/refs_store.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"fmt"
77
"log/slog"
8-
"sort"
98
"strconv"
109

1110
"github.com/go-git/go-git/v6"
@@ -392,9 +391,7 @@ func (s *gitRefsStore) List(ctx context.Context) ([]CheckpointInfo, error) {
392391
return nil, fmt.Errorf("iterate checkpoint refs: %w", err)
393392
}
394393

395-
sort.Slice(checkpoints, func(i, j int) bool {
396-
return checkpoints[i].CreatedAt.After(checkpoints[j].CreatedAt)
397-
})
394+
sortCheckpointInfosByRecency(checkpoints)
398395
return checkpoints, nil
399396
}
400397

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package checkpoint
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sort"
7+
8+
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
9+
)
10+
11+
// kindRoutingStore resolves id-keyed reads across the two git backends so a repo
12+
// running git-refs and git-branch side by side (or mid-migration between them)
13+
// can read checkpoints of BOTH formats without reconfiguring:
14+
//
15+
// - A ULID checkpoint only ever lives in the git-refs store, so a ULID ID is
16+
// read from refs and NEVER from the branch (regardless of the active backend).
17+
// - A legacy-hex ID is read from the active (configured) primary first. When the
18+
// active primary is git-refs, it also falls back to the git-branch store,
19+
// because a hex checkpoint may still sit on the pre-migration v1 branch. Under
20+
// a git-branch primary the branch is authoritative for hex, so refs is not
21+
// consulted.
22+
//
23+
// List unions both backends (disjoint ID spaces). Writes are NOT kind-routed:
24+
// they go to the configured primary (+ mirrors) via writer, since a new
25+
// checkpoint's ID is already minted to match the primary's format
26+
// (see checkpoint.GenerateCheckpointID).
27+
type kindRoutingStore struct {
28+
writer PersistentStore // configured primary + mirrors (fanout); handles Write
29+
branch PersistentStore // git-branch store; serves hex reads
30+
refs PersistentStore // git-refs store; serves ULID reads (+ hex under refs primary)
31+
primaryType string
32+
}
33+
34+
// newKindRoutingStore wraps the write fanout plus the two git read stores. It
35+
// preserves the optional AuthorReader capability (explain relies on it) when both
36+
// read stores provide it — the built-in git backends always do.
37+
func newKindRoutingStore(writer, branch, refs PersistentStore, primaryType string) PersistentStore {
38+
s := &kindRoutingStore{writer: writer, branch: branch, refs: refs, primaryType: primaryType}
39+
if _, ok := branch.(AuthorReader); ok {
40+
if _, ok := refs.(AuthorReader); ok {
41+
return &kindRoutingStoreWithAuthor{kindRoutingStore: s}
42+
}
43+
}
44+
return s
45+
}
46+
47+
// readOrder returns the stores to consult for checkpointID, in priority order,
48+
// per the routing rules above.
49+
func (s *kindRoutingStore) readOrder(checkpointID id.CheckpointID) []PersistentStore {
50+
if checkpointID.Kind() == id.KindULID {
51+
return []PersistentStore{s.refs} // ULIDs only ever live in refs
52+
}
53+
switch s.primaryType {
54+
case BackendTypeGitBranch:
55+
return []PersistentStore{s.branch} // branch is authoritative for hex
56+
case BackendTypeGitRefs:
57+
return []PersistentStore{s.refs, s.branch} // active refs, then pre-migration branch
58+
default:
59+
// A non-branch/refs git-backed primary is not a real configuration today;
60+
// try both git stores so a hex ID still resolves wherever it landed.
61+
return []PersistentStore{s.branch, s.refs}
62+
}
63+
}
64+
65+
// firstResolved calls read on each store in order and returns the first genuine
66+
// hit (a non-absent result with no error). A non-final store that reports absent
67+
// OR errors falls through to the next store, so a transient failure in one
68+
// backend (e.g. a git-refs on-demand fetch error) does not hide a checkpoint that
69+
// resolves in the fallback backend. The final store's result is returned as-is
70+
// (hit, absent, or error), so callers still see the backend's own not-found /
71+
// error signal when nothing resolved.
72+
func firstResolved[T any](stores []PersistentStore, read func(PersistentStore) (T, error), absent func(T, error) bool) (T, error) {
73+
var v T
74+
var err error
75+
for i, st := range stores {
76+
v, err = read(st)
77+
if i == len(stores)-1 || (err == nil && !absent(v, err)) {
78+
return v, err
79+
}
80+
}
81+
return v, err
82+
}
83+
84+
// checkpointNotFound reports the checkpoint-level "absent" signal: Read returns
85+
// (nil, nil) — not an error — when a checkpoint does not exist.
86+
func checkpointNotFound(v *CheckpointSummary, err error) bool {
87+
return err == nil && v == nil
88+
}
89+
90+
// sessionNotFound reports the session-level "absent" signal: the session readers
91+
// return ErrCheckpointNotFound when the checkpoint (or session) is missing.
92+
func sessionNotFound[T any](_ T, err error) bool {
93+
return errors.Is(err, ErrCheckpointNotFound)
94+
}
95+
96+
func (s *kindRoutingStore) Read(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) {
97+
return firstResolved(s.readOrder(checkpointID),
98+
func(st PersistentStore) (*CheckpointSummary, error) { return st.Read(ctx, checkpointID) },
99+
checkpointNotFound,
100+
)
101+
}
102+
103+
func (s *kindRoutingStore) List(ctx context.Context) ([]CheckpointInfo, error) {
104+
branchList, err := s.branch.List(ctx)
105+
if err != nil {
106+
return nil, err //nolint:wrapcheck // in-package store error surfaced verbatim
107+
}
108+
refsList, err := s.refs.List(ctx)
109+
if err != nil {
110+
return nil, err //nolint:wrapcheck // in-package store error surfaced verbatim
111+
}
112+
merged := make([]CheckpointInfo, 0, len(branchList)+len(refsList))
113+
merged = append(merged, branchList...)
114+
merged = append(merged, refsList...)
115+
sortCheckpointInfosByRecency(merged)
116+
// Dedup by ID: during coexistence/migration the same checkpoint can appear in
117+
// both backends (a ULID mirrored to the branch, or a hex still on the branch
118+
// and also migrated into refs). Keep the first occurrence — i.e. the most
119+
// recent after the sort.
120+
deduped := merged[:0]
121+
seen := make(map[id.CheckpointID]struct{}, len(merged))
122+
for _, info := range merged {
123+
if _, dup := seen[info.CheckpointID]; dup {
124+
continue
125+
}
126+
seen[info.CheckpointID] = struct{}{}
127+
deduped = append(deduped, info)
128+
}
129+
return deduped, nil
130+
}
131+
132+
// sortCheckpointInfosByRecency orders checkpoints most-recent-first by CreatedAt.
133+
// Shared by the git-branch, git-refs, and routing List implementations so they
134+
// present a consistent order.
135+
func sortCheckpointInfosByRecency(checkpoints []CheckpointInfo) {
136+
sort.Slice(checkpoints, func(i, j int) bool {
137+
return checkpoints[i].CreatedAt.After(checkpoints[j].CreatedAt)
138+
})
139+
}
140+
141+
func (s *kindRoutingStore) ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) {
142+
return firstResolved(s.readOrder(checkpointID),
143+
func(st PersistentStore) (*SessionContent, error) {
144+
return st.ReadSessionContent(ctx, checkpointID, sessionIndex)
145+
},
146+
sessionNotFound[*SessionContent],
147+
)
148+
}
149+
150+
func (s *kindRoutingStore) ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*Metadata, error) {
151+
return firstResolved(s.readOrder(checkpointID),
152+
func(st PersistentStore) (*Metadata, error) {
153+
return st.ReadSessionMetadata(ctx, checkpointID, sessionIndex)
154+
},
155+
sessionNotFound[*Metadata],
156+
)
157+
}
158+
159+
func (s *kindRoutingStore) ReadSessionPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (string, error) {
160+
return firstResolved(s.readOrder(checkpointID),
161+
func(st PersistentStore) (string, error) {
162+
return st.ReadSessionPrompts(ctx, checkpointID, sessionIndex)
163+
},
164+
sessionNotFound[string],
165+
)
166+
}
167+
168+
// metaAndPrompts bundles the two non-error returns of ReadSessionMetadataAndPrompts
169+
// so it can flow through the single-value firstResolved helper.
170+
type metaAndPrompts struct {
171+
meta *Metadata
172+
prompts string
173+
}
174+
175+
func (s *kindRoutingStore) ReadSessionMetadataAndPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*Metadata, string, error) {
176+
mp, err := firstResolved(s.readOrder(checkpointID),
177+
func(st PersistentStore) (metaAndPrompts, error) {
178+
m, p, e := st.ReadSessionMetadataAndPrompts(ctx, checkpointID, sessionIndex)
179+
return metaAndPrompts{meta: m, prompts: p}, e //nolint:wrapcheck // in-package store error surfaced verbatim
180+
},
181+
sessionNotFound[metaAndPrompts],
182+
)
183+
return mp.meta, mp.prompts, err
184+
}
185+
186+
// Write is not kind-routed: it targets the configured primary (+ mirrors).
187+
func (s *kindRoutingStore) Write(ctx context.Context, req WriteRequest) error {
188+
return s.writer.Write(ctx, req) //nolint:wrapcheck // primary error is the operation's error, surfaced verbatim
189+
}
190+
191+
// kindRoutingStoreWithAuthor adds the optional AuthorReader capability, routing
192+
// GetCheckpointAuthor by the same rules as the reads.
193+
type kindRoutingStoreWithAuthor struct {
194+
*kindRoutingStore
195+
}
196+
197+
func (s *kindRoutingStoreWithAuthor) GetCheckpointAuthor(ctx context.Context, checkpointID id.CheckpointID) (Author, error) {
198+
return firstResolved(s.readOrder(checkpointID),
199+
func(st PersistentStore) (Author, error) {
200+
ar, ok := st.(AuthorReader)
201+
if !ok {
202+
return Author{}, nil
203+
}
204+
return ar.GetCheckpointAuthor(ctx, checkpointID)
205+
},
206+
func(a Author, err error) bool { return err == nil && a == Author{} },
207+
)
208+
}

0 commit comments

Comments
 (0)