perf: magpie opens and shows the Agents page in a fraction of the time it took (Junjie Zhou on Discord: 8 个 agent 30 个 provider … 每次我打开的时候都会很慢,要好几秒。setting 页面秒进后 我切换到 agent 就有问题) — at start, writing the agents' model lists (agent.SyncCatalog) built every provider's catalog thousands of times with nothing held, each build reading migrations.json, settings and groups again: 6000+ file reads and ~8.5s of CPU in the first 8s with the owner's 30-odd providers, all competing with the first /api/state and the Agents page. Now SyncCatalog, magpieModels and main's start-up moves run under provider.Hold, migrations, groups, settings and a provider's fetched models are held for a second like the rest, and filememo.Hold lets a GET /api/* not stat a file it confirmed unchanged within the last second (any magpie write, Changed() or a recent mtime still reads it again). A big ~/.codex/config.toml parsed in quadratic time (the TOML parser's Shape counts newlines from the start for every key): a 3 MB one took 5.4s, now 0.04s (tomlShaper). theInstalled reads through edit.Read's memo. Measured with a copy of the owner's magpie config and agent configs under a sandbox HOME, the real server in WebKit: CPU in the first 8s 8.59s → 0.39s, page up 475 → 75ms, Agents rows drawn 775 → 325ms after start; the agents' written model files are unchanged (pi byte for byte, opencode the same models). TestTOMLShaperMatchesParser, TestTOMLBigFileParsesInLinearTime (fails on the old code), TestHold (filememo), TestHoldReadsMigrationsOnce. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
This commit is contained in:
1 parent
399e5db63d
commit
2a4042a94d
18 files changed
+266
-16
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
"github.com/yetone/magpie/internal/usage"
|
||||
@@ -182,7 +183,7 @@ func (a *Agent) theInstalled() bool {
|
||||
if !elsewhere() || a.Path == "" {
|
||||
return false
|
||||
}
|
||||
b, err := os.ReadFile(a.Path)
|
||||
b, err := edit.Read(a.Path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ func firstOf(xs []string) string {
|
||||
// magpieModels is the catalog as agent is shown it, as catalog.Models, for
|
||||
// agents that keep their own model files.
|
||||
func magpieModels(agent string) []catalog.Model {
|
||||
// the providers built once for all the models' prices, not for each:
|
||||
// dsh's round at every start built them for each of hundreds
|
||||
defer provider.Hold()()
|
||||
var out []catalog.Model
|
||||
shown, _ := provider.CatalogFor(agent)
|
||||
labels := provider.Labels(shown)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -40,11 +41,15 @@ func SyncCatalog() {
|
||||
syncing.running = true
|
||||
syncing.Unlock()
|
||||
for {
|
||||
// the catalog built once for every agent's lists, not for each
|
||||
// look-up of each (thousands at magpie's start, with 30 providers)
|
||||
release := provider.Hold()
|
||||
for _, a := range All() {
|
||||
if a.Sync != nil {
|
||||
_ = a.Sync()
|
||||
}
|
||||
}
|
||||
release()
|
||||
syncing.Lock()
|
||||
if !syncing.again {
|
||||
syncing.running = false
|
||||
|
||||
+10
-2
@@ -13,16 +13,23 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/filememo"
|
||||
"github.com/yetone/magpie/internal/steady"
|
||||
)
|
||||
|
||||
// Read returns the file contents, or (nil, nil) when the file does not exist.
|
||||
// A file unchanged since it was last read is not read again (the Agents
|
||||
// page looks at each agent's files many times over), and what is returned
|
||||
// is the caller's own to change.
|
||||
func Read(path string) ([]byte, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
b, err := filememo.Read("bytes", path, func(b []byte) ([]byte, error) { return b, nil })
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return b, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.Clone(b), nil
|
||||
}
|
||||
|
||||
// WriteAtomic writes data to path via a temp file + rename so a crash can
|
||||
@@ -32,6 +39,7 @@ func Read(path string) ([]byte, error) {
|
||||
// written in place, see writeInPlace. Once written, temp files earlier
|
||||
// writes of path left behind go, see removeStaleTemps.
|
||||
func WriteAtomic(path string, data []byte) error {
|
||||
defer filememo.Forget() // read again, where a request holds it
|
||||
path, err := Target(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+23
-2
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -413,6 +414,7 @@ type tomlFile struct {
|
||||
func parseTOMLFile(lines []string) (tomlFile, error) {
|
||||
p := unstable.Parser{KeepComments: true}
|
||||
p.Reset([]byte(strings.Join(lines, "\n")))
|
||||
shapeOf := tomlShaper(lines)
|
||||
root := tomlTableSpan{to: len(lines)}
|
||||
var tables []tomlTableSpan
|
||||
current := func() *tomlTableSpan {
|
||||
@@ -470,7 +472,7 @@ func parseTOMLFile(lines []string) (tomlFile, error) {
|
||||
keyRange.Length = key.Raw.Offset + key.Raw.Length - keyRange.Offset
|
||||
}
|
||||
}
|
||||
shape := p.Shape(keyRange)
|
||||
shape := shapeOf(keyRange)
|
||||
i := shape.Start.Line - 1
|
||||
if err := finishKey(i); err != nil {
|
||||
return tomlFile{}, err
|
||||
@@ -496,7 +498,7 @@ func parseTOMLFile(lines []string) (tomlFile, error) {
|
||||
value: string(value.Data), scalar: value.Kind != unstable.Array && value.Kind != unstable.InlineTable,
|
||||
}
|
||||
if comment := expr.Next(); comment != nil && comment.Kind == unstable.Comment {
|
||||
kv.comment = p.Shape(comment.Raw).Start.Column - 1
|
||||
kv.comment = shapeOf(comment.Raw).Start.Column - 1
|
||||
}
|
||||
table.keys = append(table.keys, kv)
|
||||
}
|
||||
@@ -510,6 +512,25 @@ func parseTOMLFile(lines []string) (tomlFile, error) {
|
||||
return tomlFile{root: root, tables: tables}, nil
|
||||
}
|
||||
|
||||
// tomlShaper is the parser's Shape for the document of lines, found from
|
||||
// where each line starts rather than by counting the newlines before each
|
||||
// range, as Shape does: that is a pass over the file for each key, which
|
||||
// took a third of a second on a 1.5 MB config.toml of Codex's.
|
||||
func tomlShaper(lines []string) func(unstable.Range) unstable.Shape {
|
||||
starts := make([]int, len(lines))
|
||||
for i, off := 1, 0; i < len(lines); i++ {
|
||||
off += len(lines[i-1]) + 1
|
||||
starts[i] = off
|
||||
}
|
||||
at := func(off int) unstable.Position {
|
||||
i := sort.Search(len(starts), func(i int) bool { return starts[i] > off }) - 1
|
||||
return unstable.Position{Offset: off, Line: i + 1, Column: off - starts[i] + 1}
|
||||
}
|
||||
return func(r unstable.Range) unstable.Shape {
|
||||
return unstable.Shape{Start: at(int(r.Offset)), End: at(int(r.Offset + r.Length))}
|
||||
}
|
||||
}
|
||||
|
||||
func tomlParseError(p *unstable.Parser, err error) error {
|
||||
pe, ok := err.(*unstable.ParserError)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package edit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2/unstable"
|
||||
)
|
||||
|
||||
// tomlShaper finds every key and comment where the parser's own Shape does.
|
||||
func TestTOMLShaperMatchesParser(t *testing.T) {
|
||||
doc := "# top\na = 1 # one\n\n[t]\n b.c = \"x#y\" # two\n\"q\" = [\n 1,\n]\n[[arr]]\nz = { k = 1 }\n"
|
||||
lines := splitLines(doc)
|
||||
shapeOf := tomlShaper(lines)
|
||||
p := unstable.Parser{KeepComments: true}
|
||||
p.Reset([]byte(strings.Join(lines, "\n")))
|
||||
n := 0
|
||||
check := func(it *unstable.Node) {
|
||||
if it.Raw.Length == 0 {
|
||||
return
|
||||
}
|
||||
if got, want := shapeOf(it.Raw), p.Shape(it.Raw); got != want {
|
||||
t.Errorf("%v %q: %+v, the parser says %+v", it.Kind, p.Raw(it.Raw), got, want)
|
||||
}
|
||||
n++
|
||||
}
|
||||
for p.NextExpression() {
|
||||
e := p.Expression()
|
||||
check(e)
|
||||
if e.Kind != unstable.Comment {
|
||||
for k := e.Key(); k.Next(); {
|
||||
check(k.Node())
|
||||
}
|
||||
}
|
||||
if c := e.Next(); c != nil {
|
||||
check(c)
|
||||
}
|
||||
}
|
||||
if err := p.Error(); err != nil || n < 8 {
|
||||
t.Fatalf("parsed %d nodes: %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A big config.toml is parsed in a time that grows with its size, not its
|
||||
// square: Codex's own reached 1.5 MB, and each key's line was counted from
|
||||
// the top of the file, a third of a second at every magpie start.
|
||||
func TestTOMLBigFileParsesInLinearTime(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := 0; b.Len() < 3<<20; i++ {
|
||||
fmt.Fprintf(&b, "[projects.\"/Users/me/p%d\"]\ntrust_level = \"trusted\" # note\n\n", i)
|
||||
}
|
||||
b.WriteString("model_provider = \"magpie\"\n")
|
||||
lines := splitLines("model_provider = \"x\"\n" + b.String())
|
||||
start := time.Now()
|
||||
f, err := parseTOMLFile(lines)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d := time.Since(start); d > time.Second {
|
||||
t.Errorf("a 3 MB file took %v to parse", d)
|
||||
}
|
||||
if len(f.tables) < 30000 || f.root.keys[0].name != "model_provider" {
|
||||
t.Fatalf("parsed %d tables, root %+v", len(f.tables), f.root.keys)
|
||||
}
|
||||
last := f.tables[len(f.tables)-1]
|
||||
if k := last.keys[len(last.keys)-1]; k.name != "model_provider" || k.from != len(lines)-2 && k.from != len(lines)-1 {
|
||||
t.Errorf("last key %+v of %d lines", k, len(lines))
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ type entry struct {
|
||||
// milliseconds) coarse, so two writes of one size inside a tick (two
|
||||
// accounts swapped) leave the same time and size
|
||||
raw []byte
|
||||
// when the file was last found unchanged, while a request held them
|
||||
// (Hold), and in which of Forget's generations
|
||||
checked time.Time
|
||||
gen uint64
|
||||
}
|
||||
|
||||
// settled is how old a file's time must be for any later write to stamp
|
||||
@@ -29,23 +33,66 @@ const settled = 2 * time.Second
|
||||
var (
|
||||
mu sync.Mutex
|
||||
seen = map[string]entry{}
|
||||
// holds is how many requests hold what was read (Hold); gen is moved
|
||||
// on by every write magpie makes (Forget)
|
||||
holds int
|
||||
gen uint64
|
||||
)
|
||||
|
||||
// heldFor is how long a file found unchanged is taken as unchanged, while
|
||||
// a request holds what was read, before it is looked at again.
|
||||
const heldFor = time.Second
|
||||
|
||||
// Hold has a file found unchanged not looked at again, for a request that
|
||||
// only reads, until release is called or a second has gone by: the GUI's
|
||||
// state looked at each agent's files hundreds of times over, a few
|
||||
// thousand looks at the disk that a slow one (a Mac's antivirus looking
|
||||
// at each) made seconds. A write meanwhile (Forget) has them looked at.
|
||||
func Hold() (release func()) {
|
||||
mu.Lock()
|
||||
holds++
|
||||
mu.Unlock()
|
||||
return sync.OnceFunc(func() {
|
||||
mu.Lock()
|
||||
holds--
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// Forget has every file looked at again on its next read: magpie wrote
|
||||
// one.
|
||||
func Forget() {
|
||||
mu.Lock()
|
||||
gen++
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Read is parse of the file at path, from what it gave last time when the
|
||||
// file has the same size and time as then (and, written just now, the same
|
||||
// bytes). kind tells apart two parses of
|
||||
// one file. What it returns is shared: the caller must not change it.
|
||||
func Read[T any](kind, path string, parse func([]byte) (T, error)) (T, error) {
|
||||
var zero T
|
||||
key := kind + "\x00" + path
|
||||
mu.Lock()
|
||||
e, ok := seen[key]
|
||||
if ok && holds > 0 && e.raw == nil && e.gen == gen && time.Since(e.checked) < heldFor {
|
||||
mu.Unlock()
|
||||
return e.v.(T), nil
|
||||
}
|
||||
g := gen
|
||||
mu.Unlock()
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
key := kind + "\x00" + path
|
||||
mu.Lock()
|
||||
e, ok := seen[key]
|
||||
mu.Unlock()
|
||||
if ok && e.mod.Equal(fi.ModTime()) && e.size == fi.Size() && e.raw == nil {
|
||||
mu.Lock()
|
||||
if holds > 0 && g == gen {
|
||||
e.checked, e.gen = time.Now(), g
|
||||
seen[key] = e
|
||||
}
|
||||
mu.Unlock()
|
||||
return e.v.(T), nil
|
||||
}
|
||||
b, err := steady.ReadFile(path)
|
||||
@@ -69,6 +116,8 @@ func Read[T any](kind, path string, parse func([]byte) (T, error)) (T, error) {
|
||||
e = entry{mod: fi.ModTime(), size: fi.Size(), v: v}
|
||||
if time.Since(e.mod) <= settled {
|
||||
e.raw = b
|
||||
} else if holds > 0 && g == gen {
|
||||
e.checked, e.gen = time.Now(), g
|
||||
}
|
||||
seen[key] = e
|
||||
mu.Unlock()
|
||||
|
||||
@@ -50,3 +50,49 @@ func TestReadSameSizeSameTick(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// While a request holds what was read, a file found unchanged is not looked
|
||||
// at again (the GUI's state looked a few thousand times, seconds on a slow
|
||||
// disk); a write magpie makes (Forget), a second gone by or the release has
|
||||
// it looked at, and a file just written is always read again.
|
||||
func TestHold(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "f")
|
||||
os.WriteFile(p, []byte("a"), 0o600)
|
||||
old := time.Now().Add(-time.Minute)
|
||||
os.Chtimes(p, old, old)
|
||||
parse := func(b []byte) (string, error) { return string(b), nil }
|
||||
write := func(s string) {
|
||||
os.WriteFile(p, []byte(s), 0o600)
|
||||
at := old.Add(time.Duration(len(s)) * time.Second)
|
||||
os.Chtimes(p, at, at)
|
||||
}
|
||||
release := Hold()
|
||||
if v, _ := Read("hold", p, parse); v != "a" {
|
||||
t.Fatal(v)
|
||||
}
|
||||
write("bb")
|
||||
if v, _ := Read("hold", p, parse); v != "a" {
|
||||
t.Fatalf("looked at again while held: %q", v)
|
||||
}
|
||||
Forget()
|
||||
if v, _ := Read("hold", p, parse); v != "bb" {
|
||||
t.Fatalf("after a write magpie made: %q", v)
|
||||
}
|
||||
write("ccc")
|
||||
release()
|
||||
release()
|
||||
if v, _ := Read("hold", p, parse); v != "ccc" {
|
||||
t.Fatalf("released: %q", v)
|
||||
}
|
||||
if holds != 0 {
|
||||
t.Fatalf("holds left: %d", holds)
|
||||
}
|
||||
// a file written just now is read each time, held or not
|
||||
defer Hold()()
|
||||
os.WriteFile(p, []byte("d"), 0o600)
|
||||
Read("hold", p, parse)
|
||||
os.WriteFile(p, []byte("e"), 0o600)
|
||||
if v, _ := Read("hold", p, parse); v != "e" {
|
||||
t.Fatalf("a file written just now: %q", v)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
"github.com/yetone/magpie/internal/catalog"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// A model that can't see images is given what a model that can says of
|
||||
@@ -96,7 +95,7 @@ func blindTo(pid, model string, in *bool) bool {
|
||||
// seer is the model that describes images: the Settings' Vision while it
|
||||
// resolves, else AutoVision's. None when Vision is off or no model sees.
|
||||
func seer() (string, bool) {
|
||||
switch v := settings.Load().Vision; v {
|
||||
switch v := provider.HeldSettings().Vision; v {
|
||||
case "off":
|
||||
return "", false
|
||||
case "":
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/yetone/magpie/internal/agent"
|
||||
"github.com/yetone/magpie/internal/autostart"
|
||||
"github.com/yetone/magpie/internal/catalog"
|
||||
"github.com/yetone/magpie/internal/filememo"
|
||||
"github.com/yetone/magpie/internal/fx"
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
"github.com/yetone/magpie/internal/library"
|
||||
@@ -1274,6 +1275,8 @@ func held(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
defer provider.Hold()()
|
||||
// and the files read, not looked at again for each look-up
|
||||
defer filememo.Hold()()
|
||||
} else {
|
||||
defer provider.Changed()
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func Groups() []Group {
|
||||
}
|
||||
|
||||
func groupsIn(entries []Entry) []Group {
|
||||
f := load()
|
||||
f := heldOf("file", load)
|
||||
var out []Group
|
||||
hidden := map[string]bool{}
|
||||
for _, g := range f.Groups {
|
||||
@@ -238,7 +238,7 @@ func groupsIn(entries []Entry) []Group {
|
||||
if f.NoAutoGroups {
|
||||
return orderedGroups(out, f.GroupOrder)
|
||||
}
|
||||
for _, g := range autoGroups(entries, settings.Load().ModelSameAs) {
|
||||
for _, g := range autoGroups(entries, heldSettings().ModelSameAs) {
|
||||
if slices.ContainsFunc(out, func(o Group) bool { return o.ID == g.ID }) {
|
||||
continue // the user changed it: theirs now
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/catalog"
|
||||
"github.com/yetone/magpie/internal/filememo"
|
||||
"github.com/yetone/magpie/internal/plugin"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
@@ -56,6 +57,7 @@ func Hold() (release func()) {
|
||||
// Changed drops what is held: a provider, an account, a plugin or a
|
||||
// setting was written.
|
||||
func Changed() {
|
||||
filememo.Forget()
|
||||
held.Lock()
|
||||
held.gen++
|
||||
held.built = nil
|
||||
@@ -102,3 +104,7 @@ func heldPlugins() []plugin.Provider {
|
||||
// catalog: a look at the agents asks them for every model of every agent.
|
||||
// Only for reading — what it gives is shared by the request's look-ups.
|
||||
func heldSettings() settings.Settings { return heldOf("settings", settings.Load) }
|
||||
|
||||
// HeldSettings is the settings as heldSettings reads them, for a look
|
||||
// outside the package made many times in one request.
|
||||
func HeldSettings() settings.Settings { return heldSettings() }
|
||||
@@ -172,3 +172,25 @@ func TestCursorKeychainReadOnce(t *testing.T) {
|
||||
t.Fatalf("after a fresh read: %q, %d runs", tok, runs())
|
||||
}
|
||||
}
|
||||
|
||||
// migrations.json is read once while a request holds the catalog (the
|
||||
// GUI's state looked at it thousands of times, one per provider's API),
|
||||
// and a move written meanwhile, even one read from the file as it was
|
||||
// before, is seen at once.
|
||||
func TestHoldReadsMigrationsOnce(t *testing.T) {
|
||||
defer Hold()()
|
||||
if Moved("held-sub") {
|
||||
t.Fatal("moved before any move")
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(migrationsPath()), 0o755)
|
||||
os.WriteFile(migrationsPath(), []byte(`{"other-sub":{"state":"plugin"}}`), 0o600)
|
||||
if Moved("other-sub") {
|
||||
t.Fatal("migrations.json read again while held")
|
||||
}
|
||||
if err := setMigration("held-sub", func(m *Migration) { m.State = MovePlugin }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Moved("held-sub") || !Moved("other-sub") {
|
||||
t.Fatalf("a move written while held: %v %v", Moved("held-sub"), Moved("other-sub"))
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ func writeLogins(ls []savedLogin) error {
|
||||
// writePrivate replaces a file readable by the user alone, atomically, so
|
||||
// an agent reading it at that moment sees either version, never half.
|
||||
func writePrivate(path string, b []byte) error {
|
||||
defer filememo.Forget() // read again, where a request holds it
|
||||
path, err := edit.Target(path) // a symlink stays, its target written
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -291,7 +291,11 @@ func migrationsPath() string { return filepath.Join(filepath.Dir(Path()), "migra
|
||||
|
||||
var migrationsMu sync.Mutex
|
||||
|
||||
func readMigrations() map[string]Migration {
|
||||
// readMigrations is held while a request reads: the GUI's state asks each
|
||||
// provider's move several times over, thousands of looks at the file.
|
||||
func readMigrations() map[string]Migration { return heldOf("migrations", readMigrationsFile) }
|
||||
|
||||
func readMigrationsFile() map[string]Migration {
|
||||
m, _ := filememo.Read("migrations", migrationsPath(), func(b []byte) (map[string]Migration, error) {
|
||||
var m map[string]Migration
|
||||
_ = json.Unmarshal(b, &m)
|
||||
@@ -344,7 +348,7 @@ func setMigration(id string, f func(m *Migration)) error {
|
||||
migrationsMu.Lock()
|
||||
defer migrationsMu.Unlock()
|
||||
all := map[string]Migration{}
|
||||
for k, v := range readMigrations() {
|
||||
for k, v := range readMigrationsFile() {
|
||||
all[k] = v
|
||||
}
|
||||
m := all[id]
|
||||
|
||||
@@ -132,7 +132,13 @@ func (p Provider) live() ([]catalog.Model, time.Time, bool) {
|
||||
if p.IsPlugin() {
|
||||
return nil, time.Time{}, false
|
||||
}
|
||||
return catalog.Live(p.ID)
|
||||
type live struct {
|
||||
ms []catalog.Model
|
||||
at time.Time
|
||||
ok bool
|
||||
}
|
||||
l := heldOf("fetched:"+p.ID, func() live { ms, at, ok := catalog.Live(p.ID); return live{ms, at, ok} })
|
||||
return l.ms, l.at, l.ok
|
||||
}
|
||||
|
||||
// Fetch asks the vendor which models it serves and remembers the answer.
|
||||
|
||||
@@ -631,6 +631,7 @@ func CheckProxy(p string) error {
|
||||
func Save(s Settings) error {
|
||||
fileMu.Lock()
|
||||
defer fileMu.Unlock()
|
||||
defer filememo.Forget() // read again, where a request holds it
|
||||
s = s.normal()
|
||||
if !slices.Contains(Themes, s.Theme) {
|
||||
return fmt.Errorf("theme must be one of %v, not %q", Themes, s.Theme)
|
||||
|
||||
@@ -192,10 +192,14 @@ func run(args []string) error {
|
||||
}
|
||||
makeDirs()
|
||||
settings.Migrate()
|
||||
// the providers and settings read once for every agent's fields, which
|
||||
// the moves below look at (a write among them reads them again)
|
||||
release := provider.Hold()
|
||||
agent.RenameLegacy()
|
||||
agent.MoveCursorEfforts()
|
||||
agent.MoveAntigravityEfforts()
|
||||
agent.MoveOffAccountIDs()
|
||||
release()
|
||||
// a provider added, edited or removed, or a list fetched anew, reaches
|
||||
// the model lists agents keep in files of their own
|
||||
catalog.Changed = agent.SyncCatalog
|
||||
|
||||
Reference in new issue
Block a user