accounts: a magpie just started no longer holds every page and request for cursor-agent and devin — who their CLIs say is signed in is kept on disk (cli-identity.json), served at once on the next start and asked again behind it; only a first look ever, or one after a sign-in or out, waits for the CLI. The window's first read went from ~4s to ~0.15s here with both signed in, and the gateway page shows its skeleton, not blank cards, while the state loads (#123)
This commit is contained in:
5 files changed
+186
-66
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user