Skip to content

Commit 6023c17

Browse files
Merge pull request #899 from gastownhall/fix/830-bd-rig-provider-resolution
fix: route beads providers by scope in mixed workspaces
2 parents e5cb4c9 + 9f3da8a commit 6023c17

26 files changed

Lines changed: 1345 additions & 84 deletions

cmd/gc/api_state.go

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,11 @@ func wrapWithCachingStore(ctx context.Context, store beads.Store, ep events.Prov
140140
// Mail providers are NOT built here — all mail uses the city-level store.
141141
// Pure function of cfg — does not read or write cs fields (safe to call unlocked).
142142
func (cs *controllerState) buildStores(cfg *config.City) map[string]beads.Store {
143-
provider := beadsProviderFor(cfg)
143+
cityProvider := rawBeadsProviderForScope(cs.cityPath, cs.cityPath)
144144
stores := make(map[string]beads.Store, len(cfg.Rigs))
145145

146146
var sharedLegacyFileStore beads.Store
147-
if provider == "file" && !fileStoreUsesScopedRoots(cs.cityPath) {
147+
if cityProvider == "file" && !fileStoreUsesScopedRoots(cs.cityPath) {
148148
store, err := openCompatibleFileStore(cs.cityPath, cs.cityPath)
149149
if err == nil {
150150
sharedLegacyFileStore = store
@@ -160,27 +160,19 @@ func (cs *controllerState) buildStores(cfg *config.City) map[string]beads.Store
160160
if strings.TrimSpace(rig.Path) == "" {
161161
continue
162162
}
163-
store := sharedLegacyFileStore
164-
if store == nil {
165-
store = cs.openRigStore(provider, rig.Name, rig.Path, rig.EffectivePrefix())
163+
scopeRoot := resolveStoreScopeRoot(cs.cityPath, rig.Path)
164+
scopeProvider := rawBeadsProviderForScope(scopeRoot, cs.cityPath)
165+
store := beads.Store(nil)
166+
if sharedLegacyFileStore != nil && scopeProvider == "file" && !scopeUsesFileStoreContract(scopeRoot) {
167+
store = sharedLegacyFileStore
168+
} else {
169+
store = cs.openRigStore(scopeProvider, rig.Name, scopeRoot, rig.EffectivePrefix())
166170
}
167171
stores[rig.Name] = wrapWithCachingStore(cs.cacheCtx, store, cs.eventProv)
168172
}
169173
return stores
170174
}
171175

172-
// beadsProviderFor returns the bead store provider name from the given config.
173-
// Pure function — does not read controllerState fields.
174-
func beadsProviderFor(cfg *config.City) string {
175-
if v := os.Getenv("GC_BEADS"); v != "" {
176-
return v
177-
}
178-
if cfg.Beads.Provider != "" {
179-
return cfg.Beads.Provider
180-
}
181-
return "bd"
182-
}
183-
184176
// openRigStore creates a bead store for a rig path using the given provider.
185177
func (cs *controllerState) openRigStore(provider, rigName, rigPath, prefix string) beads.Store {
186178
scopeRoot := resolveStoreScopeRoot(cs.cityPath, rigPath)

cmd/gc/api_state_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,107 @@ func TestControllerStateOpenRigStoreFileOpenErrorDoesNotFallbackToBd(t *testing.
418418
}
419419
}
420420

421+
func TestControllerStateBuildStoresUsesScopeAwareProviderForMixedRig(t *testing.T) {
422+
cityDir := t.TempDir()
423+
rigDir := filepath.Join(cityDir, "frontend")
424+
if err := os.MkdirAll(filepath.Join(rigDir, ".beads"), 0o755); err != nil {
425+
t.Fatal(err)
426+
}
427+
if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace]
428+
name = "demo"
429+
430+
[beads]
431+
provider = "file"
432+
`), 0o644); err != nil {
433+
t.Fatal(err)
434+
}
435+
if err := os.WriteFile(filepath.Join(rigDir, ".beads", "metadata.json"), []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"embedded","dolt_database":"fe"}`), 0o644); err != nil {
436+
t.Fatal(err)
437+
}
438+
cfg := &config.City{
439+
Workspace: config.Workspace{Name: "demo"},
440+
Rigs: []config.Rig{{
441+
Name: "frontend",
442+
Path: rigDir,
443+
Prefix: "fe",
444+
}},
445+
}
446+
447+
cs := &controllerState{cityPath: cityDir, cfg: cfg}
448+
stores := cs.buildStores(cfg)
449+
store, ok := stores["frontend"]
450+
if !ok {
451+
t.Fatal("buildStores() missing frontend store")
452+
}
453+
if _, ok := store.(*beads.FileStore); ok {
454+
t.Fatalf("buildStores() returned %T, want scope-aware non-file store for bd-backed rig", store)
455+
}
456+
}
457+
458+
func TestControllerStateBuildStoresUsesRigFileMarkerUnderLegacyFileCity(t *testing.T) {
459+
t.Setenv("GC_BEADS", "")
460+
t.Setenv("GC_BEADS_SCOPE_ROOT", "")
461+
462+
cityDir := t.TempDir()
463+
rigDir := filepath.Join(cityDir, "frontend")
464+
if err := ensurePersistedScopeLocalFileStore(cityDir); err != nil {
465+
t.Fatal(err)
466+
}
467+
if err := ensurePersistedScopeLocalFileStore(rigDir); err != nil {
468+
t.Fatal(err)
469+
}
470+
if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace]
471+
name = "demo"
472+
473+
[beads]
474+
provider = "file"
475+
`), 0o644); err != nil {
476+
t.Fatal(err)
477+
}
478+
cfg := &config.City{
479+
Workspace: config.Workspace{Name: "demo"},
480+
Rigs: []config.Rig{{
481+
Name: "frontend",
482+
Path: rigDir,
483+
Prefix: "fe",
484+
}},
485+
}
486+
487+
cs := &controllerState{cityPath: cityDir, cfg: cfg}
488+
stores := cs.buildStores(cfg)
489+
rigStore, ok := stores["frontend"]
490+
if !ok {
491+
t.Fatal("buildStores() missing frontend store")
492+
}
493+
if _, err := rigStore.Create(beads.Bead{Title: "rig bead", Type: "task"}); err != nil {
494+
t.Fatalf("rig Create: %v", err)
495+
}
496+
497+
cityStore, err := openScopeLocalFileStore(cityDir)
498+
if err != nil {
499+
t.Fatal(err)
500+
}
501+
cityList, err := cityStore.List(beads.ListQuery{AllowScan: true})
502+
if err != nil {
503+
t.Fatalf("city List: %v", err)
504+
}
505+
if len(cityList) != 0 {
506+
t.Fatalf("city store should stay empty after rig create, got %#v", cityList)
507+
}
508+
509+
persistedRigStore, err := openScopeLocalFileStore(rigDir)
510+
if err != nil {
511+
t.Fatal(err)
512+
}
513+
rigList, err := persistedRigStore.List(beads.ListQuery{AllowScan: true})
514+
if err != nil {
515+
t.Fatalf("rig List: %v", err)
516+
}
517+
if len(rigList) != 1 || rigList[0].Title != "rig bead" {
518+
t.Fatalf("rig store should contain its own bead, got %#v", rigList)
519+
}
520+
}
521+
421522
func TestControllerStateNilEventProvider(t *testing.T) {
422523
sp := runtime.NewFake()
423524
cfg := &config.City{

cmd/gc/bd_env.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ func managedBDRecoveryAllowed(cityPath, scopeRoot string, env map[string]string)
249249
}
250250

251251
func bdTransportRetryableError(cityPath, scopeRoot string, env map[string]string, err error) bool {
252-
if err == nil || !cityUsesBdStoreContract(cityPath) || !managedBDRecoveryAllowed(cityPath, scopeRoot, env) {
252+
if err == nil || !providerUsesBdStoreContract(rawBeadsProviderForScope(scopeRoot, cityPath)) || !managedBDRecoveryAllowed(cityPath, scopeRoot, env) {
253253
return false
254254
}
255255
msg := strings.ToLower(err.Error())

cmd/gc/bd_env_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,70 @@ name = "demo"
10491049
}
10501050
}
10511051

1052+
func TestOpenStoreAtForCityUsesRigBdStoreUnderFileBackedCity(t *testing.T) {
1053+
cityDir := t.TempDir()
1054+
rigDir := filepath.Join(cityDir, "frontend")
1055+
if err := os.MkdirAll(filepath.Join(rigDir, ".beads"), 0o755); err != nil {
1056+
t.Fatal(err)
1057+
}
1058+
if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace]
1059+
name = "demo"
1060+
1061+
[beads]
1062+
provider = "file"
1063+
1064+
[[rigs]]
1065+
name = "frontend"
1066+
path = "frontend"
1067+
prefix = "fe"
1068+
`), 0o644); err != nil {
1069+
t.Fatal(err)
1070+
}
1071+
if err := os.WriteFile(filepath.Join(rigDir, ".beads", "metadata.json"), []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"embedded","dolt_database":"fe"}`), 0o644); err != nil {
1072+
t.Fatal(err)
1073+
}
1074+
1075+
store, err := openStoreAtForCity(rigDir, cityDir)
1076+
if err != nil {
1077+
t.Fatalf("openStoreAtForCity(rig): %v", err)
1078+
}
1079+
if _, ok := store.(*beads.BdStore); !ok {
1080+
t.Fatalf("openStoreAtForCity(rig) returned %T, want *beads.BdStore", store)
1081+
}
1082+
}
1083+
1084+
func TestOpenStoreAtForCityUsesRigFileStoreUnderBdBackedCity(t *testing.T) {
1085+
cityDir := t.TempDir()
1086+
rigDir := filepath.Join(cityDir, "frontend")
1087+
if err := os.MkdirAll(filepath.Join(rigDir, ".gc"), 0o755); err != nil {
1088+
t.Fatal(err)
1089+
}
1090+
if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace]
1091+
name = "demo"
1092+
1093+
[beads]
1094+
provider = "bd"
1095+
1096+
[[rigs]]
1097+
name = "frontend"
1098+
path = "frontend"
1099+
prefix = "fe"
1100+
`), 0o644); err != nil {
1101+
t.Fatal(err)
1102+
}
1103+
if err := os.WriteFile(filepath.Join(rigDir, ".gc", "beads.json"), []byte("{\"seq\":0,\"beads\":[]}\n"), 0o644); err != nil {
1104+
t.Fatal(err)
1105+
}
1106+
1107+
store, err := openStoreAtForCity(rigDir, cityDir)
1108+
if err != nil {
1109+
t.Fatalf("openStoreAtForCity(rig): %v", err)
1110+
}
1111+
if _, ok := store.(*beads.FileStore); !ok {
1112+
t.Fatalf("openStoreAtForCity(rig) returned %T, want *beads.FileStore", store)
1113+
}
1114+
}
1115+
10521116
func TestOpenStoreAtForCityLegacyEmptyFileCityDoesNotFailOrCreateRigState(t *testing.T) {
10531117
cityDir := t.TempDir()
10541118
if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace]
@@ -2214,6 +2278,38 @@ func TestBdTransportRetryableErrorDoesNotTreatCommandTimeoutAsTransportFailure(t
22142278
}
22152279
}
22162280

