Files
beads/internal/configfile/configfile.go
emma 484cd9d5fd feat(dolt): add server mode config to metadata.json schema (bd-dolt.2.2)
Add Dolt server mode configuration to metadata.json for multi-writer access:

- Add DoltMode, DoltServerHost, DoltServerPort, DoltServerUser fields to Config
- Add helper methods with sensible defaults (127.0.0.1:3306, root user)
- Update factory to read server mode config and pass to dolt.Config
- Add --server, --server-host, --server-port, --server-user flags to bd init
- Validate that --server requires --backend dolt
- Add comprehensive tests for server mode configuration

Example metadata.json for server mode:
{
  "backend": "dolt",
  "database": "dolt",
  "dolt_mode": "server",
  "dolt_server_host": "192.168.1.100",
  "dolt_server_port": 3306,
  "dolt_server_user": "beads"
}

Password should be set via BEADS_DOLT_PASSWORD env var for security.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:01:46 -08:00

247 lines
7.1 KiB
Go

package configfile
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const ConfigFileName = "metadata.json"
type Config struct {
Database string `json:"database"`
JSONLExport string `json:"jsonl_export,omitempty"`
Backend string `json:"backend,omitempty"` // "sqlite" (default) or "dolt"
// Deletions configuration
DeletionsRetentionDays int `json:"deletions_retention_days,omitempty"` // 0 means use default (3 days)
// Dolt server mode configuration (bd-dolt.2.2)
// When Mode is "server", connects to external dolt sql-server instead of embedded.
// This enables multi-writer access for multi-agent environments.
DoltMode string `json:"dolt_mode,omitempty"` // "embedded" (default) or "server"
DoltServerHost string `json:"dolt_server_host,omitempty"` // Server host (default: 127.0.0.1)
DoltServerPort int `json:"dolt_server_port,omitempty"` // Server port (default: 3306)
DoltServerUser string `json:"dolt_server_user,omitempty"` // MySQL user (default: root)
// Note: Password should be set via BEADS_DOLT_PASSWORD env var for security
// Deprecated: LastBdVersion is no longer used for version tracking.
// Version is now stored in .local_version (gitignored) to prevent
// upgrade notifications firing after git operations reset metadata.json.
// bd-tok: This field is kept for backwards compatibility when reading old configs.
LastBdVersion string `json:"last_bd_version,omitempty"`
}
func DefaultConfig() *Config {
return &Config{
Database: "beads.db",
JSONLExport: "issues.jsonl", // Canonical name (bd-6xd)
}
}
func ConfigPath(beadsDir string) string {
return filepath.Join(beadsDir, ConfigFileName)
}
func Load(beadsDir string) (*Config, error) {
configPath := ConfigPath(beadsDir)
data, err := os.ReadFile(configPath) // #nosec G304 - controlled path from config
if os.IsNotExist(err) {
// Try legacy config.json location (migration path)
legacyPath := filepath.Join(beadsDir, "config.json")
data, err = os.ReadFile(legacyPath) // #nosec G304 - controlled path from config
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("reading legacy config: %w", err)
}
// Migrate: parse legacy config, save as metadata.json, remove old file
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing legacy config: %w", err)
}
// Save to new location
if err := cfg.Save(beadsDir); err != nil {
return nil, fmt.Errorf("migrating config to metadata.json: %w", err)
}
// Remove legacy file (best effort)
_ = os.Remove(legacyPath)
return &cfg, nil
}
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}
func (c *Config) Save(beadsDir string) error {
configPath := ConfigPath(beadsDir)
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil
}
func (c *Config) DatabasePath(beadsDir string) string {
backend := c.GetBackend()
// Treat Database as the on-disk storage location:
// - SQLite: filename (default: beads.db)
// - Dolt: directory name (default: dolt)
//
// Backward-compat: early dolt configs wrote "beads.db" even when Backend=dolt.
// In that case, treat it as "dolt".
if backend == BackendDolt {
db := strings.TrimSpace(c.Database)
if db == "" || db == "beads.db" {
db = "dolt"
}
if filepath.IsAbs(db) {
return db
}
return filepath.Join(beadsDir, db)
}
// SQLite (default)
db := strings.TrimSpace(c.Database)
if db == "" {
db = "beads.db"
}
if filepath.IsAbs(db) {
return db
}
return filepath.Join(beadsDir, db)
}
func (c *Config) JSONLPath(beadsDir string) string {
if c.JSONLExport == "" {
return filepath.Join(beadsDir, "issues.jsonl")
}
return filepath.Join(beadsDir, c.JSONLExport)
}
// DefaultDeletionsRetentionDays is the default retention period for deletion records.
const DefaultDeletionsRetentionDays = 3
// GetDeletionsRetentionDays returns the configured retention days, or the default if not set.
func (c *Config) GetDeletionsRetentionDays() int {
if c.DeletionsRetentionDays <= 0 {
return DefaultDeletionsRetentionDays
}
return c.DeletionsRetentionDays
}
// Backend constants
const (
BackendSQLite = "sqlite"
BackendDolt = "dolt"
)
// BackendCapabilities describes behavioral constraints for a storage backend.
//
// This is intentionally small and stable: callers should use these flags to decide
// whether to enable features like daemon/RPC/autostart and process spawning.
//
// NOTE: The embedded Dolt driver is effectively single-writer at the OS-process level.
// Even if multiple goroutines are safe within one process, multiple processes opening
// the same Dolt directory concurrently can cause lock contention and transient
// "read-only" failures. Therefore, Dolt is treated as single-process-only.
type BackendCapabilities struct {
// SingleProcessOnly indicates the backend must not be accessed from multiple
// Beads OS processes concurrently (no daemon mode, no RPC client/server split,
// no helper-process spawning).
SingleProcessOnly bool
}
// CapabilitiesForBackend returns capabilities for a backend string.
// Unknown backends are treated conservatively as single-process-only.
func CapabilitiesForBackend(backend string) BackendCapabilities {
switch strings.TrimSpace(strings.ToLower(backend)) {
case "", BackendSQLite:
return BackendCapabilities{SingleProcessOnly: false}
case BackendDolt:
return BackendCapabilities{SingleProcessOnly: true}
default:
return BackendCapabilities{SingleProcessOnly: true}
}
}
// GetBackend returns the configured backend type, defaulting to SQLite.
func (c *Config) GetBackend() string {
if c.Backend == "" {
return BackendSQLite
}
return c.Backend
}
// Dolt mode constants
const (
DoltModeEmbedded = "embedded"
DoltModeServer = "server"
)
// Default Dolt server settings
const (
DefaultDoltServerHost = "127.0.0.1"
DefaultDoltServerPort = 3306
DefaultDoltServerUser = "root"
)
// IsDoltServerMode returns true if Dolt is configured for server mode.
func (c *Config) IsDoltServerMode() bool {
return c.GetBackend() == BackendDolt && c.DoltMode == DoltModeServer
}
// GetDoltMode returns the Dolt connection mode, defaulting to embedded.
func (c *Config) GetDoltMode() string {
if c.DoltMode == "" {
return DoltModeEmbedded
}
return c.DoltMode
}
// GetDoltServerHost returns the Dolt server host, defaulting to 127.0.0.1.
func (c *Config) GetDoltServerHost() string {
if c.DoltServerHost == "" {
return DefaultDoltServerHost
}
return c.DoltServerHost
}
// GetDoltServerPort returns the Dolt server port, defaulting to 3306.
func (c *Config) GetDoltServerPort() int {
if c.DoltServerPort == 0 {
return DefaultDoltServerPort
}
return c.DoltServerPort
}
// GetDoltServerUser returns the Dolt server user, defaulting to root.
func (c *Config) GetDoltServerUser() string {
if c.DoltServerUser == "" {
return DefaultDoltServerUser
}
return c.DoltServerUser
}