From 034738fa3f90434a8a7f4b0eb5da3fb9e9001ac4 Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 2 Jun 2026 14:30:40 +0800 Subject: [PATCH] feat: add sqlite session store for remote server Co-authored-by: Cursor --- go.mod | 15 +- go.sum | 55 +++++++- internal/server/store.go | 253 ++++++++++++++++++++++++++++++++++ internal/server/store_test.go | 133 ++++++++++++++++++ 4 files changed, 449 insertions(+), 7 deletions(-) create mode 100644 internal/server/store.go create mode 100644 internal/server/store_test.go diff --git a/go.mod b/go.mod index c6ec8a8..4d6cf2e 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module github.com/longbin/agent-notify -go 1.22.2 +go 1.25.0 require ( github.com/BurntSushi/toml v1.6.0 github.com/charmbracelet/bubbletea v0.26.6 + modernc.org/sqlite v1.51.0 ) require ( @@ -12,13 +13,21 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.3.8 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index f33aa16..1148a8d 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= @@ -20,12 +30,49 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/server/store.go b/internal/server/store.go new file mode 100644 index 0000000..42bf6ab --- /dev/null +++ b/internal/server/store.go @@ -0,0 +1,253 @@ +package server + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" + + "github.com/longbin/agent-notify/internal/remote" +) + +const offlineThreshold = 5 * time.Minute + +type Store struct { + db *sql.DB +} + +type SessionRow struct { + SessionKey string `json:"session_key"` + Hostname string `json:"hostname"` + IPs []string `json:"ips"` + Agent string `json:"agent"` + CWD string `json:"cwd"` + Status string `json:"status"` + Event string `json:"event,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + LastUser string `json:"last_user,omitempty"` + LastAgent string `json:"last_agent,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + ReceivedAt time.Time `json:"received_at"` +} + +type ListFilters struct { + Host string + CWD string + Agent string + Status string +} + +type MetaResult struct { + Hosts []string `json:"hosts"` + CWDs []string `json:"cwds"` + Agents []string `json:"agents"` +} + +func Open(path string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + s := &Store{db: db} + if err := s.initSchema(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) initSchema() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS sessions ( + session_key TEXT PRIMARY KEY, + hostname TEXT NOT NULL, + ips TEXT NOT NULL, + agent TEXT NOT NULL, + cwd TEXT NOT NULL, + status TEXT NOT NULL, + event TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL DEFAULT '', + last_user TEXT NOT NULL DEFAULT '', + last_agent TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL, + received_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_sessions_hostname ON sessions(hostname)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at)`, + } + for _, stmt := range stmts { + if _, err := s.db.Exec(stmt); err != nil { + return err + } + } + return nil +} + +func (s *Store) Upsert(report remote.StatusReport) error { + key := remote.SessionKey(report.Hostname, remote.PrimaryIP(report.IPs), report.CWD, report.Agent) + ipsJSON, err := json.Marshal(report.IPs) + if err != nil { + return err + } + receivedAt := time.Now().UTC() + updatedAt := report.UpdatedAt.UTC() + if updatedAt.IsZero() { + updatedAt = receivedAt + } + + _, err = s.db.Exec(` + INSERT INTO sessions ( + session_key, hostname, ips, agent, cwd, status, event, + conversation_id, last_user, last_agent, updated_at, received_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_key) DO UPDATE SET + hostname = excluded.hostname, + ips = excluded.ips, + agent = excluded.agent, + cwd = excluded.cwd, + status = excluded.status, + event = excluded.event, + conversation_id = excluded.conversation_id, + last_user = CASE WHEN excluded.last_user = '' THEN sessions.last_user ELSE excluded.last_user END, + last_agent = CASE WHEN excluded.last_agent = '' THEN sessions.last_agent ELSE excluded.last_agent END, + updated_at = excluded.updated_at, + received_at = excluded.received_at + `, key, report.Hostname, string(ipsJSON), report.Agent, report.CWD, report.Status, + report.Event, report.ConversationID, report.LastUser, report.LastAgent, + updatedAt.Format(time.RFC3339Nano), receivedAt.Format(time.RFC3339Nano)) + return err +} + +func (s *Store) List(filters ListFilters) ([]SessionRow, error) { + query := `SELECT session_key, hostname, ips, agent, cwd, status, event, + conversation_id, last_user, last_agent, updated_at, received_at + FROM sessions WHERE 1=1` + var args []any + + if filters.Host != "" { + query += ` AND hostname = ?` + args = append(args, filters.Host) + } + if filters.CWD != "" { + query += ` AND cwd = ?` + args = append(args, filters.CWD) + } + if filters.Agent != "" { + query += ` AND agent = ?` + args = append(args, filters.Agent) + } + query += ` ORDER BY updated_at DESC` + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []SessionRow + for rows.Next() { + row, err := scanSessionRow(rows) + if err != nil { + return nil, err + } + row.Status = displayStatus(row.Status, row.UpdatedAt) + if filters.Status != "" && row.Status != filters.Status { + continue + } + result = append(result, row) + } + return result, rows.Err() +} + +func displayStatus(stored string, updatedAt time.Time) string { + if time.Since(updatedAt) > offlineThreshold { + return "offline" + } + return stored +} + +func (s *Store) Meta() (MetaResult, error) { + var meta MetaResult + for _, q := range []struct { + sql string + dest *[]string + }{ + {`SELECT DISTINCT hostname FROM sessions ORDER BY hostname`, &meta.Hosts}, + {`SELECT DISTINCT cwd FROM sessions ORDER BY cwd`, &meta.CWDs}, + {`SELECT DISTINCT agent FROM sessions ORDER BY agent`, &meta.Agents}, + } { + rows, err := s.db.Query(q.sql) + if err != nil { + return MetaResult{}, err + } + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + rows.Close() + return MetaResult{}, err + } + *q.dest = append(*q.dest, v) + } + if err := rows.Close(); err != nil { + return MetaResult{}, err + } + if err := rows.Err(); err != nil { + return MetaResult{}, err + } + } + return meta, nil +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanSessionRow(rows rowScanner) (SessionRow, error) { + var row SessionRow + var ipsJSON, updatedAtStr, receivedAtStr string + if err := rows.Scan( + &row.SessionKey, &row.Hostname, &ipsJSON, &row.Agent, &row.CWD, &row.Status, + &row.Event, &row.ConversationID, &row.LastUser, &row.LastAgent, + &updatedAtStr, &receivedAtStr, + ); err != nil { + return SessionRow{}, err + } + if err := json.Unmarshal([]byte(ipsJSON), &row.IPs); err != nil { + return SessionRow{}, fmt.Errorf("decode ips: %w", err) + } + if row.IPs == nil { + row.IPs = []string{} + } + var err error + row.UpdatedAt, err = parseTime(updatedAtStr) + if err != nil { + return SessionRow{}, fmt.Errorf("updated_at: %w", err) + } + row.ReceivedAt, err = parseTime(receivedAtStr) + if err != nil { + return SessionRow{}, fmt.Errorf("received_at: %w", err) + } + return row, nil +} + +func parseTime(s string) (time.Time, error) { + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, fmt.Errorf("invalid time %q", s) +} diff --git a/internal/server/store_test.go b/internal/server/store_test.go new file mode 100644 index 0000000..124ceb4 --- /dev/null +++ b/internal/server/store_test.go @@ -0,0 +1,133 @@ +package server + +import ( + "path/filepath" + "testing" + "time" + + "github.com/longbin/agent-notify/internal/remote" +) + +func TestStoreUpsertAndList(t *testing.T) { + db := filepath.Join(t.TempDir(), "test.db") + s, err := Open(db) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + now := time.Now().UTC() + base := remote.StatusReport{ + Hostname: "host-a", + IPs: []string{"10.0.0.1"}, + Agent: "Cursor", + CWD: "/proj", + UpdatedAt: now, + } + + r1 := base + r1.Status = "waiting" + if err := s.Upsert(r1); err != nil { + t.Fatal(err) + } + + r2 := base + r2.Status = "running" + if err := s.Upsert(r2); err != nil { + t.Fatal(err) + } + + rows, err := s.List(ListFilters{}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].Status != "running" { + t.Fatalf("expected latest status running, got %q", rows[0].Status) + } + key := remote.SessionKey("host-a", "10.0.0.1", "/proj", "Cursor") + if rows[0].SessionKey != key { + t.Fatalf("session_key: got %q want %q", rows[0].SessionKey, key) + } +} + +func TestStoreOfflineAfter5Min(t *testing.T) { + db := filepath.Join(t.TempDir(), "test.db") + s, err := Open(db) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + stale := time.Now().UTC().Add(-10 * time.Minute) + report := remote.StatusReport{ + Hostname: "host-b", + IPs: []string{"192.168.1.5"}, + Agent: "Claude", + CWD: "/work", + Status: "waiting", + UpdatedAt: stale, + } + if err := s.Upsert(report); err != nil { + t.Fatal(err) + } + + rows, err := s.List(ListFilters{}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].Status != "offline" { + t.Fatalf("expected offline status, got %q", rows[0].Status) + } +} + +func TestStoreMergePreservesLastUser(t *testing.T) { + db := filepath.Join(t.TempDir(), "test.db") + s, err := Open(db) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + now := time.Now().UTC() + base := remote.StatusReport{ + Hostname: "host-c", + IPs: []string{"10.0.0.2"}, + Agent: "Cursor", + CWD: "/proj", + Status: "waiting", + UpdatedAt: now, + } + + r1 := base + r1.LastUser = "keep me" + if err := s.Upsert(r1); err != nil { + t.Fatal(err) + } + + r2 := base + r2.LastUser = "" + r2.Status = "running" + if err := s.Upsert(r2); err != nil { + t.Fatal(err) + } + + rows, err := s.List(ListFilters{}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].LastUser != "keep me" { + t.Fatalf("last_user: got %q want %q", rows[0].LastUser, "keep me") + } + if rows[0].Status != "running" { + t.Fatalf("expected status running, got %q", rows[0].Status) + } +}