Skip to content

Commit 6799d19

Browse files
committed
feat: store app state in sqlite
1 parent fff29a4 commit 6799d19

13 files changed

Lines changed: 574 additions & 127 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# SQLite App State Store Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Move provider state from `providers.json` persistence to a SQLite database and establish SQLite as the future home for CLI/UI app state.
6+
7+
**Architecture:** Add `internal/appstate` as the SQLite storage layer. `internal/appapi.ProviderAPI` becomes the provider use-case layer over `appstate`, while `internal/providers` remains the provider model/JSON compatibility package for import and legacy config parsing.
8+
9+
**Tech Stack:** Go `database/sql`, pure-Go `modernc.org/sqlite`, existing provider/appapi DTOs, Go tests.
10+
11+
---
12+
13+
## Files
14+
15+
- Create: `internal/appstate/store.go` — SQLite store, schema migrations, provider CRUD, app_state key/value table.
16+
- Create: `internal/appstate/store_test.go` — SQLite provider CRUD and JSON import tests.
17+
- Modify: `internal/appapi/providers.go` — use `appstate.Store` instead of writing providers JSON.
18+
- Modify: `internal/appapi/providers_test.go` — assert DB-backed behavior and JSON import compatibility.
19+
- Modify: `go.mod`, `go.sum` — add `modernc.org/sqlite`.
20+
21+
## Tasks
22+
23+
1. Add pure-Go SQLite dependency.
24+
2. Implement `internal/appstate.Store` with schema creation.
25+
3. Implement provider import from `providers.json` when DB has no providers.
26+
4. Implement provider CRUD/list/rename/enable over SQLite.
27+
5. Switch `internal/appapi.ProviderAPI` to use `appstate`.
28+
6. Run focused tests, then full Go/frontend/Tauri verification.
29+
30+
## Verification
31+
32+
- `go test ./internal/appstate ./internal/appapi -v`
33+
- `go test ./...`
34+
- `npm --prefix frontend test -- --run`
35+
- `npm --prefix frontend run build`
36+
- `go vet ./...`
37+
- `make desktop-build`

