diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 424fc69d..cd83e08b 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -701,7 +701,10 @@ function fit(extra = 0, glide) { async function load() { if (!load.done) renderAgentsLoading(); + // the gateway page too waits for the state first: its skeleton, not a + // blank page, until then (#123) if (view === "providers" && !providers) renderProvidersLoading(); + if (view === "gateway" && !providers) renderGatewayLoading(); try { state = await api("state"); load.done = true; diff --git a/internal/provider/cli_identity.go b/internal/provider/cli_identity.go new file mode 100644 index 00000000..ab8d1157 --- /dev/null +++ b/internal/provider/cli_identity.go @@ -0,0 +1,100 @@ +package provider + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" +) + +// cliIdentity is who an agent's CLI says is signed in (`cursor-agent about`, +// `devin auth status`). Asking takes the CLI seconds, ten at worst, and +// every request of the gateway and every page of the window reads the +// accounts, so after the first answer a stale one is served while a fresh +// one is fetched behind it. The answer is kept on disk too: a magpie just +// started serves the last one at once rather than holding everything for +// the CLIs (#123), and asks them again behind it. +type cliIdentity struct { + sync.Mutex + name string + exe func() string + ask func() (user, plan string, ok bool) + at time.Time + refreshing bool + read bool // the one kept on disk was looked for + user, plan string + ok bool +} + +type keptIdentity struct { + User string `json:"user,omitempty"` + Plan string `json:"plan,omitempty"` + OK bool `json:"ok"` +} + +var identityFile sync.Mutex + +func identityPath() string { return filepath.Join(filepath.Dir(Path()), "cli-identity.json") } + +func readIdentities() map[string]keptIdentity { + m := map[string]keptIdentity{} + if b, err := os.ReadFile(identityPath()); err == nil { + _ = json.Unmarshal(b, &m) + } + return m +} + +func (c *cliIdentity) keep() { + identityFile.Lock() + defer identityFile.Unlock() + m := readIdentities() + k := keptIdentity{User: c.user, Plan: c.plan, OK: c.ok} + if m[c.name] == k { + return + } + m[c.name] = k + if b, err := json.MarshalIndent(m, "", " "); err == nil { + _ = writePrivate(identityPath(), append(b, '\n')) + } +} + +func (c *cliIdentity) get() (user, plan string, ok bool) { + c.Lock() + defer c.Unlock() + if c.at.IsZero() && !c.read { + c.read = true + identityFile.Lock() + k, found := readIdentities()[c.name] + identityFile.Unlock() + // served now, asked again behind it; a CLI since removed has no one + if found && c.exe() != "" { + c.user, c.plan, c.ok = k.User, k.Plan, k.OK + c.at = time.Unix(1, 0) + } + } + if c.at.IsZero() { + c.user, c.plan, c.ok = c.ask() + c.at = time.Now() + c.keep() + } else if time.Since(c.at) > time.Minute && !c.refreshing { + c.refreshing = true + go func() { + u, p, ok := c.ask() + c.Lock() + c.user, c.plan, c.ok = u, p, ok + c.at, c.refreshing = time.Now(), false + c.keep() + c.Unlock() + }() + } + return c.user, c.plan, c.ok +} + +// forget has the next look ask the CLI and wait for it: after a sign-in or +// a sign-out, the last answer is the wrong one. +func (c *cliIdentity) forget() { + c.Lock() + c.at, c.read = time.Time{}, true + c.Unlock() +} diff --git a/internal/provider/cli_identity_test.go b/internal/provider/cli_identity_test.go new file mode 100644 index 00000000..a555e6ba --- /dev/null +++ b/internal/provider/cli_identity_test.go @@ -0,0 +1,75 @@ +package provider + +import ( + "os" + "sync/atomic" + "testing" + "time" +) + +func TestCLIIdentityKeptAcrossStarts(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var asked atomic.Int32 + answer := func() (string, string, bool) { asked.Add(1); return "me@example.com", "Pro", true } + exe := func() string { return "/bin/sh" } + + // never asked before: the first look waits for the CLI, and keeps it + first := &cliIdentity{name: "x", exe: exe, ask: answer} + if u, p, ok := first.get(); !ok || u != "me@example.com" || p != "Pro" || asked.Load() != 1 { + t.Fatalf("first: %q %q %v, asked %d", u, p, ok, asked.Load()) + } + if _, err := os.Stat(identityPath()); err != nil { + t.Fatal(err) + } + + // a magpie started again serves the kept one at once, however long the + // CLI takes, and asks it behind that + release := make(chan struct{}) + slow := func() (string, string, bool) { <-release; return "other@example.com", "Ultra", true } + again := &cliIdentity{name: "x", exe: exe, ask: slow} + done := make(chan struct{}) + go func() { + defer close(done) + if u, p, ok := again.get(); !ok || u != "me@example.com" || p != "Pro" { + t.Errorf("kept: %q %q %v", u, p, ok) + } + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("a start with a kept answer waited for the CLI") + } + close(release) + deadline := time.Now().Add(2 * time.Second) + for { + if u, _, _ := again.get(); u == "other@example.com" { + break + } + if time.Now().After(deadline) { + t.Fatal("the answer behind the kept one never came") + } + time.Sleep(10 * time.Millisecond) + } + if k := readIdentities()["x"]; k.User != "other@example.com" || k.Plan != "Ultra" { + t.Fatalf("kept after the refresh: %+v", k) + } + + // after a sign-in or out, the next look waits for the CLI again + again.forget() + again.ask = func() (string, string, bool) { return "", "", false } + if _, _, ok := again.get(); ok { + t.Fatal("forget served the old answer") + } + if k := readIdentities()["x"]; k.OK { + t.Fatalf("signed out, still kept: %+v", k) + } +} + +func TestCLIIdentityKeptIgnoredWithoutTheCLI(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + (&cliIdentity{name: "x", exe: func() string { return "/bin/sh" }, ask: func() (string, string, bool) { return "me@example.com", "", true }}).get() + gone := &cliIdentity{name: "x", exe: func() string { return "" }, ask: func() (string, string, bool) { return "", "", false }} + if u, _, ok := gone.get(); ok || u != "" { + t.Fatalf("a removed CLI still served %q", u) + } +} diff --git a/internal/provider/cursor.go b/internal/provider/cursor.go index e13c2e8d..d36b440e 100644 --- a/internal/provider/cursor.go +++ b/internal/provider/cursor.go @@ -48,41 +48,12 @@ func isCursorAgent(path string) bool { return err == nil && strings.Contains(real, "cursor-agent") } -var cursorStatus struct { - sync.Mutex - at time.Time - refreshing bool - user, plan string - ok bool -} +var cursorStatus = &cliIdentity{name: "cursor", exe: func() string { return CursorExecutable() }, ask: func() (string, string, bool) { return askCursorIdentity() }} -// cursorIdentity asks cursor-agent who is signed in. `about` takes a few -// seconds, so after the first answer a stale one is served while a fresh one -// is fetched behind it. -func cursorIdentity() (user, plan string, ok bool) { - cursorStatus.Lock() - defer cursorStatus.Unlock() - if cursorStatus.at.IsZero() { - cursorStatus.user, cursorStatus.plan, cursorStatus.ok = askCursorIdentity() - cursorStatus.at = time.Now() - } else if time.Since(cursorStatus.at) > time.Minute && !cursorStatus.refreshing { - cursorStatus.refreshing = true - go func() { - u, p, ok := askCursorIdentity() - cursorStatus.Lock() - cursorStatus.user, cursorStatus.plan, cursorStatus.ok = u, p, ok - cursorStatus.at, cursorStatus.refreshing = time.Now(), false - cursorStatus.Unlock() - }() - } - return cursorStatus.user, cursorStatus.plan, cursorStatus.ok -} +// cursorIdentity is who Cursor's CLI says is signed in; see cliIdentity. +func cursorIdentity() (user, plan string, ok bool) { return cursorStatus.get() } -func forgetCursorStatus() { - cursorStatus.Lock() - cursorStatus.at = time.Time{} - cursorStatus.Unlock() -} +func forgetCursorStatus() { cursorStatus.forget() } func askCursorIdentity() (user, plan string, ok bool) { path := CursorExecutable() diff --git a/internal/provider/devin.go b/internal/provider/devin.go index 63a34cc9..d22d589b 100644 --- a/internal/provider/devin.go +++ b/internal/provider/devin.go @@ -53,41 +53,12 @@ func DevinCredentialsPath() string { return filepath.Join(base, "devin", "credentials.toml") } -var devinStatus struct { - sync.Mutex - at time.Time - refreshing bool - user, plan string - ok bool -} +var devinStatus = &cliIdentity{name: "devin", exe: func() string { return DevinExecutable() }, ask: func() (string, string, bool) { return askDevinIdentity() }} -// devinIdentity asks the CLI who is signed in. `devin auth status` takes a -// moment, so after the first answer a stale one is served while a fresh one -// is fetched behind it. -func devinIdentity() (user, plan string, ok bool) { - devinStatus.Lock() - defer devinStatus.Unlock() - if devinStatus.at.IsZero() { - devinStatus.user, devinStatus.plan, devinStatus.ok = askDevinIdentity() - devinStatus.at = time.Now() - } else if time.Since(devinStatus.at) > time.Minute && !devinStatus.refreshing { - devinStatus.refreshing = true - go func() { - u, p, ok := askDevinIdentity() - devinStatus.Lock() - devinStatus.user, devinStatus.plan, devinStatus.ok = u, p, ok - devinStatus.at, devinStatus.refreshing = time.Now(), false - devinStatus.Unlock() - }() - } - return devinStatus.user, devinStatus.plan, devinStatus.ok -} +// devinIdentity is who Devin's CLI says is signed in; see cliIdentity. +func devinIdentity() (user, plan string, ok bool) { return devinStatus.get() } -func forgetDevinStatus() { - devinStatus.Lock() - devinStatus.at = time.Time{} - devinStatus.Unlock() -} +func forgetDevinStatus() { devinStatus.forget() } func askDevinIdentity() (user, plan string, ok bool) { path := DevinExecutable()