diff --git a/internal/adapters/keymanager/keys.go b/internal/adapters/keymanager/keys.go new file mode 100644 index 00000000..23381506 --- /dev/null +++ b/internal/adapters/keymanager/keys.go @@ -0,0 +1,39 @@ +package keymanager + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/envector/rune-go/internal/adapters/config" +) + +type encKeyFile struct { + Key string `json:"enc_key"` +} + + +func SaveKeys(keyID string, encKey []byte) error { + runedir, err := config.RuneDir() + if err != nil { + return err + } + + keyDir := filepath.Join(runedir, "keys", keyID) + if err := os.MkdirAll(keyDir, config.DirPerm); err != nil { + return fmt.Errorf("keymanager: mkdir %s: %w", keyDir, err) + } + + if len(encKey) > 0 { + encPath := filepath.Join(keyDir, "EncKey.json") + b64 := base64.StdEncoding.EncodeToString(encKey) + encData, _ := json.Marshal(encKeyFile{Key: b64}) + if err := os.WriteFile(encPath, encData, config.FilePerm); err != nil { + return fmt.Errorf("keymanager: write EncKey.json: %w", err) + } + } + + return nil +} diff --git a/internal/lifecycle/boot.go b/internal/lifecycle/boot.go index 84438865..dfe5234b 100644 --- a/internal/lifecycle/boot.go +++ b/internal/lifecycle/boot.go @@ -16,10 +16,25 @@ package lifecycle import ( "context" + "fmt" + "log/slog" "sync/atomic" "time" + + "github.com/envector/rune-go/internal/adapters/config" + "github.com/envector/rune-go/internal/adapters/embedder" + "github.com/envector/rune-go/internal/adapters/envector" + "github.com/envector/rune-go/internal/adapters/keymanager" + "github.com/envector/rune-go/internal/adapters/vault" ) +// BootAdapterInjector — breaks import cycle with mcp.Deps. +type BootAdapterInjector interface { + InjectVault(client vault.Client) + InjectEmbedder(client embedder.Client) + InjectEnvector(client envector.Client) +} + // State — atomic-safe enum. type State int32 @@ -79,18 +94,120 @@ var BootBackoffs = []time.Duration{ 60 * time.Second, } -// RunBootLoop — background goroutine. Calls Vault.GetPublicKey with exp backoff -// until success, then SetState(Active). Stays alive for entire process to -// re-enter waiting_for_vault if Vault dies. -// -// TODO: -// 1. state = Starting -// 2. loop: call deps.vault.GetPublicKey -// success → cache keys (disk + memory), init envector, state = Active, return -// fail → state = WaitingForVault, log, sleep backoff[min(attempt, len-1)], retry -// 3. every attempt=20, log "persistent failure — check config" -func RunBootLoop(ctx context.Context, m *Manager /*, deps *Deps*/) { - // TODO: bit-identical to rune-mcp.md runBootLoop pseudocode - _ = ctx - _ = m +func RunBootLoop(ctx context.Context, m *Manager, deps BootAdapterInjector) { + m.SetState(StateStarting) + + attempt := 0 + for { + select { + case <-ctx.Done(): + return + default: + } + + cfg, err := config.Load() + if err != nil { + slog.Error("boot: failed to load config", "err", err) + time.Sleep(BootBackoffs[0]) + continue + } + + if cfg.Vault.Endpoint == "" || cfg.Vault.Token == "" { + m.SetState(StateWaitingForVault) + slog.Warn("boot: vault endpoint or token is empty, waiting...") + time.Sleep(BootBackoffs[len(BootBackoffs)-1]) + continue + } + + vaultOpts := vault.ClientOpts{ + CACertPath: cfg.Vault.CACert, + TLSDisable: cfg.Vault.TLSDisable, + } + + vaultClient, err := vault.NewClient(cfg.Vault.Endpoint, cfg.Vault.Token, vaultOpts) + if err != nil { + slog.Error("boot: failed to connect to vault", "err", err) + sleepBackoff(ctx, attempt) + attempt++ + continue + } + + bundle, err := vaultClient.GetAgentManifest(ctx) + if err != nil { + m.SetState(StateWaitingForVault) + m.lastError.Store(fmt.Sprintf("vault get manifest: %v", err)) + + if attempt > 0 && attempt%20 == 0 { + slog.Error("boot: persistent failure to reach vault - check config or network", "attempt", attempt) + } else { + slog.Warn("boot: waiting for vault...", "err", err) + } + sleepBackoff(ctx, attempt) + attempt++ + + continue + } + + if err := keymanager.SaveKeys(bundle.KeyID, bundle.EncKey); err != nil { + slog.Error("boot: failed to save keys to disk", "err", err) + sleepBackoff(ctx, attempt) + attempt++ + continue + } + + // Embedder + embedderClient, err := embedder.New("") // TODO: config resolution + if err != nil { + slog.Error("boot: failed to connect to embedder", "err", err) + sleepBackoff(ctx, attempt) + attempt++ + continue + } + + // enVector SDK + runedir, _ := config.RuneDir() + envectorClient, err := envector.NewClient(envector.ClientConfig{ + Endpoint: bundle.EnvectorEndpoint, + APIKey: bundle.EnvectorAPIKey, + KeyPath: runedir + "/keys", + KeyID: bundle.KeyID, + IndexName: bundle.IndexName, + }) + if err != nil { + slog.Error("boot: failed to connect to envector", "err", err) + sleepBackoff(ctx, attempt) + attempt++ + continue + } + + if err := envectorClient.OpenIndex(ctx); err != nil { + slog.Error("boot: envector index activation failed", "err", err) + sleepBackoff(ctx, attempt) + attempt++ + continue + } + + deps.InjectVault(vaultClient) + deps.InjectEmbedder(embedderClient) + deps.InjectEnvector(envectorClient) + + m.lastError.Store("") + m.attempts.Store(int32(attempt)) + m.SetState(StateActive) + + slog.Info("boot: pipelines initialized and active") + return + } +} + +func sleepBackoff(ctx context.Context, attempt int) { + idx := attempt + if idx >= len(BootBackoffs) { + idx = len(BootBackoffs) - 1 + } + + select { + case <-time.After(BootBackoffs[idx]): + case <-ctx.Done(): + } } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 08c263e4..a08d8d4e 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -21,7 +21,11 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/envector/rune-go/internal/adapters/embedder" + "github.com/envector/rune-go/internal/adapters/envector" + "github.com/envector/rune-go/internal/adapters/vault" "github.com/envector/rune-go/internal/domain" + "github.com/envector/rune-go/internal/lifecycle" "github.com/envector/rune-go/internal/service" ) @@ -35,13 +39,37 @@ import ( // Future fields (commented as a contract sketch — to be activated as the // owning adapter PR lands): // -// Vault vault.Client -// Envector envector.Client -// Embedder embedder.Client -// CaptureLog *logio.CaptureLog -// State *lifecycle.Manager -// Cfg *config.Config -type Deps struct{} +type Deps struct { + Vault vault.Client + Envector envector.Client + Embedder embedder.Client + State *lifecycle.Manager + + Capture *service.CaptureService + Recall *service.RecallService + Lifecycle *service.LifecycleService +} + +func (d *Deps) InjectVault(client vault.Client) { + d.Vault = client + if d.Capture != nil { d.Capture.Vault = client } + if d.Recall != nil { d.Recall.Vault = client } + if d.Lifecycle != nil { d.Lifecycle.Vault = client } +} + +func (d *Deps) InjectEmbedder(client embedder.Client) { + d.Embedder = client + if d.Capture != nil { d.Capture.Embedder = client } + if d.Recall != nil { d.Recall.Embedder = client } + if d.Lifecycle != nil { d.Lifecycle.Embedder = client } +} + +func (d *Deps) InjectEnvector(client envector.Client) { + d.Envector = client + if d.Capture != nil { d.Capture.Envector = client } + if d.Recall != nil { d.Recall.Envector = client } + if d.Lifecycle != nil { d.Lifecycle.Envector = client } +} // emptyArgs — input type for tools that take no arguments. type emptyArgs struct{}