From 323cc1c2a1cc12abbfb95dced900f832fef4d23b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 15:09:04 -0500 Subject: [PATCH 01/12] fix(provider): clear Initialized when a provider's source changes updateProvider re-resolved the source and downloaded replacement binaries but never touched Initialized, so a provider updated via `provider set-source --use=false` kept reporting initialized: true even though the new binary's Exec.Init had not run. loadInitializedProvider and the provider list both trust that flag, so workspace create would admit a provider that was never initialized against its current source. Reset the flag on update. The default --use=true path reloads config and re-runs ConfigureProvider afterwards, so it still ends up initialized; only the install-without-init path is now reported accurately. Covers the --version pin path too, which routes through UpdateProvider. --- pkg/workspace/provider.go | 11 +++++++ pkg/workspace/provider_update_test.go | 41 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/pkg/workspace/provider.go b/pkg/workspace/provider.go index ce7dca684..c9749fb9e 100644 --- a/pkg/workspace/provider.go +++ b/pkg/workspace/provider.go @@ -336,6 +336,7 @@ func updateProvider(ctx context.Context, p ProviderParams) (*provider.ProviderCo } cleanupOldOptions(p.DevsyConfig, providerConfig) + clearInitialized(p.DevsyConfig, providerConfig.Name) if err := config.SaveConfig(p.DevsyConfig); err != nil { return nil, err @@ -415,6 +416,16 @@ func downloadAndSaveProvider( return provider.SaveProviderConfig(p.DevsyConfig.DefaultContext, providerConfig) } +// clearInitialized marks a provider uninitialized after its source changes. +// The replacement binaries have not run their Exec.Init, so the old source's +// result says nothing about this one; leaving it set reports the provider as +// ready to loadInitializedProvider when it may not be. +func clearInitialized(devsyConfig *config.Config, providerName string) { + if providerState := devsyConfig.Current().Providers[providerName]; providerState != nil { + providerState.Initialized = false + } +} + func cleanupOldOptions(devsyConfig *config.Config, providerConfig *provider.ProviderConfig) { providerState := devsyConfig.Current().Providers[providerConfig.Name] if providerState == nil || providerState.Options == nil { diff --git a/pkg/workspace/provider_update_test.go b/pkg/workspace/provider_update_test.go index 4f1ad78c4..e079a9194 100644 --- a/pkg/workspace/provider_update_test.go +++ b/pkg/workspace/provider_update_test.go @@ -3,9 +3,50 @@ package workspace import ( "testing" + "github.com/devsy-org/devsy/pkg/config" "github.com/stretchr/testify/assert" ) +func TestClearInitialized(t *testing.T) { + newConfig := func(providers map[string]*config.ProviderConfig) *config.Config { + return &config.Config{ + DefaultContext: testDefaultContext, + Contexts: map[string]*config.ContextConfig{ + testDefaultContext: {Providers: providers}, + }, + } + } + + const updated, other = "updated-provider", "other-provider" + + t.Run("resets an initialized provider", func(t *testing.T) { + cfg := newConfig(map[string]*config.ProviderConfig{ + updated: {Initialized: true}, + }) + + clearInitialized(cfg, updated) + + assert.False(t, cfg.Current().Providers[updated].Initialized) + }) + + t.Run("leaves other providers untouched", func(t *testing.T) { + cfg := newConfig(map[string]*config.ProviderConfig{ + updated: {Initialized: true}, + other: {Initialized: true}, + }) + + clearInitialized(cfg, updated) + + assert.True(t, cfg.Current().Providers[other].Initialized) + }) + + t.Run("no-ops for an unknown provider", func(t *testing.T) { + cfg := newConfig(map[string]*config.ProviderConfig{}) + + assert.NotPanics(t, func() { clearInitialized(cfg, "missing") }) + }) +} + func TestShouldSkipProviderUpdate(t *testing.T) { tests := []struct { name string From 11c9fbbe745bd8835a82c7f95175eea6fbe70984 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 15:28:37 -0500 Subject: [PATCH 02/12] build(grpc): make the proto codegen task reproducible Two defects made `task cli:build:grpc` unusable as documented: The pinned generators (protoc-gen-go v1.36.4, grpc v1.5.1) were not the ones that produced the committed .pb.go files (v1.36.11, v1.6.2), so running the task downgraded the output and produced a 162-line diff unrelated to any actual change. It also installed the plugins to GOPATH/bin without putting that on PATH, so protoc could not resolve them and the task failed outright on a machine that did not already have them installed. Pin the versions the committed output was generated with and export the plugin dir. The task now regenerates byte-identical output. --- Taskfile.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index b9363951a..04e9669df 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -80,8 +80,12 @@ tasks: dir: pkg/agent/tunnel cmd: | # sudo apt install protobuf-compiler - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 - go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.4 + # Versions must match those recorded in the generated .pb.go headers; + # regenerating with a different one rewrites the whole file. + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 + # protoc resolves plugins via PATH, and `go install` targets GOPATH/bin. + export PATH="$(go env GOPATH)/bin:$PATH" protoc -I . tunnel.proto --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative cli:test: From c7f95e6277119b90e88d543cef1598a218dc8c62 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 15:34:44 -0500 Subject: [PATCH 03/12] feat(status): add a pipeline discriminator to status events Provider install/init will emit the same Phase/Event stream workspace up does. Phase names alone cannot tell the two apart, so a shared sink has no way to route or filter them. Add Pipeline to status.Event and the NDJSON StatusEnvelope. Producers declare their pipeline once via ForPipeline rather than at every Enter/Leave/Fail call site, so existing reporting code is untouched. Deliberately not on the tunnel's StatusUpdate: the tunnel exists for container-side reporting, and provider commands run host-side, so no provider event can reach it. The host stamps the pipeline when it builds its reporter, which would overwrite any wire value anyway. Adding the field would have committed us to a CLI<->agent wire surface that nothing reads. Parsing tolerates a missing pipeline so status lines from an older CLI are still recognized. --- cmd/workspace/up/status.go | 5 +-- pkg/agent/tunnelserver/tunnelserver.go | 2 ++ pkg/devcontainer/config/envelope.go | 33 ++++++++++--------- pkg/devcontainer/config/envelope_test.go | 40 ++++++++++++++++++++++++ pkg/status/status.go | 39 ++++++++++++++++++++--- pkg/status/status_test.go | 29 +++++++++++++++++ 6 files changed, 128 insertions(+), 20 deletions(-) diff --git a/cmd/workspace/up/status.go b/cmd/workspace/up/status.go index c325aa70a..e0c38e9cb 100644 --- a/cmd/workspace/up/status.go +++ b/cmd/workspace/up/status.go @@ -11,10 +11,11 @@ import ( // newStatusReporter drives `up`'s progress output. func newStatusReporter(emitJSON bool, out io.Writer) status.Reporter { + var r status.Reporter = plainStatusReporter{} if emitJSON { - return &jsonStatusReporter{out: out} + r = &jsonStatusReporter{out: out} } - return plainStatusReporter{} + return status.ForPipeline(r, status.PipelineWorkspaceUp) } // jsonStatusReporter serializes write events. diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index ccc898e2a..2da2b4302 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -436,6 +436,8 @@ func (t *tunnelServer) Status( ctx context.Context, update *tunnel.StatusUpdate, ) (*tunnel.Empty, error) { + // No Pipeline: the tunnel only carries container-side up events, and the + // host's reporter stamps the pipeline on arrival. t.statusReporter.Report(status.Event{ Phase: status.Phase(update.Phase), Step: update.Step, diff --git a/pkg/devcontainer/config/envelope.go b/pkg/devcontainer/config/envelope.go index 8a0ae12f7..db79d7bd2 100644 --- a/pkg/devcontainer/config/envelope.go +++ b/pkg/devcontainer/config/envelope.go @@ -36,11 +36,14 @@ type ErrorEnvelope struct { // StatusEnvelope is one NDJSON line reporting a phase transition of the up // pipeline. type StatusEnvelope struct { - Kind string `json:"kind"` - Phase string `json:"phase"` - Step string `json:"step,omitempty"` - Started bool `json:"started"` - Error string `json:"error,omitempty"` + Kind string `json:"kind"` + // Pipeline names the command being reported. Omitted by pre-discriminator + // CLIs, so an empty value means workspace up. + Pipeline string `json:"pipeline,omitempty"` + Phase string `json:"phase"` + Step string `json:"step,omitempty"` + Started bool `json:"started"` + Error string `json:"error,omitempty"` } // TaskEnvelope is the single line `up --detach` writes to stdout. @@ -96,21 +99,23 @@ func ParseStatusLine(line string) (status.Event, bool) { return status.Event{}, false } return status.Event{ - Phase: status.Phase(env.Phase), - Step: env.Step, - Started: env.Started, - Err: env.Error, + Pipeline: status.Pipeline(env.Pipeline), + Phase: status.Phase(env.Phase), + Step: env.Step, + Started: env.Started, + Err: env.Error, }, true } // WriteStatusJSON serializes a status.Event as an NDJSON status line. func WriteStatusJSON(w io.Writer, e status.Event) error { env := StatusEnvelope{ - Kind: KindStatus, - Phase: string(e.Phase), - Step: e.Step, - Started: e.Started, - Error: e.Err, + Kind: KindStatus, + Pipeline: string(e.Pipeline), + Phase: string(e.Phase), + Step: e.Step, + Started: e.Started, + Error: e.Err, } data, err := json.Marshal(env) if err != nil { diff --git a/pkg/devcontainer/config/envelope_test.go b/pkg/devcontainer/config/envelope_test.go index a006dc3fb..2eecfcbc7 100644 --- a/pkg/devcontainer/config/envelope_test.go +++ b/pkg/devcontainer/config/envelope_test.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "testing" + + "github.com/devsy-org/devsy/pkg/status" ) //nolint:goconst,cyclop,funlen // test table values @@ -212,3 +214,41 @@ func TestWriteResultJSON_Recovery(t *testing.T) { t.Error("recovery field did not round-trip") } } + +func TestStatusLineRoundTripsPipeline(t *testing.T) { + var buf bytes.Buffer + want := status.Event{ + Pipeline: status.PipelineProvider, + Phase: status.Phase("downloading_binaries"), + Step: "docker", + Started: true, + } + if err := WriteStatusJSON(&buf, want); err != nil { + t.Fatalf("WriteStatusJSON: %v", err) + } + + got, ok := ParseStatusLine(buf.String()) + if !ok { + t.Fatalf("ParseStatusLine did not recognize %q", buf.String()) + } + if got != want { + t.Errorf("round-trip: got %+v, want %+v", got, want) + } +} + +func TestParseStatusLineWithoutPipeline(t *testing.T) { + // A CLI predating the field omits it; parsing must still recognize the + // line rather than rejecting it as a non-status envelope. + line := `{"kind":"status","phase":"ready","started":false}` + + got, ok := ParseStatusLine(line) + if !ok { + t.Fatalf("ParseStatusLine(%q) = not ok", line) + } + if got.Pipeline != "" { + t.Errorf("pipeline = %q, want empty", got.Pipeline) + } + if got.Phase != status.PhaseReady { + t.Errorf("phase = %q, want %q", got.Phase, status.PhaseReady) + } +} diff --git a/pkg/status/status.go b/pkg/status/status.go index 4b6ceaa77..ae23f20ae 100644 --- a/pkg/status/status.go +++ b/pkg/status/status.go @@ -2,6 +2,16 @@ // long-running commands. package status +// Pipeline identifies which command's progress an Event describes. Consumers +// share one event stream, so phase names alone are ambiguous: without this a +// sink cannot tell a provider install from a workspace up. +type Pipeline string + +const ( + PipelineWorkspaceUp Pipeline = "workspace_up" + PipelineProvider Pipeline = "provider" +) + // Phase identifies a step in the up pipeline. type Phase string @@ -20,10 +30,14 @@ const ( // Event is one phase transition. type Event struct { - Phase Phase `json:"phase"` - Step string `json:"step,omitempty"` - Started bool `json:"started"` - Err string `json:"error,omitempty"` + // Pipeline is stamped by ForPipeline rather than at each call site, so it + // is empty on a directly-constructed event. Consumers should treat empty + // as PipelineWorkspaceUp, which is what pre-discriminator CLIs emitted. + Pipeline Pipeline `json:"pipeline,omitempty"` + Phase Phase `json:"phase"` + Step string `json:"step,omitempty"` + Started bool `json:"started"` + Err string `json:"error,omitempty"` } // Reporter receives status events as they occur. Implementations must be @@ -47,6 +61,23 @@ func Fail(r Reporter, phase Phase, err error) { r.Report(Event{Phase: PhaseFailed, Step: string(phase), Err: err.Error()}) } +// ForPipeline stamps every event a reporter receives with pipeline, so the +// Enter/Leave/Fail helpers stay pipeline-agnostic and each producer declares +// its pipeline once where it builds its reporter. +func ForPipeline(r Reporter, pipeline Pipeline) Reporter { + return pipelineReporter{next: r, pipeline: pipeline} +} + +type pipelineReporter struct { + next Reporter + pipeline Pipeline +} + +func (p pipelineReporter) Report(e Event) { + e.Pipeline = p.pipeline + p.next.Report(e) +} + type nopReporter struct{} func (nopReporter) Report(Event) {} diff --git a/pkg/status/status_test.go b/pkg/status/status_test.go index 521f82a0a..3e75c8d15 100644 --- a/pkg/status/status_test.go +++ b/pkg/status/status_test.go @@ -56,6 +56,35 @@ func TestNopDiscardsEvents(t *testing.T) { Enter(Nop(), PhaseReady, "") } +func TestForPipelineStampsEveryEvent(t *testing.T) { + r := &recordingReporter{} + stamped := ForPipeline(r, PipelineProvider) + + Enter(stamped, PhaseBuildingImage, "") + Fail(stamped, PhaseBuildingImage, errors.New("boom")) + + if len(r.events) != 2 { + t.Fatalf("expected 2 events, got %d", len(r.events)) + } + for i, e := range r.events { + if e.Pipeline != PipelineProvider { + t.Errorf("event %d: pipeline = %q, want %q", i, e.Pipeline, PipelineProvider) + } + } +} + +func TestForPipelineOverridesInboundPipeline(t *testing.T) { + r := &recordingReporter{} + ForPipeline(r, PipelineProvider).Report(Event{ + Phase: PhaseReady, + Pipeline: PipelineWorkspaceUp, + }) + + if r.events[0].Pipeline != PipelineProvider { + t.Errorf("pipeline = %q, want it restamped to %q", r.events[0].Pipeline, PipelineProvider) + } +} + func TestTeeForwardsToEachReporter(t *testing.T) { a, b := &recordingReporter{}, &recordingReporter{} Enter(Tee(a, b), PhaseReady, "") From a293e3a4c09cdff6dc8110f7190a6a8759d41e8a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 16:04:48 -0500 Subject: [PATCH 04/12] feat(provider): report structured progress from provider commands Provider add/init/set-source emitted only freeform log text, so the desktop had to sniff "Exit code: 0" out of the log stream to decide a command had finished. Emit the same Phase/Event stream workspace up does, tagged with the provider pipeline. Phases: installing_provider covers source resolution and binary download; resolving_options and running_init split provider init, since only the latter executes provider-supplied code. PhaseReady is emitted only when the provider is actually usable. The --use=false paths on add and set-source install without initializing, and callers chain `provider init` afterwards, so reporting ready there would signal completion while the provider was still uninitialized. --- cmd/provider/add.go | 23 +++++++++++- cmd/provider/configure_shared.go | 21 ++++++++++- cmd/provider/init.go | 16 +++++++-- cmd/provider/set_source.go | 19 +++++++++- cmd/provider/status.go | 60 ++++++++++++++++++++++++++++++++ pkg/status/status.go | 13 ++++++- 6 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 cmd/provider/status.go diff --git a/cmd/provider/add.go b/cmd/provider/add.go index 54769db41..de7d1f0da 100644 --- a/cmd/provider/add.go +++ b/cmd/provider/add.go @@ -3,6 +3,7 @@ package provider import ( "context" "fmt" + "os" "strings" "github.com/devsy-org/devsy/cmd/flags" @@ -11,6 +12,7 @@ import ( "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/types" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" @@ -85,18 +87,35 @@ func (cmd *AddCmd) Run(ctx context.Context, devsyConfig *config.Config, args []s return err } + reporter, err := newStatusReporter(cmd.ResultFormat, os.Stdout) + if err != nil { + return err + } + + // The name is only known for certain after resolution, so report the + // install against whatever label the caller gave us. + status.Enter(reporter, status.PhaseInstallingProvider, providerName) providerConfig, options, err := cmd.resolveProviderConfig(ctx, devsyConfig, providerName, args) if err != nil { + status.Fail(reporter, status.PhaseInstallingProvider, err) return err } + status.Leave(reporter, status.PhaseInstallingProvider, providerConfig.Name) log.Infof("installed provider: providerName=%s", providerConfig.Name) if !cmd.Use { log.Infof("To initialize the provider, run: devsy provider init %s", providerConfig.Name) + // Deliberately no PhaseReady: the provider is installed but not + // initialized, and callers that chain `provider init` treat ready as + // the signal that the whole sequence finished. return nil } - return cmd.useProvider(ctx, devsyConfig, providerConfig, options) + if err := cmd.useProvider(ctx, devsyConfig, providerConfig, options, reporter); err != nil { + return err + } + status.Leave(reporter, status.PhaseReady, providerConfig.Name) + return nil } func validateOptionalProviderName(providerName string) error { @@ -158,6 +177,7 @@ func (cmd *AddCmd) useProvider( devsyConfig *config.Config, providerConfig *provider.ProviderConfig, options []string, + reporter status.Reporter, ) error { // First add: there are no prior user values to merge, so // DiscardPriorValues is moot. Set it explicitly so future readers @@ -168,6 +188,7 @@ func (cmd *AddCmd) useProvider( UserOptions: options, DiscardPriorValues: true, SingleMachine: &cmd.SingleMachine, + Reporter: reporter, }) if configureErr != nil { devsyConfig, err := config.LoadConfig(cmd.Context, "") diff --git a/cmd/provider/configure_shared.go b/cmd/provider/configure_shared.go index 14539f7c1..82fbb6e2a 100644 --- a/cmd/provider/configure_shared.go +++ b/cmd/provider/configure_shared.go @@ -10,6 +10,7 @@ import ( "github.com/devsy-org/devsy/pkg/log" options2 "github.com/devsy-org/devsy/pkg/options" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" ) // ProviderOptionsConfig parameterizes ConfigureProvider. @@ -32,6 +33,16 @@ type ProviderOptionsConfig struct { SkipInit bool SkipSubOptions bool SingleMachine *bool + + // Reporter receives phase events; nil means don't report. + Reporter status.Reporter +} + +func (cfg ProviderOptionsConfig) reporter() status.Reporter { + if cfg.Reporter == nil { + return status.Nop() + } + return cfg.Reporter } func ConfigureProvider(ctx context.Context, cfg ProviderOptionsConfig) error { @@ -91,13 +102,18 @@ func configureProviderOptions( } // fill defaults + reporter := cfg.reporter() + status.Enter(reporter, status.PhaseResolvingOptions, cfg.Provider.Name) devsyConfig, err = options2.ResolveOptions( ctx, devsyConfig, cfg.Provider, options, cfg.SkipRequired, cfg.SkipSubOptions, cfg.SingleMachine, ) if err != nil { - return nil, fmt.Errorf("resolve options: %w", err) + err = fmt.Errorf("resolve options: %w", err) + status.Fail(reporter, status.PhaseResolvingOptions, err) + return nil, err } + status.Leave(reporter, status.PhaseResolvingOptions, cfg.Provider.Name) // run init command if !cfg.SkipInit { @@ -107,10 +123,13 @@ func configureProviderOptions( stderr := log.Writer(log.LevelError) defer func() { _ = stderr.Close() }() + status.Enter(reporter, status.PhaseRunningInit, cfg.Provider.Name) err = initProvider(ctx, devsyConfig, cfg.Provider, initIO{stdout: stdout, stderr: stderr}) if err != nil { + status.Fail(reporter, status.PhaseRunningInit, err) return nil, err } + status.Leave(reporter, status.PhaseRunningInit, cfg.Provider.Name) } return devsyConfig, nil diff --git a/cmd/provider/init.go b/cmd/provider/init.go index 857cf687e..0e75d281f 100644 --- a/cmd/provider/init.go +++ b/cmd/provider/init.go @@ -1,11 +1,14 @@ package provider import ( + "os" + "github.com/devsy-org/devsy/cmd/completion" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -39,14 +42,23 @@ func NewInitCmd(f *flags.GlobalFlags) *cobra.Command { if err != nil { return err } - return ConfigureProvider(cobraCmd.Context(), ProviderOptionsConfig{ + reporter, err := newStatusReporter(cmd.ResultFormat, os.Stdout) + if err != nil { + return err + } + if err := ConfigureProvider(cobraCmd.Context(), ProviderOptionsConfig{ Provider: p.Config, ContextName: devsyConfig.DefaultContext, UserOptions: cmd.Options, DiscardPriorValues: cmd.Reset, SkipInit: cmd.SkipInit, SingleMachine: &cmd.SingleMachine, - }) + Reporter: reporter, + }); err != nil { + return err + } + status.Leave(reporter, status.PhaseReady, name) + return nil }, ValidArgsFunction: func( rootCmd *cobra.Command, diff --git a/cmd/provider/set_source.go b/cmd/provider/set_source.go index 149e4bd6b..b60000faf 100644 --- a/cmd/provider/set_source.go +++ b/cmd/provider/set_source.go @@ -3,12 +3,14 @@ package provider import ( "context" "fmt" + "os" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -67,14 +69,24 @@ func (cmd *SetSourceCmd) Run(ctx context.Context, devsyConfig *config.Config, ar providerSource = args[1] } + reporter, err := newStatusReporter(cmd.ResultFormat, os.Stdout) + if err != nil { + return err + } + + status.Enter(reporter, status.PhaseInstallingProvider, args[0]) providerConfig, err := workspace.UpdateProvider(ctx, devsyConfig, args[0], providerSource) if err != nil { + status.Fail(reporter, status.PhaseInstallingProvider, err) return err } + status.Leave(reporter, status.PhaseInstallingProvider, providerConfig.Name) log.Infof("updated provider: providerName=%s", providerConfig.Name) if !cmd.Use { log.Infof("To initialize the provider, run: devsy provider init %s", providerConfig.Name) + // No PhaseReady: UpdateProvider cleared Initialized, so the provider + // genuinely isn't ready until a following `provider init` runs. return nil } @@ -85,11 +97,16 @@ func (cmd *SetSourceCmd) Run(ctx context.Context, devsyConfig *config.Config, ar Provider: providerConfig, ContextName: devsyConfig.DefaultContext, UserOptions: cmd.Options, + Reporter: reporter, }); err != nil { return fmt.Errorf("configure provider: %w", err) } - return writeDefaultProvider(cmd.Context, providerConfig.Name) + if err := writeDefaultProvider(cmd.Context, providerConfig.Name); err != nil { + return err + } + status.Leave(reporter, status.PhaseReady, providerConfig.Name) + return nil } func (cmd *SetSourceCmd) runPinVersion( diff --git a/cmd/provider/status.go b/cmd/provider/status.go new file mode 100644 index 000000000..07936f047 --- /dev/null +++ b/cmd/provider/status.go @@ -0,0 +1,60 @@ +package provider + +import ( + "io" + + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/status" +) + +// newStatusReporter drives provider progress output: one NDJSON status line +// per phase transition in JSON mode, human-readable info lines otherwise. +// Mirrors cmd/workspace/up's reporter so both pipelines look the same to a +// consumer reading the stream. +func newStatusReporter(resultFormat string, out io.Writer) (status.Reporter, error) { + mode, err := output.ResolveMode(resultFormat) + if err != nil { + return nil, err + } + + var r status.Reporter = plainStatusReporter{} + if mode == output.ModeJSON { + r = &jsonStatusReporter{out: out} + } + return status.ForPipeline(r, status.PipelineProvider), nil +} + +type jsonStatusReporter struct { + out io.Writer +} + +func (r *jsonStatusReporter) Report(e status.Event) { + _ = config2.WriteStatusJSON(r.out, e) +} + +type plainStatusReporter struct{} + +func (plainStatusReporter) Report(e status.Event) { + switch { + case e.Phase == status.PhaseFailed: + log.Errorf("provider: phase %q failed: %s", e.Step, e.Err) + case e.Started: + log.Infof("provider: %s", phaseLabel(e.Phase)) + } +} + +var phaseLabels = map[status.Phase]string{ + status.PhaseInstallingProvider: "installing provider", + status.PhaseResolvingOptions: "resolving options", + status.PhaseRunningInit: "running provider init", + status.PhaseReady: "ready", +} + +func phaseLabel(p status.Phase) string { + if label, ok := phaseLabels[p]; ok { + return label + } + return string(p) +} diff --git a/pkg/status/status.go b/pkg/status/status.go index ae23f20ae..5a2bc49bc 100644 --- a/pkg/status/status.go +++ b/pkg/status/status.go @@ -12,9 +12,11 @@ const ( PipelineProvider Pipeline = "provider" ) -// Phase identifies a step in the up pipeline. +// Phase identifies a step in a pipeline. PhaseReady and PhaseFailed are +// shared terminal phases; the rest belong to one pipeline. type Phase string +// Workspace up phases. const ( PhaseCloningRepository Phase = "cloning_repository" PhaseResolvingConfig Phase = "resolving_config" @@ -28,6 +30,15 @@ const ( PhaseFailed Phase = "failed" ) +// Provider phases. Installing covers source resolution and binary download; +// ResolvingOptions and RunningInit are the two halves of provider init, split +// because only the latter executes provider-supplied code. +const ( + PhaseInstallingProvider Phase = "installing_provider" + PhaseResolvingOptions Phase = "resolving_options" + PhaseRunningInit Phase = "running_init" +) + // Event is one phase transition. type Event struct { // Pipeline is stamped by ForPipeline rather than at each call site, so it From 89db5855376bf7afb3d25fcf85f56111b5b95972 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 16:14:52 -0500 Subject: [PATCH 05/12] fix(desktop): show provider lifecycle instead of a transient red badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a provider flashed "not initialized" for several seconds. The badge read the persisted `initialized` flag, which `provider add` writes as false before init runs, and the watcher faithfully reported that. The existing mitigation was a renderer-local pending set that only covered init, and was marked after `provider add` had already returned — so it missed the whole multi-second install. Move lifecycle ownership into the main process: a ProviderJobs registry fed by the CLI's status events, broadcast on the same providers-changed channel as the provider list so the two cannot arrive out of order. Job state now survives the wizard closing, navigation, and window reload, which is exactly when a long install is still running. The registry also distinguishes states the flag cannot: installing vs initializing vs updating, and failed vs never-initialized. Update and version-pin now chain `provider init`, since both swap binaries and clear the flag; without it the badge would correctly, but unhelpfully, stay red until the user initialized by hand. --- desktop/src/main/index.ts | 12 ++ desktop/src/main/ipc.ts | 131 ++++++++++++++++-- desktop/src/main/provider-jobs.ts | 100 +++++++++++++ desktop/src/main/watcher.ts | 18 ++- .../components/provider/ProviderCard.svelte | 19 ++- .../components/provider/ProviderCard.test.ts | 47 +++++-- .../components/provider/ProviderSheet.svelte | 29 ++-- .../components/provider/ProviderSheet.test.ts | 4 +- .../components/provider/ProviderWizard.svelte | 9 +- desktop/src/renderer/src/lib/ipc/events.ts | 6 +- .../src/renderer/src/lib/stores/providers.ts | 26 ++-- desktop/src/renderer/src/lib/types/index.ts | 11 ++ .../renderer/src/lib/utils/provider-status.ts | 48 +++++++ 13 files changed, 386 insertions(+), 74 deletions(-) create mode 100644 desktop/src/main/provider-jobs.ts create mode 100644 desktop/src/renderer/src/lib/utils/provider-status.ts diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 7cf9fafa2..858cc592c 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -6,6 +6,7 @@ import { CliRunner } from "./cli.js" import { DaemonManager } from "./daemon-manager.js" import { registerIpcHandlers } from "./ipc.js" import { LogStore } from "./log-store.js" +import { ProviderJobs } from "./provider-jobs.js" import { PtyManager } from "./pty.js" import { DaemonState } from "./state.js" import { AppTray } from "./tray.js" @@ -160,6 +161,11 @@ app.whenReady().then(() => { stopAutoUpdater() }) + // Shared between the IPC handlers that run provider commands and the + // watcher that broadcasts provider state, so in-flight work is reported + // alongside what's on disk. + const providerJobs = new ProviderJobs() + // Register IPC handlers const { tunnelProcesses, @@ -171,6 +177,7 @@ app.whenReady().then(() => { logStore, pty: ptyManager, getMainWindow: () => mainWindow, + providerJobs, }) // Start state watcher @@ -179,7 +186,12 @@ app.whenReady().then(() => { daemon: daemonManager.daemonClient, state, getMainWindow: () => mainWindow, + providerJobs, }) + // Phase transitions are pushed immediately rather than waiting for the + // next disk poll, which is what made the old badge lag visible. + providerJobs.onChange(() => watcher.broadcastProviders()) + void watcher.start().then(runInitialProviderUpdateCheck) scheduleProviderUpdateCheck() diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 191867971..a42525c72 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -10,6 +10,7 @@ import type { CLIError } from "../shared/cli-error.js" import { parseCliEnvelope } from "../shared/cli-error.js" import { hashWorkspaceRef, trackEvent } from "./analytics.js" import { loadCatalog } from "./image-catalog.js" +import type { ProviderJobs, ProviderPhase } from "./provider-jobs.js" import type { CliRunner } from "./cli.js" import type { LogStore } from "./log-store.js" import type { PtyManager } from "./pty.js" @@ -90,6 +91,7 @@ interface IpcDependencies { logStore: LogStore pty: PtyManager getMainWindow: () => BrowserWindow | null + providerJobs: ProviderJobs } /** Format a line in zap console format so log-parser.ts can parse it. */ @@ -161,7 +163,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { scheduleProviderUpdateCheck: () => void runInitialProviderUpdateCheck: () => void } { - const { cli, state, logStore, pty } = deps + const { cli, state, logStore, pty, providerJobs } = deps const tunnelProcesses = new Map< string, import("node:child_process").ChildProcess @@ -243,6 +245,46 @@ export function registerIpcHandlers(deps: IpcDependencies): { }) } + function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) + } + + /** + * Run a provider CLI command, feeding its NDJSON status lines into the job + * registry so the UI tracks phases as they happen instead of only learning + * the outcome at exit. + */ + function runProviderWithStatus( + name: string, + cliArgs: string[], + ): Promise { + return new Promise((resolve, reject) => { + void cli.runStreaming( + cliArgs, + (line, stream) => { + if (stream !== "stdout") return + const envelope = parseCliEnvelope(line) + if (envelope?.kind === "status") { + providerJobs.report( + name, + envelope.phase as ProviderPhase, + envelope.error, + ) + } + }, + (code, cliError) => { + if (code === 0) { + resolve() + return + } + const message = + cliError?.message ?? `${cliArgs.join(" ")} exited with ${code}` + reject(Object.assign(new Error(message), { cliError })) + }, + ) + }) + } + /** * Compute provider update information by querying the CLI for all installed providers. */ @@ -361,13 +403,27 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (args.singleMachine) { cliArgs.push("--single-machine") } - await cli.runRaw(cliArgs) + + // Marked before the CLI runs: `provider add` writes the provider to + // disk before it is initialized, and the watcher would otherwise + // report it as plainly uninitialized while the install is in flight. + providerJobs.start(args.name, "installing") + try { + await runProviderWithStatus(args.name, cliArgs) + } catch (error) { + providerJobs.finish(args.name, errorMessage(error)) + throw error + } + // Left in "installing" deliberately: the wizard chains provider_init, + // and clearing here would flash the red badge between the two calls. }, ) ipcMain.handle("provider_delete", async (_event, args: { name: string }) => { trackEvent("provider_delete") await cli.runRaw(["provider", "delete", args.name]) + // Drop any retained failure so a re-added provider starts clean. + providerJobs.clear(args.name) }) ipcMain.handle("provider_use", async (_event, args: { name: string }) => { @@ -379,12 +435,15 @@ export function registerIpcHandlers(deps: IpcDependencies): { // name/message/stack/cause on Error instances and drops arbitrary // own-properties, so a thrown Error with a .cliError attached would lose it. ipcMain.handle("provider_init", async (_event, args: { name: string }) => { + providerJobs.start(args.name, "initializing") try { - await cli.runRaw(["provider", "init", args.name]) + await runProviderWithStatus(args.name, ["provider", "init", args.name]) + providerJobs.finish(args.name) return { ok: true } as const } catch (err) { const cliError = (err as { cliError?: CLIError }).cliError - const message = err instanceof Error ? err.message : String(err) + const message = errorMessage(err) + providerJobs.finish(args.name, cliError?.message ?? message) return { ok: false, message, cliError } as const } }) @@ -395,9 +454,23 @@ export function registerIpcHandlers(deps: IpcDependencies): { const cmdId = crypto.randomUUID() const win = deps.getMainWindow() + providerJobs.start(args.name, "initializing") await cli.runStreaming( ["provider", "init", args.name], - (line, _stream, meta) => { + (line, stream, meta) => { + // Status envelopes drive the job registry; everything else is log + // text for the wizard's output pane. + if (stream === "stdout") { + const envelope = parseCliEnvelope(line) + if (envelope?.kind === "status") { + providerJobs.report( + args.name, + envelope.phase as ProviderPhase, + envelope.error, + ) + return + } + } const formatted = formatLogLine(line) win?.webContents.send("command-progress", { commandId: cmdId, @@ -407,6 +480,12 @@ export function registerIpcHandlers(deps: IpcDependencies): { }) }, (code, cliError) => { + providerJobs.finish( + args.name, + code === 0 + ? undefined + : (cliError?.message ?? `provider init exited with ${code}`), + ) const exitMsg = formatLogLine( `Exit code: ${code}`, code === 0 ? "INFO" : "ERROR", @@ -425,8 +504,24 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, ) + // set-source installs replacement binaries and clears Initialized, since + // the new binary has not run its init. Chain init so the provider ends up + // usable rather than sitting in a needs-re-init state the user must notice. ipcMain.handle("provider_update", async (_event, args: { name: string }) => { - await cli.runRaw(["provider", "set-source", args.name, "--use=false"]) + providerJobs.start(args.name, "updating") + try { + await runProviderWithStatus(args.name, [ + "provider", + "set-source", + args.name, + "--use=false", + ]) + await runProviderWithStatus(args.name, ["provider", "init", args.name]) + providerJobs.finish(args.name) + } catch (error) { + providerJobs.finish(args.name, errorMessage(error)) + throw error + } }) ipcMain.handle("provider_options", async (_event, args: { name: string }) => { @@ -485,13 +580,23 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "provider_set_version", async (_event, args: { name: string; tag: string }) => { - await cli.runRaw([ - "provider", - "set-source", - args.name, - "--version", - args.tag, - ]) + // Pinning a version swaps binaries via the same update path, so it + // clears Initialized and needs the same re-init as a source change. + providerJobs.start(args.name, "updating") + try { + await runProviderWithStatus(args.name, [ + "provider", + "set-source", + args.name, + "--version", + args.tag, + ]) + await runProviderWithStatus(args.name, ["provider", "init", args.name]) + providerJobs.finish(args.name) + } catch (error) { + providerJobs.finish(args.name, errorMessage(error)) + throw error + } }, ) diff --git a/desktop/src/main/provider-jobs.ts b/desktop/src/main/provider-jobs.ts new file mode 100644 index 000000000..c2ac8ef70 --- /dev/null +++ b/desktop/src/main/provider-jobs.ts @@ -0,0 +1,100 @@ +/** + * Tracks in-flight provider install/init/update work in the main process. + * + * The persisted `initialized` flag answers "is this provider usable", which + * is false both for a provider the user never initialized and for one being + * installed right now. Those render very differently, so the transient half + * of the lifecycle lives here rather than being inferred from disk state. + * + * Main-process ownership is deliberate: a renderer-local pending set is lost + * when the wizard closes, the user navigates away, or the window reloads, + * which is exactly when a multi-second install is still running. + */ + +/** Phases emitted by the Go provider pipeline (pkg/status). */ +export type ProviderPhase = + | "installing_provider" + | "resolving_options" + | "running_init" + | "ready" + | "failed" + +export type ProviderActivity = "installing" | "initializing" | "updating" + +export interface ProviderJob { + activity: ProviderActivity + phase?: ProviderPhase + /** Set when the job ended in failure; the job is retained so the UI can show why. */ + error?: string +} + +export class ProviderJobs { + private jobs = new Map() + private listeners = new Set<() => void>() + + /** + * Register a callback fired after every mutation, so a phase transition + * reaches the renderer without waiting for the next 3s disk poll. + */ + onChange(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private emit(): void { + for (const listener of this.listeners) listener() + } + + /** Begin tracking work on a provider, clearing any previous failure. */ + start(name: string, activity: ProviderActivity): void { + this.jobs.set(name, { activity }) + this.emit() + } + + /** + * Record a phase transition. Ignored when no job is active, so a stray + * event can't resurrect a provider the UI already considers settled. + */ + report(name: string, phase: ProviderPhase, error?: string): void { + const job = this.jobs.get(name) + if (!job) return + if (phase === "failed") { + this.jobs.set(name, { ...job, phase, error: error ?? "failed" }) + } else { + this.jobs.set(name, { ...job, phase }) + } + this.emit() + } + + /** Stop tracking a provider, discarding any recorded failure. */ + clear(name: string): void { + if (this.jobs.delete(name)) this.emit() + } + + /** + * Finish a job. A failure is retained so the UI can explain it; success + * drops the entry and lets the persisted `initialized` flag speak. + */ + finish(name: string, error?: string): void { + if (error) { + const job = this.jobs.get(name) + this.jobs.set(name, { + activity: job?.activity ?? "initializing", + phase: "failed", + error, + }) + this.emit() + return + } + if (this.jobs.delete(name)) this.emit() + } + + get(name: string): ProviderJob | undefined { + return this.jobs.get(name) + } + + /** Snapshot for IPC, keyed by provider name. */ + snapshot(): Record { + return Object.fromEntries(this.jobs) + } +} diff --git a/desktop/src/main/watcher.ts b/desktop/src/main/watcher.ts index b698f9db7..91c2f90ac 100644 --- a/desktop/src/main/watcher.ts +++ b/desktop/src/main/watcher.ts @@ -5,6 +5,7 @@ import { watch } from "chokidar" import type { BrowserWindow } from "electron" import type { CliRunner } from "./cli.js" import type { DaemonClient } from "./daemon-client.js" +import type { ProviderJobs } from "./provider-jobs.js" import type { DaemonState } from "./state.js" interface WatcherDeps { @@ -12,6 +13,7 @@ interface WatcherDeps { daemon?: DaemonClient state: DaemonState getMainWindow: () => BrowserWindow | null + providerJobs: ProviderJobs } interface ContextEntry { @@ -160,15 +162,25 @@ export class Watcher { const providers = parseProviderEntries(raw) const changed = this.deps.state.updateProviders(providers as any[]) if (changed) { - this.send("providers-changed", { - providers: this.deps.state.providerList(), - }) + this.broadcastProviders() } } catch { // Silently ignore poll failures } } + /** + * Push the provider list plus in-flight job state. Sent on one channel so + * the two can't arrive out of order and render a provider as idle-and- + * uninitialized between an install finishing and its job clearing. + */ + broadcastProviders(): void { + this.send("providers-changed", { + providers: this.deps.state.providerList(), + jobs: this.deps.providerJobs.snapshot(), + }) + } + private async pollMachines(): Promise { try { const machines = await this.queryWithFallback( diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte index aa7a06572..af5a04c9a 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte @@ -3,12 +3,13 @@ import { badgeVariants } from "$lib/components/ui/badge/index.js" import { Star, Loader2 } from "@lucide/svelte" import ProviderIcon from "./ProviderIcon.svelte" import { providerVersions } from "$lib/stores/providerVersions.js" -import { initializingProviders } from "$lib/stores/providers.js" +import { providerJobs } from "$lib/stores/providers.js" +import { providerStatus } from "$lib/utils/provider-status.js" import type { Provider } from "$lib/types/index.js" let { provider, onopen }: { provider: Provider; onopen?: () => void } = $props() -let isInitializing = $derived($initializingProviders.has(provider.name)) +let status = $derived(providerStatus(provider, $providerJobs[provider.name])) function sourceDisplay(p: Provider): string { if (p.source?.github) return p.source.github @@ -41,15 +42,19 @@ function sourceDisplay(p: Provider): string { {/if}
- {#if provider.state?.initialized} - initialized - {:else if isInitializing} + {#if status.kind === "ready"} + {status.label} + {:else if status.kind === "busy"} - initializing… + {status.label} + + {:else if status.kind === "failed"} + + {status.label} {:else} - not initialized + {status.label} {/if} {#if provider.version} {provider.version} diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderCard.test.ts b/desktop/src/renderer/src/lib/components/provider/ProviderCard.test.ts index 6e47be242..e56b0fea5 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderCard.test.ts +++ b/desktop/src/renderer/src/lib/components/provider/ProviderCard.test.ts @@ -14,11 +14,7 @@ vi.mock("$lib/stores/providerVersions.js", async () => { }) import ProviderCard from "./ProviderCard.svelte" -import { - initializingProviders, - markInitializing, - clearInitializing, -} from "$lib/stores/providers.js" +import { providerJobs } from "$lib/stores/providers.js" function makeProvider(name: string, extras: Partial = {}): Provider { return { @@ -56,8 +52,7 @@ describe("ProviderCard", () => { }) it("shows the initializing badge while an uninitialized provider is in flight", () => { - initializingProviders.set(new Set()) - markInitializing("ssh") + providerJobs.set({ ssh: { activity: "initializing", phase: "running_init" } }) const { container, unmount } = render(ProviderCard, { props: { provider: makeProvider("ssh", { state: { initialized: false } }) }, }) @@ -65,12 +60,44 @@ describe("ProviderCard", () => { const text = container.textContent ?? "" expect(text.toLowerCase()).toContain("initializing") expect(text.toLowerCase()).not.toContain("not initialized") - clearInitializing("ssh") + providerJobs.set({}) unmount() }) - it("shows not initialized when no init is in flight", () => { - initializingProviders.set(new Set()) + // The original bug: `provider add` persists the provider before init runs, + // so the watcher reports initialized:false while the install is in flight. + it("shows installing rather than not initialized during install", () => { + providerJobs.set({ + ssh: { activity: "installing", phase: "installing_provider" }, + }) + const { container, unmount } = render(ProviderCard, { + props: { provider: makeProvider("ssh", { state: { initialized: false } }) }, + }) + + const text = (container.textContent ?? "").toLowerCase() + expect(text).toContain("installing") + expect(text).not.toContain("not initialized") + providerJobs.set({}) + unmount() + }) + + it("shows a failure badge when the job recorded an error", () => { + providerJobs.set({ + ssh: { activity: "initializing", phase: "failed", error: "init: boom" }, + }) + const { container, unmount } = render(ProviderCard, { + props: { provider: makeProvider("ssh", { state: { initialized: false } }) }, + }) + + const text = (container.textContent ?? "").toLowerCase() + expect(text).toContain("failed") + expect(text).not.toContain("not initialized") + providerJobs.set({}) + unmount() + }) + + it("shows not initialized when no job is in flight", () => { + providerJobs.set({}) const { container, unmount } = render(ProviderCard, { props: { provider: makeProvider("ssh", { state: { initialized: false } }) }, }) diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte index 5ae33b6da..b5f5992c8 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte @@ -28,11 +28,7 @@ import { providerRename, providerSetVersion, } from "$lib/ipc/commands.js" -import { - providers, - markInitializing, - clearInitializing, -} from "$lib/stores/providers.js" +import { providers, providerJobs } from "$lib/stores/providers.js" import { providerVersions, loadVersionsFor, @@ -40,6 +36,7 @@ import { } from "$lib/stores/providerVersions.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" +import { providerStatus } from "$lib/utils/provider-status.js" import type { Provider, ProviderOption } from "$lib/types/index.js" let { @@ -70,6 +67,7 @@ let updating = $state(false) let confirmSwitchOpen = $state(false) let targetTag = $state("") let switching = $state(false) +let status = $derived(providerStatus(provider, $providerJobs[provider.name])) function openVersionSwitch(tag: string) { targetTag = tag @@ -181,8 +179,11 @@ function handleUpdate() { async function runUpdate() { updating = true try { + // Also re-initializes: the new binaries have not run their init, and + // set-source clears the initialized flag accordingly. await providerUpdate(provider.name) toasts.success(`Updated ${provider.name}`) + providers.set(await providerList()) await loadVersionsFor(provider.name) await refreshUpdates() } catch (err) { @@ -198,6 +199,7 @@ async function runSwitch() { try { await providerSetVersion(provider.name, targetTag) toasts.success(`Switched ${provider.name} to ${targetTag}`) + providers.set(await providerList()) await loadVersionsFor(provider.name) await refreshUpdates() } catch (err) { @@ -227,7 +229,6 @@ function extractCliError(err: unknown): CLIError | null { async function handleInitialize() { initializing = true initError = null - markInitializing(provider.name) try { await providerInit(provider.name) const updated = await providerList() @@ -248,7 +249,6 @@ async function handleInitialize() { } } finally { initializing = false - clearInitializing(provider.name) } } @@ -367,8 +367,17 @@ async function handleSaveOptions() { Default {/if} - {#if provider.state?.initialized} - initialized + {#if status.kind === "ready"} + {status.label} + {:else if status.kind === "busy"} + + + {status.label} + + {:else if status.kind === "failed"} + + {status.label} + {/if} {#if provider.description} @@ -377,7 +386,7 @@ async function handleSaveOptions() {
- {#if provider.state?.initialized !== true} + {#if status.kind !== "ready" && status.kind !== "busy"}