diff --git a/cmd/mcp/tools_provider.go b/cmd/mcp/tools_provider.go index d68c56dd2..363b533f2 100644 --- a/cmd/mcp/tools_provider.go +++ b/cmd/mcp/tools_provider.go @@ -16,6 +16,7 @@ type providerSummary struct { Name string `json:"name"` Version string `json:"version,omitempty"` Default bool `json:"default,omitempty"` + Status string `json:"status,omitempty"` } type providerListOutput struct { @@ -108,6 +109,7 @@ func handleProviderList(_ context.Context, g *flags.GlobalFlags) (providerListOu Name: entry.Config.Name, Version: entry.Config.Version, Default: entry.Config.Name == defaultProvider, + Status: string(entry.Status), }) } sort.Slice(summaries, func(i, j int) bool { diff --git a/cmd/provider/configure_shared.go b/cmd/provider/configure_shared.go index 95444122f..e09718277 100644 --- a/cmd/provider/configure_shared.go +++ b/cmd/provider/configure_shared.go @@ -182,7 +182,35 @@ func initProvider( provider *provider2.ProviderConfig, io2 initIO, ) error { - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ + lock, err := provider2.GetProviderInitLock(devsyConfig.DefaultContext, provider.Name) + if err != nil { + return fmt.Errorf("get init lock: %w", err) + } + locked, err := lock.TryLock() + if err != nil { + return fmt.Errorf("lock provider init: %w", err) + } + if !locked { + return fmt.Errorf("provider %q is already being initialized", provider.Name) + } + defer func() { _ = lock.Unlock() }() + + if devsyConfig.Current().Providers == nil { + devsyConfig.Current().Providers = map[string]*config.ProviderConfig{} + } + if devsyConfig.Current().Providers[provider.Name] == nil { + devsyConfig.Current().Providers[provider.Name] = &config.ProviderConfig{} + } + entry := devsyConfig.Current().Providers[provider.Name] + + entry.InitAttempted = true + entry.InitError = "" + entry.Initialized = false + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save init state: %w", err) + } + + runErr := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ Ctx: ctx, Command: provider.Exec.Init, Context: devsyConfig.DefaultContext, @@ -191,15 +219,24 @@ func initProvider( Stdout: io2.stdout, Stderr: io2.stderr, }) - if err != nil { - return fmt.Errorf("init: %w", err) - } - if devsyConfig.Current().Providers == nil { - devsyConfig.Current().Providers = map[string]*config.ProviderConfig{} - } - if devsyConfig.Current().Providers[provider.Name] == nil { - devsyConfig.Current().Providers[provider.Name] = &config.ProviderConfig{} + if runErr != nil { + entry.InitError = truncateInitError(runErr.Error()) + if saveErr := config.SaveConfig(devsyConfig); saveErr != nil { + log.Warnf("save init failure state for provider %s: %v", provider.Name, saveErr) + } + return fmt.Errorf("init: %w", runErr) } - devsyConfig.Current().Providers[provider.Name].Initialized = true + + entry.Initialized = true return nil } + +const maxInitErrorLen = 500 + +func truncateInitError(msg string) string { + runes := []rune(msg) + if len(runes) <= maxInitErrorLen { + return msg + } + return string(runes[:maxInitErrorLen]) + "..." +} diff --git a/cmd/provider/list.go b/cmd/provider/list.go index 0ef443321..6635b303a 100644 --- a/cmd/provider/list.go +++ b/cmd/provider/list.go @@ -115,7 +115,7 @@ func (cmd *ListCmd) renderInstalledPlain( entry.Config.Name, entry.Config.Version, strconv.FormatBool(devsyConfig.Current().DefaultProvider == entry.Config.Name), - strconv.FormatBool(entry.State != nil && entry.State.Initialized), + string(entry.Status), entry.Config.Description, }) } @@ -127,7 +127,7 @@ func (cmd *ListCmd) renderInstalledPlain( "Name", "Version", "Default", - "Initialized", + "Status", "Description", }, tableEntries) diff --git a/desktop/src/main/watcher.ts b/desktop/src/main/watcher.ts index eb4395415..4431fdcdc 100644 --- a/desktop/src/main/watcher.ts +++ b/desktop/src/main/watcher.ts @@ -34,6 +34,7 @@ export interface ProviderEntry { optionGroups?: unknown[] } state?: { initialized?: boolean; singleMachine?: boolean } + status?: string default?: boolean } @@ -47,6 +48,7 @@ export function parseProviderEntries(raw: Record) { options: entry.config.options ?? {}, optionGroups: entry.config.optionGroups ?? [], isDefault: entry.default ?? false, + status: entry.status ?? "not_initialized", state: { initialized: entry.state?.initialized ?? false, singleMachine: entry.state?.singleMachine ?? false, diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index f1f643ec1..45f7a17a3 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -67,6 +67,8 @@ export interface ProviderOption { group?: string } +export type ProviderStatus = "not_initialized" | "initializing" | "initialized" | "failed" + export interface Provider { name: string version?: string @@ -75,6 +77,7 @@ export interface Provider { options?: Record optionGroups?: ProviderOptionGroup[] isDefault?: boolean + status?: ProviderStatus state?: { initialized?: boolean singleMachine?: boolean diff --git a/pkg/config/config.go b/pkg/config/config.go index 7ede6d0c9..4eca90d4b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -73,6 +73,17 @@ type ProviderConfig struct { // Initialized holds if the provider was initialized correctly. Initialized bool `json:"initialized,omitempty"` + // InitAttempted holds if an initialization attempt has been started, used + // with the provider's init lock (see pkg/provider) to tell "never + // initialized" apart from "attempted but didn't finish" when Initialized + // is false. + InitAttempted bool `json:"initAttempted,omitempty"` + + // InitError holds the truncated error from the last failed initialization + // attempt. Empty on success, if never run, or if abandoned by a crashed + // process. + InitError string `json:"initError,omitempty"` + // SingleMachine signals Devsy if a single machine should be used for this provider. SingleMachine bool `json:"singleMachine,omitempty"` diff --git a/pkg/provider/initlock.go b/pkg/provider/initlock.go new file mode 100644 index 000000000..2c573ab62 --- /dev/null +++ b/pkg/provider/initlock.go @@ -0,0 +1,66 @@ +package provider + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/gofrs/flock" +) + +// InitState is the lifecycle state of a provider's initialization. +type InitState string + +const ( + InitStateNotInitialized InitState = "not_initialized" + InitStateInitializing InitState = "initializing" + InitStateInitialized InitState = "initialized" + InitStateFailed InitState = "failed" +) + +// GetProviderInitLock returns the advisory lock held while a provider's init +// command runs, so ResolveInitState can tell a live "initializing" run apart +// from one abandoned by a crashed process. +func GetProviderInitLock(contextName, name string) (*flock.Flock, error) { + locksDir, err := GetLocksDir(contextName) + if err != nil { + return nil, fmt.Errorf("get locks dir: %w", err) + } + // #nosec G301 -- mirrors existing lock dir permissions elsewhere in this package + if err := os.MkdirAll(locksDir, 0o755); err != nil { + return nil, fmt.Errorf("create locks dir: %w", err) + } + + return flock.New(filepath.Join(locksDir, name+".provider-init.lock")), nil +} + +// ResolveInitState derives the current init lifecycle state for a provider +// from its persisted config and the live state of its init lock. +func ResolveInitState(contextName, name string, state *config.ProviderConfig) (InitState, error) { + if state != nil && state.Initialized { + return InitStateInitialized, nil + } + + lock, err := GetProviderInitLock(contextName, name) + if err != nil { + return "", err + } + + locked, err := lock.TryRLock() + if err != nil { + return "", fmt.Errorf("check init lock: %w", err) + } + if !locked { + return InitStateInitializing, nil + } + defer func() { _ = lock.Unlock() }() + + if state != nil && state.InitAttempted { + // The lock is free but a prior attempt never reached Initialized: + // it either failed cleanly or was abandoned by a crashed process. + return InitStateFailed, nil + } + + return InitStateNotInitialized, nil +} diff --git a/pkg/provider/initlock_test.go b/pkg/provider/initlock_test.go new file mode 100644 index 000000000..eb76b6c4b --- /dev/null +++ b/pkg/provider/initlock_test.go @@ -0,0 +1,105 @@ +package provider + +import ( + "testing" + + "github.com/devsy-org/devsy/pkg/config" +) + +func useTempDevsyHome(t *testing.T) { + t.Helper() + t.Setenv(config.EnvHome, t.TempDir()) + config.SetPathManager(config.NewPathManager()) +} + +func TestResolveInitState_NeverAttempted(t *testing.T) { + useTempDevsyHome(t) + + got, err := ResolveInitState("default", "docker", &config.ProviderConfig{}) + if err != nil { + t.Fatalf("ResolveInitState: %v", err) + } + if got != InitStateNotInitialized { + t.Fatalf("got %q, want %q", got, InitStateNotInitialized) + } +} + +func TestResolveInitState_NilState(t *testing.T) { + useTempDevsyHome(t) + + got, err := ResolveInitState("default", "docker", nil) + if err != nil { + t.Fatalf("ResolveInitState: %v", err) + } + if got != InitStateNotInitialized { + t.Fatalf("got %q, want %q", got, InitStateNotInitialized) + } +} + +func TestResolveInitState_Initialized(t *testing.T) { + useTempDevsyHome(t) + + got, err := ResolveInitState("default", "docker", &config.ProviderConfig{Initialized: true}) + if err != nil { + t.Fatalf("ResolveInitState: %v", err) + } + if got != InitStateInitialized { + t.Fatalf("got %q, want %q", got, InitStateInitialized) + } +} + +func TestResolveInitState_LiveLockHeldMeansInitializing(t *testing.T) { + useTempDevsyHome(t) + + lock, err := GetProviderInitLock("default", "docker") + if err != nil { + t.Fatalf("GetProviderInitLock: %v", err) + } + locked, err := lock.TryLock() + if err != nil || !locked { + t.Fatalf("TryLock: locked=%v err=%v", locked, err) + } + defer func() { _ = lock.Unlock() }() + + got, err := ResolveInitState("default", "docker", &config.ProviderConfig{InitAttempted: true}) + if err != nil { + t.Fatalf("ResolveInitState: %v", err) + } + if got != InitStateInitializing { + t.Fatalf("got %q, want %q", got, InitStateInitializing) + } +} + +func TestResolveInitState_InitializedTakesPrecedenceOverInitError(t *testing.T) { + useTempDevsyHome(t) + + // initProvider must reset Initialized to false before a retry runs, so + // this combination shouldn't occur on disk in practice. Locks in the + // intended precedence if it ever does: a stale Initialized=true still + // reads as initialized, since InitError alone is not a failure signal. + got, err := ResolveInitState("default", "docker", &config.ProviderConfig{ + Initialized: true, + InitError: "boom", + }) + if err != nil { + t.Fatalf("ResolveInitState: %v", err) + } + if got != InitStateInitialized { + t.Fatalf("got %q, want %q", got, InitStateInitialized) + } +} + +func TestResolveInitState_FreeLockWithAttemptMeansFailed(t *testing.T) { + useTempDevsyHome(t) + + // Simulate a crash mid-init: InitAttempted was persisted before the + // process died, but nothing holds the lock anymore since the OS released + // it when the process exited. + got, err := ResolveInitState("default", "docker", &config.ProviderConfig{InitAttempted: true}) + if err != nil { + t.Fatalf("ResolveInitState: %v", err) + } + if got != InitStateFailed { + t.Fatalf("got %q, want %q", got, InitStateFailed) + } +} diff --git a/pkg/workspace/provider.go b/pkg/workspace/provider.go index c9749fb9e..e586df38a 100644 --- a/pkg/workspace/provider.go +++ b/pkg/workspace/provider.go @@ -21,6 +21,7 @@ var ErrNoWorkspaceFound = errors.New("no workspace found") type ProviderWithOptions struct { Config *provider.ProviderConfig `json:"config,omitempty"` State *config.ProviderConfig `json:"state,omitempty"` + Status provider.InitState `json:"status,omitempty"` } type ProviderParams struct { @@ -64,6 +65,21 @@ func LoadAllProviders( return nil, err } + for name, entry := range retProviders { + status, err := provider.ResolveInitState(devsyConfig.DefaultContext, name, entry.State) + if err != nil { + log.Warnf("error resolving init state for provider %s: %v", name, err) + // Never leave Status empty: an unset value renders as a blank + // column and is dropped from JSON by omitempty. + if entry.State != nil && entry.State.Initialized { + status = provider.InitStateInitialized + } else { + status = provider.InitStateNotInitialized + } + } + entry.Status = status + } + return retProviders, nil }