feat: add sqlite session store for remote server

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 14:30:40 +08:00
parent d1abd2b15d
commit 034738fa3f
4 changed files with 449 additions and 7 deletions
+12 -3
View File
@@ -1,10 +1,11 @@
module github.com/longbin/agent-notify module github.com/longbin/agent-notify
go 1.22.2 go 1.25.0
require ( require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/bubbletea v0.26.6 github.com/charmbracelet/bubbletea v0.26.6
modernc.org/sqlite v1.51.0
) )
require ( require (
@@ -12,13 +13,21 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // 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/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-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // 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 github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.3.8 // 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
) )
+51 -4
View File
@@ -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/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 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 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/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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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=
+253
View File
@@ -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)
}
+133
View File
@@ -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)
}
}