go.mod

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ require (
1919
github.com/charmbracelet/x/ansi v0.10.1 // indirect
2020
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
2121
github.com/charmbracelet/x/term v0.2.1 // indirect
22+
github.com/dustin/go-humanize v1.0.1 // indirect
2223
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
24+
github.com/google/uuid v1.6.0 // indirect
2325
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2426
github.com/kr/text v0.2.0 // indirect
2527
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@@ -29,11 +31,17 @@ require (
2931
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3032
github.com/muesli/cancelreader v0.2.2 // indirect
3133
github.com/muesli/termenv v0.16.0 // indirect
34+
github.com/ncruces/go-strftime v1.0.0 // indirect
3235
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
36+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
3337
github.com/rivo/uniseg v0.4.7 // indirect
3438
github.com/spf13/pflag v1.0.9 // indirect
3539
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3640
golang.org/x/sys v0.42.0 // indirect
3741
golang.org/x/text v0.22.0 // indirect
3842
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
43+
modernc.org/libc v1.72.3 // indirect
44+
modernc.org/mathutil v1.7.1 // indirect
45+
modernc.org/memory v1.11.0 // indirect
46+
modernc.org/sqlite v1.52.0 // indirect
3947
)

go.sum

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ
1414
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
1515
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1616
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
17+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
18+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
1719
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
1820
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
1921
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
2022
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
2123
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
2224
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
25+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
26+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
2327
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
2428
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
2529
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -42,10 +46,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
4246
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
4347
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
4448
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
49+
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
50+
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
4551
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
4652
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
4753
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
4854
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
55+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
56+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
4957
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
5058
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
5159
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -68,3 +76,11 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X
6876
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
6977
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
7078
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
79+
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
80+
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
81+
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
82+
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
83+
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
84+
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
85+
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
86+
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

internal/appapi/providers.go

Lines changed: 56 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"time"
1010

11+
"github.com/chat2anyllm/code-agent-manager/internal/appstate"
1112
"github.com/chat2anyllm/code-agent-manager/internal/pathutil"
1213
"github.com/chat2anyllm/code-agent-manager/internal/providers"
1314
)
@@ -67,6 +68,7 @@ type ProviderPatch struct {
6768
// ProviderAPI contains provider workflows shared by CLI and desktop adapters.
6869
type ProviderAPI struct {
6970
ProvidersPath string
71+
DBPath string
7072
CacheTTL time.Duration
7173
Env func(string) string
7274
}
@@ -78,6 +80,20 @@ func (api ProviderAPI) path() string {
7880
return providers.DefaultPath()
7981
}
8082

83+
func (api ProviderAPI) dbPath() string {
84+
if api.DBPath != "" {
85+
return api.DBPath
86+
}
87+
if api.ProvidersPath != "" {
88+
return api.ProvidersPath + ".db"
89+
}
90+
return appstate.DefaultPath()
91+
}
92+
93+
func (api ProviderAPI) store() appstate.Store {
94+
return appstate.New(api.dbPath())
95+
}
96+
8197
func (api ProviderAPI) getenv() func(string) string {
8298
if api.Env != nil {
8399
return api.Env
@@ -92,27 +108,30 @@ func (api ProviderAPI) cacheTTL() time.Duration {
92108
return time.Hour
93109
}
94110

95-
// Init creates an empty providers file if it does not already exist.
111+
// Init creates the SQLite app state database and imports providers.json when present.
96112
func (api ProviderAPI) Init(ctx context.Context) (OperationResult, error) {
97-
_ = ctx
98-
path := api.path()
99-
file, created, err := providers.LoadOrInit(path)
100-
if err != nil {
113+
store := api.store()
114+
if err := store.Init(ctx); err != nil {
101115
return OperationResult{}, err
102116
}
103-
if !created {
104-
return OperationResult{OK: true, Message: fmt.Sprintf("providers.json already exists at %s", path), Path: path}, nil
105-
}
106-
if err := providers.Save(path, file); err != nil {
117+
if err := store.ImportProvidersJSON(ctx, api.path()); err != nil {
107118
return OperationResult{}, err
108119
}
109-
return OperationResult{OK: true, Message: fmt.Sprintf("Created empty providers.json at %s", path), Path: path}, nil
120+
return OperationResult{OK: true, Message: fmt.Sprintf("SQLite app state ready at %s", store.Path()), Path: store.Path()}, nil
121+
}
122+
123+
// File returns providers in legacy providers.File shape for adapters that still need existing helpers.
124+
func (api ProviderAPI) File(ctx context.Context) (providers.File, error) {
125+
store := api.store()
126+
if err := store.ImportProvidersJSON(ctx, api.path()); err != nil {
127+
return providers.File{}, err
128+
}
129+
return store.ListProviders(ctx)
110130
}
111131

112132
// List returns all configured providers.
113133
func (api ProviderAPI) List(ctx context.Context) ([]Provider, error) {
114-
_ = ctx
115-
file, _, err := providers.LoadOrInit(api.path())
134+
file, err := api.File(ctx)
116135
if err != nil {
117136
return nil, err
118137
}
@@ -125,8 +144,11 @@ func (api ProviderAPI) List(ctx context.Context) ([]Provider, error) {
125144

126145
// Show returns one provider by name.
127146
func (api ProviderAPI) Show(ctx context.Context, name string) (Provider, error) {
128-
_ = ctx
129-
file, _, err := providers.LoadOrInit(api.path())
147+
store := api.store()
148+
if err := store.ImportProvidersJSON(ctx, api.path()); err != nil {
149+
return Provider{}, err
150+
}
151+
file, err := store.ListProviders(ctx)
130152
if err != nil {
131153
return Provider{}, err
132154
}
@@ -139,30 +161,15 @@ func (api ProviderAPI) Show(ctx context.Context, name string) (Provider, error)
139161

140162
// Add inserts a new provider.
141163
func (api ProviderAPI) Add(ctx context.Context, input ProviderInput) (Provider, error) {
142-
_ = ctx
143-
path := api.path()
144-
file, _, err := providers.LoadOrInit(path)
145-
if err != nil {
146-
return Provider{}, err
147-
}
148164
endpoint := endpointFromInput(input)
149-
if err := providers.Add(&file, input.Name, endpoint); err != nil {
150-
return Provider{}, err
151-
}
152-
if err := providers.Save(path, file); err != nil {
165+
if err := api.store().AddProvider(ctx, input.Name, endpoint); err != nil {
153166
return Provider{}, err
154167
}
155168
return providerFromEndpoint(input.Name, endpoint, api.getenv()), nil
156169
}
157170

158171
// Update applies a sparse provider patch.
159172
func (api ProviderAPI) Update(ctx context.Context, name string, patch ProviderPatch) (Provider, error) {
160-
_ = ctx
161-
path := api.path()
162-
file, _, err := providers.LoadOrInit(path)
163-
if err != nil {
164-
return Provider{}, err
165-
}
166173
providerPatch := providers.Patch{
167174
Endpoint: patch.Endpoint,
168175
APIKeyEnv: patch.APIKeyEnv,
@@ -177,70 +184,58 @@ func (api ProviderAPI) Update(ctx context.Context, name string, patch ProviderPa
177184
if patch.SupportedClient != nil {
178185
providerPatch.Clients = &providers.ListPatch{Op: providers.ListOpReplace, Items: splitClients(*patch.SupportedClient)}
179186
}
180-
if err := providers.Update(&file, name, providerPatch); err != nil {
187+
store := api.store()
188+
if err := store.UpdateProvider(ctx, name, providerPatch); err != nil {
181189
return Provider{}, err
182190
}
183-
if err := providers.Save(path, file); err != nil {
191+
file, err := store.ListProviders(ctx)
192+
if err != nil {
184193
return Provider{}, err
185194
}
186195
return providerFromEndpoint(name, file.Endpoints[name], api.getenv()), nil
187196
}
188197

189198
// Remove deletes a provider.
190199
func (api ProviderAPI) Remove(ctx context.Context, name string) (OperationResult, error) {
191-
_ = ctx
192-
path := api.path()
193-
file, _, err := providers.LoadOrInit(path)
194-
if err != nil {
195-
return OperationResult{}, err
196-
}
197-
if !providers.Remove(&file, name) {
200+
if !api.store().RemoveProvider(ctx, name) {
198201
return OperationResult{}, fmt.Errorf("provider %q not found: %w", name, providers.ErrNotFound)
199202
}
200-
if err := providers.Save(path, file); err != nil {
201-
return OperationResult{}, err
202-
}
203-
return OperationResult{OK: true, Message: fmt.Sprintf("Removed provider %q", name), Path: path}, nil
203+
return OperationResult{OK: true, Message: fmt.Sprintf("Removed provider %q", name), Path: api.dbPath()}, nil
204204
}
205205

206206
// SetEnabled toggles a provider's enabled state.
207207
func (api ProviderAPI) SetEnabled(ctx context.Context, name string, enabled bool) (Provider, error) {
208-
_ = ctx
209-
path := api.path()
210-
file, _, err := providers.LoadOrInit(path)
211-
if err != nil {
208+
store := api.store()
209+
if err := store.SetProviderEnabled(ctx, name, enabled); err != nil {
212210
return Provider{}, err
213211
}
214-
if err := providers.SetEnabled(&file, name, enabled); err != nil {
215-
return Provider{}, err
216-
}
217-
if err := providers.Save(path, file); err != nil {
212+
file, err := store.ListProviders(ctx)
213+
if err != nil {
218214
return Provider{}, err
219215
}
220216
return providerFromEndpoint(name, file.Endpoints[name], api.getenv()), nil
221217
}
222218

223219
// Rename changes a provider key.
224220
func (api ProviderAPI) Rename(ctx context.Context, oldName, newName string) (Provider, error) {
225-
_ = ctx
226-
path := api.path()
227-
file, _, err := providers.LoadOrInit(path)
228-
if err != nil {
229-
return Provider{}, err
230-
}
231-
if err := providers.Rename(&file, oldName, newName); err != nil {
221+
store := api.store()
222+
if err := store.RenameProvider(ctx, oldName, newName); err != nil {
232223
return Provider{}, err
233224
}
234-
if err := providers.Save(path, file); err != nil {
225+
file, err := store.ListProviders(ctx)
226+
if err != nil {
235227
return Provider{}, err
236228
}
237229
return providerFromEndpoint(newName, file.Endpoints[newName], api.getenv()), nil
238230
}
239231

240232
// ResolveModels resolves static or dynamically discovered models for a provider.
241233
func (api ProviderAPI) ResolveModels(ctx context.Context, name string) ([]string, error) {
242-
_ = ctx
243-
file, _, err := providers.LoadOrInit(api.path())
234+
store := api.store()
235+
if err := store.ImportProvidersJSON(ctx, api.path()); err != nil {
236+
return nil, err
237+
}
238+
file, err := store.ListProviders(ctx)
244239
if err != nil {
245240
return nil, err
246241
}
@@ -250,7 +245,6 @@ func (api ProviderAPI) ResolveModels(ctx context.Context, name string) ([]string
250245
}
251246
return providers.ResolveModels(endpoint, name, api.cacheTTL(), filepath.Join(pathutil.CacheDir(), "models"), api.getenv())
252247
}
253-
254248
func providerFromEndpoint(name string, endpoint providers.Endpoint, getenv func(string) string) Provider {
255249
return Provider{
256250
Name: name,

internal/appapi/providers_test.go

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package appapi
22

33
import (
44
"context"
5-
"encoding/json"
65
"os"
76
"path/filepath"
87
"testing"
@@ -18,20 +17,11 @@ func TestProviderAPIInitListShow(t *testing.T) {
1817
if err != nil {
1918
t.Fatalf("Init error = %v", err)
2019
}
21-
if !result.OK || result.Message == "" || result.Path != path {
22-
t.Fatalf("Init result = %+v, want ok message and path", result)
20+
if !result.OK || result.Message == "" || result.Path != path+".db" {
21+
t.Fatalf("Init result = %+v, want ok message and db path", result)
2322
}
24-
25-
raw, err := os.ReadFile(path)
26-
if err != nil {
27-
t.Fatalf("providers file missing: %v", err)
28-
}
29-
parsed := map[string]any{}
30-
if err := json.Unmarshal(raw, &parsed); err != nil {
31-
t.Fatalf("providers json invalid: %v", err)
32-
}
33-
if _, ok := parsed["endpoints"]; !ok {
34-
t.Fatalf("providers file missing endpoints: %s", raw)
23+
if _, err := os.Stat(result.Path); err != nil {
24+
t.Fatalf("db file missing: %v", err)
3525
}
3626

3727
file := providers.File{Endpoints: map[string]providers.Endpoint{

0 commit comments

Comments
 (0)