2281+
func TestBdTransportRetryableErrorUsesScopeProviderForMixedRig(t *testing.T) {
2282+
cityPath := t.TempDir()
2283+
_ = writeReachableManagedDoltState(t, cityPath)
2284+
rigDir := filepath.Join(cityPath, "repo")
2285+
if err := os.MkdirAll(filepath.Join(rigDir, ".beads"), 0o755); err != nil {
2286+
t.Fatal(err)
2287+
}
2288+
if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte(`[workspace]
2289+
name = "demo"
2290+
2291+
[beads]
2292+
provider = "file"
2293+
`), 0o644); err != nil {
2294+
t.Fatal(err)
2295+
}
2296+
if err := os.WriteFile(filepath.Join(rigDir, ".beads", "config.yaml"), []byte(`issue_prefix: repo
2297+
gc.endpoint_origin: inherited_city
2298+
gc.endpoint_status: verified
2299+
dolt.auto-start: false
2300+
`), 0o644); err != nil {
2301+
t.Fatal(err)
2302+
}
2303+
if err := os.WriteFile(filepath.Join(rigDir, ".beads", "metadata.json"), []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"repo"}`), 0o644); err != nil {
2304+
t.Fatal(err)
2305+
}
2306+
env := map[string]string{"GC_DOLT_HOST": "", "GC_DOLT_PORT": "3307"}
2307+
2308+
if !bdTransportRetryableError(cityPath, rigDir, env, fmt.Errorf("server unreachable at 127.0.0.1:3307")) {
2309+
t.Fatal("bd-backed rig under file-backed city should still be transport-retryable")
2310+
}
2311+
}
2312+
22172313
func TestBdCommandRunnerWithManagedRetryRecoversAndRerunsWithFreshEnv(t *testing.T) {
22182314
t.Setenv("GC_BEADS", "bd")
22192315

cmd/gc/beads_provider_lifecycle.go

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -859,26 +859,40 @@ func enforceCanonicalScopeMetadataForInit(fs fsys.FS, scopeRoot, doltDatabase st
859859
}
860860

861861
func normalizeCanonicalBdScopeFiles(cityPath string, cfg *config.City) error {
862-
if cfg == nil || !cityUsesBdStoreContract(cityPath) {
862+
if cfg == nil {
863863
return nil
864864
}
865865
resolveRigPaths(cityPath, cfg.Rigs)
866-
if err := ensureCanonicalScopeMetadataForInit(fsys.OSFS{}, cityPath, defaultScopeDoltDatabase(cityPath, cityPath, config.EffectiveHQPrefix(cfg))); err != nil {
867-
return fmt.Errorf("canonicalizing city metadata: %w", err)
866+
if scopeUsesManagedBdStoreContract(cityPath, cityPath) {
867+
if err := ensureCanonicalScopeMetadataForInit(fsys.OSFS{}, cityPath, defaultScopeDoltDatabase(cityPath, cityPath, config.EffectiveHQPrefix(cfg))); err != nil {
868+
return fmt.Errorf("canonicalizing city metadata: %w", err)
869+
}
868870
}
869871
for i := range cfg.Rigs {
872+
if !rigUsesManagedBdStoreContract(cityPath, cfg.Rigs[i]) {
873+
continue
874+
}
870875
if err := ensureCanonicalScopeMetadataForInit(fsys.OSFS{}, cfg.Rigs[i].Path, defaultScopeDoltDatabase(cityPath, cfg.Rigs[i].Path, cfg.Rigs[i].EffectivePrefix())); err != nil {
871876
return fmt.Errorf("canonicalizing rig %q metadata: %w", cfg.Rigs[i].Name, err)
872877
}
873878
}
874-
if err := syncConfiguredDoltPortFiles(cityPath, rawBeadsProvider(cityPath), cfg.Dolt, config.EffectiveHQPrefix(cfg), cfg.Rigs); err != nil {
879+
if err := syncConfiguredDoltPortFiles(cityPath, cfg.Dolt, config.EffectiveHQPrefix(cfg), cfg.Rigs); err != nil {
875880
return fmt.Errorf("syncing canonical dolt config: %w", err)
876881
}
877882
return nil
878883
}
879884

880-
func syncConfiguredDoltPortFiles(cityPath, provider string, cityDolt config.DoltConfig, cityPrefix string, rigs []config.Rig) error {
881-
if !providerUsesBdStoreContract(provider) {
885+
func syncConfiguredDoltPortFiles(cityPath string, cityDolt config.DoltConfig, cityPrefix string, rigs []config.Rig) error {
886+
resolveRigPaths(cityPath, rigs)
887+
cityUsesBd := scopeUsesManagedBdStoreContract(cityPath, cityPath)
888+
anyRigUsesBd := false
889+
for _, rig := range rigs {
890+
if rigUsesManagedBdStoreContract(cityPath, rig) {
891+
anyRigUsesBd = true
892+
break
893+
}
894+
}
895+
if !cityUsesBd && !anyRigUsesBd {
882896
return nil
883897
}
884898
// .beads/config.yaml is a bd compatibility mirror, not the canonical
@@ -895,11 +909,15 @@ func syncConfiguredDoltPortFiles(cityPath, provider string, cityDolt config.Dolt
895909
if cityState.EndpointOrigin == contract.EndpointOriginManagedCity {
896910
managedPort = currentDoltPort(cityPath)
897911
}
898-
if err := normalizeScopeDoltConfig(cityPath, cityState); err != nil {
899-
return err
900-
}
901-
if managedPort != "" {
902-
writeDoltPortFile(cityPath, managedPort)
912+
if cityUsesBd {
913+
if err := normalizeScopeDoltConfig(cityPath, cityState); err != nil {
914+
return err
915+
}
916+
if managedPort != "" {
917+
writeDoltPortFile(cityPath, managedPort)
918+
} else {
919+
removeDoltPortFile(cityPath)
920+
}
903921
} else {
904922
removeDoltPortFile(cityPath)
905923
}
@@ -909,6 +927,10 @@ func syncConfiguredDoltPortFiles(cityPath, provider string, cityDolt config.Dolt
909927
if strings.TrimSpace(rig.Path) == "" {
910928
continue
911929
}
930+
if !rigUsesManagedBdStoreContract(cityPath, rig) {
931+
removeDoltPortFile(rig.Path)
932+
continue
933+
}
912934
rigState, err := syncDesiredRigDoltConfigState(cityPath, rig, cityState)
913935
if err != nil {
914936
return err
@@ -1021,7 +1043,7 @@ func wrapInvalidEndpointStateError(scope string, err error) error {
10211043
}
10221044

10231045
func validateCanonicalCompatDoltDrift(cityPath string, cfg *config.City) error {
1024-
if cfg == nil || !cityUsesBdStoreContract(cityPath) {
1046+
if cfg == nil || !workspaceUsesManagedBdStoreContract(cityPath, cfg.Rigs) {
10251047
return nil
10261048
}
10271049
cityResolved, err := contract.ResolveScopeConfigState(fsys.OSFS{}, cityPath, cityPath, config.EffectiveHQPrefix(cfg))

0 commit comments

Comments
 (0)