diff --git a/.gitignore b/.gitignore index f8ed7ff3..beb8b117 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,19 @@ ui/web/dist/ ui/web/.vite/ *.tsbuildinfo +# UI Desktop (Wails + React) +ui/desktop/frontend/node_modules/ +ui/desktop/frontend/dist/*.js +ui/desktop/frontend/dist/*.css +ui/desktop/frontend/dist/assets/ +ui/desktop/frontend/.vite/ +ui/desktop/frontend/wailsjs/ +ui/desktop/frontend/package.json.md5 +ui/desktop/build/bin/ +ui/desktop/desktop +goclaw-lite +/desktop + .mcp.json tmp .claude/ diff --git a/cmd/gateway.go b/cmd/gateway.go index 423c1bfc..59ca2fb5 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -25,6 +25,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels/zalo" zalopersonal "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/edition" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/heartbeat" "github.com/nextlevelbuilder/goclaw/internal/gateway/methods" @@ -77,6 +78,21 @@ func runGateway() { os.Exit(1) } + // Edition override: explicit GOCLAW_EDITION takes precedence over auto-detection. + // Auto-detection happens later in setupStoresAndTracing (sqlite → lite). + if edName := os.Getenv("GOCLAW_EDITION"); edName != "" { + switch edName { + case "lite": + edition.SetCurrent(edition.Lite) + slog.Info("edition: lite (explicit)") + case "standard": + edition.SetCurrent(edition.Standard) + slog.Info("edition: standard (explicit)") + default: + slog.Warn("unknown GOCLAW_EDITION, using standard", "value", edName) + } + } + // Create core components msgBus := bus.New() @@ -433,6 +449,9 @@ func runGateway() { // API documentation (OpenAPI spec + Swagger UI at /docs) server.SetDocsHandler(httpapi.NewDocsHandler()) + // Edition info (public, no auth — used by desktop UI comparison modal) + server.SetEditionHandler(httpapi.NewEditionHandler()) + if pgStores != nil && pgStores.APIKeys != nil { server.SetAPIKeysHandler(httpapi.NewAPIKeysHandler(pgStores.APIKeys, msgBus)) server.SetAPIKeyStore(pgStores.APIKeys) diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go index 71f6f209..2dca1635 100644 --- a/cmd/gateway_builtin_tools.go +++ b/cmd/gateway_builtin_tools.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" + "github.com/nextlevelbuilder/goclaw/internal/edition" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) @@ -12,7 +13,7 @@ import ( // builtinToolSeedData returns the canonical list of built-in tools to seed into the database. // Seed preserves user-customized enabled/settings values across upgrades. func builtinToolSeedData() []store.BuiltinToolDef { - return []store.BuiltinToolDef{ + defs := []store.BuiltinToolDef{ // filesystem {Name: "read_file", DisplayName: "Read File", Description: "Read the contents of a file from the agent's workspace by path", Category: "filesystem", Enabled: true}, {Name: "write_file", DisplayName: "Write File", Description: "Write content to a file in the workspace, creating directories as needed", Category: "filesystem", Enabled: true}, @@ -107,6 +108,19 @@ func builtinToolSeedData() []store.BuiltinToolDef { Requires: []string{"managed_mode", "teams"}, }, } + + // Lite edition: remove skill management tools — not available on desktop. + if !edition.Current().TeamFullMode { + liteHidden := map[string]bool{"skill_manage": true, "publish_skill": true} + filtered := defs[:0] + for _, d := range defs { + if !liteHidden[d.Name] { + filtered = append(filtered, d) + } + } + return filtered + } + return defs } // seedBuiltinTools seeds built-in tool definitions into the database. @@ -220,14 +234,17 @@ func applyBuiltinToolDisables(ctx context.Context, bts store.BuiltinToolStore, t return } - var disabled int + var disabledCount, enabledCount int for _, t := range all { if !t.Enabled { - toolsReg.Unregister(t.Name) - disabled++ + toolsReg.Disable(t.Name) + disabledCount++ + } else { + toolsReg.Enable(t.Name) + enabledCount++ } } - if disabled > 0 { - slog.Info("builtin tools disabled", "count", disabled) + if disabledCount > 0 { + slog.Info("builtin tools updated", "disabled", disabledCount, "enabled", enabledCount) } } diff --git a/cmd/gateway_export.go b/cmd/gateway_export.go new file mode 100644 index 00000000..a2e92d97 --- /dev/null +++ b/cmd/gateway_export.go @@ -0,0 +1,7 @@ +package cmd + +// RunGateway starts the gateway server. +// Exported for embedding in desktop app. +func RunGateway() { + runGateway() +} diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 163cee4d..0890057a 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -21,6 +21,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/sandbox" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/edition" "github.com/nextlevelbuilder/goclaw/internal/store/pg" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/internal/tracing" @@ -177,7 +178,7 @@ func wireExtras( if c, ok := m["content"].(string); ok && strings.Contains(c, "/v1/") { m["content"] = httpapi.SignFileURLs(c, secret) } - // Convert media local paths → signed /v1/files/basename?ft=hash + // Convert media local paths → signed /v1/files/{full_path}?ft=hash if rawMedia, ok := m["media"].([]agent.MediaResult); ok { // Clone slice — the original is shared with RunResult.Media; // mutating in-place corrupts paths for downstream consumers @@ -185,8 +186,10 @@ func wireExtras( signed := make([]agent.MediaResult, len(rawMedia)) for i, mr := range rawMedia { signed[i] = mr - basename := filepath.Base(mr.Path) - url := "/v1/files/" + basename + // Use full path so backend resolves directly via os.Stat, + // no findInWorkspace fallback needed. + urlPath := strings.TrimPrefix(filepath.Clean(mr.Path), "/") + url := "/v1/files/" + urlPath ft := httpapi.SignFileToken(url, secret, httpapi.FileTokenTTL) signed[i].Path = url + "?ft=" + ft } @@ -278,7 +281,7 @@ func wireExtras( ms.SetMemoryStore(stores.Memory) } } - slog.Info("memory layering enabled (Postgres)") + slog.Info("memory layering enabled") } // Wire knowledge graph store on KG tool + hint in memory_search results @@ -436,7 +439,11 @@ func wireExtras( if stores.Teams != nil && stores.Agents != nil { teamMgr := tools.NewTeamToolManager(stores.Teams, stores.Agents, msgBus, workspace) postTurn = teamMgr - toolsReg.Register(tools.NewTeamTasksTool(teamMgr)) + var teamPolicy tools.TeamActionPolicy = tools.FullTeamPolicy{} + if !edition.Current().TeamFullMode { + teamPolicy = tools.LiteTeamPolicy{} + } + toolsReg.Register(tools.NewTeamTasksTool(teamMgr, teamPolicy)) // Wire workspace interceptor into write_file so team workspace validation // and event broadcasting happen transparently via existing file tools. wsInterceptor := tools.NewWorkspaceInterceptor(teamMgr) diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index a9edfbb2..49b3e446 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -17,6 +17,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/sandbox" + "github.com/nextlevelbuilder/goclaw/internal/edition" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" @@ -222,6 +223,9 @@ func setupToolRegistry( filepath.Join(workspace, "memory.db-shm"), filepath.Join(workspace, "config.json"), filepath.Join(workspace, "delegate"), + filepath.Join(dataDir, "goclaw.db"), + filepath.Join(dataDir, "goclaw.db-wal"), + filepath.Join(dataDir, "goclaw.db-shm"), ) if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" { et.DenyPaths(cfgPath) @@ -236,12 +240,14 @@ func setupToolRegistry( // deny paths add defense-in-depth. internalDenyPaths := []string{ "config.json", "memory.db", "memory.db-wal", "memory.db-shm", + "goclaw.db", "goclaw.db-wal", "goclaw.db-shm", "memory/", ".media/", ".uploads/", "delegate/", } // read_file: allow .media/ access (uploaded documents accessed via AllowPaths // for backward compat; new uploads go to per-user .uploads/ within workspace). readFileDenyPaths := []string{ "config.json", "memory.db", "memory.db-wal", "memory.db-shm", + "goclaw.db", "goclaw.db-wal", "goclaw.db-shm", "memory/", "delegate/", } if rf, ok := toolsReg.Get("read_file"); ok { @@ -268,39 +274,17 @@ func setupToolRegistry( return } -// setupStoresAndTracing creates PG stores, tracing collector, snapshot worker, and wires cron config. -// Exits the process on unrecoverable errors (missing DSN, schema mismatch, store creation failure). -func setupStoresAndTracing( +// wireTracingAndCron sets up tracing collector, snapshot worker, and cron config +// on an already-created store set. Shared between PG and SQLite build variants. +func wireTracingAndCron( cfg *config.Config, - dataDir string, + stores *store.Stores, msgBus *bus.MessageBus, -) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) { - // --- Store creation (Postgres) --- - if cfg.Database.PostgresDSN == "" { - slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.") - os.Exit(1) - } - + dataDir string, +) (*tracing.Collector, *tracing.SnapshotWorker) { var traceCollector *tracing.Collector - - // Schema compatibility check: ensure DB schema matches this binary. - if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil { - slog.Error("schema compatibility check failed", "error", err) - os.Exit(1) - } - - storeCfg := store.StoreConfig{ - PostgresDSN: cfg.Database.PostgresDSN, - EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), - SkillsStorageDir: filepath.Join(dataDir, "skills-store"), - } - pgStores, pgErr := pg.NewPGStores(storeCfg) - if pgErr != nil { - slog.Error("failed to create PG stores", "error", pgErr) - os.Exit(1) - } - if pgStores.Tracing != nil { - traceCollector = tracing.NewCollector(pgStores.Tracing) + if stores.Tracing != nil { + traceCollector = tracing.NewCollector(stores.Tracing) traceCollector.OnFlush = func(traceIDs []uuid.UUID) { ids := make([]string, len(traceIDs)) for i, id := range traceIDs { @@ -317,8 +301,8 @@ func setupStoresAndTracing( // Start snapshot worker for hourly usage aggregation var snapshotWorker *tracing.SnapshotWorker - if pgStores.Snapshots != nil { - snapshotWorker = tracing.NewSnapshotWorker(pgStores.DB, pgStores.Snapshots) + if stores.Snapshots != nil { + snapshotWorker = tracing.NewSnapshotWorker(stores.DB, stores.Snapshots) snapshotWorker.Start() // Backfill historical data in background @@ -334,24 +318,25 @@ func setupStoresAndTracing( // Wire cron config from config.json cronRetryCfg := cfg.Cron.ToRetryConfig() - // Apply retry config via type assertion on the concrete cron store. - pgStores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below - _ = cronRetryCfg // config available; pg cron store reads it internally - if cfg.Cron.DefaultTimezone != "" { - pgStores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone) + if stores.Cron != nil { + stores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below + _ = cronRetryCfg // config available; cron store reads it internally + if cfg.Cron.DefaultTimezone != "" { + stores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone) + } } // Load secrets from config_secrets table before env overrides. // Precedence: config.json → DB secrets → env vars (highest). - if pgStores.ConfigSecrets != nil { - if secrets, err := pgStores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 { + if stores.ConfigSecrets != nil { + if secrets, err := stores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 { cfg.ApplyDBSecrets(secrets) cfg.ApplyEnvOverrides() slog.Info("config secrets loaded from DB", "count", len(secrets)) } } - return pgStores, traceCollector, snapshotWorker + return traceCollector, snapshotWorker } // setupMemoryEmbeddings wires embedding provider to PGMemoryStore and triggers backfill. @@ -547,8 +532,9 @@ func setupSkillsSystem( } } - // Publish skill tool — lets agents register created skills in the database - if pgStores.Skills != nil { + // Publish skill tool — lets agents register created skills in the database. + // Disabled in lite edition: agents should not self-manage skills on desktop. + if pgStores.Skills != nil && edition.Current().TeamFullMode { if manageStore, ok := pgStores.Skills.(store.SkillManageStore); ok { storeDirs := pgStores.Skills.Dirs() if len(storeDirs) > 0 { diff --git a/cmd/gateway_stores_pg.go b/cmd/gateway_stores_pg.go new file mode 100644 index 00000000..90220c05 --- /dev/null +++ b/cmd/gateway_stores_pg.go @@ -0,0 +1,47 @@ +//go:build !sqlite && !sqliteonly + +package cmd + +import ( + "log/slog" + "os" + "path/filepath" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +// setupStoresAndTracing creates PG stores, tracing collector, snapshot worker, and wires cron config. +// This is the default (PG-only) build — no SQLite support compiled in. +func setupStoresAndTracing( + cfg *config.Config, + dataDir string, + msgBus *bus.MessageBus, +) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) { + if cfg.Database.PostgresDSN == "" { + slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.") + os.Exit(1) + } + + if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil { + slog.Error("schema compatibility check failed", "error", err) + os.Exit(1) + } + + storeCfg := store.StoreConfig{ + PostgresDSN: cfg.Database.PostgresDSN, + EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), + SkillsStorageDir: filepath.Join(dataDir, "skills-store"), + } + pgStores, pgErr := pg.NewPGStores(storeCfg) + if pgErr != nil { + slog.Error("failed to create PG stores", "error", pgErr) + os.Exit(1) + } + + traceCollector, snapshotWorker := wireTracingAndCron(cfg, pgStores, msgBus, dataDir) + return pgStores, traceCollector, snapshotWorker +} diff --git a/cmd/gateway_stores_sqlite.go b/cmd/gateway_stores_sqlite.go new file mode 100644 index 00000000..68286722 --- /dev/null +++ b/cmd/gateway_stores_sqlite.go @@ -0,0 +1,88 @@ +//go:build sqlite && !sqliteonly + +package cmd + +import ( + "log/slog" + "os" + "path/filepath" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/edition" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" + "github.com/nextlevelbuilder/goclaw/internal/store/sqlitestore" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +// setupStoresAndTracing creates stores (PG or SQLite based on config), tracing collector, +// snapshot worker, and wires cron config. +// Built with -tags sqlite: supports both backends, selected via GOCLAW_STORAGE_BACKEND env. +func setupStoresAndTracing( + cfg *config.Config, + dataDir string, + msgBus *bus.MessageBus, +) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) { + backend := cfg.Database.StorageBackend + if backend == "" { + backend = "postgres" + } + + var stores *store.Stores + + switch backend { + case "sqlite": + sqlitePath := cfg.Database.SQLitePath + if sqlitePath == "" { + sqlitePath = filepath.Join(dataDir, "goclaw.db") + } + storeCfg := store.StoreConfig{ + SQLitePath: sqlitePath, + StorageBackend: "sqlite", + EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), + SkillsStorageDir: filepath.Join(dataDir, "skills-store"), + } + s, err := sqlitestore.NewSQLiteStores(storeCfg) + if err != nil { + slog.Error("failed to create SQLite stores", "error", err, "path", sqlitePath) + os.Exit(1) + } + stores = s + // SQLite backend auto-defaults to Lite edition unless explicitly overridden. + if os.Getenv("GOCLAW_EDITION") == "" { + edition.SetCurrent(edition.Lite) + slog.Info("edition: lite (auto, sqlite backend)") + } + slog.Info("storage backend: sqlite", "path", sqlitePath) + + case "postgres": + if cfg.Database.PostgresDSN == "" { + slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.") + os.Exit(1) + } + if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil { + slog.Error("schema compatibility check failed", "error", err) + os.Exit(1) + } + storeCfg := store.StoreConfig{ + PostgresDSN: cfg.Database.PostgresDSN, + EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), + SkillsStorageDir: filepath.Join(dataDir, "skills-store"), + } + s, err := pg.NewPGStores(storeCfg) + if err != nil { + slog.Error("failed to create PG stores", "error", err) + os.Exit(1) + } + stores = s + slog.Info("storage backend: postgres") + + default: + slog.Error("unknown GOCLAW_STORAGE_BACKEND; expected 'postgres' or 'sqlite'", "value", backend) + os.Exit(1) + } + + traceCollector, snapshotWorker := wireTracingAndCron(cfg, stores, msgBus, dataDir) + return stores, traceCollector, snapshotWorker +} diff --git a/cmd/gateway_stores_sqliteonly.go b/cmd/gateway_stores_sqliteonly.go new file mode 100644 index 00000000..318b89ae --- /dev/null +++ b/cmd/gateway_stores_sqliteonly.go @@ -0,0 +1,50 @@ +//go:build sqliteonly + +package cmd + +import ( + "log/slog" + "os" + "path/filepath" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/edition" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/sqlitestore" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +// setupStoresAndTracing creates SQLite-only stores, tracing collector, and snapshot worker. +// Built with -tags sqliteonly: no PostgreSQL dependency compiled in. +func setupStoresAndTracing( + cfg *config.Config, + dataDir string, + msgBus *bus.MessageBus, +) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) { + sqlitePath := cfg.Database.SQLitePath + if sqlitePath == "" { + sqlitePath = filepath.Join(dataDir, "goclaw.db") + } + storeCfg := store.StoreConfig{ + SQLitePath: sqlitePath, + StorageBackend: "sqlite", + EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), + SkillsStorageDir: filepath.Join(dataDir, "skills-store"), + } + stores, err := sqlitestore.NewSQLiteStores(storeCfg) + if err != nil { + slog.Error("failed to create SQLite stores", "error", err, "path", sqlitePath) + os.Exit(1) + } + + // SQLite-only always defaults to Lite edition unless explicitly overridden. + if os.Getenv("GOCLAW_EDITION") == "" { + edition.SetCurrent(edition.Lite) + slog.Info("edition: lite (auto, sqliteonly build)") + } + slog.Info("storage backend: sqlite (sqliteonly build)", "path", sqlitePath) + + traceCollector, snapshotWorker := wireTracingAndCron(cfg, stores, msgBus, dataDir) + return stores, traceCollector, snapshotWorker +} diff --git a/go.mod b/go.mod index 47b1782f..ea4022e6 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/aws/smithy-go v1.24.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/bep/debounce v1.2.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect @@ -60,22 +61,32 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/creachadair/msync v0.7.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.18.0 // indirect github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect - github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect @@ -85,9 +96,14 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/safchain/ethtool v0.3.0 // indirect + github.com/samber/lo v1.49.1 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect @@ -95,10 +111,16 @@ require ( github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/wailsapp/wails/v2 v2.11.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/zalando/go-keyring v0.2.8 // indirect go.uber.org/atomic v1.11.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect @@ -109,6 +131,10 @@ require ( golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect ) require ( @@ -153,7 +179,7 @@ require ( golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.49.0 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.33.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/go.sum b/go.sum index e0d02565..314f5941 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -127,6 +129,8 @@ github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoi github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -186,6 +190,8 @@ github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -231,6 +237,8 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -252,6 +260,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -260,6 +280,10 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I= github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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= @@ -298,6 +322,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= +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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -308,6 +334,8 @@ github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= @@ -323,6 +351,8 @@ github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2 github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -333,6 +363,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44= github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= @@ -375,8 +407,11 @@ github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4= github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s= github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= @@ -389,8 +424,16 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= +github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -415,6 +458,8 @@ github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -465,6 +510,7 @@ golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= @@ -472,17 +518,24 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -518,6 +571,14 @@ honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLa honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +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/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= tailscale.com v1.94.2 h1:H+0NYSG81K1RBXnh6FfWee9G1KEeX9pvYspPrVdIfII= diff --git a/internal/agent/loop_finalize.go b/internal/agent/loop_finalize.go index bf2c53da..ddaf0a4f 100644 --- a/internal/agent/loop_finalize.go +++ b/internal/agent/loop_finalize.go @@ -8,11 +8,29 @@ import ( "log/slog" + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) +// isUserFilePopulated checks if USER.md has been filled with actual user data +// beyond the blank template. The template has "- **Name:**\n" with no value. +func isUserFilePopulated(content string) bool { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return false + } + // Template markers: "**Name:**" followed by newline (no value) or just whitespace + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "- **Name:**" || line == "**Name:**" { + return false // name field still empty + } + } + return true +} + // finalizeRun performs post-loop processing: sanitization, media dedup, session flush, // bootstrap cleanup, and builds the final RunResult. func (l *Loop) finalizeRun( @@ -90,6 +108,7 @@ func (l *Loop) finalizeRun( ID: filepath.Base(mr.Path), MimeType: mr.ContentType, Kind: kind, + Path: mr.Path, }) } rs.pendingMsgs = append(rs.pendingMsgs, assistantMsg) @@ -134,6 +153,7 @@ func (l *Loop) finalizeRun( l.sessions.Save(ctx, req.SessionKey) // Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md. + // If USER.md is still the blank template, inject a reminder so the agent fills it. if hadBootstrap && l.bootstrapCleanup != nil { userTurns := 1 // current user message for _, m := range history { @@ -146,6 +166,19 @@ func (l *Loop) finalizeRun( slog.Warn("bootstrap auto-cleanup failed", "error", cleanErr, "agent", l.id, "user", req.UserID) } else { slog.Info("bootstrap auto-cleanup completed", "agent", l.id, "user", req.UserID, "turns", userTurns) + // Check if USER.md is still the blank template — nudge agent to fill it + if l.contextFileLoader != nil { + files := l.contextFileLoader(ctx, l.agentUUID, req.UserID, l.agentType) + for _, f := range files { + if f.Path == bootstrap.UserFile && !isUserFilePopulated(f.Content) { + rs.pendingMsgs = append(rs.pendingMsgs, providers.Message{ + Role: "user", + Content: "[System] You completed onboarding but USER.md is still empty. Please update USER.md with the user's name and details from this conversation using write_file.", + }) + break + } + } + } } } } diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index b6a84b3e..3d03a9df 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -12,12 +12,21 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/edition" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/safego" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) +// teamGuidance returns edition-specific system prompt guidance for team members. +func teamGuidance(fullMode bool) string { + if fullMode { + return tools.FullTeamPolicy{}.MemberGuidance() + } + return tools.LiteTeamPolicy{}.MemberGuidance() +} + // filteredToolNames returns tool names after applying policy filters. // Used for system prompt so denied tools don't appear in ## Tooling section. func (l *Loop) filteredToolNames() []string { @@ -187,6 +196,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s HasTeam: hasTeamTools, TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx), TeamMembers: teamMembers, + TeamGuidance: teamGuidance(edition.Current().TeamFullMode), HasSkillSearch: hasSkillSearch, HasSkillManage: l.skillEvolve && hasSkillManage, HasMCPToolSearch: hasMCPToolSearch, diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index caabdbf3..92396ea5 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -70,8 +70,9 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration span.TenantID = store.MasterTenantID } - // Verbose mode: include input messages (same stripping as emitLLMSpan). - if collector.Verbose() && len(messages) > 0 { + // Include input messages preview as truncated JSON. + if len(messages) > 0 { + previewLimit := previewLimitForVerbose(collector.Verbose()) stripped := make([]providers.Message, len(messages)) copy(stripped, messages) for i := range stripped { @@ -84,7 +85,7 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration } } if b, err := json.Marshal(stripped); err == nil { - span.InputPreview = truncateStr(string(b), 100000) + span.InputPreview = tracing.TruncateJSON(string(b), previewLimit) } } @@ -144,16 +145,12 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time. } } updates["finish_reason"] = resp.FinishReason - verbose := collector.Verbose() - if verbose { - preview := resp.Content - if resp.Thinking != "" { - preview = "\n" + resp.Thinking + "\n\n" + resp.Content - } - updates["output_preview"] = truncateStr(preview, 100000) - } else { - updates["output_preview"] = truncateStr(resp.Content, 500) + limit := previewLimitForVerbose(collector.Verbose()) + preview := resp.Content + if resp.Thinking != "" { + preview = "\n" + resp.Thinking + "\n\n" + resp.Content } + updates["output_preview"] = tracing.TruncateMid(preview, limit) } collector.EmitSpanUpdate(spanID, traceID, updates) @@ -173,10 +170,7 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName, return uuid.Nil } - previewLimit := 500 - if collector.Verbose() { - previewLimit = 100000 - } + previewLimit := previewLimitForVerbose(collector.Verbose()) spanID := store.GenNewID() span := store.SpanData{ @@ -187,7 +181,7 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName, StartTime: start, ToolName: toolName, ToolCallID: toolCallID, - InputPreview: truncateStr(input, previewLimit), + InputPreview: tracing.TruncateJSON(input, previewLimit), Status: store.SpanStatusRunning, Level: store.SpanLevelDefault, CreatedAt: start, @@ -222,16 +216,13 @@ func (l *Loop) emitToolSpanEnd(ctx context.Context, spanID uuid.UUID, start time } now := time.Now().UTC() - previewLimit := 500 - if collector.Verbose() { - previewLimit = 100000 - } + previewLimit := previewLimitForVerbose(collector.Verbose()) updates := map[string]any{ "end_time": now, "duration_ms": int(now.Sub(start).Milliseconds()), "status": store.SpanStatusCompleted, - "output_preview": truncateStr(result.ForLLM, previewLimit), + "output_preview": tracing.TruncateMid(result.ForLLM, previewLimit), } if result.IsError { @@ -282,10 +273,7 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st return } - previewLimit := 500 - if collector.Verbose() { - previewLimit = 100000 - } + previewLimit := previewLimitForVerbose(collector.Verbose()) spanName := l.id span := store.SpanData{ @@ -298,7 +286,7 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st Level: store.SpanLevelDefault, Model: l.model, Provider: l.provider.Name(), - InputPreview: truncateStr(inputPreview, previewLimit), + InputPreview: tracing.TruncateMid(inputPreview, previewLimit), CreatedAt: start, } // Nest under parent root span if this is an announce run. @@ -341,11 +329,8 @@ func (l *Loop) emitAgentSpanEnd(ctx context.Context, agentSpanID uuid.UUID, star updates["status"] = store.SpanStatusError updates["error"] = runErr.Error() } else if result != nil { - limit := 500 - if collector.Verbose() { - limit = 100000 - } - updates["output_preview"] = truncateStr(result.Content, limit) + limit := previewLimitForVerbose(collector.Verbose()) + updates["output_preview"] = tracing.TruncateMid(result.Content, limit) // Note: token counts are NOT set on agent spans to avoid double-counting // with child llm_call spans. Trace aggregation sums only llm_call spans. } @@ -353,16 +338,26 @@ func (l *Loop) emitAgentSpanEnd(ctx context.Context, agentSpanID uuid.UUID, star collector.EmitSpanUpdate(agentSpanID, traceID, updates) } +// previewLimitForVerbose returns the preview character limit based on verbose mode. +func previewLimitForVerbose(verbose bool) int { + if verbose { + return 200_000 + } + return 40_000 +} + func truncateStr(s string, maxLen int) string { s = strings.ToValidUTF8(s, "") if len(s) <= maxLen { return s } + // Keep the tail — recent context is more useful for debugging. + start := len(s) - maxLen // Don't cut in the middle of a multi-byte rune - for maxLen > 0 && !utf8.RuneStart(s[maxLen]) { - maxLen-- + for start < len(s) && !utf8.RuneStart(s[start]) { + start++ } - return s[:maxLen] + "..." + return "..." + s[start:] } // EstimateTokens returns a rough token estimate for a slice of messages. diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 598289b3..5ce1a871 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -37,6 +37,7 @@ type SystemPromptConfig struct { HasTeam bool // agent belongs to a team? (skips generic spawn section) TeamWorkspace string // absolute path to team shared workspace (empty if not in team) TeamMembers []store.TeamMemberData // team member roster for task assignment + TeamGuidance string // edition-specific guidance from TeamActionPolicy.MemberGuidance() ContextFiles []bootstrap.ContextFile // bootstrap files for # Project Context ExtraPrompt string // extra system prompt (subagent context, etc.) AgentType string // "open" or "predefined" — affects context file framing @@ -228,7 +229,7 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string { // 6.4. ## Team Members — inject roster so agent knows who to assign tasks to if !cfg.IsBootstrap && len(cfg.TeamMembers) > 0 { - lines = append(lines, buildTeamMembersSection(cfg.TeamMembers)...) + lines = append(lines, buildTeamMembersSection(cfg.TeamMembers, cfg.TeamGuidance)...) } // 6.5 ## Sandbox (matching TS sandboxInfo section) — skip during bootstrap diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index c9af9681..b3f02205 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -392,7 +392,8 @@ func buildTeamWorkspaceSection(teamWsPath string) []string { } // buildTeamMembersSection lists team members so the agent knows who to assign tasks to. -func buildTeamMembersSection(members []store.TeamMemberData) []string { +// teamGuidance is injected from TeamActionPolicy.MemberGuidance() — varies by edition. +func buildTeamMembersSection(members []store.TeamMemberData, teamGuidance string) []string { lines := []string{ "## Team Members", "", @@ -413,7 +414,10 @@ func buildTeamMembersSection(members []store.TeamMemberData) []string { "", "When creating tasks with team_tasks, set assignee to the agent_key of the best-suited member.", "Do NOT invent agent keys — only use the keys listed above.", - "", ) + if teamGuidance != "" { + lines = append(lines, teamGuidance) + } + lines = append(lines, "") return lines } diff --git a/internal/config/config.go b/internal/config/config.go index 47811969..989c73c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,11 +66,13 @@ type TailscaleConfig struct { EnableTLS bool `json:"enable_tls,omitempty"` // use ListenTLS for auto HTTPS certs } -// DatabaseConfig configures the PostgreSQL connection and optional Redis cache. +// DatabaseConfig configures the database connection and optional Redis cache. // DSN fields are NEVER read from config.json (secrets) — only from env vars. type DatabaseConfig struct { - PostgresDSN string `json:"-"` // from env GOCLAW_POSTGRES_DSN only - RedisDSN string `json:"-"` // from env GOCLAW_REDIS_DSN only (optional, requires -tags redis) + PostgresDSN string `json:"-"` // from env GOCLAW_POSTGRES_DSN only + RedisDSN string `json:"-"` // from env GOCLAW_REDIS_DSN only (optional, requires -tags redis) + StorageBackend string `json:"-"` // from env GOCLAW_STORAGE_BACKEND only ("postgres" or "sqlite", default "postgres") + SQLitePath string `json:"-"` // from env GOCLAW_SQLITE_PATH only (default: {dataDir}/goclaw.db) } // SkillsConfig configures the skills storage system. diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 15a706c1..5d414551 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -186,6 +186,8 @@ func (c *Config) applyEnvOverrides() { // Database envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN) envStr("GOCLAW_REDIS_DSN", &c.Database.RedisDSN) + envStr("GOCLAW_STORAGE_BACKEND", &c.Database.StorageBackend) + envStr("GOCLAW_SQLITE_PATH", &c.Database.SQLitePath) // Deprecation warning for GOCLAW_MODE (removed — PostgreSQL is always active) if v := os.Getenv("GOCLAW_MODE"); v != "" { diff --git a/internal/edition/edition.go b/internal/edition/edition.go new file mode 100644 index 00000000..73918b91 --- /dev/null +++ b/internal/edition/edition.go @@ -0,0 +1,79 @@ +// Package edition defines feature tiers for GoClaw. +// Set once at startup via SetCurrent(), read everywhere via Current(). +// Adding a new edition = add one preset struct. No code changes elsewhere. +package edition + +import "sync/atomic" + +// Edition defines the feature limits for a GoClaw instance. +type Edition struct { + Name string `json:"name"` // "standard" or "lite" + MaxAgents int `json:"max_agents"` // 0 = unlimited + MaxTeams int `json:"max_teams"` // 0 = unlimited + MaxTeamMembers int `json:"max_team_members"` // 0 = unlimited + MaxChannels map[string]int `json:"max_channels"` // per channel type, nil = unlimited + KGEnabled bool `json:"kg_enabled"` + RBACEnabled bool `json:"rbac_enabled"` + TeamFullMode bool `json:"team_full_mode"` // false = lite task actions only + VectorSearch bool `json:"vector_search"` // false = FTS5 only +} + +// --- Presets --- + +// Standard is the default edition: all features enabled, no limits. +var Standard = Edition{ + Name: "standard", + KGEnabled: true, + RBACEnabled: true, + TeamFullMode: true, + VectorSearch: true, +} + +// Lite is the desktop/self-hosted edition with sensible limits. +var Lite = Edition{ + Name: "lite", + MaxAgents: 5, + MaxTeams: 1, + MaxTeamMembers: 5, + MaxChannels: map[string]int{"telegram": 1, "discord": 1}, + KGEnabled: false, + RBACEnabled: false, + TeamFullMode: false, + VectorSearch: false, +} + +// --- Global state --- + +// current holds the active edition. Atomic pointer for safe concurrent reads. +var current atomic.Pointer[Edition] + +func init() { + std := Standard + current.Store(&std) +} + +// Current returns the active edition. Safe for concurrent use. +func Current() Edition { + return *current.Load() +} + +// SetCurrent sets the active edition. Call once at startup. +func SetCurrent(e Edition) { + current.Store(&e) +} + +// --- Helpers --- + +// IsLimited returns true if the edition enforces resource limits. +func (e Edition) IsLimited() bool { + return e.MaxAgents > 0 || e.MaxTeams > 0 +} + +// ChannelLimit returns the max instances for a channel type. +// Returns 0 (unlimited) if the channel type is not in MaxChannels. +func (e Edition) ChannelLimit(channelType string) int { + if e.MaxChannels == nil { + return 0 + } + return e.MaxChannels[channelType] +} diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go index c8d91ca1..29039759 100644 --- a/internal/gateway/methods/teams.go +++ b/internal/gateway/methods/teams.go @@ -125,6 +125,10 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client, client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "lead"))) return } + if len(params.Members) == 0 { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "at least 1 member is required")) + return + } // Resolve lead agent leadAgent, err := resolveAgentInfo(ctx, m.agentStore, params.Lead) diff --git a/internal/gateway/server.go b/internal/gateway/server.go index fe276124..c7e48f5f 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -8,6 +8,7 @@ import ( "log/slog" "net" "net/http" + "os" "strings" "sync" "time" @@ -279,10 +280,16 @@ func tokenAuthMiddleware(token string, next http.Handler) http.Handler { func (s *Server) Start(ctx context.Context) error { mux := s.BuildMux() + // Wrap with CORS for desktop dev mode (Wails serves frontend on different port). + var handler http.Handler = mux + if os.Getenv("GOCLAW_DESKTOP") == "1" { + handler = desktopCORS(mux) + } + addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port) s.httpServer = &http.Server{ Addr: addr, - Handler: mux, + Handler: handler, } slog.Info("gateway starting", "addr", addr) @@ -469,6 +476,9 @@ func (s *Server) SetUsageHandler(h *httpapi.UsageHandler) { s.handlers = append( // SetDocsHandler sets the OpenAPI spec + Swagger UI handler. func (s *Server) SetDocsHandler(h *httpapi.DocsHandler) { s.handlers = append(s.handlers, h) } +// SetEditionHandler sets the edition info handler. +func (s *Server) SetEditionHandler(h *httpapi.EditionHandler) { s.handlers = append(s.handlers, h) } + // SetAgentStore sets the agent store for context injection in tools_invoke. func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as } @@ -612,3 +622,18 @@ func StartTestServer(s *Server, ctx context.Context) (addr string, start func()) return addr, start } + +// desktopCORS wraps a handler with permissive CORS headers for desktop dev mode. +// Only active when GOCLAW_DESKTOP=1 (set by desktop app.go). +func desktopCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GoClaw-Tenant-Id, X-GoClaw-User-Id") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/http/edition.go b/internal/http/edition.go new file mode 100644 index 00000000..ed4ba40f --- /dev/null +++ b/internal/http/edition.go @@ -0,0 +1,23 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/nextlevelbuilder/goclaw/internal/edition" +) + +// EditionHandler serves the current edition info for UI feature comparison. +type EditionHandler struct{} + +func NewEditionHandler() *EditionHandler { return &EditionHandler{} } + +// RegisterRoutes adds the /v1/edition route. No auth required — edition info is public. +func (h *EditionHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/edition", h.handleGet) +} + +func (h *EditionHandler) handleGet(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(edition.Current()) +} diff --git a/internal/http/files.go b/internal/http/files.go index 951a2fbe..b16f6981 100644 --- a/internal/http/files.go +++ b/internal/http/files.go @@ -1,6 +1,7 @@ package http import ( + "encoding/json" "fmt" "log/slog" "mime" @@ -10,6 +11,7 @@ import ( "strings" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/edition" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -33,6 +35,29 @@ func NewFilesHandler(workspace, dataDir string) *FilesHandler { // RegisterRoutes registers the file serving route. func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/files/{path...}", h.auth(h.handleServe)) + mux.HandleFunc("POST /v1/files/sign", h.handleSign) +} + +// handleSign accepts a JSON body with a "path" field (absolute file path), +// returns a signed /v1/files/ URL with ?ft= token. Requires Bearer auth. +func (h *FilesHandler) handleSign(w http.ResponseWriter, r *http.Request) { + provided := extractBearerToken(r) + authedReq, ok := requireAuthBearer("", provided, w, r) + if !ok { + return + } + var body struct { + Path string `json:"path"` + } + if err := json.NewDecoder(authedReq.Body).Decode(&body); err != nil || body.Path == "" { + http.Error(w, `{"error":"path required"}`, http.StatusBadRequest) + return + } + urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(body.Path), "/") + ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL) + writeJSON(w, http.StatusOK, map[string]string{ + "url": urlPath + "?ft=" + ft, + }) } func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -93,21 +118,52 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) { } } - // Tenant isolation: validate absolute path is within tenant's allowed directories. - // Skip for HMAC file tokens — the path is cryptographically bound in the signature, - // so it cannot be tampered with. File tokens are generated server-side with the correct path. + // Path isolation: validate file path is within allowed directories. + // Skip for HMAC file tokens — path is cryptographically bound in the signature. if r.URL.Query().Get("ft") == "" { - tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context())) - tenantWs := h.tenantWorkspace(r) - if !strings.HasPrefix(absPath, tenantData+string(filepath.Separator)) && - !strings.HasPrefix(absPath, tenantWs+string(filepath.Separator)) && - absPath != tenantData && absPath != tenantWs { + allowed := false + + // Always allow files within workspace root and data dir root. + // These are the two top-level directories that contain all user files. + sep := string(filepath.Separator) + if h.workspace != "" && (strings.HasPrefix(absPath, h.workspace+sep) || absPath == h.workspace) { + allowed = true + } + if !allowed && h.dataDir != "" && (strings.HasPrefix(absPath, h.dataDir+sep) || absPath == h.dataDir) { + allowed = true + } + + // Multi-tenant (standard edition): additionally restrict to tenant-scoped subdirectories. + if allowed && edition.Current().RBACEnabled { + tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context())) + tenantWs := h.tenantWorkspace(r) + if !strings.HasPrefix(absPath, tenantData+sep) && + !strings.HasPrefix(absPath, tenantWs+sep) && + absPath != tenantData && absPath != tenantWs { + allowed = false + } + } + + if !allowed { + slog.Warn("security.files_path_denied", "path", absPath, "workspace", h.workspace, "data_dir", h.dataDir) http.NotFound(w, r) return } } info, err := os.Stat(absPath) + if err != nil && !os.IsNotExist(err) { + http.NotFound(w, r) + return + } + // Fuzzy match: generated files have timestamp suffixes (e.g. "file_20260326-232559_269000.png") + // but LLM may reference them without timestamp (e.g. "file.png"). Try prefix match in same dir. + if err != nil { + if resolved := fuzzyMatchInDir(absPath); resolved != "" { + absPath = resolved + info, err = os.Stat(absPath) + } + } if err != nil || info.IsDir() { // Fallback: search workspace for file by basename (handles LLM-hallucinated paths). // Generated media filenames include timestamps and are globally unique. @@ -162,8 +218,16 @@ func (h *FilesHandler) findInWorkspace(workspace, basename string) string { } if d.IsDir() { name := d.Name() - // Allow workspace root + known directory structures - if name == "teams" || name == "generated" || name == "system" || name == ".uploads" || path == workspace { + // Allow workspace root + if path == workspace { + return nil + } + // Allow direct children of workspace root (agent workspace dirs like "quill", "goclaw") + if filepath.Dir(path) == workspace { + return nil + } + // Allow known directory structures + if name == "teams" || name == "generated" || name == "system" || name == "ws" || name == ".uploads" || name == "tenants" { return nil } // Allow date directories (e.g. 2026-03-20) @@ -185,6 +249,32 @@ func (h *FilesHandler) findInWorkspace(workspace, basename string) string { return found } +// fuzzyMatchInDir handles LLM-hallucinated filenames missing timestamp suffixes. +// E.g. requested "file.png" matches "file_20260326-232559_269000.png" in the same directory. +func fuzzyMatchInDir(absPath string) string { + dir := filepath.Dir(absPath) + base := filepath.Base(absPath) + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) // "smart-home-cover" + + entries, err := os.ReadDir(dir) + if err != nil { + return "" + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + // Match: starts with stem, has same extension, has timestamp between + // e.g. "smart-home-cover_20260326-232444_269000.png" + if strings.HasPrefix(name, stem) && strings.HasSuffix(name, ext) && name != base { + return filepath.Join(dir, name) + } + } + return "" +} + func isNumeric(s string) bool { for _, c := range s { if c < '0' || c > '9' { @@ -193,3 +283,4 @@ func isNumeric(s string) bool { } return len(s) > 0 } + diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index e7533790..fac4089a 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -156,16 +156,19 @@ func (a *AgentData) ParseMemoryConfig() *config.MemoryConfig { } // ParseThinkingLevel extracts thinking_level from other_config JSONB. -// Returns "" if not configured (meaning "off"). +// Returns "low" if not configured (default thinking mode). func (a *AgentData) ParseThinkingLevel() string { if len(a.OtherConfig) == 0 { - return "" + return "low" } var cfg struct { ThinkingLevel string `json:"thinking_level"` } if json.Unmarshal(a.OtherConfig, &cfg) != nil { - return "" + return "low" + } + if cfg.ThinkingLevel == "" { + return "low" } return cfg.ThinkingLevel } diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go index bb99c2e7..706a4925 100644 --- a/internal/store/pg/tracing.go +++ b/internal/store/pg/tracing.go @@ -547,3 +547,20 @@ func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSumm } return result, nil } + +// DeleteTracesOlderThan deletes traces and their spans older than cutoff. +// Spans are deleted first (FK), then traces. Returns total traces deleted. +func (s *PGTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { + // Delete spans belonging to old traces. + _, err := s.db.ExecContext(ctx, + `DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < $1)`, cutoff) + if err != nil { + return 0, fmt.Errorf("delete old spans: %w", err) + } + + res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < $1`, cutoff) + if err != nil { + return 0, fmt.Errorf("delete old traces: %w", err) + } + return res.RowsAffected() +} diff --git a/internal/store/sqlitestore/activity.go b/internal/store/sqlitestore/activity.go new file mode 100644 index 00000000..cf4b0b9e --- /dev/null +++ b/internal/store/sqlitestore/activity.go @@ -0,0 +1,120 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteActivityStore implements store.ActivityStore backed by SQLite. +type SQLiteActivityStore struct { + db *sql.DB +} + +// NewSQLiteActivityStore creates a new SQLiteActivityStore. +func NewSQLiteActivityStore(db *sql.DB) *SQLiteActivityStore { + return &SQLiteActivityStore{db: db} +} + +func (s *SQLiteActivityStore) Log(ctx context.Context, entry *store.ActivityLog) error { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + id := uuid.New() + _, err := s.db.ExecContext(ctx, + `INSERT INTO activity_logs (id, actor_type, actor_id, action, entity_type, entity_id, details, ip_address, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, entry.ActorType, entry.ActorID, entry.Action, + entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress, tenantID, + ) + return err +} + +func (s *SQLiteActivityStore) List(ctx context.Context, opts store.ActivityListOpts) ([]store.ActivityLog, error) { + where, args := buildActivityWhere(ctx, opts) + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + args = append(args, limit, opts.Offset) + + query := fmt.Sprintf( + `SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), COALESCE(details,'null'), COALESCE(ip_address,''), created_at + FROM activity_logs %s ORDER BY created_at DESC LIMIT ? OFFSET ?`, + where, + ) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.ActivityLog + for rows.Next() { + var a store.ActivityLog + var createdAt sqliteTime + if err := rows.Scan(&a.ID, &a.ActorType, &a.ActorID, &a.Action, &a.EntityType, &a.EntityID, &a.Details, &a.IPAddress, &createdAt); err != nil { + return nil, err + } + a.CreatedAt = createdAt.Time + result = append(result, a) + } + return result, rows.Err() +} + +func (s *SQLiteActivityStore) Count(ctx context.Context, opts store.ActivityListOpts) (int, error) { + where, args := buildActivityWhere(ctx, opts) + query := fmt.Sprintf("SELECT COUNT(*) FROM activity_logs %s", where) + + var count int + err := s.db.QueryRowContext(ctx, query, args...).Scan(&count) + return count, err +} + +func buildActivityWhere(ctx context.Context, opts store.ActivityListOpts) (string, []any) { + var conditions []string + var args []any + + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, "tenant_id = ?") + args = append(args, tenantID) + } + } + + if opts.ActorType != "" { + conditions = append(conditions, "actor_type = ?") + args = append(args, opts.ActorType) + } + if opts.ActorID != "" { + conditions = append(conditions, "actor_id = ?") + args = append(args, opts.ActorID) + } + if opts.Action != "" { + conditions = append(conditions, "action = ?") + args = append(args, opts.Action) + } + if opts.EntityType != "" { + conditions = append(conditions, "entity_type = ?") + args = append(args, opts.EntityType) + } + if opts.EntityID != "" { + conditions = append(conditions, "entity_id = ?") + args = append(args, opts.EntityID) + } + + if len(conditions) == 0 { + return "", nil + } + return "WHERE " + strings.Join(conditions, " AND "), args +} diff --git a/internal/store/sqlitestore/agents.go b/internal/store/sqlitestore/agents.go new file mode 100644 index 00000000..d642e3fa --- /dev/null +++ b/internal/store/sqlitestore/agents.go @@ -0,0 +1,274 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteAgentStore implements store.AgentStore backed by SQLite. +type SQLiteAgentStore struct { + db *sql.DB + // embProvider is intentionally omitted — vector search not supported in SQLite lite edition. +} + +func NewSQLiteAgentStore(db *sql.DB) *SQLiteAgentStore { + return &SQLiteAgentStore{db: db} +} + +// SetEmbeddingProvider is a no-op for SQLite — vector not supported. +func (s *SQLiteAgentStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {} + +// agentSelectCols is the column list for all agent SELECT queries. +const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, provider, model, + context_window, max_tool_iterations, workspace, restrict_to_workspace, + tools_config, sandbox_config, subagents_config, memory_config, + compaction_config, context_pruning, other_config, + agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id` + +func (s *SQLiteAgentStore) Create(ctx context.Context, agent *store.AgentData) error { + if agent.ID == uuid.Nil { + agent.ID = store.GenNewID() + } + now := time.Now() + agent.CreatedAt = now + agent.UpdatedAt = now + tenantID := agent.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model, + context_window, max_tool_iterations, workspace, restrict_to_workspace, + tools_config, sandbox_config, subagents_config, memory_config, + compaction_config, context_pruning, other_config, + agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + agent.ID, agent.AgentKey, + agent.DisplayName, + sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""}, + agent.OwnerID, agent.Provider, agent.Model, + agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace, + jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig), + jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig), + agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, + now, now, tenantID, + ) + return err +} + +func (s *SQLiteAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.AgentData, error) { + var row *sql.Row + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` FROM agents WHERE agent_key = ? AND deleted_at IS NULL`, agentKey) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("agent not found: %s", agentKey) + } + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` FROM agents WHERE agent_key = ? AND deleted_at IS NULL AND tenant_id = ?`, + agentKey, tid) + } + d, err := scanAgentRow(row) + if err != nil { + return nil, fmt.Errorf("agent not found: %s", agentKey) + } + return d, nil +} + +func (s *SQLiteAgentStore) GetByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) { + var row *sql.Row + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL`, id) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("agent not found: %s", id) + } + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL AND tenant_id = ?`, id, tid) + } + d, err := scanAgentRow(row) + if err != nil { + return nil, fmt.Errorf("agent not found: %s", id) + } + return d, nil +} + +func (s *SQLiteAgentStore) GetByIDUnscoped(ctx context.Context, id uuid.UUID) (*store.AgentData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL`, id) + d, err := scanAgentRow(row) + if err != nil { + return nil, fmt.Errorf("agent not found: %s", id) + } + return d, nil +} + +func (s *SQLiteAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + + // Unset existing default before setting a new one (scoped to same tenant). + if v, ok := updates["is_default"]; ok { + if isDefault, _ := v.(bool); isDefault { + if store.IsCrossTenant(ctx) { + if _, err := s.db.ExecContext(ctx, + "UPDATE agents SET is_default = 0 WHERE is_default = 1 AND id != ? AND deleted_at IS NULL", id); err != nil { + slog.Warn("agents.unset_default", "error", err) + } + } else { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + if _, err := s.db.ExecContext(ctx, + "UPDATE agents SET is_default = 0 WHERE is_default = 1 AND id != ? AND deleted_at IS NULL AND tenant_id = ?", + id, tid); err != nil { + slog.Warn("agents.unset_default", "error", err) + } + } + } + } + } + + updates["updated_at"] = time.Now() + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "agents", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("agent not found: %s", id) + } + return execMapUpdateWhereTenant(ctx, s.db, "agents", updates, id, tid) +} + +func (s *SQLiteAgentStore) Delete(ctx context.Context, id uuid.UUID) error { + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = ?", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("agent not found: %s", id) + } + _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = ? AND tenant_id = ?", id, tid) + return err +} + +func (s *SQLiteAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentData, error) { + q := `SELECT ` + agentSelectCols + ` FROM agents WHERE deleted_at IS NULL` + var args []any + + if ownerID != "" { + q += " AND owner_id = ?" + args = append(args, ownerID) + } + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND tenant_id = ?" + args = append(args, tid) + } + } + + q += " ORDER BY created_at DESC" + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAgentRows(rows) +} + +func (s *SQLiteAgentStore) GetDefault(ctx context.Context) (*store.AgentData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE deleted_at IS NULL + ORDER BY is_default DESC, created_at ASC LIMIT 1`) + return scanAgentRow(row) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("agent not found: default") + } + row := s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE deleted_at IS NULL AND tenant_id = ? + ORDER BY is_default DESC, created_at ASC LIMIT 1`, tid) + return scanAgentRow(row) +} + +// --- Scan helpers --- + +type agentRowScanner interface { + Scan(dest ...any) error +} + +func scanAgentRow(row agentRowScanner) (*store.AgentData, error) { + var d store.AgentData + var frontmatter sql.NullString + var toolsCfg, sandboxCfg, subagentsCfg, memoryCfg, compactionCfg, pruningCfg, otherCfg *[]byte + createdAt, updatedAt := scanTimePair() + err := row.Scan( + &d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model, + &d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace, + &toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg, + &d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, + createdAt, updatedAt, &d.TenantID, + ) + if err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + if frontmatter.Valid { + d.Frontmatter = frontmatter.String + } + if toolsCfg != nil { + d.ToolsConfig = *toolsCfg + } + if sandboxCfg != nil { + d.SandboxConfig = *sandboxCfg + } + if subagentsCfg != nil { + d.SubagentsConfig = *subagentsCfg + } + if memoryCfg != nil { + d.MemoryConfig = *memoryCfg + } + if compactionCfg != nil { + d.CompactionConfig = *compactionCfg + } + if pruningCfg != nil { + d.ContextPruning = *pruningCfg + } + if otherCfg != nil { + d.OtherConfig = *otherCfg + } + return &d, nil +} + +func scanAgentRows(rows *sql.Rows) ([]store.AgentData, error) { + var result []store.AgentData + for rows.Next() { + d, err := scanAgentRow(rows) + if err != nil { + continue + } + result = append(result, *d) + } + return result, rows.Err() +} diff --git a/internal/store/sqlitestore/agents_access.go b/internal/store/sqlitestore/agents_access.go new file mode 100644 index 00000000..fd6a9e31 --- /dev/null +++ b/internal/store/sqlitestore/agents_access.go @@ -0,0 +1,180 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *SQLiteAgentStore) ShareAgent(ctx context.Context, agentID uuid.UUID, userID, role, grantedBy string) error { + if err := store.ValidateUserID(userID); err != nil { + return err + } + if err := store.ValidateUserID(grantedBy); err != nil { + return err + } + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, + `INSERT INTO agent_shares (id, agent_id, user_id, role, granted_by, tenant_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (agent_id, user_id) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by`, + store.GenNewID(), agentID, userID, role, grantedBy, tid, time.Now(), + ) + return err +} + +func (s *SQLiteAgentStore) RevokeShare(ctx context.Context, agentID uuid.UUID, userID string) error { + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, + "DELETE FROM agent_shares WHERE agent_id = ? AND user_id = ?", agentID, userID) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, + "DELETE FROM agent_shares WHERE agent_id = ? AND user_id = ? AND tenant_id = ?", agentID, userID, tid) + return err +} + +func (s *SQLiteAgentStore) ListShares(ctx context.Context, agentID uuid.UUID) ([]store.AgentShareData, error) { + q := "SELECT id, agent_id, user_id, role, granted_by, created_at FROM agent_shares WHERE agent_id = ?" + args := []any{agentID} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + q += " AND tenant_id = ?" + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.AgentShareData + for rows.Next() { + var d store.AgentShareData + var createdAt sqliteTime + if err := rows.Scan(&d.ID, &d.AgentID, &d.UserID, &d.Role, &d.GrantedBy, &createdAt); err != nil { + continue + } + d.CreatedAt = createdAt.Time + result = append(result, d) + } + return result, rows.Err() +} + +func (s *SQLiteAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID string) (bool, string, error) { + var ownerID string + var isDefault bool + var err error + if store.IsCrossTenant(ctx) { + err = s.db.QueryRowContext(ctx, + "SELECT owner_id, is_default FROM agents WHERE id = ? AND deleted_at IS NULL", agentID, + ).Scan(&ownerID, &isDefault) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return false, "", fmt.Errorf("agent not found") + } + err = s.db.QueryRowContext(ctx, + "SELECT owner_id, is_default FROM agents WHERE id = ? AND deleted_at IS NULL AND tenant_id = ?", + agentID, tid, + ).Scan(&ownerID, &isDefault) + } + if err != nil { + return false, "", fmt.Errorf("agent not found") + } + if isDefault { + if ownerID == userID { + return true, "owner", nil + } + return true, "user", nil + } + if ownerID == userID { + return true, "owner", nil + } + // Check shares + var role string + if store.IsCrossTenant(ctx) { + err = s.db.QueryRowContext(ctx, + "SELECT role FROM agent_shares WHERE agent_id = ? AND user_id = ?", agentID, userID, + ).Scan(&role) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return false, "", nil + } + err = s.db.QueryRowContext(ctx, + "SELECT role FROM agent_shares WHERE agent_id = ? AND user_id = ? AND tenant_id = ?", + agentID, userID, tid, + ).Scan(&role) + } + if err != nil { + return false, "", nil + } + return true, role, nil +} + +func (s *SQLiteAgentStore) ListAccessible(ctx context.Context, userID string) ([]store.AgentData, error) { + if store.IsCrossTenant(ctx) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents + WHERE deleted_at IS NULL AND ( + owner_id = ? + OR is_default = 1 + OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = ?) + OR (agent_type = 'predefined' AND id IN ( + SELECT agent_id FROM channel_instances ci + WHERE ci.enabled = 1 + AND EXISTS ( + SELECT 1 FROM json_each(json_extract(ci.config, '$.allow_from')) + WHERE json_each.value = ? + ) + )) + ) + ORDER BY created_at DESC`, userID, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAgentRows(rows) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents + WHERE deleted_at IS NULL AND tenant_id = ? AND ( + owner_id = ? + OR is_default = 1 + OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = ? AND tenant_id = ?) + OR (agent_type = 'predefined' AND id IN ( + SELECT agent_id FROM channel_instances ci + WHERE ci.enabled = 1 AND ci.tenant_id = ? + AND EXISTS ( + SELECT 1 FROM json_each(json_extract(ci.config, '$.allow_from')) + WHERE json_each.value = ? + ) + )) + ) + ORDER BY created_at DESC`, tid, userID, userID, tid, tid, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAgentRows(rows) +} diff --git a/internal/store/sqlitestore/agents_batch.go b/internal/store/sqlitestore/agents_batch.go new file mode 100644 index 00000000..d667f380 --- /dev/null +++ b/internal/store/sqlitestore/agents_batch.go @@ -0,0 +1,87 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// GetByKeys returns agents matching the given keys in a single query. +// Replaces PG's = ANY($1) with a dynamic IN (?, ?, ...) clause. +func (s *SQLiteAgentStore) GetByKeys(ctx context.Context, keys []string) ([]store.AgentData, error) { + if len(keys) == 0 { + return nil, nil + } + + placeholders := strings.Repeat("?,", len(keys)) + placeholders = placeholders[:len(placeholders)-1] // trim trailing comma + + args := make([]any, len(keys)) + for i, k := range keys { + args[i] = k + } + + var q string + if store.IsCrossTenant(ctx) { + q = `SELECT ` + agentSelectCols + ` + FROM agents WHERE agent_key IN (` + placeholders + `) AND deleted_at IS NULL` + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("no tenant context for batch agent lookup") + } + q = `SELECT ` + agentSelectCols + ` + FROM agents WHERE agent_key IN (` + placeholders + `) AND deleted_at IS NULL AND tenant_id = ?` + args = append(args, tid) + } + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("batch agent key lookup: %w", err) + } + defer rows.Close() + return scanAgentRows(rows) +} + +// GetByIDs returns agents matching the given UUIDs in a single query. +// Replaces PG's = ANY($1) with a dynamic IN (?, ?, ...) clause. +func (s *SQLiteAgentStore) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]store.AgentData, error) { + if len(ids) == 0 { + return nil, nil + } + + placeholders := strings.Repeat("?,", len(ids)) + placeholders = placeholders[:len(placeholders)-1] + + args := make([]any, len(ids)) + for i, id := range ids { + args[i] = id + } + + var q string + if store.IsCrossTenant(ctx) { + q = `SELECT ` + agentSelectCols + ` + FROM agents WHERE id IN (` + placeholders + `) AND deleted_at IS NULL` + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("no tenant context for batch agent lookup") + } + q = `SELECT ` + agentSelectCols + ` + FROM agents WHERE id IN (` + placeholders + `) AND deleted_at IS NULL AND tenant_id = ?` + args = append(args, tid) + } + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("batch agent ID lookup: %w", err) + } + defer rows.Close() + return scanAgentRows(rows) +} diff --git a/internal/store/sqlitestore/agents_context.go b/internal/store/sqlitestore/agents_context.go new file mode 100644 index 00000000..7d0b6c43 --- /dev/null +++ b/internal/store/sqlitestore/agents_context.go @@ -0,0 +1,305 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "path/filepath" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// --- Agent-level Context Files --- + +func (s *SQLiteAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UUID) ([]store.AgentContextFileData, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = ?"+tClause, + append([]any{agentID}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.AgentContextFileData + for rows.Next() { + var d store.AgentContextFileData + if err := rows.Scan(&d.AgentID, &d.FileName, &d.Content); err != nil { + continue + } + result = append(result, d) + } + return result, rows.Err() +} + +func (s *SQLiteAgentStore) SetAgentContextFile(ctx context.Context, agentID uuid.UUID, fileName, content string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO agent_context_files (id, agent_id, file_name, content, updated_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (agent_id, file_name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`, + store.GenNewID(), agentID, fileName, content, time.Now(), tenantIDForInsert(ctx), + ) + return err +} + +// PropagateContextFile copies an agent-level context file to all existing user +// instances that already have that file. Returns updated row count. +// SQLite does not support UPDATE...FROM, so we use a 2-step approach. +func (s *SQLiteAgentStore) PropagateContextFile(ctx context.Context, agentID uuid.UUID, fileName string) (int, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return 0, err + } + + // Step 1: fetch the agent-level content. + var content string + err = s.db.QueryRowContext(ctx, + "SELECT content FROM agent_context_files WHERE agent_id = ? AND file_name = ?"+tClause, + append([]any{agentID, fileName}, tArgs...)..., + ).Scan(&content) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, err + } + + // Step 2: update all matching user context files. + res, err := s.db.ExecContext(ctx, + "UPDATE user_context_files SET content = ?, updated_at = ? WHERE agent_id = ? AND file_name = ?"+tClause, + append([]any{content, time.Now(), agentID, fileName}, tArgs...)..., + ) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil +} + +// --- Per-user Context Files --- + +func (s *SQLiteAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUID, userID string) ([]store.UserContextFileData, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = ? AND user_id = ?"+tClause, + append([]any{agentID, userID}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.UserContextFileData + for rows.Next() { + var d store.UserContextFileData + if err := rows.Scan(&d.AgentID, &d.UserID, &d.FileName, &d.Content); err != nil { + continue + } + result = append(result, d) + } + return result, rows.Err() +} + +func (s *SQLiteAgentStore) SetUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName, content string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (agent_id, user_id, file_name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`, + store.GenNewID(), agentID, userID, fileName, content, time.Now(), tenantIDForInsert(ctx), + ) + return err +} + +func (s *SQLiteAgentStore) DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM user_context_files WHERE agent_id = ? AND user_id = ? AND file_name = ?"+tClause, + append([]any{agentID, userID, fileName}, tArgs...)...) + return err +} + +// --- User-Agent Profiles --- + +// GetOrCreateUserProfile upserts a user profile and returns (isNew, effectiveWorkspace, error). +// SQLite has no xmax trick; we use INSERT OR IGNORE + UPDATE + SELECT to detect insert vs update. +func (s *SQLiteAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (bool, string, error) { + effectiveWs := config.ContractHome(workspace) + if channel != "" { + effectiveWs = filepath.Join(effectiveWs, channel) + } + + tid := tenantIDForInsert(ctx) + now := time.Now() + + // Try insert (ignored on conflict). Use changes() to detect if row was actually inserted. + res, err := s.db.ExecContext(ctx, + `INSERT OR IGNORE INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at, tenant_id) + VALUES (?, ?, NULLIF(?, ''), ?, ?, ?)`, + agentID, userID, effectiveWs, now, now, tid, + ) + if err != nil { + return false, effectiveWs, err + } + inserted, _ := res.RowsAffected() + isNew := inserted > 0 + + // Always bump last_seen_at for existing rows. + if !isNew { + _, err = s.db.ExecContext(ctx, + `UPDATE user_agent_profiles SET last_seen_at = ? WHERE agent_id = ? AND user_id = ?`, + now, agentID, userID, + ) + if err != nil { + return false, effectiveWs, err + } + } + + // Read back stored workspace. + var storedWorkspace sql.NullString + err = s.db.QueryRowContext(ctx, + `SELECT workspace FROM user_agent_profiles WHERE agent_id = ? AND user_id = ?`, + agentID, userID, + ).Scan(&storedWorkspace) + if err != nil { + return false, effectiveWs, err + } + + ws := effectiveWs + if storedWorkspace.Valid && storedWorkspace.String != "" { + ws = storedWorkspace.String + } + return isNew, ws, nil +} + +// EnsureUserProfile creates a minimal user_agent_profiles row if not exists. +func (s *SQLiteAgentStore) EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error { + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT OR IGNORE INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at, tenant_id) + VALUES (?, ?, ?, ?, ?)`, + agentID, userID, now, now, tenantIDForInsert(ctx), + ) + return err +} + +// --- User Instances --- + +func (s *SQLiteAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) { + tClause, tArgs, err := scopeClauseAlias(ctx, "p") + if err != nil { + return nil, err + } + subTenantFilter := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + subTenantFilter = " AND tenant_id = ?" + // tArgs already has tid; reuse it by appending another copy below. + } + } + // Build args: subquery agent_id [+ optional tenant_id], main WHERE agent_id, scope args. + var queryArgs []any + queryArgs = append(queryArgs, agentID) // subquery: agent_id = ? + if subTenantFilter != "" { + queryArgs = append(queryArgs, tArgs[0]) // subquery: tenant_id = ? + } + queryArgs = append(queryArgs, agentID) // main WHERE: p.agent_id = ? + queryArgs = append(queryArgs, tArgs...) // scope clause args + rows, err := s.db.QueryContext(ctx, ` + SELECT p.user_id, + strftime('%Y-%m-%dT%H:%M:%SZ', p.first_seen_at) AS first_seen_at, + strftime('%Y-%m-%dT%H:%M:%SZ', p.last_seen_at) AS last_seen_at, + COALESCE(fc.cnt, 0) AS file_count, + COALESCE(p.metadata, '{}') + FROM user_agent_profiles p + LEFT JOIN ( + SELECT user_id, COUNT(*) AS cnt + FROM user_context_files + WHERE agent_id = ?`+subTenantFilter+` + GROUP BY user_id + ) fc ON fc.user_id = p.user_id + WHERE p.agent_id = ?`+tClause+` + ORDER BY p.last_seen_at DESC + `, queryArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.UserInstanceData + for rows.Next() { + var d store.UserInstanceData + var metaJSON []byte + if err := rows.Scan(&d.UserID, &d.FirstSeenAt, &d.LastSeenAt, &d.FileCount, &metaJSON); err != nil { + continue + } + if len(metaJSON) > 0 { + json.Unmarshal(metaJSON, &d.Metadata) + } + result = append(result, d) + } + return result, rows.Err() +} + +func (s *SQLiteAgentStore) UpdateUserProfileMetadata(ctx context.Context, agentID uuid.UUID, userID string, metadata map[string]string) error { + metaJSON, err := json.Marshal(metadata) + if err != nil { + return err + } + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `UPDATE user_agent_profiles SET metadata = json_patch(COALESCE(metadata, '{}'), ?) + WHERE agent_id = ? AND user_id = ?`+tClause, + append([]any{metaJSON, agentID, userID}, tArgs...)..., + ) + return err +} + +// --- User Overrides --- + +func (s *SQLiteAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*store.UserAgentOverrideData, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + var d store.UserAgentOverrideData + err = s.db.QueryRowContext(ctx, + "SELECT agent_id, user_id, provider, model FROM user_agent_overrides WHERE agent_id = ? AND user_id = ?"+tClause, + append([]any{agentID, userID}, tArgs...)..., + ).Scan(&d.AgentID, &d.UserID, &d.Provider, &d.Model) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, nil + } + return &d, nil +} + +func (s *SQLiteAgentStore) SetUserOverride(ctx context.Context, override *store.UserAgentOverrideData) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model, tenant_id) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (agent_id, user_id) DO UPDATE SET provider = excluded.provider, model = excluded.model`, + store.GenNewID(), override.AgentID, override.UserID, override.Provider, override.Model, tenantIDForInsert(ctx), + ) + return err +} diff --git a/internal/store/sqlitestore/api_keys.go b/internal/store/sqlitestore/api_keys.go new file mode 100644 index 00000000..11aeeafc --- /dev/null +++ b/internal/store/sqlitestore/api_keys.go @@ -0,0 +1,224 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteAPIKeyStore implements store.APIKeyStore backed by SQLite. +type SQLiteAPIKeyStore struct { + db *sql.DB +} + +// NewSQLiteAPIKeyStore creates a new SQLite-backed API key store. +func NewSQLiteAPIKeyStore(db *sql.DB) *SQLiteAPIKeyStore { + return &SQLiteAPIKeyStore{db: db} +} + +func (s *SQLiteAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) error { + var ownerID *string + if key.OwnerID != "" { + ownerID = &key.OwnerID + } + var tenantID *uuid.UUID + if key.TenantID != uuid.Nil { + tenantID = &key.TenantID + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO api_keys (id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + key.ID, key.Name, key.Prefix, key.KeyHash, jsonStringArray(key.Scopes), + ownerID, tenantID, key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt, + ) + return err +} + +func (s *SQLiteAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.APIKeyData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at + FROM api_keys + WHERE key_hash = ? AND NOT revoked AND (expires_at IS NULL OR expires_at > datetime('now'))`, + keyHash, + ) + + var k store.APIKeyData + var createdBy *string + var ownerID *string + var tenantID *uuid.UUID + var scopesRaw []byte + var expiresAt, lastUsedAt nullSqliteTime + createdAt, updatedAt := scanTimePair() + err := row.Scan( + &k.ID, &k.Name, &k.Prefix, &k.KeyHash, &scopesRaw, + &ownerID, &tenantID, &expiresAt, &lastUsedAt, &k.Revoked, &createdBy, + createdAt, updatedAt, + ) + if err != nil { + return nil, err + } + k.CreatedAt = createdAt.Time + k.UpdatedAt = updatedAt.Time + if expiresAt.Valid { + k.ExpiresAt = &expiresAt.Time + } + if lastUsedAt.Valid { + k.LastUsedAt = &lastUsedAt.Time + } + scanJSONStringArray(scopesRaw, &k.Scopes) + if createdBy != nil { + k.CreatedBy = *createdBy + } + if ownerID != nil { + k.OwnerID = *ownerID + } + if tenantID != nil { + k.TenantID = *tenantID + } + return &k, nil +} + +func (s *SQLiteAPIKeyStore) List(ctx context.Context, ownerID string) ([]store.APIKeyData, error) { + var conditions []string + var args []any + + if ownerID != "" { + conditions = append(conditions, "owner_id = ?") + args = append(args, ownerID) + } + + // Tenant filter: include tenant-scoped keys + system keys (NULL tenant_id). + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + conditions = append(conditions, "(tenant_id = ? OR tenant_id IS NULL)") + args = append(args, tid) + } + } + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, prefix, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at + FROM api_keys`+where+` + ORDER BY created_at DESC`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var keys []store.APIKeyData + for rows.Next() { + var k store.APIKeyData + var createdBy *string + var oID *string + var tID *uuid.UUID + var scopesRaw []byte + var expiresAt, lastUsedAt nullSqliteTime + createdAt, updatedAt := scanTimePair() + if err := rows.Scan( + &k.ID, &k.Name, &k.Prefix, &scopesRaw, + &oID, &tID, &expiresAt, &lastUsedAt, &k.Revoked, &createdBy, + createdAt, updatedAt, + ); err != nil { + return nil, err + } + k.CreatedAt = createdAt.Time + k.UpdatedAt = updatedAt.Time + if expiresAt.Valid { + k.ExpiresAt = &expiresAt.Time + } + if lastUsedAt.Valid { + k.LastUsedAt = &lastUsedAt.Time + } + scanJSONStringArray(scopesRaw, &k.Scopes) + if createdBy != nil { + k.CreatedBy = *createdBy + } + if oID != nil { + k.OwnerID = *oID + } + if tID != nil { + k.TenantID = *tID + } + keys = append(keys, k) + } + return keys, rows.Err() +} + +func (s *SQLiteAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID, ownerID string) error { + q := "UPDATE api_keys SET revoked = 1, updated_at = ? WHERE id = ?" + args := []any{time.Now(), id} + + if ownerID != "" { + q += " AND owner_id = ?" + args = append(args, ownerID) + } + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND (tenant_id = ? OR tenant_id IS NULL)" + args = append(args, tid) + } + } + + res, err := s.db.ExecContext(ctx, q, args...) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *SQLiteAPIKeyStore) Delete(ctx context.Context, id uuid.UUID, ownerID string) error { + q := "DELETE FROM api_keys WHERE id = ?" + args := []any{id} + + if ownerID != "" { + q += " AND owner_id = ?" + args = append(args, ownerID) + } + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND (tenant_id = ? OR tenant_id IS NULL)" + args = append(args, tid) + } + } + + res, err := s.db.ExecContext(ctx, q, args...) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *SQLiteAPIKeyStore) TouchLastUsed(ctx context.Context, id uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, + time.Now(), id, + ) + return err +} + +// ensure interface is satisfied at compile time +var _ store.APIKeyStore = (*SQLiteAPIKeyStore)(nil) diff --git a/internal/store/sqlitestore/builtin-tools.go b/internal/store/sqlitestore/builtin-tools.go new file mode 100644 index 00000000..33e603e0 --- /dev/null +++ b/internal/store/sqlitestore/builtin-tools.go @@ -0,0 +1,229 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteBuiltinToolStore implements store.BuiltinToolStore backed by SQLite. +type SQLiteBuiltinToolStore struct { + db *sql.DB +} + +func NewSQLiteBuiltinToolStore(db *sql.DB) *SQLiteBuiltinToolStore { + return &SQLiteBuiltinToolStore{db: db} +} + +const builtinToolSelectCols = `name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at` + +func (s *SQLiteBuiltinToolStore) List(ctx context.Context) ([]store.BuiltinToolDef, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+builtinToolSelectCols+` FROM builtin_tools ORDER BY category, name`) + if err != nil { + return nil, err + } + return s.scanTools(rows) +} + +func (s *SQLiteBuiltinToolStore) ListEnabled(ctx context.Context) ([]store.BuiltinToolDef, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+builtinToolSelectCols+` FROM builtin_tools WHERE enabled = 1 ORDER BY category, name`) + if err != nil { + return nil, err + } + return s.scanTools(rows) +} + +func (s *SQLiteBuiltinToolStore) Get(ctx context.Context, name string) (*store.BuiltinToolDef, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+builtinToolSelectCols+` FROM builtin_tools WHERE name = ?`, name) + return s.scanTool(row) +} + +func (s *SQLiteBuiltinToolStore) GetSettings(ctx context.Context, name string) (json.RawMessage, error) { + var settings json.RawMessage + err := s.db.QueryRowContext(ctx, + `SELECT settings FROM builtin_tools WHERE name = ?`, name).Scan(&settings) + if err != nil { + return nil, err + } + return settings, nil +} + +func (s *SQLiteBuiltinToolStore) Update(ctx context.Context, name string, updates map[string]any) error { + allowed := make(map[string]any) + if v, ok := updates["enabled"]; ok { + allowed["enabled"] = v + } + if v, ok := updates["settings"]; ok { + switch sv := v.(type) { + case json.RawMessage: + allowed["settings"] = []byte(sv) + case map[string]any: + b, err := json.Marshal(sv) + if err != nil { + return fmt.Errorf("marshal settings: %w", err) + } + allowed["settings"] = b + case []byte: + allowed["settings"] = sv + case string: + allowed["settings"] = []byte(sv) + } + } + if len(allowed) == 0 { + return nil + } + allowed["updated_at"] = time.Now() + + var setClauses []string + var args []any + for col, val := range allowed { + setClauses = append(setClauses, col+" = ?") + args = append(args, val) + } + args = append(args, name) + q := fmt.Sprintf("UPDATE builtin_tools SET %s WHERE name = ?", strings.Join(setClauses, ", ")) + _, err := s.db.ExecContext(ctx, q, args...) + return err +} + +// Seed inserts or updates builtin tool definitions. +// Preserves user-customized enabled and settings values across upgrades. +func (s *SQLiteBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinToolDef) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, + `INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + category = excluded.category, + requires = excluded.requires, + metadata = excluded.metadata, + settings = CASE + WHEN builtin_tools.settings IS NULL OR builtin_tools.settings IN ('{}', 'null') + THEN excluded.settings + ELSE builtin_tools.settings + END, + updated_at = excluded.updated_at`) + if err != nil { + return fmt.Errorf("prepare seed stmt: %w", err) + } + defer stmt.Close() + + now := time.Now() + names := make([]string, 0, len(tools)) + for _, t := range tools { + settings := t.Settings + if settings == nil { + settings = json.RawMessage("{}") + } + metadata := t.Metadata + if metadata == nil { + metadata = json.RawMessage("{}") + } + _, err := stmt.ExecContext(ctx, + t.Name, t.DisplayName, t.Description, t.Category, + t.Enabled, []byte(settings), jsonStringArray(t.Requires), []byte(metadata), now, now, + ) + if err != nil { + return fmt.Errorf("seed tool %s: %w", t.Name, err) + } + names = append(names, t.Name) + } + + // Reconcile: remove stale entries not in the current seed list. + // SQLite has no ANY($1) — build NOT IN (?, ?, ...) instead. + if len(names) > 0 { + placeholders := strings.Repeat("?,", len(names)) + placeholders = placeholders[:len(placeholders)-1] + delArgs := make([]any, len(names)) + for i, n := range names { + delArgs[i] = n + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM builtin_tools WHERE name NOT IN (`+placeholders+`)`, delArgs...); err != nil { + return fmt.Errorf("reconcile stale builtin tools: %w", err) + } + } else { + if _, err := tx.ExecContext(ctx, `DELETE FROM builtin_tools`); err != nil { + return fmt.Errorf("reconcile stale builtin tools: %w", err) + } + } + + return tx.Commit() +} + +func (s *SQLiteBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, error) { + var def store.BuiltinToolDef + var settings []byte + var requires []byte + var metadata []byte + createdAt, updatedAt := scanTimePair() + + err := row.Scan( + &def.Name, &def.DisplayName, &def.Description, &def.Category, + &def.Enabled, &settings, &requires, &metadata, createdAt, updatedAt, + ) + if err != nil { + return nil, err + } + def.CreatedAt = createdAt.Time + def.UpdatedAt = updatedAt.Time + + if settings != nil { + def.Settings = json.RawMessage(settings) + } + if metadata != nil { + def.Metadata = json.RawMessage(metadata) + } + scanJSONStringArray(requires, &def.Requires) + + return &def, nil +} + +func (s *SQLiteBuiltinToolStore) scanTools(rows *sql.Rows) ([]store.BuiltinToolDef, error) { + defer rows.Close() + var result []store.BuiltinToolDef + for rows.Next() { + var def store.BuiltinToolDef + var settings []byte + var requires []byte + var metadata []byte + createdAt, updatedAt := scanTimePair() + + if err := rows.Scan( + &def.Name, &def.DisplayName, &def.Description, &def.Category, + &def.Enabled, &settings, &requires, &metadata, createdAt, updatedAt, + ); err != nil { + continue + } + def.CreatedAt = createdAt.Time + def.UpdatedAt = updatedAt.Time + + if settings != nil { + def.Settings = json.RawMessage(settings) + } + if metadata != nil { + def.Metadata = json.RawMessage(metadata) + } + scanJSONStringArray(requires, &def.Requires) + + result = append(result, def) + } + return result, rows.Err() +} diff --git a/internal/store/sqlitestore/channel_instances.go b/internal/store/sqlitestore/channel_instances.go new file mode 100644 index 00000000..dc88edce --- /dev/null +++ b/internal/store/sqlitestore/channel_instances.go @@ -0,0 +1,365 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "maps" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteChannelInstanceStore implements store.ChannelInstanceStore backed by SQLite. +type SQLiteChannelInstanceStore struct { + db *sql.DB + encKey string +} + +func NewSQLiteChannelInstanceStore(db *sql.DB, encryptionKey string) *SQLiteChannelInstanceStore { + return &SQLiteChannelInstanceStore{db: db, encKey: encryptionKey} +} + +const channelInstanceSelectCols = `id, name, display_name, channel_type, agent_id, + credentials, config, enabled, created_by, created_at, updated_at, tenant_id` + +func (s *SQLiteChannelInstanceStore) Create(ctx context.Context, inst *store.ChannelInstanceData) error { + if err := store.ValidateUserID(inst.CreatedBy); err != nil { + return err + } + if inst.ID == uuid.Nil { + inst.ID = store.GenNewID() + } + + var credsBytes []byte + if len(inst.Credentials) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(inst.Credentials), s.encKey) + if err != nil { + return fmt.Errorf("encrypt credentials: %w", err) + } + credsBytes = []byte(encrypted) + } else { + credsBytes = inst.Credentials + } + + now := time.Now() + inst.CreatedAt = now + inst.UpdatedAt = now + tid := tenantIDForInsert(ctx) + + _, err := s.db.ExecContext(ctx, + `INSERT INTO channel_instances (id, name, display_name, channel_type, agent_id, + credentials, config, enabled, created_by, created_at, updated_at, tenant_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + inst.ID, inst.Name, inst.DisplayName, inst.ChannelType, inst.AgentID, + credsBytes, jsonOrEmpty(inst.Config), + inst.Enabled, inst.CreatedBy, now, now, tid, + ) + return err +} + +func (s *SQLiteChannelInstanceStore) Get(ctx context.Context, id uuid.UUID) (*store.ChannelInstanceData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = ?`, id) + return s.scanInstance(row) + } + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + args := append([]any{id}, tArgs...) + row := s.db.QueryRowContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = ?`+tClause, args...) + return s.scanInstance(row) +} + +func (s *SQLiteChannelInstanceStore) GetByName(ctx context.Context, name string) (*store.ChannelInstanceData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = ?`, name) + return s.scanInstance(row) + } + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + args := append([]any{name}, tArgs...) + row := s.db.QueryRowContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = ?`+tClause, args...) + return s.scanInstance(row) +} + +func (s *SQLiteChannelInstanceStore) scanInstance(row *sql.Row) (*store.ChannelInstanceData, error) { + var inst store.ChannelInstanceData + var displayName *string + var creds []byte + var config *[]byte + createdAt, updatedAt := scanTimePair() + + err := row.Scan( + &inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID, + &creds, &config, + &inst.Enabled, &inst.CreatedBy, createdAt, updatedAt, &inst.TenantID, + ) + if err != nil { + return nil, err + } + inst.CreatedAt = createdAt.Time + inst.UpdatedAt = updatedAt.Time + + inst.DisplayName = derefStr(displayName) + if config != nil { + inst.Config = *config + } + + if len(creds) > 0 && s.encKey != "" { + decrypted, err := crypto.Decrypt(string(creds), s.encKey) + if err != nil { + slog.Warn("channel_instances: failed to decrypt credentials", "name", inst.Name, "error", err) + } else { + inst.Credentials = []byte(decrypted) + } + } else { + inst.Credentials = creds + } + + return &inst, nil +} + +func (s *SQLiteChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelInstanceData, error) { + defer rows.Close() + var result []store.ChannelInstanceData + for rows.Next() { + var inst store.ChannelInstanceData + var displayName *string + var creds []byte + var config *[]byte + createdAt, updatedAt := scanTimePair() + + if err := rows.Scan( + &inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID, + &creds, &config, + &inst.Enabled, &inst.CreatedBy, createdAt, updatedAt, &inst.TenantID, + ); err != nil { + continue + } + inst.CreatedAt = createdAt.Time + inst.UpdatedAt = updatedAt.Time + + inst.DisplayName = derefStr(displayName) + if config != nil { + inst.Config = *config + } + if len(creds) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(creds), s.encKey); err == nil { + inst.Credentials = []byte(decrypted) + } + } else { + inst.Credentials = creds + } + + result = append(result, inst) + } + return result, rows.Err() +} + +func (s *SQLiteChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error { + if credsVal, ok := updates["credentials"]; ok && credsVal != nil { + var newCreds map[string]any + switch v := credsVal.(type) { + case map[string]any: + newCreds = v + default: + var raw []byte + switch vv := v.(type) { + case []byte: + raw = vv + case string: + raw = []byte(vv) + default: + if b, err := json.Marshal(v); err == nil { + raw = b + } + } + if len(raw) > 0 { + _ = json.Unmarshal(raw, &newCreds) + } + } + + if len(newCreds) > 0 { + existing, err := s.loadExistingCreds(ctx, id) + if err != nil { + return fmt.Errorf("load existing credentials for merge: %w", err) + } + maps.Copy(existing, newCreds) + newCreds = existing + } + + var credsBytes []byte + if len(newCreds) > 0 { + credsBytes, _ = json.Marshal(newCreds) + } + if len(credsBytes) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(credsBytes), s.encKey) + if err != nil { + return fmt.Errorf("encrypt credentials: %w", err) + } + credsBytes = []byte(encrypted) + } + updates["credentials"] = credsBytes + } + updates["updated_at"] = time.Now() + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "channel_instances", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "channel_instances", updates, id, tid) +} + +func (s *SQLiteChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid.UUID) (map[string]any, error) { + var raw []byte + err := s.db.QueryRowContext(ctx, "SELECT credentials FROM channel_instances WHERE id = ?", id).Scan(&raw) + if err != nil || len(raw) == 0 { + return make(map[string]any), nil + } + if s.encKey != "" { + if dec, err := crypto.Decrypt(string(raw), s.encKey); err == nil { + raw = []byte(dec) + } + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return make(map[string]any), nil + } + return m, nil +} + +func (s *SQLiteChannelInstanceStore) Delete(ctx context.Context, id uuid.UUID) error { + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = ?", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = ? AND tenant_id = ?", id, tid) + return err +} + +func (s *SQLiteChannelInstanceStore) ListEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) { + query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE enabled = 1` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + query += tClause + qArgs = tArgs + } + query += ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) + if err != nil { + return nil, err + } + return s.scanInstances(rows) +} + +func (s *SQLiteChannelInstanceStore) ListAll(ctx context.Context) ([]store.ChannelInstanceData, error) { + query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE 1=1` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + query += tClause + qArgs = tArgs + } + query += ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) + if err != nil { + return nil, err + } + return s.scanInstances(rows) +} + +func (s *SQLiteChannelInstanceStore) ListAllInstances(ctx context.Context) ([]store.ChannelInstanceData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances ORDER BY name`) + if err != nil { + return nil, err + } + return s.scanInstances(rows) +} + +func (s *SQLiteChannelInstanceStore) ListAllEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE enabled = 1 ORDER BY name`) + if err != nil { + return nil, err + } + return s.scanInstances(rows) +} + +func buildChannelInstanceWhereSQLite(ctx context.Context, opts store.ChannelInstanceListOpts) (string, []any) { + var conditions []string + var args []any + + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, "tenant_id = ?") + args = append(args, tenantID) + } + } + + if opts.Search != "" { + escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search) + conditions = append(conditions, "(name LIKE ? ESCAPE '\\' OR display_name LIKE ? ESCAPE '\\' OR channel_type LIKE ? ESCAPE '\\')") + p := "%" + escaped + "%" + args = append(args, p, p, p) + } + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + return where, args +} + +func (s *SQLiteChannelInstanceStore) ListPaged(ctx context.Context, opts store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) { + where, args := buildChannelInstanceWhereSQLite(ctx, opts) + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + q := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances` + where + + fmt.Sprintf(" ORDER BY name LIMIT %d OFFSET %d", limit, opts.Offset) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + return s.scanInstances(rows) +} + +func (s *SQLiteChannelInstanceStore) CountInstances(ctx context.Context, opts store.ChannelInstanceListOpts) (int, error) { + where, args := buildChannelInstanceWhereSQLite(ctx, opts) + var count int + err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_instances"+where, args...).Scan(&count) + return count, err +} diff --git a/internal/store/sqlitestore/config-secrets.go b/internal/store/sqlitestore/config-secrets.go new file mode 100644 index 00000000..c3e014e1 --- /dev/null +++ b/internal/store/sqlitestore/config-secrets.go @@ -0,0 +1,101 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" +) + +// SQLiteConfigSecretsStore implements store.ConfigSecretsStore backed by SQLite. +type SQLiteConfigSecretsStore struct { + db *sql.DB + encKey string +} + +func NewSQLiteConfigSecretsStore(db *sql.DB, encryptionKey string) *SQLiteConfigSecretsStore { + return &SQLiteConfigSecretsStore{db: db, encKey: encryptionKey} +} + +func (s *SQLiteConfigSecretsStore) Get(ctx context.Context, key string) (string, error) { + tid := tenantIDForInsert(ctx) + var value []byte + err := s.db.QueryRowContext(ctx, + `SELECT value FROM config_secrets WHERE key = ? AND tenant_id = ?`, key, tid).Scan(&value) + if err != nil { + return "", err + } + + if len(value) > 0 && s.encKey != "" { + decrypted, err := crypto.Decrypt(string(value), s.encKey) + if err != nil { + return "", fmt.Errorf("decrypt secret %q: %w", key, err) + } + return decrypted, nil + } + return string(value), nil +} + +func (s *SQLiteConfigSecretsStore) Set(ctx context.Context, key, value string) error { + var stored []byte + if s.encKey != "" { + encrypted, err := crypto.Encrypt(value, s.encKey) + if err != nil { + return fmt.Errorf("encrypt secret %q: %w", key, err) + } + stored = []byte(encrypted) + } else { + stored = []byte(value) + } + + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, + `INSERT INTO config_secrets (key, value, updated_at, tenant_id) VALUES (?, ?, ?, ?) + ON CONFLICT (key, tenant_id) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + key, stored, time.Now(), tid, + ) + return err +} + +func (s *SQLiteConfigSecretsStore) Delete(ctx context.Context, key string) error { + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, + `DELETE FROM config_secrets WHERE key = ? AND tenant_id = ?`, key, tid) + return err +} + +func (s *SQLiteConfigSecretsStore) GetAll(ctx context.Context) (map[string]string, error) { + tid := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT key, value FROM config_secrets WHERE tenant_id = ?`, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var key string + var value []byte + if err := rows.Scan(&key, &value); err != nil { + continue + } + + if len(value) > 0 && s.encKey != "" { + decrypted, err := crypto.Decrypt(string(value), s.encKey) + if err != nil { + slog.Warn("config_secrets: failed to decrypt", "key", key, "error", err) + continue + } + result[key] = decrypted + } else { + result[key] = string(value) + } + } + return result, rows.Err() +} diff --git a/internal/store/sqlitestore/config_permissions.go b/internal/store/sqlitestore/config_permissions.go new file mode 100644 index 00000000..3841f982 --- /dev/null +++ b/internal/store/sqlitestore/config_permissions.go @@ -0,0 +1,279 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const permCacheTTL = 60 * time.Second + +type permRow struct { + Scope string + ConfigType string + Permission string +} + +type permCacheEntry struct { + rows []permRow + fetched time.Time +} + +type fwCacheEntry struct { + rows []store.ConfigPermission + fetched time.Time +} + +// SQLiteConfigPermissionStore implements store.ConfigPermissionStore backed by SQLite. +// Includes a TTL cache for CheckPermission to avoid per-request DB queries. +type SQLiteConfigPermissionStore struct { + db *sql.DB + mu sync.RWMutex + cache map[string]permCacheEntry // key: "tenantID:agentID:userID" + fwMu sync.RWMutex + fwCache map[string]fwCacheEntry // key: "tenantID:agentID:scope" +} + +func NewSQLiteConfigPermissionStore(db *sql.DB) *SQLiteConfigPermissionStore { + return &SQLiteConfigPermissionStore{ + db: db, + cache: make(map[string]permCacheEntry), + fwCache: make(map[string]fwCacheEntry), + } +} + +// InvalidateCache clears all cached permission entries. +func (s *SQLiteConfigPermissionStore) InvalidateCache() { + s.mu.Lock() + s.cache = make(map[string]permCacheEntry) + s.mu.Unlock() + + s.fwMu.Lock() + s.fwCache = make(map[string]fwCacheEntry) + s.fwMu.Unlock() +} + +func (s *SQLiteConfigPermissionStore) CheckPermission(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) (bool, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + cacheKey := tid.String() + ":" + agentID.String() + ":" + userID + + s.mu.RLock() + if entry, ok := s.cache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL { + s.mu.RUnlock() + return evalPermRows(entry.rows, scope, configType), nil + } + s.mu.RUnlock() + + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return false, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT scope, config_type, permission FROM agent_config_permissions + WHERE agent_id = ? AND user_id = ?`+tClause, + append([]any{agentID, userID}, tArgs...)..., + ) + if err != nil { + return false, err + } + defer rows.Close() + + var permRows []permRow + for rows.Next() { + var r permRow + if err := rows.Scan(&r.Scope, &r.ConfigType, &r.Permission); err != nil { + return false, err + } + permRows = append(permRows, r) + } + if err := rows.Err(); err != nil { + return false, err + } + + s.mu.Lock() + s.cache[cacheKey] = permCacheEntry{rows: permRows, fetched: time.Now()} + s.mu.Unlock() + + return evalPermRows(permRows, scope, configType), nil +} + +func (s *SQLiteConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigPermission) error { + meta := perm.Metadata + if meta == nil { + meta = json.RawMessage("{}") + } + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at, tenant_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (agent_id, scope, config_type, user_id) DO UPDATE SET + permission = excluded.permission, + granted_by = excluded.granted_by, + metadata = excluded.metadata, + updated_at = excluded.updated_at`, + perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now, now, tenantIDForInsert(ctx), + ) + if err == nil { + s.InvalidateCache() + } + return err +} + +func (s *SQLiteConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) error { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM agent_config_permissions WHERE agent_id = ? AND scope = ? AND config_type = ? AND user_id = ?`+tClause, + append([]any{agentID, scope, configType, userID}, tArgs...)..., + ) + if err == nil { + s.InvalidateCache() + } + return err +} + +func (s *SQLiteConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]store.ConfigPermission, error) { + query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at + FROM agent_config_permissions WHERE agent_id = ?` + args := []any{agentID} + + if configType != "" { + query += " AND config_type = ?" + args = append(args, configType) + } + if scope != "" { + query += " AND scope = ?" + args = append(args, scope) + } + + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + if tClause != "" { + query += tClause + args = append(args, tArgs...) + } + query += " ORDER BY created_at" + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanConfigPermissions(rows) +} + +func (s *SQLiteConfigPermissionStore) ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]store.ConfigPermission, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + cacheKey := tid.String() + ":" + agentID.String() + ":" + scope + + s.fwMu.RLock() + if entry, ok := s.fwCache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL { + s.fwMu.RUnlock() + return entry.rows, nil + } + s.fwMu.RUnlock() + + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at + FROM agent_config_permissions + WHERE agent_id = ? AND config_type = 'file_writer' AND scope = ? AND permission = 'allow'`+tClause+` + ORDER BY created_at`, + append([]any{agentID, scope}, tArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + perms, err := scanConfigPermissions(rows) + if err != nil { + return nil, err + } + + s.fwMu.Lock() + s.fwCache[cacheKey] = fwCacheEntry{rows: perms, fetched: time.Now()} + s.fwMu.Unlock() + + return perms, nil +} + +func scanConfigPermissions(rows *sql.Rows) ([]store.ConfigPermission, error) { + var perms []store.ConfigPermission + for rows.Next() { + var p store.ConfigPermission + var metadata []byte + createdAt, updatedAt := scanTimePair() + if err := rows.Scan( + &p.ID, &p.AgentID, &p.Scope, &p.ConfigType, &p.UserID, &p.Permission, &p.GrantedBy, &metadata, createdAt, updatedAt, + ); err != nil { + return nil, err + } + p.CreatedAt = createdAt.Time + p.UpdatedAt = updatedAt.Time + p.Metadata = metadata + perms = append(perms, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + return perms, nil +} + +// evalPermRows evaluates cached permission rows against scope and configType (deny-first). +func evalPermRows(rows []permRow, scope, configType string) bool { + var hasDeny, hasAllow bool + for _, r := range rows { + if !matchWildcard(r.Scope, scope) || !matchWildcard(r.ConfigType, configType) { + continue + } + switch r.Permission { + case "deny": + hasDeny = true + case "allow": + hasAllow = true + } + } + if hasDeny { + return false + } + return hasAllow +} + +// matchWildcard performs simple wildcard matching for scope/config_type. +func matchWildcard(pattern, value string) bool { + if pattern == "*" { + return true + } + if strings.HasSuffix(pattern, ":*") { + prefix := strings.TrimSuffix(pattern, "*") + return strings.HasPrefix(value, prefix) || value == strings.TrimSuffix(prefix, ":") + } + return pattern == value +} + +// ensure interface is satisfied at compile time +var _ store.ConfigPermissionStore = (*SQLiteConfigPermissionStore)(nil) diff --git a/internal/store/sqlitestore/contacts.go b/internal/store/sqlitestore/contacts.go new file mode 100644 index 00000000..9d872d86 --- /dev/null +++ b/internal/store/sqlitestore/contacts.go @@ -0,0 +1,255 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteContactStore implements store.ContactStore backed by SQLite. +type SQLiteContactStore struct { + db *sql.DB +} + +func NewSQLiteContactStore(db *sql.DB) *SQLiteContactStore { + return &SQLiteContactStore{db: db} +} + +func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + // SQLite UPSERT: ON CONFLICT ... DO UPDATE SET + // NULLIF emulated with CASE WHEN + _, err := s.db.ExecContext(ctx, ` + INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, tenant_id) + VALUES (?, NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), ?) + ON CONFLICT (tenant_id, channel_type, sender_id) DO UPDATE SET + display_name = COALESCE(NULLIF(excluded.display_name,''), channel_contacts.display_name), + username = COALESCE(NULLIF(excluded.username,''), channel_contacts.username), + user_id = COALESCE(NULLIF(excluded.user_id,''), channel_contacts.user_id), + channel_instance = COALESCE(NULLIF(excluded.channel_instance,''), channel_contacts.channel_instance), + peer_kind = COALESCE(NULLIF(excluded.peer_kind,''), channel_contacts.peer_kind), + last_seen_at = CURRENT_TIMESTAMP`, + channelType, + channelInstance, "", + senderID, + userID, "", + displayName, "", + username, "", + peerKind, "", + tenantID, + ) + return err +} + +func contactWhereSQLite(ctx context.Context, opts store.ContactListOpts) (string, []any) { + var conditions []string + var args []any + + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, "tenant_id = ?") + args = append(args, tenantID) + } + } + + if opts.ChannelType != "" { + conditions = append(conditions, "channel_type = ?") + args = append(args, opts.ChannelType) + } + if opts.PeerKind != "" { + conditions = append(conditions, "peer_kind = ?") + args = append(args, opts.PeerKind) + } + if opts.Search != "" { + escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search) + pattern := escaped + "%" + conditions = append(conditions, "(display_name LIKE ? ESCAPE '\\' OR username LIKE ? ESCAPE '\\' OR sender_id LIKE ? ESCAPE '\\')") + args = append(args, pattern, pattern, pattern) + } + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + return where, args +} + +const contactSelectCols = `id, channel_type, channel_instance, sender_id, user_id, + display_name, username, avatar_url, peer_kind, merged_id, + first_seen_at, last_seen_at` + +func scanContact(rows *sql.Rows) (store.ChannelContact, error) { + var c store.ChannelContact + err := rows.Scan( + &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID, + &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID, + &c.FirstSeenAt, &c.LastSeenAt, + ) + return c, err +} + +func (s *SQLiteContactStore) ListContacts(ctx context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) { + where, args := contactWhereSQLite(ctx, opts) + + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + query := `SELECT ` + contactSelectCols + ` FROM channel_contacts` + where + + fmt.Sprintf(" ORDER BY last_seen_at DESC LIMIT %d", limit) + if opts.Offset > 0 { + query += fmt.Sprintf(" OFFSET %d", opts.Offset) + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var contacts []store.ChannelContact + for rows.Next() { + c, err := scanContact(rows) + if err != nil { + return nil, err + } + contacts = append(contacts, c) + } + return contacts, rows.Err() +} + +func (s *SQLiteContactStore) CountContacts(ctx context.Context, opts store.ContactListOpts) (int, error) { + where, args := contactWhereSQLite(ctx, opts) + var count int + err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_contacts"+where, args...).Scan(&count) + return count, err +} + +func (s *SQLiteContactStore) GetContactsBySenderIDs(ctx context.Context, senderIDs []string) (map[string]store.ChannelContact, error) { + if len(senderIDs) == 0 { + return map[string]store.ChannelContact{}, nil + } + + placeholders := make([]string, len(senderIDs)) + args := make([]any, len(senderIDs)) + for i, id := range senderIDs { + placeholders[i] = "?" + args[i] = id + } + + // SQLite has no DISTINCT ON; emulate with GROUP BY + MAX rowid trick via subquery + query := `SELECT id, channel_type, channel_instance, sender_id, user_id, + display_name, username, avatar_url, peer_kind, merged_id, + first_seen_at, last_seen_at + FROM channel_contacts + WHERE sender_id IN (` + strings.Join(placeholders, ",") + `) + GROUP BY sender_id + ORDER BY sender_id, last_seen_at DESC` + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]store.ChannelContact, len(senderIDs)) + for rows.Next() { + c, err := scanContact(rows) + if err != nil { + return nil, err + } + result[c.SenderID] = c + } + return result, rows.Err() +} + +func (s *SQLiteContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (*store.ChannelContact, error) { + tid := store.TenantIDFromContext(ctx) + row := s.db.QueryRowContext(ctx, + `SELECT `+contactSelectCols+` + FROM channel_contacts WHERE id = ? AND tenant_id = ?`, id, tid) + var c store.ChannelContact + if err := row.Scan( + &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID, + &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID, + &c.FirstSeenAt, &c.LastSeenAt, + ); err != nil { + return nil, err + } + return &c, nil +} + +func (s *SQLiteContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error { + if len(contactIDs) == 0 { + return nil + } + tid := store.TenantIDFromContext(ctx) + + placeholders := make([]string, len(contactIDs)) + args := make([]any, len(contactIDs)) + for i, id := range contactIDs { + placeholders[i] = "?" + args[i] = id + } + args = append(args, tenantUserID, tid) + q := fmt.Sprintf( + "UPDATE channel_contacts SET merged_id = ? WHERE id IN (%s) AND tenant_id = ?", + strings.Join(placeholders, ","), + ) + _, err := s.db.ExecContext(ctx, q, args...) + return err +} + +func (s *SQLiteContactStore) UnmergeContacts(ctx context.Context, contactIDs []uuid.UUID) error { + if len(contactIDs) == 0 { + return nil + } + tid := store.TenantIDFromContext(ctx) + + placeholders := make([]string, len(contactIDs)) + args := make([]any, len(contactIDs)) + for i, id := range contactIDs { + placeholders[i] = "?" + args[i] = id + } + args = append(args, tid) + q := fmt.Sprintf( + "UPDATE channel_contacts SET merged_id = NULL WHERE id IN (%s) AND tenant_id = ?", + strings.Join(placeholders, ","), + ) + _, err := s.db.ExecContext(ctx, q, args...) + return err +} + +func (s *SQLiteContactStore) GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]store.ChannelContact, error) { + tid := store.TenantIDFromContext(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT `+contactSelectCols+` + FROM channel_contacts WHERE merged_id = ? AND tenant_id = ? + ORDER BY last_seen_at DESC`, mergedID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + var contacts []store.ChannelContact + for rows.Next() { + c, err := scanContact(rows) + if err != nil { + return nil, err + } + contacts = append(contacts, c) + } + return contacts, rows.Err() +} diff --git a/internal/store/sqlitestore/cron.go b/internal/store/sqlitestore/cron.go new file mode 100644 index 00000000..3f959b4f --- /dev/null +++ b/internal/store/sqlitestore/cron.go @@ -0,0 +1,249 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/adhocore/gronx" + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/cron" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const defaultCronCacheTTL = 2 * time.Minute + +// SQLiteCronStore implements store.CronStore backed by SQLite. +type SQLiteCronStore struct { + db *sql.DB + mu sync.Mutex + baseCtx context.Context + cancelCtx context.CancelFunc + onJob func(job *store.CronJob) (*store.CronJobResult, error) + onEvent func(event store.CronEvent) + running bool + stop chan struct{} + + jobCache []store.CronJob + cacheLoaded bool + cacheTime time.Time + cacheTTL time.Duration + + retryCfg cron.RetryConfig + defaultTZ string +} + +func NewSQLiteCronStore(db *sql.DB) *SQLiteCronStore { + return &SQLiteCronStore{db: db, cacheTTL: defaultCronCacheTTL, retryCfg: cron.DefaultRetryConfig()} +} + +func (s *SQLiteCronStore) SetRetryConfig(cfg cron.RetryConfig) { + s.mu.Lock() + defer s.mu.Unlock() + s.retryCfg = cfg +} + +func (s *SQLiteCronStore) SetDefaultTimezone(tz string) { + s.mu.Lock() + defer s.mu.Unlock() + s.defaultTZ = tz +} + +func (s *SQLiteCronStore) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.running { + return nil + } + s.baseCtx, s.cancelCtx = context.WithCancel(context.Background()) + s.stop = make(chan struct{}) + s.running = true + s.recomputeStaleJobs() + go s.runLoop() + slog.Info("sqlite cron service started") + return nil +} + +func (s *SQLiteCronStore) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + if !s.running { + return + } + close(s.stop) + if s.cancelCtx != nil { + s.cancelCtx() + } + s.running = false +} + +func (s *SQLiteCronStore) SetOnJob(handler func(job *store.CronJob) (*store.CronJobResult, error)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onJob = handler +} + +func (s *SQLiteCronStore) SetOnEvent(handler func(event store.CronEvent)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onEvent = handler +} + +func (s *SQLiteCronStore) emitEvent(event store.CronEvent) { + s.mu.Lock() + fn := s.onEvent + s.mu.Unlock() + if fn != nil { + fn(event) + } +} + +// --- Scan helpers --- + +type cronRowScanner interface { + Scan(dest ...any) error +} + +func scanCronRow(row cronRowScanner) (*store.CronJob, error) { + var id uuid.UUID + var tenantID uuid.UUID + var agentID *uuid.UUID + var userID *string + var name, scheduleKind string + var enabled, deleteAfterRun bool + var cronExpr, tz, lastStatus, lastError *string + var runAt, nextRunAt, lastRunAt *time.Time + var intervalMS *int64 + var payloadJSON []byte + var createdAt, updatedAt time.Time + + err := row.Scan(&id, &tenantID, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz, + &intervalMS, &payloadJSON, &deleteAfterRun, &nextRunAt, &lastRunAt, &lastStatus, &lastError, + &createdAt, &updatedAt) + if err != nil { + return nil, err + } + + var payload store.CronPayload + if len(payloadJSON) > 0 { + if err := json.Unmarshal(payloadJSON, &payload); err != nil { + return nil, fmt.Errorf("failed to parse cron job payload: %w", err) + } + } + + job := &store.CronJob{ + ID: id.String(), + TenantID: tenantID, + Name: name, + Enabled: enabled, + Schedule: store.CronSchedule{Kind: scheduleKind}, + Payload: payload, + CreatedAtMS: createdAt.UnixMilli(), + UpdatedAtMS: updatedAt.UnixMilli(), + DeleteAfterRun: deleteAfterRun, + } + + if agentID != nil { + job.AgentID = agentID.String() + } + if userID != nil { + job.UserID = *userID + } + if cronExpr != nil { + job.Schedule.Expr = *cronExpr + } + if runAt != nil { + ms := runAt.UnixMilli() + job.Schedule.AtMS = &ms + } + if intervalMS != nil { + job.Schedule.EveryMS = intervalMS + } + if tz != nil { + job.Schedule.TZ = *tz + } + if nextRunAt != nil { + ms := nextRunAt.UnixMilli() + job.State.NextRunAtMS = &ms + } + if lastRunAt != nil { + ms := lastRunAt.UnixMilli() + job.State.LastRunAtMS = &ms + } + if lastStatus != nil { + job.State.LastStatus = *lastStatus + } + if lastError != nil { + job.State.LastError = *lastError + } + + return job, nil +} + +// computeNextRun calculates the next run time for a schedule. +func computeNextRun(schedule *store.CronSchedule, now time.Time, defaultTZ string) *time.Time { + switch schedule.Kind { + case "at": + if schedule.AtMS != nil { + t := time.UnixMilli(*schedule.AtMS) + if t.After(now) { + return &t + } + } + return nil + case "every": + if schedule.EveryMS != nil && *schedule.EveryMS > 0 { + t := now.Add(time.Duration(*schedule.EveryMS) * time.Millisecond) + return &t + } + return nil + case "cron": + if schedule.Expr == "" { + return nil + } + tz := schedule.TZ + if tz == "" { + tz = defaultTZ + } + evalTime := now + if tz != "" { + if loc, err := time.LoadLocation(tz); err == nil { + evalTime = now.In(loc) + } + } + nextTime, err := gronx.NextTickAfter(schedule.Expr, evalTime, false) + if err != nil { + return nil + } + utcNext := nextTime.UTC() + return &utcNext + default: + return nil + } +} + +func (s *SQLiteCronStore) scanJob(ctx context.Context, id uuid.UUID) (*store.CronJob, error) { + q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, + created_at, updated_at FROM cron_jobs WHERE id = ?` + args := []any{id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + q += " AND tenant_id = ?" + args = append(args, tid) + } + + row := s.db.QueryRowContext(ctx, q, args...) + return scanCronRow(row) +} diff --git a/internal/store/sqlitestore/cron_crud.go b/internal/store/sqlitestore/cron_crud.go new file mode 100644 index 00000000..3a3c3023 --- /dev/null +++ b/internal/store/sqlitestore/cron_crud.go @@ -0,0 +1,413 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/adhocore/gronx" + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *SQLiteCronStore) AddJob(ctx context.Context, name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { + if schedule.TZ == "" && schedule.Kind == "cron" && s.defaultTZ != "" { + schedule.TZ = s.defaultTZ + } + if schedule.TZ != "" { + if _, err := time.LoadLocation(schedule.TZ); err != nil { + return nil, fmt.Errorf("invalid timezone: %s", schedule.TZ) + } + } + + payload := store.CronPayload{ + Kind: "agent_turn", Message: message, Deliver: deliver, Channel: channel, To: to, + } + payloadJSON, _ := json.Marshal(payload) + + id := uuid.Must(uuid.NewV7()) + now := time.Now() + scheduleKind := schedule.Kind + deleteAfterRun := schedule.Kind == "at" + + var cronExpr, tz *string + var runAt *time.Time + if schedule.Expr != "" { + cronExpr = &schedule.Expr + } + if schedule.AtMS != nil { + t := time.UnixMilli(*schedule.AtMS) + runAt = &t + } + if schedule.TZ != "" { + tz = &schedule.TZ + } + + var agentUUID *uuid.UUID + if agentID != "" { + if aid, err := uuid.Parse(agentID); err == nil { + agentUUID = &aid + } + } + + var userIDPtr *string + if userID != "" { + userIDPtr = &userID + } + + var intervalMS *int64 + if schedule.EveryMS != nil { + intervalMS = schedule.EveryMS + } + + nextRun := computeNextRun(&schedule, now, s.defaultTZ) + + _, err := s.db.ExecContext(ctx, + `INSERT INTO cron_jobs (id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + interval_ms, payload, delete_after_run, next_run_at, created_at, updated_at) + VALUES (?,?,?,?,?,1,?,?,?,?,?,?,?,?,?,?)`, + id, tenantIDForInsert(ctx), agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz, + intervalMS, payloadJSON, deleteAfterRun, nextRun, now, now, + ) + if err != nil { + return nil, fmt.Errorf("create cron job: %w", err) + } + + s.InvalidateCache() + job, _ := s.GetJob(ctx, id.String()) + return job, nil +} + +func (s *SQLiteCronStore) GetJob(ctx context.Context, jobID string) (*store.CronJob, bool) { + id, err := uuid.Parse(jobID) + if err != nil { + return nil, false + } + job, err := s.scanJob(ctx, id) + if err != nil { + return nil, false + } + return job, true +} + +func (s *SQLiteCronStore) ListJobs(ctx context.Context, includeDisabled bool, agentID, userID string) []store.CronJob { + q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, + created_at, updated_at FROM cron_jobs WHERE 1=1` + + var args []any + + if !includeDisabled { + q += " AND enabled = ?" + args = append(args, true) + } + if agentID != "" { + if aid, err := uuid.Parse(agentID); err == nil { + q += " AND agent_id = ?" + args = append(args, aid) + } + } + if userID != "" { + q += " AND user_id = ?" + args = append(args, userID) + } + + clause, targs, tErr := scopeClause(ctx) + if tErr != nil { + slog.Warn("cron.ListJobs: tenant context missing, returning empty (fail-closed)", "error", tErr) + return nil + } + if clause != "" { + q += clause + args = append(args, targs...) + } + + q += " ORDER BY created_at DESC" + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil + } + defer rows.Close() + + var result []store.CronJob + for rows.Next() { + job, err := scanCronRow(rows) + if err != nil { + continue + } + result = append(result, *job) + } + if err := rows.Err(); err != nil { + slog.Warn("cron: list jobs iteration error", "error", err) + } + return result +} + +func (s *SQLiteCronStore) RemoveJob(ctx context.Context, jobID string) error { + id, err := uuid.Parse(jobID) + if err != nil { + return fmt.Errorf("invalid job ID: %s", jobID) + } + + q := "DELETE FROM cron_jobs WHERE id = ?" + args := []any{id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + q += " AND tenant_id = ?" + args = append(args, tid) + } + + res, err := s.db.ExecContext(ctx, q, args...) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("job not found") + } + s.InvalidateCache() + return nil +} + +func (s *SQLiteCronStore) EnableJob(ctx context.Context, jobID string, enabled bool) error { + id, err := uuid.Parse(jobID) + if err != nil { + return fmt.Errorf("invalid job ID: %s", jobID) + } + + q := "UPDATE cron_jobs SET enabled = ?, updated_at = ? WHERE id = ?" + args := []any{enabled, time.Now(), id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + q += " AND tenant_id = ?" + args = append(args, tid) + } + + res, err := s.db.ExecContext(ctx, q, args...) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("job not found") + } + s.InvalidateCache() + return nil +} + +func (s *SQLiteCronStore) UpdateJob(ctx context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) { + id, err := uuid.Parse(jobID) + if err != nil { + return nil, fmt.Errorf("invalid job ID: %s", jobID) + } + + updates := make(map[string]any) + if patch.Name != "" { + updates["name"] = patch.Name + } + if patch.Enabled != nil { + updates["enabled"] = *patch.Enabled + } + + // Build tenant WHERE suffix for schedule reads. + tenantSuffix := "" + var tenantArg []any + if !store.IsCrossTenant(ctx) { + if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil { + tenantSuffix = " AND tenant_id = ?" + tenantArg = append(tenantArg, tid) + } + } + + if patch.Schedule != nil { + var curKind string + var curExpr, curTZ *string + var curIntervalMS *int64 + var curRunAt *time.Time + if err := s.db.QueryRowContext(ctx, + "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = ?"+tenantSuffix, + append([]any{id}, tenantArg...)..., + ).Scan(&curKind, &curExpr, &curTZ, &curIntervalMS, &curRunAt); err != nil { + return nil, fmt.Errorf("failed to fetch current schedule for job %s: %w", jobID, err) + } + + newKind := patch.Schedule.Kind + if newKind == "" { + newKind = curKind + } + updates["schedule_kind"] = newKind + + switch newKind { + case "cron": + if patch.Schedule.Expr != "" { + updates["cron_expression"] = patch.Schedule.Expr + } + if patch.Schedule.TZ != "" { + if _, err := time.LoadLocation(patch.Schedule.TZ); err != nil { + return nil, fmt.Errorf("invalid timezone: %s", patch.Schedule.TZ) + } + updates["timezone"] = patch.Schedule.TZ + } + if curKind != "cron" { + updates["interval_ms"] = nil + updates["run_at"] = nil + } + case "every": + if patch.Schedule.EveryMS != nil { + updates["interval_ms"] = *patch.Schedule.EveryMS + } + if curKind != "every" { + updates["cron_expression"] = nil + updates["timezone"] = nil + updates["run_at"] = nil + } + case "at": + if patch.Schedule.AtMS != nil { + t := time.UnixMilli(*patch.Schedule.AtMS) + updates["run_at"] = t + } + if curKind != "at" { + updates["cron_expression"] = nil + updates["timezone"] = nil + updates["interval_ms"] = nil + } + } + + merged := buildMergedSchedule(newKind, curKind, patch.Schedule, curExpr, curTZ, curIntervalMS, curRunAt) + if err := validateSchedule(&merged); err != nil { + return nil, err + } + next := computeNextRun(&merged, time.Now(), s.defaultTZ) + updates["next_run_at"] = next + } + + if patch.DeleteAfterRun != nil { + updates["delete_after_run"] = *patch.DeleteAfterRun + } + if patch.AgentID != nil { + if *patch.AgentID == "" { + updates["agent_id"] = nil + } else if aid, parseErr := uuid.Parse(*patch.AgentID); parseErr == nil { + updates["agent_id"] = aid + } + } + + needsPayloadUpdate := patch.Message != "" || patch.Deliver != nil || patch.Channel != nil || patch.To != nil || patch.WakeHeartbeat != nil + if needsPayloadUpdate { + var payloadJSON []byte + if scanErr := s.db.QueryRowContext(ctx, "SELECT payload FROM cron_jobs WHERE id = ?"+tenantSuffix, append([]any{id}, tenantArg...)...).Scan(&payloadJSON); scanErr == nil { + var p store.CronPayload + if len(payloadJSON) > 0 { + if err := json.Unmarshal(payloadJSON, &p); err != nil { + return nil, fmt.Errorf("failed to parse existing payload for job %s: %w", jobID, err) + } + } + if patch.Message != "" { + p.Message = patch.Message + } + if patch.Deliver != nil { + p.Deliver = *patch.Deliver + } + if patch.Channel != nil { + p.Channel = *patch.Channel + } + if patch.To != nil { + p.To = *patch.To + } + if patch.WakeHeartbeat != nil { + p.WakeHeartbeat = *patch.WakeHeartbeat + } + merged, err := json.Marshal(p) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload for job %s: %w", jobID, err) + } + updates["payload"] = merged + } + } + + updates["updated_at"] = time.Now() + + var execErr error + if store.IsCrossTenant(ctx) { + execErr = execMapUpdate(ctx, s.db, "cron_jobs", id, updates) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + execErr = fmt.Errorf("tenant_id required for update") + } else { + execErr = execMapUpdateWhereTenant(ctx, s.db, "cron_jobs", updates, id, tid) + } + } + if execErr != nil { + return nil, execErr + } + + s.InvalidateCache() + job, _ := s.scanJob(ctx, id) + return job, nil +} + +func buildMergedSchedule(newKind, curKind string, patch *store.CronSchedule, curExpr, curTZ *string, curIntervalMS *int64, curRunAt *time.Time) store.CronSchedule { + merged := store.CronSchedule{Kind: newKind} + switch newKind { + case "cron": + if patch.Expr != "" { + merged.Expr = patch.Expr + } else if curExpr != nil { + merged.Expr = *curExpr + } + if patch.TZ != "" { + merged.TZ = patch.TZ + } else if curTZ != nil && newKind == curKind { + merged.TZ = *curTZ + } + case "every": + if patch.EveryMS != nil { + merged.EveryMS = patch.EveryMS + } else if curIntervalMS != nil { + merged.EveryMS = curIntervalMS + } + case "at": + if patch.AtMS != nil { + merged.AtMS = patch.AtMS + } else if curRunAt != nil { + ms := curRunAt.UnixMilli() + merged.AtMS = &ms + } + } + return merged +} + +func validateSchedule(s *store.CronSchedule) error { + switch s.Kind { + case "cron": + if s.Expr == "" { + return fmt.Errorf("cron schedule requires expr") + } + gx := gronx.New() + if !gx.IsValid(s.Expr) { + return fmt.Errorf("invalid cron expression: %s", s.Expr) + } + case "every": + if s.EveryMS == nil || *s.EveryMS <= 0 { + return fmt.Errorf("every schedule requires positive everyMs") + } + case "at": + if s.AtMS == nil { + return fmt.Errorf("at schedule requires atMs") + } + } + return nil +} diff --git a/internal/store/sqlitestore/cron_exec.go b/internal/store/sqlitestore/cron_exec.go new file mode 100644 index 00000000..3afa808b --- /dev/null +++ b/internal/store/sqlitestore/cron_exec.go @@ -0,0 +1,137 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *SQLiteCronStore) RunJob(ctx context.Context, jobID string, force bool) (bool, string, error) { + job, ok := s.GetJob(ctx, jobID) + if !ok { + return false, "", fmt.Errorf("job %s not found", jobID) + } + + s.mu.Lock() + handler := s.onJob + s.mu.Unlock() + + if handler == nil { + return false, "", fmt.Errorf("no job handler configured") + } + + if id, parseErr := uuid.Parse(jobID); parseErr == nil { + if _, execErr := s.db.ExecContext(ctx, "UPDATE cron_jobs SET last_status = 'running', updated_at = ? WHERE id = ?", time.Now(), id); execErr != nil { + slog.Warn("cron: failed to mark job running", "job", jobID, "error", execErr) + } + } + s.InvalidateCache() + + s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name, UserID: job.UserID}) + s.executeOneJob(*job, handler) + + s.mu.Lock() + s.cacheLoaded = false + s.mu.Unlock() + return true, "", nil +} + +func (s *SQLiteCronStore) GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]store.CronRunLogEntry, int) { + if limit <= 0 { + limit = 20 + } + if offset < 0 { + offset = 0 + } + + const cols = "r.job_id, r.status, r.error, r.summary, r.ran_at, COALESCE(r.duration_ms, 0), COALESCE(r.input_tokens, 0), COALESCE(r.output_tokens, 0)" + + // Tenant isolation via JOIN with cron_jobs. + var tenantJoin, tenantWhere string + var tenantArgs []any + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + tenantJoin = " JOIN cron_jobs j ON r.job_id = j.id" + tenantWhere = " AND j.tenant_id = ?" + tenantArgs = append(tenantArgs, tid) + } + } + + var total int + var rows *sql.Rows + var err error + + if jobID != "" { + id, parseErr := uuid.Parse(jobID) + if parseErr != nil { + return nil, 0 + } + countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE r.job_id = ?%s", tenantJoin, tenantWhere) + countArgs := append(tenantArgs, id) + s.db.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) + + dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE r.job_id = ?%s ORDER BY r.ran_at DESC LIMIT ? OFFSET ?", + cols, tenantJoin, tenantWhere) + dataArgs := append(tenantArgs, id, limit, offset) + rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...) + } else { + countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE 1=1%s", tenantJoin, tenantWhere) + s.db.QueryRowContext(ctx, countQ, tenantArgs...).Scan(&total) + + dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE 1=1%s ORDER BY r.ran_at DESC LIMIT ? OFFSET ?", + cols, tenantJoin, tenantWhere) + dataArgs := append(tenantArgs, limit, offset) + rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...) + } + if err != nil { + return nil, 0 + } + defer rows.Close() + + var result []store.CronRunLogEntry + for rows.Next() { + var jobUUID uuid.UUID + var status string + var errStr, summary *string + var ranAt time.Time + var durationMS int64 + var inputTokens, outputTokens int + if err := rows.Scan(&jobUUID, &status, &errStr, &summary, &ranAt, &durationMS, &inputTokens, &outputTokens); err != nil { + continue + } + result = append(result, store.CronRunLogEntry{ + Ts: ranAt.UnixMilli(), + JobID: jobUUID.String(), + Status: status, + Error: derefStr(errStr), + Summary: derefStr(summary), + DurationMS: durationMS, + InputTokens: inputTokens, + OutputTokens: outputTokens, + }) + } + if rErr := rows.Err(); rErr != nil { + slog.Warn("cron: run log iteration error", "error", rErr) + } + return result, total +} + +func (s *SQLiteCronStore) Status() map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + var count int64 + s.db.QueryRow("SELECT COUNT(*) FROM cron_jobs WHERE enabled = 1").Scan(&count) + return map[string]any{ + "enabled": s.running, + "jobs": count, + } +} diff --git a/internal/store/sqlitestore/cron_scheduler.go b/internal/store/sqlitestore/cron_scheduler.go new file mode 100644 index 00000000..a3bd32eb --- /dev/null +++ b/internal/store/sqlitestore/cron_scheduler.go @@ -0,0 +1,253 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/cron" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *SQLiteCronStore) GetDueJobs(now time.Time) []store.CronJob { + s.mu.Lock() + if !s.cacheLoaded || time.Since(s.cacheTime) > s.cacheTTL { + s.refreshJobCache() + } + jobs := s.jobCache + s.mu.Unlock() + + var due []store.CronJob + for i := range jobs { + if jobs[i].Enabled && jobs[i].State.NextRunAtMS != nil { + nextRun := time.UnixMilli(*jobs[i].State.NextRunAtMS) + if !nextRun.After(now) { + due = append(due, jobs[i]) + } + } + } + return due +} + +// refreshJobCache reloads all enabled jobs from DB. Must be called with mu held. +func (s *SQLiteCronStore) refreshJobCache() { + rows, err := s.db.QueryContext(s.baseCtx, + `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, + created_at, updated_at FROM cron_jobs WHERE enabled = 1`) + if err != nil { + return + } + defer rows.Close() + + s.jobCache = nil + for rows.Next() { + job, err := scanCronRow(rows) + if err != nil { + continue + } + s.jobCache = append(s.jobCache, *job) + } + if err := rows.Err(); err != nil { + slog.Warn("cron: cache refresh iteration error", "error", err) + } + s.cacheLoaded = true + s.cacheTime = time.Now() +} + +// InvalidateCache forces a cache refresh on the next GetDueJobs call. +func (s *SQLiteCronStore) InvalidateCache() { + s.mu.Lock() + s.cacheLoaded = false + s.mu.Unlock() +} + +// recomputeStaleJobs fixes enabled jobs with next_run_at = NULL on startup. +func (s *SQLiteCronStore) recomputeStaleJobs() { + rows, err := s.db.QueryContext(s.baseCtx, + `SELECT id, schedule_kind, cron_expression, run_at, timezone, interval_ms + FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NULL`) + if err != nil { + slog.Warn("cron: failed to query stale jobs", "error", err) + return + } + defer rows.Close() + + now := time.Now() + var fixed int + for rows.Next() { + var id uuid.UUID + var scheduleKind string + var cronExpr, tz *string + var runAt *time.Time + var intervalMS *int64 + + if err := rows.Scan(&id, &scheduleKind, &cronExpr, &runAt, &tz, &intervalMS); err != nil { + continue + } + + schedule := store.CronSchedule{Kind: scheduleKind} + if cronExpr != nil { + schedule.Expr = *cronExpr + } + if runAt != nil { + ms := runAt.UnixMilli() + schedule.AtMS = &ms + } + if intervalMS != nil { + schedule.EveryMS = intervalMS + } + if tz != nil { + schedule.TZ = *tz + } + + next := computeNextRun(&schedule, now, s.defaultTZ) + if next == nil { + if scheduleKind == "at" { + s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET enabled = 0, updated_at = ? WHERE id = ?", now, id) + } + continue + } + + s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?", *next, now, id) + fixed++ + } + if err := rows.Err(); err != nil { + slog.Warn("cron: recompute stale iteration error", "error", err) + } + + if fixed > 0 { + slog.Info("cron: recomputed stale next_run_at on startup", "fixed", fixed) + } +} + +func (s *SQLiteCronStore) runLoop() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + for { + select { + case <-s.stop: + return + case <-ticker.C: + s.checkAndRunDueJobs() + } + } +} + +func (s *SQLiteCronStore) checkAndRunDueJobs() { + dueJobs := s.GetDueJobs(time.Now()) + if len(dueJobs) == 0 { + return + } + + s.mu.Lock() + handler := s.onJob + s.mu.Unlock() + + if handler == nil { + return + } + + // Clear next_run for all due jobs first to prevent duplicate fires. + for _, job := range dueJobs { + if id, parseErr := uuid.Parse(job.ID); parseErr == nil { + s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET next_run_at = NULL WHERE id = ?", id) + } + } + + // Execute jobs in parallel — scheduler enforces per-session serialization. + var wg sync.WaitGroup + for _, job := range dueJobs { + wg.Add(1) + go func(job store.CronJob) { + defer wg.Done() + s.executeOneJob(job, handler) + }(job) + } + wg.Wait() + + s.mu.Lock() + s.cacheLoaded = false + s.mu.Unlock() +} + +// executeOneJob runs a single cron job with retry, logs the result, and updates next_run_at. +func (s *SQLiteCronStore) executeOneJob(job store.CronJob, handler func(job *store.CronJob) (*store.CronJobResult, error)) { + startTime := time.Now() + + var lastResult *store.CronJobResult + resultStr, attempts, err := cron.ExecuteWithRetry(func() (string, error) { + r, e := handler(&job) + if e != nil { + return "", e + } + lastResult = r + if r != nil { + return r.Content, nil + } + return "", nil + }, s.retryCfg) + + durationMS := time.Since(startTime).Milliseconds() + + if attempts > 1 { + slog.Info("cron job retried", "id", job.ID, "attempts", attempts, "success", err == nil) + } + + now := time.Now() + status := "ok" + var lastError *string + if err != nil { + status = "error" + errStr := err.Error() + lastError = &errStr + } + + var inputTokens, outputTokens int + if lastResult != nil { + inputTokens = lastResult.InputTokens + outputTokens = lastResult.OutputTokens + } + + logID := uuid.Must(uuid.NewV7()) + var summary *string + if err == nil { + truncated := cron.TruncateOutput(resultStr) + summary = &truncated + } + if id, parseErr := uuid.Parse(job.ID); parseErr == nil { + var agentUUID *uuid.UUID + if aid, aidErr := uuid.Parse(job.AgentID); aidErr == nil { + agentUUID = &aid + } + s.db.ExecContext(s.baseCtx, + `INSERT INTO cron_run_logs (id, job_id, agent_id, status, error, summary, duration_ms, input_tokens, output_tokens, ran_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + logID, id, agentUUID, status, lastError, summary, durationMS, inputTokens, outputTokens, now, + ) + } + + if job.DeleteAfterRun { + if id, parseErr := uuid.Parse(job.ID); parseErr == nil { + s.db.ExecContext(s.baseCtx, "DELETE FROM cron_jobs WHERE id = ?", id) + } + } else if id, parseErr := uuid.Parse(job.ID); parseErr == nil { + schedule := job.Schedule + next := computeNextRun(&schedule, now, s.defaultTZ) + s.db.ExecContext(s.baseCtx, + "UPDATE cron_jobs SET last_run_at = ?, last_status = ?, last_error = ?, next_run_at = ?, updated_at = ? WHERE id = ?", + now, status, lastError, next, now, id, + ) + } + + evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, UserID: job.UserID, Status: status} + if err != nil { + evt.Action = "error" + evt.Error = err.Error() + } + s.emitEvent(evt) +} diff --git a/internal/store/sqlitestore/factory.go b/internal/store/sqlitestore/factory.go new file mode 100644 index 00000000..88b60adf --- /dev/null +++ b/internal/store/sqlitestore/factory.go @@ -0,0 +1,57 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "fmt" + "log/slog" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// NewSQLiteStores creates all stores backed by SQLite. +// Mirrors pg.NewPGStores() — returns the same *store.Stores struct. +func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) { + db, err := OpenDB(cfg.SQLitePath) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + // Apply schema (create tables on first run, migrate on upgrade). + if err := EnsureSchema(db); err != nil { + db.Close() + return nil, fmt.Errorf("ensure schema: %w", err) + } + + slog.Info("sqlite stores initialized", "path", cfg.SQLitePath) + + return &store.Stores{ + DB: db, + Sessions: NewSQLiteSessionStore(db), + Agents: NewSQLiteAgentStore(db), + Providers: NewSQLiteProviderStore(db, cfg.EncryptionKey), + Tracing: NewSQLiteTracingStore(db), + ConfigSecrets: NewSQLiteConfigSecretsStore(db, cfg.EncryptionKey), + BuiltinTools: NewSQLiteBuiltinToolStore(db), + Heartbeats: NewSQLiteHeartbeatStore(db), + Tenants: NewSQLiteTenantStore(db), + BuiltinToolTenantCfgs: NewSQLiteBuiltinToolTenantConfigStore(db), + SkillTenantCfgs: NewSQLiteSkillTenantConfigStore(db), + SystemConfigs: NewSQLiteSystemConfigStore(db), + Snapshots: NewSQLiteSnapshotStore(db), + Cron: NewSQLiteCronStore(db), + ChannelInstances: NewSQLiteChannelInstanceStore(db, cfg.EncryptionKey), + Pairing: NewSQLitePairingStore(db), + PendingMessages: NewSQLitePendingMessageStore(db), + Contacts: NewSQLiteContactStore(db), + Teams: NewSQLiteTeamStore(db), + Skills: NewSQLiteSkillStore(db, cfg.SkillsStorageDir), + MCP: NewSQLiteMCPServerStore(db, cfg.EncryptionKey), + Activity: NewSQLiteActivityStore(db), + APIKeys: NewSQLiteAPIKeyStore(db), + ConfigPermissions: NewSQLiteConfigPermissionStore(db), + Memory: NewSQLiteMemoryStore(db), + // Phase 2 Batch B+C stores (nil = gracefully skipped by gateway): + // AgentLinks, KnowledgeGraph, SecureCLI + }, nil +} diff --git a/internal/store/sqlitestore/heartbeat.go b/internal/store/sqlitestore/heartbeat.go new file mode 100644 index 00000000..f84c099b --- /dev/null +++ b/internal/store/sqlitestore/heartbeat.go @@ -0,0 +1,371 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteHeartbeatStore implements store.HeartbeatStore backed by SQLite. +type SQLiteHeartbeatStore struct { + db *sql.DB + mu sync.Mutex + onEvent func(store.HeartbeatEvent) + + dueCache []store.AgentHeartbeat + cacheLoaded bool + cacheTime time.Time + cacheTTL time.Duration +} + +const defaultHeartbeatCacheTTL = 30 * time.Second + +func NewSQLiteHeartbeatStore(db *sql.DB) *SQLiteHeartbeatStore { + return &SQLiteHeartbeatStore{db: db, cacheTTL: defaultHeartbeatCacheTTL} +} + +func (s *SQLiteHeartbeatStore) SetOnEvent(fn func(store.HeartbeatEvent)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onEvent = fn +} + +func (s *SQLiteHeartbeatStore) emitEvent(event store.HeartbeatEvent) { + s.mu.Lock() + fn := s.onEvent + s.mu.Unlock() + if fn != nil { + fn(event) + } +} + +func (s *SQLiteHeartbeatStore) InvalidateCache() { + s.mu.Lock() + defer s.mu.Unlock() + s.cacheLoaded = false +} + +const heartbeatSelectCols = `id, agent_id, enabled, interval_sec, prompt, provider_id, model, + isolated_session, light_context, ack_max_chars, max_retries, + active_hours_start, active_hours_end, timezone, + channel, chat_id, + next_run_at, last_run_at, last_status, last_error, + run_count, suppress_count, metadata, created_at, updated_at` + +func scanHeartbeat(row interface { + Scan(...any) error +}) (*store.AgentHeartbeat, error) { + var hb store.AgentHeartbeat + var metadata []byte + var nextRunAt, lastRunAt nullSqliteTime + createdAt, updatedAt := scanTimePair() + err := row.Scan( + &hb.ID, &hb.AgentID, &hb.Enabled, &hb.IntervalSec, &hb.Prompt, &hb.ProviderID, &hb.Model, + &hb.IsolatedSession, &hb.LightContext, &hb.AckMaxChars, &hb.MaxRetries, + &hb.ActiveHoursStart, &hb.ActiveHoursEnd, &hb.Timezone, + &hb.Channel, &hb.ChatID, + &nextRunAt, &lastRunAt, &hb.LastStatus, &hb.LastError, + &hb.RunCount, &hb.SuppressCount, &metadata, createdAt, updatedAt, + ) + if err != nil { + return nil, err + } + hb.CreatedAt = createdAt.Time + hb.UpdatedAt = updatedAt.Time + if nextRunAt.Valid { + hb.NextRunAt = &nextRunAt.Time + } + if lastRunAt.Valid { + hb.LastRunAt = &lastRunAt.Time + } + hb.Metadata = metadata + return &hb, nil +} + +func (s *SQLiteHeartbeatStore) Get(ctx context.Context, agentID uuid.UUID) (*store.AgentHeartbeat, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+heartbeatSelectCols+` FROM agent_heartbeats WHERE agent_id = ?`, agentID, + ) + return scanHeartbeat(row) +} + +func (s *SQLiteHeartbeatStore) Upsert(ctx context.Context, hb *store.AgentHeartbeat) error { + meta := hb.Metadata + if meta == nil { + meta = json.RawMessage("{}") + } + now := time.Now().UTC() + createdAt, updatedAt := scanTimePair() + err := s.db.QueryRowContext(ctx, + `INSERT INTO agent_heartbeats + (agent_id, enabled, interval_sec, prompt, provider_id, model, + isolated_session, light_context, ack_max_chars, max_retries, + active_hours_start, active_hours_end, timezone, + channel, chat_id, next_run_at, metadata, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (agent_id) DO UPDATE SET + enabled = excluded.enabled, + interval_sec = excluded.interval_sec, + prompt = excluded.prompt, + provider_id = excluded.provider_id, + model = excluded.model, + isolated_session = excluded.isolated_session, + light_context = excluded.light_context, + ack_max_chars = excluded.ack_max_chars, + max_retries = excluded.max_retries, + active_hours_start = excluded.active_hours_start, + active_hours_end = excluded.active_hours_end, + timezone = excluded.timezone, + channel = excluded.channel, + chat_id = excluded.chat_id, + next_run_at = excluded.next_run_at, + metadata = excluded.metadata, + updated_at = excluded.updated_at + RETURNING id, created_at, updated_at`, + hb.AgentID, hb.Enabled, hb.IntervalSec, hb.Prompt, hb.ProviderID, hb.Model, + hb.IsolatedSession, hb.LightContext, hb.AckMaxChars, hb.MaxRetries, + hb.ActiveHoursStart, hb.ActiveHoursEnd, hb.Timezone, + hb.Channel, hb.ChatID, hb.NextRunAt, meta, now, now, + ).Scan(&hb.ID, createdAt, updatedAt) + if err != nil { + return err + } + hb.CreatedAt = createdAt.Time + hb.UpdatedAt = updatedAt.Time + s.InvalidateCache() + return nil +} + +func (s *SQLiteHeartbeatStore) ListDue(ctx context.Context, now time.Time) ([]store.AgentHeartbeat, error) { + s.mu.Lock() + if s.cacheLoaded && time.Since(s.cacheTime) < s.cacheTTL { + cached := s.dueCache + s.mu.Unlock() + var due []store.AgentHeartbeat + for _, hb := range cached { + if hb.NextRunAt != nil && !hb.NextRunAt.After(now) { + due = append(due, hb) + } + } + return due, nil + } + s.mu.Unlock() + + rows, err := s.db.QueryContext(ctx, + `SELECT `+heartbeatSelectCols+` + FROM agent_heartbeats + WHERE enabled = 1 AND next_run_at IS NOT NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + + var all []store.AgentHeartbeat + for rows.Next() { + hb, err := scanHeartbeat(rows) + if err != nil { + return nil, err + } + all = append(all, *hb) + } + if err := rows.Err(); err != nil { + return nil, err + } + + s.mu.Lock() + s.dueCache = all + s.cacheLoaded = true + s.cacheTime = time.Now() + s.mu.Unlock() + + var due []store.AgentHeartbeat + for _, hb := range all { + if hb.NextRunAt != nil && !hb.NextRunAt.After(now) { + due = append(due, hb) + } + } + return due, nil +} + +func (s *SQLiteHeartbeatStore) UpdateState(ctx context.Context, id uuid.UUID, state store.HeartbeatState) error { + _, err := s.db.ExecContext(ctx, + `UPDATE agent_heartbeats SET + next_run_at = ?, last_run_at = ?, last_status = ?, last_error = ?, + run_count = ?, suppress_count = ?, updated_at = ? + WHERE id = ?`, + state.NextRunAt, state.LastRunAt, state.LastStatus, state.LastError, + state.RunCount, state.SuppressCount, time.Now().UTC(), id, + ) + if err == nil { + s.InvalidateCache() + } + return err +} + +func (s *SQLiteHeartbeatStore) Delete(ctx context.Context, agentID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_heartbeats WHERE agent_id = ?`, agentID) + if err == nil { + s.InvalidateCache() + } + return err +} + +func (s *SQLiteHeartbeatStore) InsertLog(ctx context.Context, log *store.HeartbeatRunLog) error { + meta := log.Metadata + if meta == nil { + meta = json.RawMessage("{}") + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO heartbeat_run_logs + (heartbeat_id, agent_id, status, summary, error, + duration_ms, input_tokens, output_tokens, skip_reason, metadata, ran_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + log.HeartbeatID, log.AgentID, log.Status, log.Summary, log.Error, + log.DurationMS, log.InputTokens, log.OutputTokens, log.SkipReason, meta, log.RanAt, + ) + return err +} + +func (s *SQLiteHeartbeatStore) ListLogs(ctx context.Context, agentID uuid.UUID, limit, offset int) ([]store.HeartbeatRunLog, int, error) { + if limit <= 0 { + limit = 20 + } + + var total int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM heartbeat_run_logs WHERE agent_id = ?`, agentID, + ).Scan(&total); err != nil { + return nil, 0, err + } + + rows, err := s.db.QueryContext(ctx, + `SELECT id, heartbeat_id, agent_id, status, summary, error, + duration_ms, input_tokens, output_tokens, skip_reason, metadata, ran_at, created_at + FROM heartbeat_run_logs WHERE agent_id = ? + ORDER BY ran_at DESC LIMIT ? OFFSET ?`, + agentID, limit, offset, + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var logs []store.HeartbeatRunLog + for rows.Next() { + var l store.HeartbeatRunLog + var metadata []byte + var ranAt, createdAt sqliteTime + if err := rows.Scan( + &l.ID, &l.HeartbeatID, &l.AgentID, &l.Status, &l.Summary, &l.Error, + &l.DurationMS, &l.InputTokens, &l.OutputTokens, &l.SkipReason, &metadata, &ranAt, &createdAt, + ); err != nil { + return nil, 0, err + } + l.RanAt = ranAt.Time + l.CreatedAt = createdAt.Time + l.Metadata = metadata + logs = append(logs, l) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return logs, total, nil +} + +// ListDeliveryTargets returns distinct (channel, chatID, title, kind) pairs from sessions. +// SQLite adaptation: replaces DISTINCT ON + split_part with GROUP BY + substr/instr + Go-side dedup. +// Session key format: agent:{key}:{channel}:{kind}:{chatId} +func (s *SQLiteHeartbeatStore) ListDeliveryTargets(ctx context.Context, agentID uuid.UUID) ([]store.DeliveryTarget, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT + s.channel, + substr(s.session_key, instr(s.session_key||':', ':') + 1) AS raw_tail, + json_extract(s.metadata, '$.chat_title') AS chat_title, + s.session_key, + cc.display_name, + cc.username + FROM sessions s + LEFT JOIN channel_contacts cc + ON cc.channel_type = s.channel + WHERE s.agent_id = ? + AND s.channel IS NOT NULL AND s.channel != '' + AND s.session_key NOT LIKE '%:heartbeat%' + AND s.session_key NOT LIKE '%:cron%' + AND s.session_key NOT LIKE '%:subagent%' + AND s.session_key NOT LIKE '%:team:%' + GROUP BY s.channel, s.session_key`, + agentID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + seen := make(map[string]bool) + var targets []store.DeliveryTarget + for rows.Next() { + var channel, rawTail, sessionKey string + var chatTitle, displayName, username *string + if err := rows.Scan(&channel, &rawTail, &chatTitle, &sessionKey, &displayName, &username); err != nil { + return nil, err + } + + // Parse chatId: 5th colon-separated segment of session_key. + chatID := extractSessionKeyPart(sessionKey, 5) + if chatID == "" { + continue + } + key := channel + ":" + chatID + if seen[key] { + continue + } + seen[key] = true + + title := "" + switch { + case chatTitle != nil && *chatTitle != "": + title = *chatTitle + case displayName != nil && *displayName != "": + title = *displayName + case username != nil && *username != "": + title = "@" + *username + } + + kind := "other" + switch { + case strings.Contains(sessionKey, ":group:"): + kind = "group" + case strings.Contains(sessionKey, ":direct:"): + kind = "dm" + } + + targets = append(targets, store.DeliveryTarget{ + Channel: channel, + ChatID: chatID, + Title: title, + Kind: kind, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return targets, nil +} + +// extractSessionKeyPart returns the n-th colon-delimited segment (1-indexed). +func extractSessionKeyPart(key string, n int) string { + parts := strings.SplitN(key, ":", n+1) + if len(parts) < n { + return "" + } + return parts[n-1] +} diff --git a/internal/store/sqlitestore/helpers.go b/internal/store/sqlitestore/helpers.go new file mode 100644 index 00000000..e8d70b10 --- /dev/null +++ b/internal/store/sqlitestore/helpers.go @@ -0,0 +1,220 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// validColumnName matches safe SQL identifiers (letters, digits, underscores). +var validColumnName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +// --- Nullable helpers --- + +func nilStr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func nilInt(v int) *int { + if v == 0 { + return nil + } + return &v +} + +func nilUUID(u *uuid.UUID) *uuid.UUID { + if u == nil || *u == uuid.Nil { + return nil + } + return u +} + +func nilTime(t *time.Time) *time.Time { + if t == nil || t.IsZero() { + return nil + } + return t +} + +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +func derefUUID(u *uuid.UUID) uuid.UUID { + if u == nil { + return uuid.Nil + } + return *u +} + +// --- JSON helpers --- + +func jsonOrEmpty(data []byte) []byte { + if data == nil { + return []byte("{}") + } + return data +} + +func jsonOrEmptyArray(data []byte) []byte { + if data == nil { + return []byte("[]") + } + return data +} + +func jsonOrNull(data json.RawMessage) any { + if data == nil { + return nil + } + // Return []byte for consistency with PG helpers (store implementations expect []byte). + return []byte(data) +} + +func derefBytes(b *[]byte) []byte { + if b == nil { + return nil + } + return *b +} + +// jsonStringArray converts a Go string slice to a JSON array string for SQLite storage. +// SQLite stores arrays as JSON text (no native array type). +func jsonStringArray(arr []string) string { + if arr == nil { + return "[]" + } + data, _ := json.Marshal(arr) + return string(data) +} + +// scanJSONStringArray parses a JSON array stored as TEXT into a Go string slice. +func scanJSONStringArray(data []byte, dest *[]string) { + if data == nil || len(data) == 0 { + return + } + _ = json.Unmarshal(data, dest) +} + +// sqliteVal marshals complex Go types (maps, slices) to JSON strings +// since the SQLite driver cannot serialize them directly. +func sqliteVal(v any) any { + if v == nil { + return nil + } + switch v.(type) { + case string, int, int64, float64, bool, time.Time, []byte, json.RawMessage: + return v + } + // For maps, slices, etc. — marshal to JSON string. + b, err := json.Marshal(v) + if err != nil { + return nil + } + return string(b) +} + +// --- Dynamic UPDATE helper --- + +// execMapUpdate builds and runs a dynamic UPDATE with ? placeholders. +func execMapUpdate(ctx context.Context, db *sql.DB, table string, id uuid.UUID, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + var setClauses []string + var args []any + for col, val := range updates { + if !validColumnName.MatchString(col) { + slog.Warn("security.invalid_column_name", "table", table, "column", col) + return fmt.Errorf("invalid column name: %q", col) + } + setClauses = append(setClauses, col+" = ?") + args = append(args, sqliteVal(val)) + } + // Auto-set updated_at for tables that have the column. + if _, ok := updates["updated_at"]; !ok && tableHasUpdatedAt(table) { + setClauses = append(setClauses, "updated_at = ?") + args = append(args, time.Now().UTC()) + } + args = append(args, id) + q := fmt.Sprintf("UPDATE %s SET %s WHERE id = ?", table, strings.Join(setClauses, ", ")) + _, err := db.ExecContext(ctx, q, args...) + return err +} + +var tablesWithUpdatedAt = map[string]bool{ + "agents": true, "llm_providers": true, "sessions": true, + "channel_instances": true, "cron_jobs": true, + "skills": true, "mcp_servers": true, "agent_links": true, + "agent_teams": true, "team_tasks": true, "builtin_tools": true, + "agent_context_files": true, "user_context_files": true, + "user_agent_overrides": true, "config_secrets": true, + "memory_documents": true, "memory_chunks": true, "embedding_cache": true, + "secure_cli_binaries": true, "tenants": true, +} + +func tableHasUpdatedAt(table string) bool { + return tablesWithUpdatedAt[table] +} + +// --- Tenant filter helpers --- + +func tenantIDForInsert(ctx context.Context) uuid.UUID { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return store.MasterTenantID + } + return tid +} + +func requireTenantID(ctx context.Context) (uuid.UUID, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return uuid.Nil, fmt.Errorf("tenant_id required") + } + return tid, nil +} + +// execMapUpdateWhereTenant builds and runs a dynamic UPDATE with ? placeholders, +// adding both id and tenant_id to the WHERE clause for tenant-scoped updates. +func execMapUpdateWhereTenant(ctx context.Context, db *sql.DB, table string, updates map[string]any, id, tenantID uuid.UUID) error { + if len(updates) == 0 { + return nil + } + var setClauses []string + var args []any + for col, val := range updates { + if !validColumnName.MatchString(col) { + slog.Warn("security.invalid_column_name", "table", table, "column", col) + return fmt.Errorf("invalid column name: %q", col) + } + setClauses = append(setClauses, col+" = ?") + args = append(args, sqliteVal(val)) + } + if _, ok := updates["updated_at"]; !ok && tableHasUpdatedAt(table) { + setClauses = append(setClauses, "updated_at = ?") + args = append(args, time.Now().UTC()) + } + args = append(args, id, tenantID) + q := fmt.Sprintf("UPDATE %s SET %s WHERE id = ? AND tenant_id = ?", + table, strings.Join(setClauses, ", ")) + _, err := db.ExecContext(ctx, q, args...) + return err +} diff --git a/internal/store/sqlitestore/mcp_servers.go b/internal/store/sqlitestore/mcp_servers.go new file mode 100644 index 00000000..eb29a074 --- /dev/null +++ b/internal/store/sqlitestore/mcp_servers.go @@ -0,0 +1,272 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteMCPServerStore implements store.MCPServerStore backed by SQLite. +type SQLiteMCPServerStore struct { + db *sql.DB + encKey string +} + +func NewSQLiteMCPServerStore(db *sql.DB, encryptionKey string) *SQLiteMCPServerStore { + return &SQLiteMCPServerStore{db: db, encKey: encryptionKey} +} + +func (s *SQLiteMCPServerStore) CreateServer(ctx context.Context, srv *store.MCPServerData) error { + if err := store.ValidateUserID(srv.CreatedBy); err != nil { + return err + } + if srv.ID == uuid.Nil { + srv.ID = store.GenNewID() + } + + apiKey := srv.APIKey + if s.encKey != "" && apiKey != "" { + encrypted, err := crypto.Encrypt(apiKey, s.encKey) + if err != nil { + return fmt.Errorf("encrypt api key: %w", err) + } + apiKey = encrypted + } + + now := time.Now().UTC() + srv.CreatedAt = now + srv.UpdatedAt = now + encHeaders := s.encryptJSON(jsonOrEmpty(srv.Headers)) + encEnv := s.encryptJSON(jsonOrEmpty(srv.Env)) + + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_servers (id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at, tenant_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + srv.ID, srv.Name, nilStr(srv.DisplayName), srv.Transport, nilStr(srv.Command), + jsonOrEmpty(srv.Args), nilStr(srv.URL), encHeaders, encEnv, + nilStr(apiKey), nilStr(srv.ToolPrefix), srv.TimeoutSec, + jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, tenantID, + ) + return err +} + +func (s *SQLiteMCPServerStore) GetServer(ctx context.Context, id uuid.UUID) (*store.MCPServerData, error) { + if store.IsCrossTenant(ctx) { + return s.scanServer(s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at + FROM mcp_servers WHERE id = ?`, id)) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } + return s.scanServer(s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at + FROM mcp_servers WHERE id = ? AND tenant_id = ?`, id, tenantID)) +} + +func (s *SQLiteMCPServerStore) GetServerByName(ctx context.Context, name string) (*store.MCPServerData, error) { + if store.IsCrossTenant(ctx) { + return s.scanServer(s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at + FROM mcp_servers WHERE name = ?`, name)) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } + return s.scanServer(s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at + FROM mcp_servers WHERE name = ? AND tenant_id = ?`, name, tenantID)) +} + +func (s *SQLiteMCPServerStore) scanServer(row *sql.Row) (*store.MCPServerData, error) { + var srv store.MCPServerData + var displayName, command, url, apiKey, toolPrefix *string + var args, headers, env *[]byte + createdAt, updatedAt := scanTimePair() + err := row.Scan( + &srv.ID, &srv.Name, &displayName, &srv.Transport, &command, + &args, &url, &headers, &env, + &apiKey, &toolPrefix, &srv.TimeoutSec, + &srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt, + ) + if err != nil { + return nil, err + } + srv.CreatedAt = createdAt.Time + srv.UpdatedAt = updatedAt.Time + srv.DisplayName = derefStr(displayName) + srv.Command = derefStr(command) + srv.URL = derefStr(url) + srv.ToolPrefix = derefStr(toolPrefix) + srv.Args = derefBytes(args) + srv.Headers = s.decryptJSON(derefBytes(headers)) + srv.Env = s.decryptJSON(derefBytes(env)) + if apiKey != nil && *apiKey != "" && s.encKey != "" { + decrypted, err := crypto.Decrypt(*apiKey, s.encKey) + if err != nil { + slog.Warn("mcp: failed to decrypt api key", "server", srv.Name, "error", err) + } else { + srv.APIKey = decrypted + } + } else { + srv.APIKey = derefStr(apiKey) + } + return &srv, nil +} + +func (s *SQLiteMCPServerStore) ListServers(ctx context.Context) ([]store.MCPServerData, error) { + query := `SELECT id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at + FROM mcp_servers` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return []store.MCPServerData{}, nil + } + query += ` WHERE tenant_id = ?` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]store.MCPServerData, 0) + for rows.Next() { + var srv store.MCPServerData + var displayName, command, url, apiKey, toolPrefix *string + var args, headers, env *[]byte + createdAt, updatedAt := scanTimePair() + if err := rows.Scan( + &srv.ID, &srv.Name, &displayName, &srv.Transport, &command, + &args, &url, &headers, &env, + &apiKey, &toolPrefix, &srv.TimeoutSec, + &srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt, + ); err != nil { + continue + } + srv.CreatedAt = createdAt.Time + srv.UpdatedAt = updatedAt.Time + srv.DisplayName = derefStr(displayName) + srv.Command = derefStr(command) + srv.URL = derefStr(url) + srv.ToolPrefix = derefStr(toolPrefix) + srv.Args = derefBytes(args) + srv.Headers = s.decryptJSON(derefBytes(headers)) + srv.Env = s.decryptJSON(derefBytes(env)) + if apiKey != nil && *apiKey != "" && s.encKey != "" { + if decrypted, err := crypto.Decrypt(*apiKey, s.encKey); err == nil { + srv.APIKey = decrypted + } + } else { + srv.APIKey = derefStr(apiKey) + } + result = append(result, srv) + } + return result, rows.Err() +} + +func (s *SQLiteMCPServerStore) UpdateServer(ctx context.Context, id uuid.UUID, updates map[string]any) error { + if key, ok := updates["api_key"]; ok { + if keyStr, isStr := key.(string); isStr && keyStr != "" && s.encKey != "" { + encrypted, err := crypto.Encrypt(keyStr, s.encKey) + if err != nil { + return fmt.Errorf("encrypt api key: %w", err) + } + updates["api_key"] = encrypted + } + } + for _, field := range []string{"env", "headers"} { + if v, ok := updates[field]; ok { + var raw []byte + switch val := v.(type) { + case json.RawMessage: + raw = []byte(val) + default: + raw, _ = json.Marshal(val) + } + if len(raw) > 0 { + updates[field] = json.RawMessage(s.encryptJSON(raw)) + } + } + } + updates["updated_at"] = time.Now().UTC() + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "mcp_servers", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "mcp_servers", updates, id, tid) +} + +func (s *SQLiteMCPServerStore) DeleteServer(ctx context.Context, id uuid.UUID) error { + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = ?", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = ? AND tenant_id = ?", id, tid) + return err +} + +// encryptJSON encrypts a JSON blob by wrapping ciphertext as a JSON string. +// Unencrypted: {"key":"val"} (JSON object). Encrypted: "aes-gcm:..." (JSON string). +func (s *SQLiteMCPServerStore) encryptJSON(data []byte) []byte { + if s.encKey == "" || len(data) == 0 || string(data) == "{}" || string(data) == "null" { + return data + } + enc, err := crypto.Encrypt(string(data), s.encKey) + if err != nil { + slog.Warn("mcp: failed to encrypt json", "error", err) + return data + } + wrapped, _ := json.Marshal(enc) + return wrapped +} + +// decryptJSON decrypts a JSON blob if it is an encrypted JSON string. +func (s *SQLiteMCPServerStore) decryptJSON(data []byte) []byte { + if s.encKey == "" || len(data) == 0 || data[0] != '"' { + return data + } + var encStr string + if json.Unmarshal(data, &encStr) != nil { + return data + } + dec, err := crypto.Decrypt(encStr, s.encKey) + if err != nil { + slog.Warn("mcp: failed to decrypt json", "error", err) + return data + } + return []byte(dec) +} diff --git a/internal/store/sqlitestore/mcp_servers_access.go b/internal/store/sqlitestore/mcp_servers_access.go new file mode 100644 index 00000000..afae89fe --- /dev/null +++ b/internal/store/sqlitestore/mcp_servers_access.go @@ -0,0 +1,391 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// --- Agent Grants --- + +func (s *SQLiteMCPServerStore) GrantToAgent(ctx context.Context, g *store.MCPAgentGrant) error { + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + g.CreatedAt = time.Now().UTC() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at, tenant_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (server_id, agent_id) DO UPDATE SET + enabled = excluded.enabled, tool_allow = excluded.tool_allow, + tool_deny = excluded.tool_deny, config_overrides = excluded.config_overrides, + granted_by = excluded.granted_by`, + g.ID, g.ServerID, g.AgentID, g.Enabled, + jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), + g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx), + ) + return err +} + +func (s *SQLiteMCPServerStore) RevokeFromAgent(ctx context.Context, serverID, agentID uuid.UUID) error { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM mcp_agent_grants WHERE server_id = ? AND agent_id = ?"+tClause, + append([]any{serverID, agentID}, tArgs...)...) + return err +} + +func (s *SQLiteMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]store.MCPAgentGrant, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at + FROM mcp_agent_grants WHERE agent_id = ?`+tClause, + append([]any{agentID}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.MCPAgentGrant + for rows.Next() { + var g store.MCPAgentGrant + var toolAllow, toolDeny, configOverrides *string + var createdAt sqliteTime + if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled, + &toolAllow, &toolDeny, &configOverrides, &g.GrantedBy, &createdAt); err != nil { + continue + } + if toolAllow != nil { + g.ToolAllow = json.RawMessage(*toolAllow) + } + if toolDeny != nil { + g.ToolDeny = json.RawMessage(*toolDeny) + } + if configOverrides != nil { + g.ConfigOverrides = json.RawMessage(*configOverrides) + } + g.CreatedAt = createdAt.Time + result = append(result, g) + } + return result, rows.Err() +} + +func (s *SQLiteMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.UUID) ([]store.MCPAgentGrant, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, server_id, agent_id, enabled, + COALESCE(tool_allow, '[]'), COALESCE(tool_deny, '[]'), + COALESCE(config_overrides, '{}'), granted_by, created_at + FROM mcp_agent_grants WHERE server_id = ?`+tClause+` ORDER BY created_at`, + append([]any{serverID}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]store.MCPAgentGrant, 0) + for rows.Next() { + var g store.MCPAgentGrant + var toolAllow, toolDeny, configOverrides string + var createdAt sqliteTime + if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled, + &toolAllow, &toolDeny, &configOverrides, &g.GrantedBy, &createdAt); err != nil { + slog.Warn("mcp.list_server_grants.scan", "error", err) + continue + } + g.ToolAllow = json.RawMessage(toolAllow) + g.ToolDeny = json.RawMessage(toolDeny) + g.ConfigOverrides = json.RawMessage(configOverrides) + g.CreatedAt = createdAt.Time + result = append(result, g) + } + return result, rows.Err() +} + +// --- Counts --- + +func (s *SQLiteMCPServerStore) CountAgentGrantsByServer(ctx context.Context) (map[uuid.UUID]int, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT server_id, COUNT(*) FROM mcp_agent_grants WHERE 1=1`+tClause+` GROUP BY server_id`, + tArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[uuid.UUID]int) + for rows.Next() { + var serverID uuid.UUID + var count int + if err := rows.Scan(&serverID, &count); err != nil { + continue + } + result[serverID] = count + } + return result, rows.Err() +} + +// --- User Grants --- + +func (s *SQLiteMCPServerStore) GrantToUser(ctx context.Context, g *store.MCPUserGrant) error { + if err := store.ValidateUserID(g.UserID); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + g.CreatedAt = time.Now().UTC() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at, tenant_id) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT (server_id, user_id) DO UPDATE SET + enabled = excluded.enabled, tool_allow = excluded.tool_allow, + tool_deny = excluded.tool_deny, granted_by = excluded.granted_by`, + g.ID, g.ServerID, g.UserID, g.Enabled, + jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), + g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx), + ) + return err +} + +func (s *SQLiteMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUID, userID string) error { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM mcp_user_grants WHERE server_id = ? AND user_id = ?"+tClause, + append([]any{serverID, userID}, tArgs...)...) + return err +} + +// --- Resolution --- + +func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) { + tClause, tArgs, err := scopeClauseAlias(ctx, "ms") + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env, + ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at, + mag.tool_allow, mag.tool_deny + FROM mcp_servers ms + INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = ? AND mag.enabled = 1 + LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = ? + WHERE ms.enabled = 1 + AND (mug.id IS NULL OR mug.enabled = 1)`+tClause, + append([]any{agentID, userID}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]store.MCPAccessInfo, 0) + for rows.Next() { + var srv store.MCPServerData + var displayName, command, url, apiKey, toolPrefix *string + var args, headers, env *[]byte + var toolAllowJSON, toolDenyJSON *[]byte + + createdAt, updatedAt := scanTimePair() + if err := rows.Scan( + &srv.ID, &srv.Name, &displayName, &srv.Transport, &command, + &args, &url, &headers, &env, + &apiKey, &toolPrefix, &srv.TimeoutSec, + &srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt, + &toolAllowJSON, &toolDenyJSON, + ); err != nil { + continue + } + srv.CreatedAt = createdAt.Time + srv.UpdatedAt = updatedAt.Time + srv.DisplayName = derefStr(displayName) + srv.Command = derefStr(command) + srv.URL = derefStr(url) + srv.ToolPrefix = derefStr(toolPrefix) + srv.Args = derefBytes(args) + srv.Headers = s.decryptJSON(derefBytes(headers)) + srv.Env = s.decryptJSON(derefBytes(env)) + if apiKey != nil && *apiKey != "" && s.encKey != "" { + if decrypted, err := crypto.Decrypt(*apiKey, s.encKey); err == nil { + srv.APIKey = decrypted + } + } else { + srv.APIKey = derefStr(apiKey) + } + + info := store.MCPAccessInfo{Server: srv} + if toolAllowJSON != nil { + json.Unmarshal(*toolAllowJSON, &info.ToolAllow) + } + if toolDenyJSON != nil { + json.Unmarshal(*toolDenyJSON, &info.ToolDeny) + } + result = append(result, info) + } + return result, rows.Err() +} + +// --- Access Requests --- + +func (s *SQLiteMCPServerStore) CreateRequest(ctx context.Context, req *store.MCPAccessRequest) error { + if err := store.ValidateUserID(req.RequestedBy); err != nil { + return err + } + if req.ID == uuid.Nil { + req.ID = store.GenNewID() + } + req.Status = "pending" + req.CreatedAt = time.Now().UTC() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at, tenant_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + req.ID, req.ServerID, nilUUID(req.AgentID), nilStr(req.UserID), + req.Scope, req.Status, nilStr(req.Reason), + jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, tenantIDForInsert(ctx), + ) + return err +} + +func (s *SQLiteMCPServerStore) ListPendingRequests(ctx context.Context) ([]store.MCPAccessRequest, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, + reviewed_by, reviewed_at, review_note, created_at + FROM mcp_access_requests WHERE status = 'pending'`+tClause+` ORDER BY created_at`, + tArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.MCPAccessRequest + for rows.Next() { + var r store.MCPAccessRequest + var agentID *uuid.UUID + var userID, reviewedBy, reviewNote *string + var reviewedAtSt, createdAtSt sqliteTime + if err := rows.Scan(&r.ID, &r.ServerID, &agentID, &userID, &r.Scope, &r.Status, + &r.Reason, &r.ToolAllow, &r.RequestedBy, + &reviewedBy, &reviewedAtSt, &reviewNote, &createdAtSt); err != nil { + continue + } + if !reviewedAtSt.Time.IsZero() { + r.ReviewedAt = &reviewedAtSt.Time + } + r.CreatedAt = createdAtSt.Time + r.AgentID = agentID + r.UserID = derefStr(userID) + r.ReviewedBy = derefStr(reviewedBy) + r.ReviewNote = derefStr(reviewNote) + result = append(result, r) + } + return result, rows.Err() +} + +func (s *SQLiteMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUID, approved bool, reviewedBy, note string) error { + if err := store.ValidateUserID(reviewedBy); err != nil { + return err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + + var req store.MCPAccessRequest + var agentID *uuid.UUID + var userID *string + err = tx.QueryRowContext(ctx, + `SELECT id, server_id, agent_id, user_id, scope, status, tool_allow + FROM mcp_access_requests WHERE id = ? AND status = 'pending'`+tClause, + append([]any{requestID}, tArgs...)..., + ).Scan(&req.ID, &req.ServerID, &agentID, &userID, &req.Scope, &req.Status, &req.ToolAllow) + if err != nil { + return fmt.Errorf("request not found or not pending: %w", err) + } + + status := "rejected" + if approved { + status = "approved" + } + now := time.Now().UTC() + + _, err = tx.ExecContext(ctx, + `UPDATE mcp_access_requests SET status = ?, reviewed_by = ?, reviewed_at = ?, review_note = ? WHERE id = ?`, + status, reviewedBy, now, nilStr(note), requestID, + ) + if err != nil { + return err + } + + if approved { + switch req.Scope { + case "agent": + if agentID == nil { + return fmt.Errorf("agent_id required for agent scope") + } + _, err = tx.ExecContext(ctx, + `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at, tenant_id) + VALUES (?,?,?,1,?,?,?,?) + ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = 1, tool_allow = excluded.tool_allow, granted_by = excluded.granted_by`, + store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx), + ) + case "user": + if userID == nil || *userID == "" { + return fmt.Errorf("user_id required for user scope") + } + _, err = tx.ExecContext(ctx, + `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at, tenant_id) + VALUES (?,?,?,1,?,?,?,?) + ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = 1, tool_allow = excluded.tool_allow, granted_by = excluded.granted_by`, + store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx), + ) + default: + return fmt.Errorf("unknown scope: %s", req.Scope) + } + if err != nil { + return err + } + } + + return tx.Commit() +} diff --git a/internal/store/sqlitestore/mcp_user_credentials.go b/internal/store/sqlitestore/mcp_user_credentials.go new file mode 100644 index 00000000..33d70c9b --- /dev/null +++ b/internal/store/sqlitestore/mcp_user_credentials.go @@ -0,0 +1,103 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// GetUserCredentials returns per-user credential overrides for an MCP server. +// Returns (nil, nil) if no per-user credentials exist. +func (s *SQLiteMCPServerStore) GetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) (*store.MCPUserCredentials, error) { + tid := tenantIDForInsert(ctx) + var apiKey sql.NullString + var headersEnc, envEnc []byte + + err := s.db.QueryRowContext(ctx, + `SELECT api_key, headers, env FROM mcp_user_credentials + WHERE server_id = ? AND user_id = ? AND tenant_id = ?`, + serverID, userID, tid, + ).Scan(&apiKey, &headersEnc, &envEnc) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + + creds := &store.MCPUserCredentials{} + if apiKey.Valid && apiKey.String != "" && s.encKey != "" { + if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil { + creds.APIKey = dec + } + } else if apiKey.Valid { + creds.APIKey = apiKey.String + } + if len(headersEnc) > 0 { + dec := s.decryptJSON(headersEnc) + json.Unmarshal(dec, &creds.Headers) + } + if len(envEnc) > 0 { + dec := s.decryptJSON(envEnc) + json.Unmarshal(dec, &creds.Env) + } + + return creds, nil +} + +// SetUserCredentials creates or updates per-user MCP credentials. +func (s *SQLiteMCPServerStore) SetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string, creds store.MCPUserCredentials) error { + tid := tenantIDForInsert(ctx) + + var apiKeyEnc sql.NullString + if creds.APIKey != "" && s.encKey != "" { + enc, err := crypto.Encrypt(creds.APIKey, s.encKey) + if err != nil { + return fmt.Errorf("encrypt mcp user api_key: %w", err) + } + apiKeyEnc = sql.NullString{String: enc, Valid: true} + } else if creds.APIKey != "" { + apiKeyEnc = sql.NullString{String: creds.APIKey, Valid: true} + } + + var headersEnc, envEnc []byte + if len(creds.Headers) > 0 { + raw, _ := json.Marshal(creds.Headers) + headersEnc = s.encryptJSON(raw) + } + if len(creds.Env) > 0 { + raw, _ := json.Marshal(creds.Env) + envEnc = s.encryptJSON(raw) + } + + now := time.Now().UTC() + id := store.GenNewID() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_user_credentials (id, server_id, user_id, api_key, headers, env, tenant_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (server_id, user_id, tenant_id) DO UPDATE SET + api_key = excluded.api_key, headers = excluded.headers, env = excluded.env, updated_at = excluded.updated_at`, + id, serverID, userID, apiKeyEnc, headersEnc, envEnc, tid, now, now, + ) + return err +} + +// DeleteUserCredentials removes per-user MCP credentials. +func (s *SQLiteMCPServerStore) DeleteUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) error { + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_user_credentials WHERE server_id = ? AND user_id = ? AND tenant_id = ?`, + serverID, userID, tid, + ) + return err +} diff --git a/internal/store/sqlitestore/memory.go b/internal/store/sqlitestore/memory.go new file mode 100644 index 00000000..c30d13cd --- /dev/null +++ b/internal/store/sqlitestore/memory.go @@ -0,0 +1,86 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "database/sql" + "sync" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteMemoryStore implements store.MemoryStore backed by SQLite. +// Vector search is not available in the Lite edition (VectorSearch: false). +// FTS uses simple LIKE queries instead of tsvector. +type SQLiteMemoryStore struct { + db *sql.DB + provider store.EmbeddingProvider + mu sync.RWMutex + cfg SQLiteMemoryConfig +} + +// SQLiteMemoryConfig configures the SQLite memory store. +type SQLiteMemoryConfig struct { + MaxChunkLen int + ChunkOverlap int + MaxResults int + TextWeight float64 + VectorWeight float64 +} + +// DefaultSQLiteMemoryConfig returns sensible defaults. +func DefaultSQLiteMemoryConfig() SQLiteMemoryConfig { + return SQLiteMemoryConfig{ + MaxChunkLen: 1000, + ChunkOverlap: 200, + MaxResults: 6, + TextWeight: 1.0, + VectorWeight: 0.0, // no vector search in SQLite edition + } +} + +// NewSQLiteMemoryStore creates a new SQLite-backed memory store. +func NewSQLiteMemoryStore(db *sql.DB) *SQLiteMemoryStore { + return &SQLiteMemoryStore{db: db, cfg: DefaultSQLiteMemoryConfig()} +} + +// SetEmbeddingProvider stores the provider reference but embeddings are not +// persisted in SQLite (no vector column). Kept for interface compatibility. +func (s *SQLiteMemoryStore) SetEmbeddingProvider(provider store.EmbeddingProvider) { + s.provider = provider +} + +// UpdateChunkConfig updates chunk splitting parameters at runtime. +func (s *SQLiteMemoryStore) UpdateChunkConfig(maxChunkLen, chunkOverlap int) { + s.mu.Lock() + defer s.mu.Unlock() + if maxChunkLen > 0 { + s.cfg.MaxChunkLen = maxChunkLen + } + if chunkOverlap >= 0 { + s.cfg.ChunkOverlap = chunkOverlap + } +} + +// chunkConfig returns current chunk parameters (thread-safe). +func (s *SQLiteMemoryStore) chunkConfig() (maxLen, overlap int) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.MaxChunkLen, s.cfg.ChunkOverlap +} + +func (s *SQLiteMemoryStore) Close() error { return nil } + +// scanDocumentRow scans (path, hash, user_id, updated_at) into DocumentInfo. +func scanDocumentRow(path, hash string, uid *string, updatedAt time.Time) store.DocumentInfo { + info := store.DocumentInfo{ + Path: path, + Hash: hash, + UpdatedAt: updatedAt.UnixMilli(), + } + if uid != nil { + info.UserID = *uid + } + return info +} diff --git a/internal/store/sqlitestore/memory_docs.go b/internal/store/sqlitestore/memory_docs.go new file mode 100644 index 00000000..fcbae213 --- /dev/null +++ b/internal/store/sqlitestore/memory_docs.go @@ -0,0 +1,401 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/memory" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *SQLiteMemoryStore) GetDocument(ctx context.Context, agentID, userID, path string) (string, error) { + aid := agentID + var content string + var err error + + if userID == "" { + tc, tcArgs, tcErr := scopeClause(ctx) + if tcErr != nil { + return "", tcErr + } + err = s.db.QueryRowContext(ctx, + "SELECT content FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id IS NULL"+tc, + append([]any{aid, path}, tcArgs...)...).Scan(&content) + } else { + tc, tcArgs, tcErr := scopeClause(ctx) + if tcErr != nil { + return "", tcErr + } + err = s.db.QueryRowContext(ctx, + "SELECT content FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id = ?"+tc, + append([]any{aid, path, userID}, tcArgs...)...).Scan(&content) + } + if err != nil { + return "", err + } + return content, nil +} + +func (s *SQLiteMemoryStore) PutDocument(ctx context.Context, agentID, userID, path, content string) error { + hash := memory.ContentHash(content) + id := uuid.Must(uuid.NewV7()).String() + now := time.Now().UTC() + tid := tenantIDForInsert(ctx).String() + + var uid *string + if userID != "" { + uid = &userID + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, tenant_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (agent_id, COALESCE(user_id, ''), path) + DO UPDATE SET content = excluded.content, hash = excluded.hash, + tenant_id = excluded.tenant_id, updated_at = excluded.updated_at`, + id, agentID, uid, path, content, hash, tid, now, + ) + return err +} + +func (s *SQLiteMemoryStore) DeleteDocument(ctx context.Context, agentID, userID, path string) error { + var res sql.Result + var err error + + if userID == "" { + tc, tcArgs, tcErr := scopeClause(ctx) + if tcErr != nil { + return tcErr + } + res, err = s.db.ExecContext(ctx, + "DELETE FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id IS NULL"+tc, + append([]any{agentID, path}, tcArgs...)...) + } else { + tc, tcArgs, tcErr := scopeClause(ctx) + if tcErr != nil { + return tcErr + } + res, err = s.db.ExecContext(ctx, + "DELETE FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id = ?"+tc, + append([]any{agentID, path, userID}, tcArgs...)...) + } + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("document not found: %s", path) + } + return nil +} + +func (s *SQLiteMemoryStore) ListDocuments(ctx context.Context, agentID, userID string) ([]store.DocumentInfo, error) { + var rows *sql.Rows + var err error + + if userID == "" { + tc, tcArgs, tcErr := scopeClause(ctx) + if tcErr != nil { + return nil, tcErr + } + rows, err = s.db.QueryContext(ctx, + "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = ? AND user_id IS NULL"+tc, + append([]any{agentID}, tcArgs...)...) + } else { + tc, tcArgs, tcErr := scopeClause(ctx) + if tcErr != nil { + return nil, tcErr + } + rows, err = s.db.QueryContext(ctx, + "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = ? AND (user_id IS NULL OR user_id = ?)"+tc, + append([]any{agentID, userID}, tcArgs...)...) + } + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.DocumentInfo + for rows.Next() { + var path, hash string + var uid *string + var updatedAt time.Time + if err := rows.Scan(&path, &hash, &uid, &updatedAt); err != nil { + continue + } + result = append(result, scanDocumentRow(path, hash, uid, updatedAt)) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} + +// IndexDocument chunks a document and stores chunks (without embeddings in SQLite). +func (s *SQLiteMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path string) error { + content, err := s.GetDocument(ctx, agentID, userID, path) + if err != nil { + return err + } + + // Get document ID + var docID string + if userID == "" { + tc, tcArgs, tcErr := scopeClause(ctx) + if tcErr != nil { + return tcErr + } + err = s.db.QueryRowContext(ctx, + "SELECT id FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id IS NULL"+tc, + append([]any{agentID, path}, tcArgs...)...).Scan(&docID) + } else { + tc, tcArgs, tcErr := scopeClause(ctx) + if tcErr != nil { + return tcErr + } + err = s.db.QueryRowContext(ctx, + "SELECT id FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id = ?"+tc, + append([]any{agentID, path, userID}, tcArgs...)...).Scan(&docID) + } + if err != nil { + return err + } + + // Delete old chunks + if _, delErr := s.db.ExecContext(ctx, "DELETE FROM memory_chunks WHERE document_id = ?", docID); delErr != nil { + return fmt.Errorf("delete old chunks: %w", delErr) + } + + // Resolve chunk config: per-agent override → global default + chunkLen, chunkOverlap := s.chunkConfig() + if rc := store.RunContextFromCtx(ctx); rc != nil && rc.MemoryCfg != nil { + if rc.MemoryCfg.MaxChunkLen > 0 { + chunkLen = rc.MemoryCfg.MaxChunkLen + } + if rc.MemoryCfg.ChunkOverlap > 0 { + chunkOverlap = rc.MemoryCfg.ChunkOverlap + } + } + + chunks := memory.ChunkText(content, chunkLen, chunkOverlap) + if len(chunks) == 0 { + return nil + } + + tid := tenantIDForInsert(ctx).String() + var uid *string + if userID != "" { + uid = &userID + } + + for _, tc := range chunks { + hash := memory.ContentHash(tc.Text) + chunkID := uuid.Must(uuid.NewV7()).String() + now := time.Now().UTC() + + if _, err := s.db.ExecContext(ctx, + `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, tenant_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING`, + chunkID, agentID, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, tid, now, + ); err != nil { + slog.Warn("memory: insert chunk failed", "path", path, "error", err) + } + } + return nil +} + +func (s *SQLiteMemoryStore) IndexAll(ctx context.Context, agentID, userID string) error { + docs, err := s.ListDocuments(ctx, agentID, userID) + if err != nil { + return err + } + for _, doc := range docs { + s.IndexDocument(ctx, agentID, doc.UserID, doc.Path) + } + return nil +} + +// ListAllDocumentsGlobal returns all documents across all agents (admin overview). +func (s *SQLiteMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.DocumentInfo, error) { + var q string + var args []any + + if !store.IsCrossTenant(ctx) { + tid, err := requireTenantID(ctx) + if err != nil { + return nil, err + } + q = `SELECT agent_id, path, hash, user_id, updated_at + FROM memory_documents WHERE tenant_id = ? ORDER BY updated_at DESC` + args = []any{tid.String()} + } else { + q = `SELECT agent_id, path, hash, user_id, updated_at + FROM memory_documents ORDER BY updated_at DESC` + } + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.DocumentInfo + for rows.Next() { + var agentID, path, hash string + var uid *string + var updatedAt time.Time + if err := rows.Scan(&agentID, &path, &hash, &uid, &updatedAt); err != nil { + continue + } + info := scanDocumentRow(path, hash, uid, updatedAt) + info.AgentID = agentID + result = append(result, info) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} + +// ListAllDocuments returns all documents for an agent across all users. +func (s *SQLiteMemoryStore) ListAllDocuments(ctx context.Context, agentID string) ([]store.DocumentInfo, error) { + tc, tcArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT agent_id, path, hash, user_id, updated_at + FROM memory_documents WHERE agent_id = ?`+tc+` + ORDER BY updated_at DESC`, + append([]any{agentID}, tcArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.DocumentInfo + for rows.Next() { + var aID, path, hash string + var uid *string + var updatedAt time.Time + if err := rows.Scan(&aID, &path, &hash, &uid, &updatedAt); err != nil { + continue + } + info := scanDocumentRow(path, hash, uid, updatedAt) + info.AgentID = aID + result = append(result, info) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} + +// GetDocumentDetail returns full document info with chunk count. +// EmbeddedCount is always 0 (no embedding column in SQLite). +func (s *SQLiteMemoryStore) GetDocumentDetail(ctx context.Context, agentID, userID, path string) (*store.DocumentDetail, error) { + var q string + var args []any + + if userID == "" { + tc, tcArgs, err := scopeClauseAlias(ctx, "d") + if err != nil { + return nil, err + } + q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at, + COUNT(c.id) AS chunk_count + FROM memory_documents d + LEFT JOIN memory_chunks c ON c.document_id = d.id + WHERE d.agent_id = ? AND d.path = ? AND d.user_id IS NULL` + tc + ` + GROUP BY d.id` + args = append([]any{agentID, path}, tcArgs...) + } else { + tc, tcArgs, err := scopeClauseAlias(ctx, "d") + if err != nil { + return nil, err + } + q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at, + COUNT(c.id) AS chunk_count + FROM memory_documents d + LEFT JOIN memory_chunks c ON c.document_id = d.id + WHERE d.agent_id = ? AND d.path = ? AND d.user_id = ?` + tc + ` + GROUP BY d.id` + args = append([]any{agentID, path, userID}, tcArgs...) + } + + var detail store.DocumentDetail + var uid *string + var createdAt, updatedAt time.Time + err := s.db.QueryRowContext(ctx, q, args...).Scan( + &detail.Path, &detail.Content, &detail.Hash, &uid, + &createdAt, &updatedAt, &detail.ChunkCount, + ) + if err != nil { + return nil, err + } + if uid != nil { + detail.UserID = *uid + } + detail.CreatedAt = createdAt.UnixMilli() + detail.UpdatedAt = updatedAt.UnixMilli() + // EmbeddedCount always 0 — no embedding column in SQLite + return &detail, nil +} + +// ListChunks returns chunks for a document identified by agent, user, and path. +func (s *SQLiteMemoryStore) ListChunks(ctx context.Context, agentID, userID, path string) ([]store.ChunkInfo, error) { + var q string + var args []any + + if userID == "" { + tc, tcArgs, err := scopeClauseAlias(ctx, "d") + if err != nil { + return nil, err + } + q = `SELECT c.id, c.start_line, c.end_line, c.text + FROM memory_chunks c + JOIN memory_documents d ON c.document_id = d.id + WHERE d.agent_id = ? AND d.path = ? AND d.user_id IS NULL` + tc + ` + ORDER BY c.start_line` + args = append([]any{agentID, path}, tcArgs...) + } else { + tc, tcArgs, err := scopeClauseAlias(ctx, "d") + if err != nil { + return nil, err + } + q = `SELECT c.id, c.start_line, c.end_line, c.text + FROM memory_chunks c + JOIN memory_documents d ON c.document_id = d.id + WHERE d.agent_id = ? AND d.path = ? AND d.user_id = ?` + tc + ` + ORDER BY c.start_line` + args = append([]any{agentID, path, userID}, tcArgs...) + } + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.ChunkInfo + for rows.Next() { + var ci store.ChunkInfo + if err := rows.Scan(&ci.ID, &ci.StartLine, &ci.EndLine, &ci.TextPreview); err != nil { + continue + } + // HasEmbedding always false — no embedding column in SQLite + result = append(result, ci) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} diff --git a/internal/store/sqlitestore/memory_search.go b/internal/store/sqlitestore/memory_search.go new file mode 100644 index 00000000..5ab22660 --- /dev/null +++ b/internal/store/sqlitestore/memory_search.go @@ -0,0 +1,120 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// Search performs LIKE-based text search over memory_chunks. +// Vector search is not available in the SQLite edition — VectorSearch is always false. +// Merges global (user_id IS NULL) + per-user chunks, with user boost. +func (s *SQLiteMemoryStore) Search(ctx context.Context, query string, agentID, userID string, opts store.MemorySearchOptions) ([]store.MemorySearchResult, error) { + maxResults := opts.MaxResults + if maxResults <= 0 { + maxResults = s.cfg.MaxResults + } + + results, err := s.likeSearch(ctx, query, agentID, userID, maxResults*2) + if err != nil { + return nil, err + } + + // Apply filters and cap results + var filtered []store.MemorySearchResult + for _, r := range results { + if opts.MinScore > 0 && r.Score < opts.MinScore { + continue + } + if opts.PathPrefix != "" && !strings.HasPrefix(r.Path, opts.PathPrefix) { + continue + } + filtered = append(filtered, r) + if len(filtered) >= maxResults { + break + } + } + return filtered, nil +} + +// likeSearch performs a case-insensitive LIKE search across chunk text. +// Returns results scored 1.0 (global) or 1.2 (personal, boosted). +func (s *SQLiteMemoryStore) likeSearch(ctx context.Context, query, agentID, userID string, limit int) ([]store.MemorySearchResult, error) { + pattern := "%" + escapeLike(query) + "%" + + var q string + var args []any + + if userID != "" { + tc, tcArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + q = `SELECT path, start_line, end_line, text, user_id + FROM memory_chunks + WHERE agent_id = ? AND (user_id IS NULL OR user_id = ?) + AND text LIKE ? ESCAPE '\'` + tc + ` + ORDER BY user_id DESC + LIMIT ?` + args = append([]any{agentID, userID, pattern}, tcArgs...) + args = append(args, limit) + } else { + tc, tcArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + q = `SELECT path, start_line, end_line, text, user_id + FROM memory_chunks + WHERE agent_id = ? AND user_id IS NULL + AND text LIKE ? ESCAPE '\'` + tc + ` + LIMIT ?` + args = append([]any{agentID, pattern}, tcArgs...) + args = append(args, limit) + } + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []store.MemorySearchResult + for rows.Next() { + var path, text string + var startLine, endLine int + var uid *string + if err := rows.Scan(&path, &startLine, &endLine, &text, &uid); err != nil { + continue + } + scope := "global" + score := 1.0 + if uid != nil && *uid != "" { + scope = "personal" + score = 1.2 // personal boost, mirrors PG hybrid merge + } + results = append(results, store.MemorySearchResult{ + Path: path, + StartLine: startLine, + EndLine: endLine, + Score: score, + Snippet: text, + Source: "memory", + Scope: scope, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// escapeLike escapes special LIKE metacharacters: % _ \ +func escapeLike(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `%`, `\%`) + s = strings.ReplaceAll(s, `_`, `\_`) + return s +} diff --git a/internal/store/sqlitestore/pairing.go b/internal/store/sqlitestore/pairing.go new file mode 100644 index 00000000..0d93c4c1 --- /dev/null +++ b/internal/store/sqlitestore/pairing.go @@ -0,0 +1,243 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const ( + codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + codeLength = 8 + codeTTL = 60 * time.Minute + pairedDeviceTTL = 30 * 24 * time.Hour + maxPendingPerAccount = 3 +) + +// SQLitePairingStore implements store.PairingStore backed by SQLite. +type SQLitePairingStore struct { + db *sql.DB + onRequest func(code, senderID, channel, chatID string) +} + +func NewSQLitePairingStore(db *sql.DB) *SQLitePairingStore { + return &SQLitePairingStore{db: db} +} + +func (s *SQLitePairingStore) SetOnRequest(cb func(code, senderID, channel, chatID string)) { + s.onRequest = cb +} + +func (s *SQLitePairingStore) RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) { + tid := tenantIDForInsert(ctx) + now := time.Now() + + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now) + + var count int64 + s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pairing_requests WHERE account_id = ? AND tenant_id = ?", accountID, tid).Scan(&count) + if count >= maxPendingPerAccount { + return "", fmt.Errorf("max pending pairing requests (%d) exceeded", maxPendingPerAccount) + } + + var existingCode string + err := s.db.QueryRowContext(ctx, "SELECT code FROM pairing_requests WHERE sender_id = ? AND channel = ? AND tenant_id = ?", senderID, channel, tid).Scan(&existingCode) + if err == nil { + return existingCode, nil + } + + metaJSON := []byte("{}") + if len(metadata) > 0 { + metaJSON, _ = json.Marshal(metadata) + } + + code := generatePairingCode() + _, err = s.db.ExecContext(ctx, + `INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, tid, + ) + if err != nil { + return "", fmt.Errorf("create pairing request: %w", err) + } + if s.onRequest != nil { + go s.onRequest(code, senderID, channel, chatID) + } + return code, nil +} + +func (s *SQLitePairingStore) ApprovePairing(ctx context.Context, code, approvedBy string) (*store.PairedDeviceData, error) { + now := time.Now() + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now) + + var reqID uuid.UUID + var senderID, channel, chatID string + var metaJSON []byte + var reqTenantID uuid.UUID + + err := s.db.QueryRowContext(ctx, + "SELECT id, sender_id, channel, chat_id, COALESCE(metadata, '{}'), tenant_id FROM pairing_requests WHERE code = ?", code, + ).Scan(&reqID, &senderID, &channel, &chatID, &metaJSON, &reqTenantID) + if err != nil { + return nil, fmt.Errorf("pairing code %s not found or expired", code) + } + + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE id = ?", reqID) + + expiresAt := now.Add(pairedDeviceTTL) + _, err = s.db.ExecContext(ctx, + `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt, reqTenantID, + ) + if err != nil { + return nil, fmt.Errorf("create paired device: %w", err) + } + + var meta map[string]string + if len(metaJSON) > 0 { + json.Unmarshal(metaJSON, &meta) + } + + return &store.PairedDeviceData{ + SenderID: senderID, + Channel: channel, + ChatID: chatID, + PairedAt: now.UnixMilli(), + PairedBy: approvedBy, + Metadata: meta, + }, nil +} + +func (s *SQLitePairingStore) DenyPairing(ctx context.Context, code string) error { + result, err := s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE code = ?", code) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("pairing code %s not found or expired", code) + } + return nil +} + +func (s *SQLitePairingStore) RevokePairing(ctx context.Context, senderID, channel string) error { + tid := tenantIDForInsert(ctx) + result, err := s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE sender_id = ? AND channel = ? AND tenant_id = ?", senderID, channel, tid) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("paired device not found: %s/%s", channel, senderID) + } + return nil +} + +func (s *SQLitePairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) { + tid := tenantIDForInsert(ctx) + var count int64 + err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM paired_devices WHERE sender_id = ? AND channel = ? AND tenant_id = ? AND (expires_at IS NULL OR expires_at > ?)", + senderID, channel, tid, time.Now(), + ).Scan(&count) + if err != nil { + return false, fmt.Errorf("pairing check query: %w", err) + } + return count > 0, nil +} + +func (s *SQLitePairingStore) ListPending(ctx context.Context) []store.PairingRequestData { + tid := tenantIDForInsert(ctx) + now := time.Now() + + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now) + + rows, err := s.db.QueryContext(ctx, + `SELECT code, sender_id, channel, chat_id, account_id, created_at, expires_at, COALESCE(metadata, '{}') + FROM pairing_requests WHERE tenant_id = ? ORDER BY created_at DESC`, tid) + if err != nil { + return nil + } + defer rows.Close() + + var result []store.PairingRequestData + for rows.Next() { + var d store.PairingRequestData + var createdAt, expiresAt time.Time + var metaJSON []byte + if err := rows.Scan(&d.Code, &d.SenderID, &d.Channel, &d.ChatID, &d.AccountID, &createdAt, &expiresAt, &metaJSON); err != nil { + continue + } + d.CreatedAt = createdAt.UnixMilli() + d.ExpiresAt = expiresAt.UnixMilli() + if len(metaJSON) > 0 { + json.Unmarshal(metaJSON, &d.Metadata) + } + result = append(result, d) + } + if err := rows.Err(); err != nil { + slog.Warn("pairing: list pending iteration error", "error", err) + } + if result == nil { + return []store.PairingRequestData{} + } + return result +} + +func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDeviceData { + tid := tenantIDForInsert(ctx) + now := time.Now() + + s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < ?", now) + + rows, err := s.db.QueryContext(ctx, + `SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') + FROM paired_devices WHERE tenant_id = ? ORDER BY paired_at DESC`, tid) + if err != nil { + return nil + } + defer rows.Close() + + var result []store.PairedDeviceData + for rows.Next() { + var d store.PairedDeviceData + var pairedAt time.Time + var metaJSON []byte + if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAt, &metaJSON); err != nil { + continue + } + d.PairedAt = pairedAt.UnixMilli() + if len(metaJSON) > 0 { + json.Unmarshal(metaJSON, &d.Metadata) + } + result = append(result, d) + } + if err := rows.Err(); err != nil { + slog.Warn("pairing: list paired iteration error", "error", err) + } + if result == nil { + return []store.PairedDeviceData{} + } + return result +} + +func generatePairingCode() string { + b := make([]byte, codeLength) + rand.Read(b) + code := make([]byte, codeLength) + for i := range code { + code[i] = codeAlphabet[int(b[i])%len(codeAlphabet)] + } + return string(code) +} diff --git a/internal/store/sqlitestore/pending_messages.go b/internal/store/sqlitestore/pending_messages.go new file mode 100644 index 00000000..fbedf38a --- /dev/null +++ b/internal/store/sqlitestore/pending_messages.go @@ -0,0 +1,286 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLitePendingMessageStore implements store.PendingMessageStore backed by SQLite. +type SQLitePendingMessageStore struct { + db *sql.DB +} + +func NewSQLitePendingMessageStore(db *sql.DB) *SQLitePendingMessageStore { + return &SQLitePendingMessageStore{db: db} +} + +func (s *SQLitePendingMessageStore) AppendBatch(ctx context.Context, msgs []store.PendingMessage) error { + if len(msgs) == 0 { + return nil + } + + const cols = 11 + placeholders := make([]string, len(msgs)) + args := make([]any, 0, len(msgs)*cols) + now := time.Now() + tid := tenantIDForInsert(ctx) + + for i := range msgs { + if msgs[i].ID == uuid.Nil { + msgs[i].ID = uuid.Must(uuid.NewV7()) + } + placeholders[i] = "(?,?,?,?,?,?,?,?,?,?,?)" + args = append(args, msgs[i].ID, msgs[i].ChannelName, msgs[i].HistoryKey, + msgs[i].Sender, msgs[i].SenderID, msgs[i].Body, msgs[i].PlatformMsgID, msgs[i].IsSummary, now, now, tid) + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id) + VALUES `+strings.Join(placeholders, ","), + args..., + ) + return err +} + +func (s *SQLitePendingMessageStore) ListByKey(ctx context.Context, channelName, historyKey string) ([]store.PendingMessage, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + args := append([]any{channelName, historyKey}, tArgs...) + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at + FROM channel_pending_messages + WHERE channel_name = ? AND history_key = ?`+tClause+` + ORDER BY created_at ASC`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.PendingMessage + for rows.Next() { + var m store.PendingMessage + createdAt, updatedAt := scanTimePair() + if err := rows.Scan(&m.ID, &m.ChannelName, &m.HistoryKey, &m.Sender, &m.SenderID, &m.Body, &m.PlatformMsgID, &m.IsSummary, createdAt, updatedAt); err != nil { + return nil, err + } + m.CreatedAt = createdAt.Time + m.UpdatedAt = updatedAt.Time + result = append(result, m) + } + return result, rows.Err() +} + +func (s *SQLitePendingMessageStore) DeleteByKey(ctx context.Context, channelName, historyKey string) error { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + args := append([]any{channelName, historyKey}, tArgs...) + _, err = s.db.ExecContext(ctx, + `DELETE FROM channel_pending_messages WHERE channel_name = ? AND history_key = ?`+tClause, + args..., + ) + return err +} + +func (s *SQLitePendingMessageStore) Compact(ctx context.Context, deleteIDs []uuid.UUID, summary *store.PendingMessage) error { + if len(deleteIDs) == 0 { + return nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin compact tx: %w", err) + } + defer tx.Rollback() + + placeholders := make([]string, len(deleteIDs)) + args := make([]any, len(deleteIDs)) + for i, id := range deleteIDs { + placeholders[i] = "?" + args[i] = id + } + + res, err := tx.ExecContext(ctx, + fmt.Sprintf("DELETE FROM channel_pending_messages WHERE id IN (%s)", strings.Join(placeholders, ",")), + args..., + ) + if err != nil { + return fmt.Errorf("compact delete: %w", err) + } + + affected, _ := res.RowsAffected() + if affected == 0 { + return nil + } + + if summary.ID == uuid.Nil { + summary.ID = uuid.Must(uuid.NewV7()) + } + now := time.Now() + _, err = tx.ExecContext(ctx, + `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + summary.ID, summary.ChannelName, summary.HistoryKey, summary.Sender, summary.SenderID, summary.Body, summary.PlatformMsgID, true, now, now, tenantIDForInsert(ctx), + ) + if err != nil { + return fmt.Errorf("compact insert summary: %w", err) + } + + return tx.Commit() +} + +func (s *SQLitePendingMessageStore) DeleteStale(ctx context.Context, olderThan time.Duration) (int64, error) { + cutoff := time.Now().Add(-olderThan) + tid := tenantIDForInsert(ctx) + result, err := s.db.ExecContext(ctx, + `DELETE FROM channel_pending_messages WHERE updated_at < ? AND tenant_id = ?`, + cutoff, tid, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *SQLitePendingMessageStore) ListGroups(ctx context.Context) ([]store.PendingMessageGroup, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + // SQLite: BOOL_OR → MAX(is_summary), EXISTS subquery logic preserved + q := `SELECT channel_name, history_key, + COUNT(*) AS message_count, + MAX(is_summary) AND NOT EXISTS ( + SELECT 1 FROM channel_pending_messages n + WHERE n.channel_name = m.channel_name + AND n.history_key = m.history_key + AND NOT n.is_summary + AND n.created_at > ( + SELECT MAX(s.created_at) + FROM channel_pending_messages s + WHERE s.channel_name = m.channel_name + AND s.history_key = m.history_key + AND s.is_summary + ) + ) AS has_summary, + MAX(created_at) AS last_activity + FROM channel_pending_messages m + WHERE 1=1` + tClause + ` + GROUP BY channel_name, history_key + ORDER BY last_activity DESC` + + rows, err := s.db.QueryContext(ctx, q, tArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.PendingMessageGroup + for rows.Next() { + var g store.PendingMessageGroup + if err := rows.Scan(&g.ChannelName, &g.HistoryKey, &g.MessageCount, &g.HasSummary, &g.LastActivity); err != nil { + return nil, err + } + result = append(result, g) + } + return result, rows.Err() +} + +func (s *SQLitePendingMessageStore) CountAll(ctx context.Context) (int64, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return 0, err + } + var count int64 + err = s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM channel_pending_messages WHERE 1=1`+tClause, + tArgs..., + ).Scan(&count) + return count, err +} + +func (s *SQLitePendingMessageStore) CountByKey(ctx context.Context, channelName, historyKey string) (int, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return 0, err + } + args := append([]any{channelName, historyKey}, tArgs...) + var count int + err = s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM channel_pending_messages WHERE channel_name = ? AND history_key = ?`+tClause, + args..., + ).Scan(&count) + return count, err +} + +func (s *SQLitePendingMessageStore) ResolveGroupTitles(ctx context.Context, groups []store.PendingMessageGroup) (map[string]string, error) { + if len(groups) == 0 { + return nil, nil + } + + // Build OR conditions using LIKE with ? placeholders + conditions := make([]string, 0, len(groups)) + args := make([]any, 0, len(groups)*2) + for _, g := range groups { + conditions = append(conditions, "(session_key LIKE '%:' || ? || ':group:' || ? || '%')") + args = append(args, g.ChannelName, g.HistoryKey) + } + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + args = append(args, tid) + } + + tenantFilter := "" + if !store.IsCrossTenant(ctx) { + tenantFilter = " AND tenant_id = ?" + } + + rows, err := s.db.QueryContext(ctx, + "SELECT session_key, json_extract(metadata, '$.chat_title')"+ + " FROM sessions"+ + " WHERE json_extract(metadata, '$.chat_title') != ''"+ + " AND ("+strings.Join(conditions, " OR ")+")"+tenantFilter, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var sessionKey, title string + if err := rows.Scan(&sessionKey, &title); err != nil { + return nil, err + } + for _, g := range groups { + pattern := ":" + g.ChannelName + ":group:" + g.HistoryKey + if strings.Contains(sessionKey, pattern) { + mapKey := g.ChannelName + ":" + g.HistoryKey + if _, exists := result[mapKey]; !exists { + result[mapKey] = title + } + break + } + } + } + return result, rows.Err() +} diff --git a/internal/store/sqlitestore/pool.go b/internal/store/sqlitestore/pool.go new file mode 100644 index 00000000..84af8280 --- /dev/null +++ b/internal/store/sqlitestore/pool.go @@ -0,0 +1,52 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "database/sql" + "fmt" + "log/slog" + + _ "modernc.org/sqlite" // pure-Go SQLite driver +) + +// OpenDB opens a SQLite database at the given path with WAL mode and recommended pragmas. +// Uses modernc.org/sqlite (pure Go, zero CGo). +// +// Desktop app concurrency model: +// - WAL mode: allows concurrent readers alongside a single writer +// - MaxOpenConns(2): limits contention; one reader + one writer can proceed in parallel +// - busy_timeout=15000: writer retries for 15s before SQLITE_BUSY error +// - _txlock=immediate: write transactions acquire lock immediately (fail-fast on contention) +func OpenDB(path string) (*sql.DB, error) { + dsn := fmt.Sprintf("file:%s?_txlock=immediate&_foreign_keys=ON", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + // SQLite is single-writer; WAL allows concurrent readers. + // Keep pool small to reduce lock contention. + db.SetMaxOpenConns(2) + + // Set PRAGMAs explicitly — DSN params may not be applied by modernc.org/sqlite. + pragmas := []string{ + "PRAGMA journal_mode = WAL", + "PRAGMA busy_timeout = 15000", + "PRAGMA synchronous = NORMAL", + "PRAGMA cache_size = -8000", // 8MB cache + } + for _, p := range pragmas { + if _, err := db.Exec(p); err != nil { + slog.Warn("sqlite pragma failed", "pragma", p, "error", err) + } + } + + // Verify connection works. + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("ping sqlite: %w", err) + } + + return db, nil +} diff --git a/internal/store/sqlitestore/providers.go b/internal/store/sqlitestore/providers.go new file mode 100644 index 00000000..be8a6854 --- /dev/null +++ b/internal/store/sqlitestore/providers.go @@ -0,0 +1,199 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteProviderStore implements store.ProviderStore backed by SQLite. +type SQLiteProviderStore struct { + db *sql.DB + encKey string // AES-256 encryption key for API keys (empty = plain text) +} + +func NewSQLiteProviderStore(db *sql.DB, encryptionKey string) *SQLiteProviderStore { + if encryptionKey != "" { + slog.Info("provider store: API key encryption enabled") + } else { + slog.Warn("provider store: API key encryption disabled (plain text storage)") + } + return &SQLiteProviderStore{db: db, encKey: encryptionKey} +} + +func (s *SQLiteProviderStore) CreateProvider(ctx context.Context, p *store.LLMProviderData) error { + if p.ID == uuid.Nil { + p.ID = store.GenNewID() + } + + apiKey := p.APIKey + if s.encKey != "" && apiKey != "" { + encrypted, err := crypto.Encrypt(apiKey, s.encKey) + if err != nil { + return fmt.Errorf("encrypt api key: %w", err) + } + apiKey = encrypted + } + + settings := p.Settings + if len(settings) == 0 { + settings = []byte("{}") + } + + now := time.Now() + p.CreatedAt = now + p.UpdatedAt = now + tid := tenantIDForInsert(ctx) + p.TenantID = tid + _, err := s.db.ExecContext(ctx, + `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now, tid, + ) + return err +} + +func (s *SQLiteProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store.LLMProviderData, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + var p store.LLMProviderData + var apiKey string + createdAt, updatedAt := scanTimePair() + args := append([]any{id}, tArgs...) + err = s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers WHERE id = ?`+tClause, + args..., + ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, createdAt, updatedAt, &p.TenantID) + if err != nil { + return nil, fmt.Errorf("provider not found: %s", id) + } + p.CreatedAt = createdAt.Time + p.UpdatedAt = updatedAt.Time + p.APIKey = s.decryptKey(apiKey, p.Name) + return &p, nil +} + +func (s *SQLiteProviderStore) GetProviderByName(ctx context.Context, name string) (*store.LLMProviderData, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + var p store.LLMProviderData + var apiKey string + createdAt, updatedAt := scanTimePair() + args := append([]any{name}, tArgs...) + err = s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers WHERE name = ?`+tClause, + args..., + ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, createdAt, updatedAt, &p.TenantID) + if err != nil { + return nil, fmt.Errorf("provider not found: %s", name) + } + p.CreatedAt = createdAt.Time + p.UpdatedAt = updatedAt.Time + p.APIKey = s.decryptKey(apiKey, p.Name) + return &p, nil +} + +func (s *SQLiteProviderStore) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + q := `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers WHERE true` + tClause + ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, q, tArgs...) + if err != nil { + return nil, err + } + return s.scanProviders(rows) +} + +// ListAllProviders returns all providers across all tenants. Server-internal only. +func (s *SQLiteProviderStore) ListAllProviders(ctx context.Context) ([]store.LLMProviderData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers ORDER BY name`) + if err != nil { + return nil, err + } + return s.scanProviders(rows) +} + +func (s *SQLiteProviderStore) UpdateProvider(ctx context.Context, id uuid.UUID, updates map[string]any) error { + if apiKey, ok := updates["api_key"]; ok && s.encKey != "" { + if keyStr, ok := apiKey.(string); ok && keyStr != "" { + encrypted, err := crypto.Encrypt(keyStr, s.encKey) + if err != nil { + return fmt.Errorf("encrypt api key: %w", err) + } + updates["api_key"] = encrypted + } + } + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "llm_providers", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + return execMapUpdateWhereTenant(ctx, s.db, "llm_providers", updates, id, tid) +} + +func (s *SQLiteProviderStore) DeleteProvider(ctx context.Context, id uuid.UUID) error { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + args := append([]any{id}, tArgs...) + _, err = s.db.ExecContext(ctx, + "DELETE FROM llm_providers WHERE id = ?"+tClause, + args..., + ) + return err +} + +func (s *SQLiteProviderStore) decryptKey(apiKey, providerName string) string { + if s.encKey != "" && apiKey != "" { + decrypted, err := crypto.Decrypt(apiKey, s.encKey) + if err != nil { + slog.Warn("failed to decrypt provider API key", "provider", providerName, "error", err) + return apiKey + } + return decrypted + } + return apiKey +} + +func (s *SQLiteProviderStore) scanProviders(rows *sql.Rows) ([]store.LLMProviderData, error) { + defer rows.Close() + var result []store.LLMProviderData + for rows.Next() { + var p store.LLMProviderData + var apiKey string + createdAt, updatedAt := scanTimePair() + if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, createdAt, updatedAt, &p.TenantID); err != nil { + slog.Error("providers.scan", "error", err) + continue + } + p.CreatedAt = createdAt.Time + p.UpdatedAt = updatedAt.Time + p.APIKey = s.decryptKey(apiKey, p.Name) + result = append(result, p) + } + return result, rows.Err() +} + diff --git a/internal/store/sqlitestore/scan_time.go b/internal/store/sqlitestore/scan_time.go new file mode 100644 index 00000000..fc3eacf8 --- /dev/null +++ b/internal/store/sqlitestore/scan_time.go @@ -0,0 +1,95 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "database/sql" + "fmt" + "strings" + "time" +) + +// sqliteTime wraps time.Time to handle SQLite's text-based timestamp storage. +// modernc.org/sqlite returns timestamps as strings; this type implements sql.Scanner +// to parse them automatically during rows.Scan(). +type sqliteTime struct { + Time time.Time +} + +// Scan implements sql.Scanner for SQLite text timestamps. +func (st *sqliteTime) Scan(src any) error { + if src == nil { + st.Time = time.Time{} + return nil + } + switch v := src.(type) { + case time.Time: + st.Time = v + return nil + case string: + return st.parseString(v) + case []byte: + return st.parseString(string(v)) + default: + return fmt.Errorf("sqliteTime: unsupported type %T", src) + } +} + +var timeFormats = []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.000Z", + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02 15:04:05.999999999 -0700 -07", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + "2006-01-02", +} + +func (st *sqliteTime) parseString(s string) error { + // Strip Go monotonic clock suffix (e.g. " m=+12.492694459") + if idx := strings.Index(s, " m="); idx > 0 { + s = s[:idx] + } + for _, layout := range timeFormats { + if t, err := time.Parse(layout, s); err == nil { + st.Time = t + return nil + } + } + return fmt.Errorf("sqliteTime: cannot parse %q", s) +} + +// scanTimePair is a convenience for scanning created_at + updated_at columns. +// After scan, copy .Time back to the target time.Time fields. +func scanTimePair() (createdAt, updatedAt *sqliteTime) { + return &sqliteTime{}, &sqliteTime{} +} + +// nullSqliteTime wraps sql.NullTime equivalent for nullable timestamp columns. +type nullSqliteTime struct { + Time time.Time + Valid bool +} + +// Scan implements sql.Scanner for nullable SQLite text timestamps. +func (nt *nullSqliteTime) Scan(src any) error { + if src == nil { + nt.Time = time.Time{} + nt.Valid = false + return nil + } + st := &sqliteTime{} + if err := st.Scan(src); err != nil { + return err + } + nt.Time = st.Time + nt.Valid = true + return nil +} + +// NullTime returns sql.NullTime from nullSqliteTime. +func (nt *nullSqliteTime) NullTime() sql.NullTime { + return sql.NullTime{Time: nt.Time, Valid: nt.Valid} +} diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go new file mode 100644 index 00000000..f9e321ae --- /dev/null +++ b/internal/store/sqlitestore/schema.go @@ -0,0 +1,116 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "database/sql" + _ "embed" + "fmt" + "log/slog" +) + +//go:embed schema.sql +var schemaSQL string + +// SchemaVersion is the current SQLite schema version. +// Bump this when adding new migration steps below. +const SchemaVersion = 1 + +// migrations maps version → SQL to apply when upgrading FROM that version. +// schema.sql always represents the LATEST full schema (for fresh DBs). +// Existing DBs are patched incrementally via these steps. +// +// Example: to add a new column in the future: +// +// var migrations = map[int]string{ +// 1: `ALTER TABLE agents ADD COLUMN new_col TEXT DEFAULT '';`, +// } +// +// Then bump SchemaVersion to 2. +var migrations = map[int]string{ + // Version 1 is the initial schema — no patch needed (schema.sql covers it). +} + +// EnsureSchema creates tables if they don't exist and applies incremental migrations. +// +// Flow: +// 1. Fresh DB (no schema_version row) → apply full schema.sql + set version = SchemaVersion +// 2. Existing DB with version < SchemaVersion → apply patches sequentially +// 3. Existing DB with version == SchemaVersion → no-op +// 4. Always: seed master tenant (idempotent) +func EnsureSchema(db *sql.DB) error { + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL PRIMARY KEY + )`); err != nil { + return fmt.Errorf("create schema_version: %w", err) + } + + var current int + err := db.QueryRow("SELECT version FROM schema_version LIMIT 1").Scan(¤t) + if err == sql.ErrNoRows { + // Fresh database — apply full schema. + slog.Info("sqlite: applying initial schema", "version", SchemaVersion) + tx, txErr := db.Begin() + if txErr != nil { + return fmt.Errorf("begin schema tx: %w", txErr) + } + if _, err := tx.Exec(schemaSQL); err != nil { + tx.Rollback() + return fmt.Errorf("apply schema: %w", err) + } + if _, err := tx.Exec("INSERT INTO schema_version (version) VALUES (?)", SchemaVersion); err != nil { + tx.Rollback() + return fmt.Errorf("set schema version: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit schema tx: %w", err) + } + return seedMasterTenant(db) + } + if err != nil { + return fmt.Errorf("read schema version: %w", err) + } + + // Apply incremental migrations for existing DBs. + if current < SchemaVersion { + slog.Info("sqlite: migrating schema", "from", current, "to", SchemaVersion) + for v := current; v < SchemaVersion; v++ { + patch, ok := migrations[v] + if !ok { + return fmt.Errorf("sqlite: missing migration for version %d → %d", v, v+1) + } + tx, txErr := db.Begin() + if txErr != nil { + return fmt.Errorf("begin migration tx v%d: %w", v, txErr) + } + if _, err := tx.Exec(patch); err != nil { + tx.Rollback() + return fmt.Errorf("apply migration v%d: %w", v, err) + } + if _, err := tx.Exec( + "UPDATE schema_version SET version = ? WHERE version = ?", v+1, v, + ); err != nil { + tx.Rollback() + return fmt.Errorf("update schema version v%d: %w", v, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration v%d: %w", v, err) + } + slog.Info("sqlite: applied migration", "version", v+1) + } + } + + return seedMasterTenant(db) +} + +// seedMasterTenant ensures the master tenant row exists (idempotent). +func seedMasterTenant(db *sql.DB) error { + _, err := db.Exec( + `INSERT OR IGNORE INTO tenants (id, name, slug, status) VALUES (?, 'Master', 'master', 'active')`, + "0193a5b0-7000-7000-8000-000000000001", + ) + if err != nil { + slog.Warn("sqlite: seed master tenant failed", "error", err) + } + return nil +} diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql new file mode 100644 index 00000000..420443aa --- /dev/null +++ b/internal/store/sqlitestore/schema.sql @@ -0,0 +1,1307 @@ +-- GoClaw SQLite Schema (auto-translated from PG migrations 000001-000029) +-- +-- Translation rules applied: +-- UUID → TEXT (36-char string) +-- TIMESTAMPTZ → TEXT (RFC3339) +-- JSONB → TEXT (JSON string) +-- BYTEA → BLOB +-- SERIAL/BIGSERIAL → INTEGER PRIMARY KEY AUTOINCREMENT +-- DEFAULT uuid_generate_v7() / gen_random_uuid() → removed (generated in Go) +-- DEFAULT NOW() / CURRENT_TIMESTAMP → DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +-- text[] / uuid[] → TEXT (JSON array) +-- vector(N) → OMITTED (sqlite-vec virtual tables added separately) +-- tsvector → OMITTED (FTS5 virtual tables added separately) +-- CREATE EXTENSION / CREATE OR REPLACE FUNCTION → OMITTED +-- CREATE INDEX USING gin / hnsw → OMITTED +-- USING btree → removed from CREATE INDEX +-- +-- Tables dropped across migrations (not included): +-- group_file_writers (migration 23: merged into agent_config_permissions) +-- handoff_routes (migration 26: dropped) +-- delegation_history (migration 26: dropped) +-- team_messages (migration 24: dropped) +-- team_workspace_files (migration 24: dropped) +-- team_workspace_comments (migration 24: dropped) +-- team_workspace_file_versions (migration 24: dropped) +-- team_task_attachments (migration 24: old version dropped, new path-based version created) +-- custom_tools (migration 27: dropped) +-- +-- FK cascade constraints reflect final state after migration 23 alterations. + +PRAGMA foreign_keys = ON; + +-- ============================================================ +-- Table: tenants +-- (created first — referenced by almost all other tables) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS tenants ( + id TEXT NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + settings TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug); +CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status) WHERE status = 'active'; + +-- Seed master tenant (required by all FK references) +INSERT OR IGNORE INTO tenants (id, name, slug, status) +VALUES ('0193a5b0-7000-7000-8000-000000000001', 'Master', 'master', 'active'); + +-- ============================================================ +-- Table: tenant_users +-- ============================================================ + +CREATE TABLE IF NOT EXISTS tenant_users ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + role VARCHAR(20) NOT NULL DEFAULT 'member', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_tenant_users_user ON tenant_users(user_id); +CREATE INDEX IF NOT EXISTS idx_tenant_users_tenant ON tenant_users(tenant_id); + +-- ============================================================ +-- Table: llm_providers +-- ============================================================ + +CREATE TABLE IF NOT EXISTS llm_providers ( + id TEXT NOT NULL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + display_name VARCHAR(255), + provider_type VARCHAR(30) NOT NULL DEFAULT 'openai_compat', + api_base TEXT, + api_key TEXT, + enabled BOOLEAN NOT NULL DEFAULT 1, + settings TEXT NOT NULL DEFAULT '{}', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- tenant-scoped unique name (migration 27 Phase I) +CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_providers_tenant_name ON llm_providers(tenant_id, name); +CREATE INDEX IF NOT EXISTS idx_llm_providers_tenant ON llm_providers(tenant_id); + +-- ============================================================ +-- Table: agents +-- Note: tsv (tsvector) and embedding (vector) columns omitted +-- ============================================================ + +CREATE TABLE IF NOT EXISTS agents ( + id TEXT NOT NULL PRIMARY KEY, + agent_key VARCHAR(100) NOT NULL, + display_name VARCHAR(255), + owner_id VARCHAR(255) NOT NULL, + provider VARCHAR(50) NOT NULL DEFAULT 'openrouter', + model VARCHAR(200) NOT NULL, + context_window INT NOT NULL DEFAULT 200000, + max_tool_iterations INT NOT NULL DEFAULT 20, + workspace TEXT NOT NULL DEFAULT '.', + restrict_to_workspace BOOLEAN NOT NULL DEFAULT 1, + tools_config TEXT NOT NULL DEFAULT '{}', + sandbox_config TEXT, + subagents_config TEXT, + memory_config TEXT, + compaction_config TEXT, + context_pruning TEXT, + other_config TEXT NOT NULL DEFAULT '{}', + is_default BOOLEAN NOT NULL DEFAULT 0, + agent_type VARCHAR(20) NOT NULL DEFAULT 'open', + status VARCHAR(20) DEFAULT 'active', + frontmatter TEXT, + budget_monthly_cents INTEGER, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + deleted_at TEXT +); + +-- Final unique constraint: tenant-scoped agent_key for active agents (migration 27 Phase I) +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_tenant_agent_key_active ON agents(tenant_id, agent_key) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_agents_owner ON agents(owner_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_agents_tenant ON agents(tenant_id); +CREATE INDEX IF NOT EXISTS idx_agents_tenant_active ON agents(tenant_id) WHERE deleted_at IS NULL; + +-- ============================================================ +-- Table: agent_shares +-- ============================================================ + +CREATE TABLE IF NOT EXISTS agent_shares ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', + granted_by VARCHAR(255) NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(agent_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_agent_shares_user ON agent_shares(user_id); +CREATE INDEX IF NOT EXISTS idx_agent_shares_tenant ON agent_shares(tenant_id); + +-- ============================================================ +-- Table: agent_context_files +-- ============================================================ + +CREATE TABLE IF NOT EXISTS agent_context_files ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + content TEXT NOT NULL DEFAULT '', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(agent_id, file_name) +); + +CREATE INDEX IF NOT EXISTS idx_agent_context_files_tenant ON agent_context_files(tenant_id); + +-- ============================================================ +-- Table: user_context_files +-- ============================================================ + +CREATE TABLE IF NOT EXISTS user_context_files ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + file_name VARCHAR(255) NOT NULL, + content TEXT NOT NULL DEFAULT '', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(agent_id, user_id, file_name) +); + +CREATE INDEX IF NOT EXISTS idx_user_context_files_tenant ON user_context_files(tenant_id); + +-- ============================================================ +-- Table: user_agent_profiles +-- ============================================================ + +CREATE TABLE IF NOT EXISTS user_agent_profiles ( + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + workspace TEXT, + metadata TEXT DEFAULT '{}', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + first_seen_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_seen_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (agent_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_agent_profiles_tenant ON user_agent_profiles(tenant_id); + +-- ============================================================ +-- Table: user_agent_overrides +-- ============================================================ + +CREATE TABLE IF NOT EXISTS user_agent_overrides ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + provider VARCHAR(50), + model VARCHAR(200), + settings TEXT NOT NULL DEFAULT '{}', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(agent_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_agent_overrides_tenant ON user_agent_overrides(tenant_id); + +-- ============================================================ +-- Table: agent_teams +-- (defined before sessions/memory since they reference it) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS agent_teams ( + id TEXT NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + lead_agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'active', + settings TEXT NOT NULL DEFAULT '{}', + created_by VARCHAR(255) NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_agent_teams_tenant ON agent_teams(tenant_id); + +-- ============================================================ +-- Table: sessions +-- ============================================================ + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT NOT NULL PRIMARY KEY, + session_key VARCHAR(500) NOT NULL, + agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255), + messages TEXT NOT NULL DEFAULT '[]', + summary TEXT, + model VARCHAR(200), + provider VARCHAR(50), + channel VARCHAR(50), + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + compaction_count INT NOT NULL DEFAULT 0, + memory_flush_compaction_count INT NOT NULL DEFAULT 0, + memory_flush_at BIGINT DEFAULT 0, + label VARCHAR(500), + spawned_by VARCHAR(200), + spawn_depth INT NOT NULL DEFAULT 0, + metadata TEXT DEFAULT '{}', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- tenant-scoped unique session_key (migration 27 Phase I) +CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_tenant_session_key ON sessions(tenant_id, session_key); +CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON sessions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_sessions_tenant_user ON sessions(tenant_id, user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_team ON sessions(team_id) WHERE team_id IS NOT NULL; + +-- ============================================================ +-- Table: memory_documents +-- ============================================================ + +CREATE TABLE IF NOT EXISTS memory_documents ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255), + path VARCHAR(500) NOT NULL, + content TEXT NOT NULL DEFAULT '', + hash VARCHAR(64) NOT NULL, + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_memdoc_unique ON memory_documents(agent_id, COALESCE(user_id, ''), path); +CREATE INDEX IF NOT EXISTS idx_memdoc_agent_user ON memory_documents(agent_id, user_id); +CREATE INDEX IF NOT EXISTS idx_memdoc_team ON memory_documents(team_id) WHERE team_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_memory_documents_tenant ON memory_documents(tenant_id); + +-- ============================================================ +-- Table: memory_chunks +-- Note: embedding (vector) and tsv (tsvector) columns omitted +-- ============================================================ + +CREATE TABLE IF NOT EXISTS memory_chunks ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + document_id TEXT REFERENCES memory_documents(id) ON DELETE CASCADE, + user_id VARCHAR(255), + path TEXT NOT NULL, + start_line INT NOT NULL DEFAULT 0, + end_line INT NOT NULL DEFAULT 0, + hash VARCHAR(64) NOT NULL, + text TEXT NOT NULL, + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_mem_agent_user ON memory_chunks(agent_id, user_id); +CREATE INDEX IF NOT EXISTS idx_mem_global ON memory_chunks(agent_id) WHERE user_id IS NULL; +CREATE INDEX IF NOT EXISTS idx_mem_document ON memory_chunks(document_id); +CREATE INDEX IF NOT EXISTS idx_memchunk_team ON memory_chunks(team_id) WHERE team_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_memory_chunks_tenant ON memory_chunks(tenant_id); + +-- ============================================================ +-- Table: embedding_cache +-- Note: embedding (vector) column omitted +-- ============================================================ + +CREATE TABLE IF NOT EXISTS embedding_cache ( + hash VARCHAR(64) NOT NULL, + provider VARCHAR(50) NOT NULL, + model VARCHAR(200) NOT NULL, + dims INT NOT NULL DEFAULT 0, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (hash, provider, model) +); + +CREATE INDEX IF NOT EXISTS idx_embedding_cache_tenant ON embedding_cache(tenant_id); + +-- ============================================================ +-- Table: skills +-- Note: embedding (vector) column omitted +-- ============================================================ + +CREATE TABLE IF NOT EXISTS skills ( + id TEXT NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT, + owner_id VARCHAR(255) NOT NULL, + visibility VARCHAR(10) NOT NULL DEFAULT 'private', + version INT NOT NULL DEFAULT 1, + status VARCHAR(20) NOT NULL DEFAULT 'active', + frontmatter TEXT NOT NULL DEFAULT '{}', + file_path TEXT NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + file_hash VARCHAR(64), + tags TEXT, + is_system BOOLEAN NOT NULL DEFAULT 0, + deps TEXT NOT NULL DEFAULT '{}', + enabled BOOLEAN NOT NULL DEFAULT 1, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- tenant-scoped unique slug (migration 27 Phase I) +CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_tenant_slug ON skills(tenant_id, slug); +CREATE INDEX IF NOT EXISTS idx_skills_owner ON skills(owner_id); +CREATE INDEX IF NOT EXISTS idx_skills_visibility ON skills(visibility) WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS idx_skills_system ON skills(is_system) WHERE is_system = 1; +CREATE INDEX IF NOT EXISTS idx_skills_enabled ON skills(enabled) WHERE enabled = 0; +CREATE INDEX IF NOT EXISTS idx_skills_tenant ON skills(tenant_id); + +-- ============================================================ +-- Table: skill_agent_grants +-- ============================================================ + +CREATE TABLE IF NOT EXISTS skill_agent_grants ( + id TEXT NOT NULL PRIMARY KEY, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + pinned_version INT NOT NULL, + granted_by VARCHAR(255) NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(skill_id, agent_id) +); + +CREATE INDEX IF NOT EXISTS idx_skill_agent_grants_agent ON skill_agent_grants(agent_id); +CREATE INDEX IF NOT EXISTS idx_skill_agent_grants_tenant ON skill_agent_grants(tenant_id); + +-- ============================================================ +-- Table: skill_user_grants +-- ============================================================ + +CREATE TABLE IF NOT EXISTS skill_user_grants ( + id TEXT NOT NULL PRIMARY KEY, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + granted_by VARCHAR(255) NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(skill_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_skill_user_grants_user ON skill_user_grants(user_id); +CREATE INDEX IF NOT EXISTS idx_skill_user_grants_tenant ON skill_user_grants(tenant_id); + +-- ============================================================ +-- Table: skill_tenant_configs +-- ============================================================ + +CREATE TABLE IF NOT EXISTS skill_tenant_configs ( + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (skill_id, tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_skill_tenant_configs_tenant ON skill_tenant_configs(tenant_id); + +-- ============================================================ +-- Table: cron_jobs +-- ============================================================ + +CREATE TABLE IF NOT EXISTS cron_jobs ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE, + user_id TEXT, + name VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + schedule_kind VARCHAR(10) NOT NULL, + cron_expression VARCHAR(100), + interval_ms BIGINT, + run_at TEXT, + timezone VARCHAR(50), + payload TEXT NOT NULL, + delete_after_run BOOLEAN NOT NULL DEFAULT 0, + next_run_at TEXT, + last_run_at TEXT, + last_status VARCHAR(20), + last_error TEXT, + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_cron_jobs_user_id ON cron_jobs(user_id); +CREATE INDEX IF NOT EXISTS idx_cron_jobs_agent_user ON cron_jobs(agent_id, user_id); +CREATE INDEX IF NOT EXISTS idx_cron_jobs_team ON cron_jobs(team_id) WHERE team_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_cron_jobs_tenant ON cron_jobs(tenant_id); + +-- ============================================================ +-- Table: cron_run_logs +-- ============================================================ + +CREATE TABLE IF NOT EXISTS cron_run_logs ( + id TEXT NOT NULL PRIMARY KEY, + job_id TEXT NOT NULL REFERENCES cron_jobs(id) ON DELETE CASCADE, + agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL, + status VARCHAR(20) NOT NULL, + summary TEXT, + error TEXT, + duration_ms INT, + input_tokens INT DEFAULT 0, + output_tokens INT DEFAULT 0, + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + ran_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_cron_run_logs_job ON cron_run_logs(job_id, ran_at DESC); +CREATE INDEX IF NOT EXISTS idx_cron_run_logs_team ON cron_run_logs(team_id) WHERE team_id IS NOT NULL; + +-- ============================================================ +-- Table: pairing_requests +-- ============================================================ + +CREATE TABLE IF NOT EXISTS pairing_requests ( + id TEXT NOT NULL PRIMARY KEY, + code VARCHAR(8) NOT NULL UNIQUE, + sender_id VARCHAR(200) NOT NULL, + channel VARCHAR(255) NOT NULL, + chat_id VARCHAR(200) NOT NULL, + account_id VARCHAR(100) NOT NULL DEFAULT 'default', + metadata TEXT DEFAULT '{}', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_pairing_requests_tenant ON pairing_requests(tenant_id); + +-- ============================================================ +-- Table: paired_devices +-- ============================================================ + +CREATE TABLE IF NOT EXISTS paired_devices ( + id TEXT NOT NULL PRIMARY KEY, + sender_id VARCHAR(200) NOT NULL, + channel VARCHAR(255) NOT NULL, + chat_id VARCHAR(200) NOT NULL, + paired_by VARCHAR(100) NOT NULL DEFAULT 'operator', + metadata TEXT DEFAULT '{}', + expires_at TEXT, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + paired_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- tenant-scoped unique (migration 27 Phase I) +CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_devices_tenant_sender_channel ON paired_devices(tenant_id, sender_id, channel); +CREATE INDEX IF NOT EXISTS idx_paired_devices_tenant ON paired_devices(tenant_id); + +-- ============================================================ +-- Table: traces +-- ============================================================ + +CREATE TABLE IF NOT EXISTS traces ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT, + user_id VARCHAR(255), + session_key TEXT, + run_id TEXT, + start_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + end_time TEXT, + duration_ms INT, + name TEXT, + channel VARCHAR(50), + input_preview TEXT, + output_preview TEXT, + total_input_tokens INT DEFAULT 0, + total_output_tokens INT DEFAULT 0, + total_cost NUMERIC(12,6) DEFAULT 0, + span_count INT DEFAULT 0, + llm_call_count INT DEFAULT 0, + tool_call_count INT DEFAULT 0, + status VARCHAR(20) DEFAULT 'running', + error TEXT, + metadata TEXT, + tags TEXT, + parent_trace_id TEXT, + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_traces_agent_time ON traces(agent_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_traces_user_time ON traces(user_id, created_at DESC) WHERE user_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_key, created_at DESC) WHERE session_key IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_traces_status ON traces(status) WHERE status = 'error'; +CREATE INDEX IF NOT EXISTS idx_traces_parent ON traces(parent_trace_id) WHERE parent_trace_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_traces_quota ON traces(user_id, created_at DESC) WHERE parent_trace_id IS NULL AND user_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_traces_start_root ON traces(start_time DESC) WHERE parent_trace_id IS NULL; +CREATE INDEX IF NOT EXISTS idx_traces_team ON traces(team_id, created_at DESC) WHERE team_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_traces_tenant ON traces(tenant_id); +CREATE INDEX IF NOT EXISTS idx_traces_tenant_time ON traces(tenant_id, created_at DESC); + +-- ============================================================ +-- Table: spans +-- ============================================================ + +CREATE TABLE IF NOT EXISTS spans ( + id TEXT NOT NULL PRIMARY KEY, + trace_id TEXT NOT NULL, + parent_span_id TEXT, + agent_id TEXT, + span_type VARCHAR(20) NOT NULL, + name TEXT, + start_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + end_time TEXT, + duration_ms INT, + status VARCHAR(20) DEFAULT 'running', + error TEXT, + level VARCHAR(10) DEFAULT 'DEFAULT', + model VARCHAR(200), + provider VARCHAR(50), + input_tokens INT, + output_tokens INT, + total_cost NUMERIC(12,8), + finish_reason VARCHAR(50), + model_params TEXT, + tool_name VARCHAR(200), + tool_call_id VARCHAR(100), + input_preview TEXT, + output_preview TEXT, + metadata TEXT, + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_spans_trace ON spans(trace_id, start_time); +CREATE INDEX IF NOT EXISTS idx_spans_trace_type ON spans(trace_id, span_type); +CREATE INDEX IF NOT EXISTS idx_spans_parent ON spans(parent_span_id) WHERE parent_span_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_spans_agent_time ON spans(agent_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_spans_type ON spans(span_type, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_spans_model ON spans(model, created_at DESC) WHERE model IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_spans_error ON spans(status) WHERE status = 'error'; +CREATE INDEX IF NOT EXISTS idx_spans_team ON spans(team_id) WHERE team_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_spans_tenant ON spans(tenant_id); + +-- ============================================================ +-- Table: mcp_servers +-- ============================================================ + +CREATE TABLE IF NOT EXISTS mcp_servers ( + id TEXT NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + transport VARCHAR(50) NOT NULL, + command TEXT, + args TEXT DEFAULT '[]', + url TEXT, + headers TEXT DEFAULT '{}', + env TEXT DEFAULT '{}', + api_key TEXT, + tool_prefix VARCHAR(50), + timeout_sec INT DEFAULT 60, + settings TEXT NOT NULL DEFAULT '{}', + enabled BOOLEAN NOT NULL DEFAULT 1, + created_by VARCHAR(255) NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- tenant-scoped unique name (migration 27 Phase I) +CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_servers_tenant_name ON mcp_servers(tenant_id, name); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_tenant ON mcp_servers(tenant_id); + +-- ============================================================ +-- Table: mcp_agent_grants +-- ============================================================ + +CREATE TABLE IF NOT EXISTS mcp_agent_grants ( + id TEXT NOT NULL PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT 1, + tool_allow TEXT, + tool_deny TEXT, + config_overrides TEXT, + granted_by VARCHAR(255) NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(server_id, agent_id) +); + +CREATE INDEX IF NOT EXISTS idx_mcp_agent_grants_agent ON mcp_agent_grants(agent_id); +CREATE INDEX IF NOT EXISTS idx_mcp_agent_grants_tenant ON mcp_agent_grants(tenant_id); + +-- ============================================================ +-- Table: mcp_user_grants +-- ============================================================ + +CREATE TABLE IF NOT EXISTS mcp_user_grants ( + id TEXT NOT NULL PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + tool_allow TEXT, + tool_deny TEXT, + granted_by VARCHAR(255) NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(server_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_mcp_user_grants_user ON mcp_user_grants(user_id); +CREATE INDEX IF NOT EXISTS idx_mcp_user_grants_tenant ON mcp_user_grants(tenant_id); + +-- ============================================================ +-- Table: mcp_access_requests +-- ============================================================ + +CREATE TABLE IF NOT EXISTS mcp_access_requests ( + id TEXT NOT NULL PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255), + scope VARCHAR(10) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + reason TEXT, + tool_allow TEXT, + requested_by VARCHAR(255) NOT NULL, + reviewed_by VARCHAR(255), + reviewed_at TEXT, + review_note TEXT, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_mcp_requests_status ON mcp_access_requests(status) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_mcp_requests_server ON mcp_access_requests(server_id); +CREATE INDEX IF NOT EXISTS idx_mcp_access_requests_tenant ON mcp_access_requests(tenant_id); + +-- ============================================================ +-- Table: mcp_user_credentials +-- ============================================================ + +CREATE TABLE IF NOT EXISTS mcp_user_credentials ( + id TEXT NOT NULL PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + api_key TEXT, + headers BLOB, + env BLOB, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(server_id, user_id, tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_mcp_user_credentials_tenant ON mcp_user_credentials(tenant_id); +CREATE INDEX IF NOT EXISTS idx_mcp_user_credentials_server ON mcp_user_credentials(server_id); + +-- ============================================================ +-- Table: channel_instances +-- ============================================================ + +CREATE TABLE IF NOT EXISTS channel_instances ( + id TEXT NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + display_name VARCHAR(255) DEFAULT '', + channel_type VARCHAR(50) NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + credentials BLOB, + config TEXT DEFAULT '{}', + enabled BOOLEAN DEFAULT 1, + created_by VARCHAR(255) DEFAULT '', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- tenant-scoped unique name (migration 27 Phase I) +CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_instances_tenant_name ON channel_instances(tenant_id, name); +CREATE INDEX IF NOT EXISTS idx_channel_instances_type ON channel_instances(channel_type); +CREATE INDEX IF NOT EXISTS idx_channel_instances_agent ON channel_instances(agent_id); +CREATE INDEX IF NOT EXISTS idx_channel_instances_tenant ON channel_instances(tenant_id); + +-- ============================================================ +-- Table: config_secrets +-- PK changed to (key, tenant_id) in migration 27 Phase I +-- ============================================================ + +CREATE TABLE IF NOT EXISTS config_secrets ( + key VARCHAR(100) NOT NULL, + value BLOB NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (key, tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_config_secrets_tenant ON config_secrets(tenant_id); + +-- ============================================================ +-- Table: agent_links +-- ============================================================ + +CREATE TABLE IF NOT EXISTS agent_links ( + id TEXT NOT NULL PRIMARY KEY, + source_agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + target_agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + direction VARCHAR(20) NOT NULL DEFAULT 'outbound', + description TEXT, + max_concurrent INT NOT NULL DEFAULT 3, + settings TEXT NOT NULL DEFAULT '{}', + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_by VARCHAR(255) NOT NULL, + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(source_agent_id, target_agent_id), + CHECK (source_agent_id != target_agent_id) +); + +CREATE INDEX IF NOT EXISTS idx_agent_links_source ON agent_links(source_agent_id) WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS idx_agent_links_target ON agent_links(target_agent_id) WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS idx_agent_links_tenant ON agent_links(tenant_id); + +-- ============================================================ +-- Table: agent_team_members +-- ============================================================ + +CREATE TABLE IF NOT EXISTS agent_team_members ( + team_id TEXT NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL DEFAULT 'member', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + joined_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (team_id, agent_id) +); + +CREATE INDEX IF NOT EXISTS idx_agent_team_members_tenant ON agent_team_members(tenant_id); + +-- ============================================================ +-- Table: team_tasks +-- Note: tsv (tsvector) and embedding (vector) columns omitted +-- blocked_by stored as TEXT (JSON array of UUIDs) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS team_tasks ( + id TEXT NOT NULL PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE, + subject VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + owner_agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL, + blocked_by TEXT NOT NULL DEFAULT '[]', + priority INT NOT NULL DEFAULT 0, + result TEXT, + metadata TEXT NOT NULL DEFAULT '{}', + user_id VARCHAR(255), + channel VARCHAR(50), + task_type VARCHAR(30) NOT NULL DEFAULT 'general', + task_number INT NOT NULL DEFAULT 0, + identifier VARCHAR(20), + created_by_agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL, + assignee_user_id VARCHAR(255), + parent_id TEXT REFERENCES team_tasks(id) ON DELETE SET NULL, + chat_id VARCHAR(255) DEFAULT '', + locked_at TEXT, + lock_expires_at TEXT, + progress_percent INT DEFAULT 0 CHECK (progress_percent BETWEEN 0 AND 100), + progress_step TEXT, + followup_at TEXT, + followup_count INT NOT NULL DEFAULT 0, + followup_max INT NOT NULL DEFAULT 0, + followup_message TEXT, + followup_channel VARCHAR(60), + followup_chat_id VARCHAR(255), + confidence_score REAL, + comment_count INT NOT NULL DEFAULT 0, + attachment_count INT NOT NULL DEFAULT 0, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_team_tasks_team ON team_tasks(team_id); +CREATE INDEX IF NOT EXISTS idx_team_tasks_status ON team_tasks(team_id, status); +CREATE INDEX IF NOT EXISTS idx_team_tasks_user_scope ON team_tasks(team_id, user_id) WHERE user_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_tt_parent ON team_tasks(parent_id) WHERE parent_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_tt_scope ON team_tasks(team_id, channel, chat_id); +CREATE INDEX IF NOT EXISTS idx_tt_type ON team_tasks(team_id, task_type); +CREATE INDEX IF NOT EXISTS idx_tt_lock ON team_tasks(lock_expires_at) WHERE lock_expires_at IS NOT NULL AND status = 'in_progress'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_tt_identifier ON team_tasks(team_id, identifier) WHERE identifier IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_tt_followup ON team_tasks(followup_at) WHERE followup_at IS NOT NULL AND status = 'in_progress'; +-- idx_tt_blocked_by (GIN on array) omitted: Go code handles JSON array filtering +CREATE INDEX IF NOT EXISTS idx_tt_owner_status ON team_tasks(team_id, owner_agent_id, status); +CREATE INDEX IF NOT EXISTS idx_team_tasks_tenant ON team_tasks(tenant_id); + +-- ============================================================ +-- Table: team_task_comments +-- ============================================================ + +CREATE TABLE IF NOT EXISTS team_task_comments ( + id TEXT NOT NULL PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE, + agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL, + user_id VARCHAR(255), + content TEXT NOT NULL, + metadata TEXT DEFAULT '{}', + comment_type VARCHAR(20) NOT NULL DEFAULT 'note', + confidence_score REAL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_ttc_task ON team_task_comments(task_id); +CREATE INDEX IF NOT EXISTS idx_team_task_comments_tenant ON team_task_comments(tenant_id); + +-- ============================================================ +-- Table: team_task_events +-- ============================================================ + +CREATE TABLE IF NOT EXISTS team_task_events ( + id TEXT NOT NULL PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE, + event_type VARCHAR(30) NOT NULL, + actor_type VARCHAR(10) NOT NULL, + actor_id VARCHAR(255) NOT NULL, + data TEXT, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_tte_task ON team_task_events(task_id); +CREATE INDEX IF NOT EXISTS idx_team_task_events_tenant ON team_task_events(tenant_id); + +-- ============================================================ +-- Table: team_task_attachments +-- (new path-based version from migration 24) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS team_task_attachments ( + id TEXT NOT NULL PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE, + team_id TEXT NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE, + chat_id VARCHAR(255) NOT NULL DEFAULT '', + path TEXT NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + mime_type VARCHAR(100) DEFAULT '', + created_by_agent_id TEXT REFERENCES agents(id), + created_by_sender_id VARCHAR(255) DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(task_id, path) +); + +CREATE INDEX IF NOT EXISTS idx_tta_task ON team_task_attachments(task_id); +CREATE INDEX IF NOT EXISTS idx_tta_team ON team_task_attachments(team_id); +CREATE INDEX IF NOT EXISTS idx_team_task_attachments_tenant ON team_task_attachments(tenant_id); + +-- ============================================================ +-- Table: team_user_grants +-- ============================================================ + +CREATE TABLE IF NOT EXISTS team_user_grants ( + id TEXT NOT NULL PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + granted_by VARCHAR(255), + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(team_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_team_user_grants_user ON team_user_grants(user_id); +CREATE INDEX IF NOT EXISTS idx_team_user_grants_team ON team_user_grants(team_id); +CREATE INDEX IF NOT EXISTS idx_team_user_grants_tenant ON team_user_grants(tenant_id); + +-- ============================================================ +-- Table: kg_entities +-- Note: embedding (vector) column omitted +-- ============================================================ + +CREATE TABLE IF NOT EXISTS kg_entities ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL DEFAULT '', + external_id VARCHAR(255) NOT NULL, + name TEXT NOT NULL, + entity_type VARCHAR(100) NOT NULL, + description TEXT DEFAULT '', + properties TEXT DEFAULT '{}', + source_id VARCHAR(255) DEFAULT '', + confidence REAL NOT NULL DEFAULT 1.0, + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(agent_id, user_id, external_id) +); + +CREATE INDEX IF NOT EXISTS idx_kg_entities_scope ON kg_entities(agent_id, user_id); +CREATE INDEX IF NOT EXISTS idx_kg_entities_type ON kg_entities(agent_id, user_id, entity_type); +CREATE INDEX IF NOT EXISTS idx_kg_entities_team ON kg_entities(team_id) WHERE team_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_kg_entities_tenant ON kg_entities(tenant_id); + +-- ============================================================ +-- Table: kg_relations +-- ============================================================ + +CREATE TABLE IF NOT EXISTS kg_relations ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL DEFAULT '', + source_entity_id TEXT NOT NULL REFERENCES kg_entities(id) ON DELETE CASCADE, + relation_type VARCHAR(200) NOT NULL, + target_entity_id TEXT NOT NULL REFERENCES kg_entities(id) ON DELETE CASCADE, + confidence REAL NOT NULL DEFAULT 1.0, + properties TEXT DEFAULT '{}', + team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(agent_id, user_id, source_entity_id, relation_type, target_entity_id) +); + +CREATE INDEX IF NOT EXISTS idx_kg_relations_source ON kg_relations(source_entity_id, relation_type); +CREATE INDEX IF NOT EXISTS idx_kg_relations_target ON kg_relations(target_entity_id); +CREATE INDEX IF NOT EXISTS idx_kg_relations_team ON kg_relations(team_id) WHERE team_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_kg_relations_tenant ON kg_relations(tenant_id); + +-- ============================================================ +-- Table: channel_pending_messages +-- ============================================================ + +CREATE TABLE IF NOT EXISTS channel_pending_messages ( + id TEXT NOT NULL PRIMARY KEY, + channel_name VARCHAR(100) NOT NULL, + history_key VARCHAR(200) NOT NULL, + sender VARCHAR(255) NOT NULL, + sender_id VARCHAR(255) NOT NULL DEFAULT '', + body TEXT NOT NULL, + platform_msg_id VARCHAR(100) NOT NULL DEFAULT '', + is_summary BOOLEAN NOT NULL DEFAULT 0, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_channel_pending_messages_lookup ON channel_pending_messages(channel_name, history_key, created_at); +CREATE INDEX IF NOT EXISTS idx_channel_pending_messages_tenant ON channel_pending_messages(tenant_id); + +-- ============================================================ +-- Table: channel_contacts +-- ============================================================ + +CREATE TABLE IF NOT EXISTS channel_contacts ( + id TEXT NOT NULL PRIMARY KEY, + channel_type VARCHAR(50) NOT NULL, + channel_instance VARCHAR(255), + sender_id VARCHAR(255) NOT NULL, + user_id VARCHAR(255), + display_name VARCHAR(255), + username VARCHAR(255), + avatar_url TEXT, + peer_kind VARCHAR(20), + metadata TEXT DEFAULT '{}', + merged_id TEXT, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- tenant-scoped unique (migration 27 Phase I) +CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_contacts_tenant_type_sender ON channel_contacts(tenant_id, channel_type, sender_id); +CREATE INDEX IF NOT EXISTS idx_channel_contacts_instance ON channel_contacts(channel_instance) WHERE channel_instance IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_channel_contacts_merged ON channel_contacts(merged_id) WHERE merged_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_channel_contacts_search ON channel_contacts(display_name, username); +CREATE INDEX IF NOT EXISTS idx_channel_contacts_tenant ON channel_contacts(tenant_id); + +-- ============================================================ +-- Table: activity_logs +-- ============================================================ + +CREATE TABLE IF NOT EXISTS activity_logs ( + id TEXT NOT NULL PRIMARY KEY, + actor_type VARCHAR(20) NOT NULL, + actor_id VARCHAR(255) NOT NULL, + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50), + entity_id VARCHAR(255), + details TEXT, + ip_address VARCHAR(45), + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_activity_logs_actor ON activity_logs(actor_type, actor_id); +CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs(action); +CREATE INDEX IF NOT EXISTS idx_activity_logs_entity ON activity_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_activity_logs_created ON activity_logs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_activity_logs_tenant ON activity_logs(tenant_id); + +-- ============================================================ +-- Table: usage_snapshots +-- ============================================================ + +CREATE TABLE IF NOT EXISTS usage_snapshots ( + id TEXT NOT NULL PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + bucket_hour TEXT NOT NULL, + agent_id TEXT, + provider VARCHAR(50) NOT NULL DEFAULT '', + model VARCHAR(200) NOT NULL DEFAULT '', + channel VARCHAR(50) NOT NULL DEFAULT '', + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + cache_create_tokens BIGINT NOT NULL DEFAULT 0, + thinking_tokens BIGINT NOT NULL DEFAULT 0, + total_cost NUMERIC(12,6) NOT NULL DEFAULT 0, + request_count INTEGER NOT NULL DEFAULT 0, + llm_call_count INTEGER NOT NULL DEFAULT 0, + tool_call_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + unique_users INTEGER NOT NULL DEFAULT 0, + avg_duration_ms INTEGER NOT NULL DEFAULT 0, + memory_docs INTEGER NOT NULL DEFAULT 0, + memory_chunks INTEGER NOT NULL DEFAULT 0, + kg_entities INTEGER NOT NULL DEFAULT 0, + kg_relations INTEGER NOT NULL DEFAULT 0, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_usage_snapshots_bucket ON usage_snapshots(bucket_hour DESC); +CREATE INDEX IF NOT EXISTS idx_usage_snapshots_agent_bucket ON usage_snapshots(agent_id, bucket_hour DESC); +CREATE INDEX IF NOT EXISTS idx_usage_snapshots_provider_bucket ON usage_snapshots(provider, bucket_hour DESC) WHERE provider != ''; +CREATE INDEX IF NOT EXISTS idx_usage_snapshots_channel_bucket ON usage_snapshots(channel, bucket_hour DESC) WHERE channel != ''; +-- COALESCE NULLs to sentinel so upsert dedup works (SQLite treats NULL != NULL in unique indexes). +CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_snapshots_unique ON usage_snapshots( + bucket_hour, + COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), + COALESCE(provider, ''), + COALESCE(model, ''), + COALESCE(channel, ''), + tenant_id +); +CREATE INDEX IF NOT EXISTS idx_usage_snapshots_tenant ON usage_snapshots(tenant_id); + +-- ============================================================ +-- Table: builtin_tools +-- ============================================================ + +CREATE TABLE IF NOT EXISTS builtin_tools ( + name VARCHAR(100) NOT NULL PRIMARY KEY, + display_name VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + category VARCHAR(50) NOT NULL DEFAULT 'general', + enabled BOOLEAN NOT NULL DEFAULT 1, + settings TEXT NOT NULL DEFAULT '{}', + requires TEXT DEFAULT '[]', + metadata TEXT DEFAULT '{}', + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_builtin_tools_category ON builtin_tools(category); + +-- ============================================================ +-- Table: builtin_tool_tenant_configs +-- ============================================================ + +CREATE TABLE IF NOT EXISTS builtin_tool_tenant_configs ( + tool_name VARCHAR(100) NOT NULL REFERENCES builtin_tools(name) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + enabled BOOLEAN, + settings TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (tool_name, tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_builtin_tool_tenant_configs_tenant ON builtin_tool_tenant_configs(tenant_id); + +-- ============================================================ +-- Table: secure_cli_binaries +-- ============================================================ + +CREATE TABLE IF NOT EXISTS secure_cli_binaries ( + id TEXT NOT NULL PRIMARY KEY, + binary_name TEXT NOT NULL, + binary_path TEXT, + description TEXT NOT NULL DEFAULT '', + encrypted_env BLOB NOT NULL, + deny_args TEXT NOT NULL DEFAULT '[]', + deny_verbose TEXT NOT NULL DEFAULT '[]', + timeout_seconds INTEGER NOT NULL DEFAULT 30, + tips TEXT NOT NULL DEFAULT '', + agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT 1, + created_by TEXT NOT NULL DEFAULT '', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_secure_cli_binary_name ON secure_cli_binaries(binary_name); +CREATE INDEX IF NOT EXISTS idx_secure_cli_agent_id ON secure_cli_binaries(agent_id) WHERE agent_id IS NOT NULL; +-- COALESCE(agent_id, '') simulates the PG COALESCE(agent_id, null-sentinel) unique constraint +CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_unique_binary_agent ON secure_cli_binaries(binary_name, COALESCE(agent_id, '')); +CREATE INDEX IF NOT EXISTS idx_secure_cli_binaries_tenant ON secure_cli_binaries(tenant_id); + +-- ============================================================ +-- Table: api_keys +-- ============================================================ + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + prefix VARCHAR(8) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, + scopes TEXT NOT NULL DEFAULT '[]', + expires_at TEXT, + last_used_at TEXT, + revoked BOOLEAN NOT NULL DEFAULT 0, + created_by VARCHAR(255), + owner_id VARCHAR(255), + tenant_id TEXT REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash) WHERE NOT revoked; +CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(prefix); +CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_api_keys_tenant ON api_keys(tenant_id) WHERE tenant_id IS NOT NULL; + +-- ============================================================ +-- Table: agent_heartbeats +-- ============================================================ + +CREATE TABLE IF NOT EXISTS agent_heartbeats ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL UNIQUE REFERENCES agents(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT 0, + interval_sec INT NOT NULL DEFAULT 1800, + prompt TEXT, + provider_id TEXT REFERENCES llm_providers(id), + model VARCHAR(200), + isolated_session BOOLEAN NOT NULL DEFAULT 1, + light_context BOOLEAN NOT NULL DEFAULT 0, + ack_max_chars INT NOT NULL DEFAULT 300, + max_retries INT NOT NULL DEFAULT 2, + active_hours_start VARCHAR(5), + active_hours_end VARCHAR(5), + timezone TEXT, + channel VARCHAR(50), + chat_id TEXT, + next_run_at TEXT, + last_run_at TEXT, + last_status VARCHAR(20), + last_error TEXT, + run_count INT NOT NULL DEFAULT 0, + suppress_count INT NOT NULL DEFAULT 0, + metadata TEXT DEFAULT '{}', + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_heartbeats_due ON agent_heartbeats(next_run_at) WHERE enabled = 1 AND next_run_at IS NOT NULL; + +-- ============================================================ +-- Table: heartbeat_run_logs +-- ============================================================ + +CREATE TABLE IF NOT EXISTS heartbeat_run_logs ( + id TEXT NOT NULL PRIMARY KEY, + heartbeat_id TEXT NOT NULL REFERENCES agent_heartbeats(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL, + summary TEXT, + error TEXT, + duration_ms INT, + input_tokens INT DEFAULT 0, + output_tokens INT DEFAULT 0, + skip_reason VARCHAR(50), + metadata TEXT DEFAULT '{}', + ran_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_hb_logs_heartbeat ON heartbeat_run_logs(heartbeat_id, ran_at DESC); +CREATE INDEX IF NOT EXISTS idx_hb_logs_agent ON heartbeat_run_logs(agent_id, ran_at DESC); + +-- ============================================================ +-- Table: agent_config_permissions +-- (scope widened to VARCHAR(255) in migration 23; +-- includes migrated group_file_writers rows) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS agent_config_permissions ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + scope VARCHAR(255) NOT NULL, + config_type VARCHAR(50) NOT NULL, + user_id VARCHAR(255) NOT NULL, + permission VARCHAR(10) NOT NULL, + granted_by VARCHAR(255), + metadata TEXT DEFAULT '{}', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(agent_id, scope, config_type, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_acp_lookup ON agent_config_permissions(agent_id, scope, config_type); +CREATE INDEX IF NOT EXISTS idx_agent_config_permissions_tenant ON agent_config_permissions(tenant_id); + +-- ============================================================ +-- Table: skill_tenant_configs (already defined above) +-- Table: builtin_tool_tenant_configs (already defined above) +-- ============================================================ + +-- ============================================================ +-- Table: system_configs +-- ============================================================ + +CREATE TABLE IF NOT EXISTS system_configs ( + key VARCHAR(100) NOT NULL, + value TEXT NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (key, tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_system_configs_tenant ON system_configs(tenant_id); diff --git a/internal/store/sqlitestore/scope.go b/internal/store/sqlitestore/scope.go new file mode 100644 index 00000000..324090a1 --- /dev/null +++ b/internal/store/sqlitestore/scope.go @@ -0,0 +1,49 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// scopeClause extracts QueryScope from context and generates SQLite WHERE conditions +// using ? placeholders (instead of PG's $N positional params). +func scopeClause(ctx context.Context) (clause string, args []any, err error) { + scope, err := store.ScopeFromContext(ctx) + if err != nil { + return "", nil, err + } + clause = " AND tenant_id = ?" + args = []any{scope.TenantID} + + if scope.ProjectID != nil { + clause += " AND project_id = ?" + args = append(args, *scope.ProjectID) + } + return clause, args, nil +} + +// scopeClauseAlias is like scopeClause but qualifies columns with a table alias. +// SECURITY: alias is interpolated — callers MUST pass hardcoded string literals only. +func scopeClauseAlias(ctx context.Context, alias string) (clause string, args []any, err error) { + for _, c := range alias { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + return "", nil, fmt.Errorf("invalid table alias: %q", alias) + } + } + scope, err := store.ScopeFromContext(ctx) + if err != nil { + return "", nil, err + } + clause = " AND " + alias + ".tenant_id = ?" + args = []any{scope.TenantID} + + if scope.ProjectID != nil { + clause += " AND " + alias + ".project_id = ?" + args = append(args, *scope.ProjectID) + } + return clause, args, nil +} diff --git a/internal/store/sqlitestore/sessions.go b/internal/store/sqlitestore/sessions.go new file mode 100644 index 00000000..23bde44d --- /dev/null +++ b/internal/store/sqlitestore/sessions.go @@ -0,0 +1,333 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "maps" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteSessionStore implements store.SessionStore backed by SQLite. +type SQLiteSessionStore struct { + db *sql.DB + mu sync.RWMutex + cache map[string]*store.SessionData + // OnDelete is called with the session key when a session is deleted. + OnDelete func(sessionKey string) +} + +func NewSQLiteSessionStore(db *sql.DB) *SQLiteSessionStore { + // No migrateLegacyWSKeys — SQLite has no regexp_replace. + return &SQLiteSessionStore{db: db, cache: make(map[string]*store.SessionData)} +} + +// sessionCacheKey prefixes session key with tenant UUID to prevent cross-tenant cache collisions. +func sessionCacheKey(ctx context.Context, key string) string { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + return tid.String() + ":" + key +} + +func (s *SQLiteSessionStore) GetOrCreate(ctx context.Context, key string) *store.SessionData { + s.mu.Lock() + defer s.mu.Unlock() + + if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return cached + } + + data := s.loadFromDB(ctx, key) + if data != nil { + s.cache[sessionCacheKey(ctx, key)] = data + return data + } + + now := time.Now() + data = &store.SessionData{ + Key: key, + Messages: []providers.Message{}, + Created: now, + Updated: now, + } + + // Extract team_id from team session keys (agent:{agentId}:team:{teamId}:{chatId}). + var teamID *uuid.UUID + if parts := strings.SplitN(key, ":", 5); len(parts) >= 4 && parts[2] == "team" { + if tid, err := uuid.Parse(parts[3]); err == nil { + teamID = &tid + data.TeamID = teamID + } + } + s.cache[sessionCacheKey(ctx, key)] = data + + msgsJSON, _ := json.Marshal([]providers.Message{}) + s.db.ExecContext(ctx, + `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id, tenant_id) + VALUES (?,?,?,?,?,?,?) ON CONFLICT (tenant_id, session_key) DO NOTHING`, + uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, tenantIDForInsert(ctx), + ) + + return data +} + +// Get returns the session if it exists (cache or DB), nil otherwise. Never creates. +func (s *SQLiteSessionStore) Get(ctx context.Context, key string) *store.SessionData { + s.mu.RLock() + if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + s.mu.RUnlock() + return cached + } + s.mu.RUnlock() + + s.mu.Lock() + defer s.mu.Unlock() + + if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return cached + } + + data := s.loadFromDB(ctx, key) + if data != nil { + s.cache[sessionCacheKey(ctx, key)] = data + } + return data +} + +func (s *SQLiteSessionStore) AddMessage(ctx context.Context, key string, msg providers.Message) { + s.mu.Lock() + defer s.mu.Unlock() + + if msg.CreatedAt == nil { + now := time.Now().UTC() + msg.CreatedAt = &now + } + + data := s.getOrInit(ctx, key) + data.Messages = append(data.Messages, msg) + data.Updated = time.Now() +} + +func (s *SQLiteSessionStore) GetHistory(ctx context.Context, key string) []providers.Message { + s.mu.RLock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + msgs := make([]providers.Message, len(data.Messages)) + copy(msgs, data.Messages) + s.mu.RUnlock() + return msgs + } + s.mu.RUnlock() + + s.mu.Lock() + defer s.mu.Unlock() + + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + msgs := make([]providers.Message, len(data.Messages)) + copy(msgs, data.Messages) + return msgs + } + + data := s.loadFromDB(ctx, key) + if data == nil { + return nil + } + s.cache[sessionCacheKey(ctx, key)] = data + msgs := make([]providers.Message, len(data.Messages)) + copy(msgs, data.Messages) + return msgs +} + +func (s *SQLiteSessionStore) GetSummary(ctx context.Context, key string) string { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return data.Summary + } + return "" +} + +func (s *SQLiteSessionStore) SetSummary(ctx context.Context, key, summary string) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.Summary = summary + data.Updated = time.Now() + } +} + +func (s *SQLiteSessionStore) GetLabel(ctx context.Context, key string) string { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return data.Label + } + return "" +} + +func (s *SQLiteSessionStore) SetLabel(ctx context.Context, key, label string) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.Label = label + data.Updated = time.Now() + } +} + +func (s *SQLiteSessionStore) GetSessionMetadata(ctx context.Context, key string) map[string]string { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok && data.Metadata != nil { + out := make(map[string]string, len(data.Metadata)) + maps.Copy(out, data.Metadata) + return out + } + return nil +} + +func (s *SQLiteSessionStore) SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) { + s.mu.Lock() + defer s.mu.Unlock() + data := s.getOrInit(ctx, key) + if data.Metadata == nil { + data.Metadata = make(map[string]string) + } + maps.Copy(data.Metadata, metadata) + data.Updated = time.Now() +} + +func (s *SQLiteSessionStore) SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) { + s.mu.Lock() + defer s.mu.Unlock() + data := s.getOrInit(ctx, key) + if agentUUID != uuid.Nil { + data.AgentUUID = agentUUID + } + if userID != "" { + data.UserID = userID + } +} + +func (s *SQLiteSessionStore) UpdateMetadata(ctx context.Context, key, model, provider, channel string) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + if model != "" { + data.Model = model + } + if provider != "" { + data.Provider = provider + } + if channel != "" { + data.Channel = channel + } + } +} + +// --- helpers --- + +func (s *SQLiteSessionStore) getOrInit(ctx context.Context, key string) *store.SessionData { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return data + } + + data := s.loadFromDB(ctx, key) + if data != nil { + s.cache[sessionCacheKey(ctx, key)] = data + return data + } + + now := time.Now() + data = &store.SessionData{ + Key: key, + Messages: []providers.Message{}, + Created: now, + Updated: now, + } + s.cache[sessionCacheKey(ctx, key)] = data + + msgsJSON, _ := json.Marshal([]providers.Message{}) + s.db.ExecContext(ctx, + `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, tenant_id) + VALUES (?,?,?,?,?,?) ON CONFLICT (tenant_id, session_key) DO NOTHING`, + uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, tenantIDForInsert(ctx), + ) + return data +} + +func (s *SQLiteSessionStore) loadFromDB(ctx context.Context, key string) *store.SessionData { + var sessionKey string + var msgsJSON []byte + var summary, model, provider, channel, label, spawnedBy, userID *string + var agentID, teamID *uuid.UUID + var inputTokens, outputTokens int64 + var compactionCount, memoryFlushCompactionCount, spawnDepth int + var memoryFlushAt int64 + createdAt, updatedAt := scanTimePair() + var metaJSON *[]byte + + tid := tenantIDForInsert(ctx) + err := s.db.QueryRowContext(ctx, + `SELECT session_key, messages, summary, model, provider, channel, + input_tokens, output_tokens, compaction_count, + memory_flush_compaction_count, memory_flush_at, + label, spawned_by, spawn_depth, agent_id, user_id, + COALESCE(metadata, '{}'), created_at, updated_at, team_id + FROM sessions WHERE session_key = ? AND tenant_id = ?`, key, tid, + ).Scan(&sessionKey, &msgsJSON, &summary, &model, &provider, &channel, + &inputTokens, &outputTokens, &compactionCount, + &memoryFlushCompactionCount, &memoryFlushAt, + &label, &spawnedBy, &spawnDepth, &agentID, &userID, + &metaJSON, createdAt, updatedAt, &teamID) + if err != nil { + return nil + } + + var msgs []providers.Message + json.Unmarshal(msgsJSON, &msgs) + + var meta map[string]string + if metaJSON != nil { + json.Unmarshal(*metaJSON, &meta) + } + + return &store.SessionData{ + Key: sessionKey, + Messages: msgs, + Summary: derefStr(summary), + Created: createdAt.Time, + Updated: updatedAt.Time, + AgentUUID: derefUUID(agentID), + UserID: derefStr(userID), + TeamID: teamID, + Model: derefStr(model), + Provider: derefStr(provider), + Channel: derefStr(channel), + InputTokens: inputTokens, + OutputTokens: outputTokens, + CompactionCount: compactionCount, + MemoryFlushCompactionCount: memoryFlushCompactionCount, + MemoryFlushAt: memoryFlushAt, + Label: derefStr(label), + SpawnedBy: derefStr(spawnedBy), + SpawnDepth: spawnDepth, + Metadata: meta, + } +} + +func nilSessionUUID(u uuid.UUID) *uuid.UUID { + if u == uuid.Nil { + return nil + } + return &u +} diff --git a/internal/store/sqlitestore/sessions_list.go b/internal/store/sqlitestore/sessions_list.go new file mode 100644 index 00000000..c8b0445c --- /dev/null +++ b/internal/store/sqlitestore/sessions_list.go @@ -0,0 +1,246 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// buildSessionFilter builds a dynamic WHERE clause from SessionListOpts using ? placeholders. +func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string, []any) { + prefix := "" + if tableAlias != "" { + prefix = tableAlias + "." + } + var conditions []string + var args []any + + if opts.AgentID != "" { + conditions = append(conditions, prefix+"session_key LIKE ?") + args = append(args, "agent:"+opts.AgentID+":%") + } + if opts.Channel != "" { + conditions = append(conditions, prefix+"session_key LIKE ?") + args = append(args, "agent:%:"+opts.Channel+":%") + } + if opts.UserID != "" { + conditions = append(conditions, prefix+"user_id = ?") + args = append(args, opts.UserID) + } + if opts.TenantID != uuid.Nil { + conditions = append(conditions, prefix+"tenant_id = ?") + args = append(args, opts.TenantID) + } + + if len(conditions) == 0 { + return "", nil + } + return " WHERE " + strings.Join(conditions, " AND "), args +} + +func (s *SQLiteSessionStore) List(ctx context.Context, agentID string) []store.SessionInfo { + var conditions []string + var args []any + + if agentID != "" { + conditions = append(conditions, "session_key LIKE ?") + args = append(args, "agent:"+agentID+":%") + } + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + conditions = append(conditions, "tenant_id = ?") + args = append(args, tid) + } + } + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + + rows, err := s.db.QueryContext(ctx, + "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions"+where+" ORDER BY updated_at DESC", + args...) + if err != nil { + return nil + } + defer rows.Close() + + var result []store.SessionInfo + for rows.Next() { + var key string + var msgsJSON []byte + stCreated, stUpdated := scanTimePair() + var label, channel, userID *string + var metaJSON []byte + if err := rows.Scan(&key, &msgsJSON, stCreated, stUpdated, &label, &channel, &userID, &metaJSON); err != nil { + continue + } + var msgs []providers.Message + json.Unmarshal(msgsJSON, &msgs) + var meta map[string]string + if len(metaJSON) > 0 { + json.Unmarshal(metaJSON, &meta) + } + result = append(result, store.SessionInfo{ + Key: key, + MessageCount: len(msgs), + Created: stCreated.Time, + Updated: stUpdated.Time, + Label: derefStr(label), + Channel: derefStr(channel), + UserID: derefStr(userID), + Metadata: meta, + }) + } + return result +} + +func (s *SQLiteSessionStore) ListPaged(ctx context.Context, opts store.SessionListOpts) store.SessionListResult { + limit := opts.Limit + if limit <= 0 { + limit = 20 + } + offset := max(opts.Offset, 0) + + where, whereArgs := buildSessionFilter(opts, "") + + var total int + countQ := "SELECT COUNT(*) FROM sessions" + where + if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil { + return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: 0} + } + + // Use json_array_length (SQLite built-in) instead of jsonb_array_length. + selectQ := fmt.Sprintf(`SELECT session_key, json_array_length(messages), created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') + FROM sessions%s ORDER BY updated_at DESC LIMIT ? OFFSET ?`, where) + selectArgs := append(append([]any{}, whereArgs...), limit, offset) + + rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...) + if err != nil { + return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: total} + } + defer rows.Close() + + var result []store.SessionInfo + for rows.Next() { + var key string + var msgCount int + stCreated, stUpdated := scanTimePair() + var label, channel, userID *string + var metaJSON []byte + if err := rows.Scan(&key, &msgCount, stCreated, stUpdated, &label, &channel, &userID, &metaJSON); err != nil { + continue + } + var meta map[string]string + if len(metaJSON) > 0 { + json.Unmarshal(metaJSON, &meta) + } + result = append(result, store.SessionInfo{ + Key: key, + MessageCount: msgCount, + Created: stCreated.Time, + Updated: stUpdated.Time, + Label: derefStr(label), + Channel: derefStr(channel), + UserID: derefStr(userID), + Metadata: meta, + }) + } + if result == nil { + result = []store.SessionInfo{} + } + return store.SessionListResult{Sessions: result, Total: total} +} + +// ListPagedRich returns enriched session info for API responses (includes model, tokens, agent name). +func (s *SQLiteSessionStore) ListPagedRich(ctx context.Context, opts store.SessionListOpts) store.SessionListRichResult { + limit := opts.Limit + if limit <= 0 { + limit = 20 + } + offset := max(opts.Offset, 0) + + where, whereArgs := buildSessionFilter(opts, "s") + + var total int + countQ := "SELECT COUNT(*) FROM sessions s" + where + if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil { + return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: 0} + } + + // Use json_array_length and length() instead of PG-specific functions. + const richCols = `s.session_key, json_array_length(s.messages), s.created_at, s.updated_at, + s.label, s.channel, s.user_id, COALESCE(s.metadata, '{}'), + s.model, s.provider, s.input_tokens, s.output_tokens, + COALESCE(a.display_name, ''), + length(s.messages) / 4 + 12000, + COALESCE(a.context_window, 200000), + s.compaction_count` + + selectQ := fmt.Sprintf(`SELECT %s + FROM sessions s LEFT JOIN agents a ON s.agent_id = a.id + %s ORDER BY s.updated_at DESC LIMIT ? OFFSET ?`, richCols, where) + selectArgs := append(append([]any{}, whereArgs...), limit, offset) + + rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...) + if err != nil { + return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: total} + } + defer rows.Close() + + var result []store.SessionInfoRich + for rows.Next() { + var key string + var msgCount int + stCreated, stUpdated := scanTimePair() + var label, channel, userID *string + var metaJSON []byte + var model, provider *string + var inputTokens, outputTokens int64 + var agentName string + var estimatedTokens, contextWindow, compactionCount int + if err := rows.Scan(&key, &msgCount, stCreated, stUpdated, &label, &channel, &userID, &metaJSON, + &model, &provider, &inputTokens, &outputTokens, &agentName, + &estimatedTokens, &contextWindow, &compactionCount); err != nil { + continue + } + var meta map[string]string + if len(metaJSON) > 0 { + json.Unmarshal(metaJSON, &meta) + } + result = append(result, store.SessionInfoRich{ + SessionInfo: store.SessionInfo{ + Key: key, + MessageCount: msgCount, + Created: stCreated.Time, + Updated: stUpdated.Time, + Label: derefStr(label), + Channel: derefStr(channel), + UserID: derefStr(userID), + Metadata: meta, + }, + Model: derefStr(model), + Provider: derefStr(provider), + InputTokens: inputTokens, + OutputTokens: outputTokens, + AgentName: agentName, + EstimatedTokens: estimatedTokens, + ContextWindow: contextWindow, + CompactionCount: compactionCount, + }) + } + if result == nil { + result = []store.SessionInfoRich{} + } + return store.SessionListRichResult{Sessions: result, Total: total} +} diff --git a/internal/store/sqlitestore/sessions_ops.go b/internal/store/sqlitestore/sessions_ops.go new file mode 100644 index 00000000..ee98e581 --- /dev/null +++ b/internal/store/sqlitestore/sessions_ops.go @@ -0,0 +1,136 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +func (s *SQLiteSessionStore) Save(ctx context.Context, key string) error { + s.mu.RLock() + data, ok := s.cache[sessionCacheKey(ctx, key)] + if !ok { + s.mu.RUnlock() + return nil + } + snapshot := *data + msgs := make([]providers.Message, len(data.Messages)) + copy(msgs, data.Messages) + snapshot.Messages = msgs + s.mu.RUnlock() + + msgsJSON, _ := json.Marshal(snapshot.Messages) + metaJSON := []byte("{}") + if len(snapshot.Metadata) > 0 { + metaJSON, _ = json.Marshal(snapshot.Metadata) + } + + _, err := s.db.ExecContext(ctx, + `UPDATE sessions SET + messages = ?, summary = ?, model = ?, provider = ?, channel = ?, + input_tokens = ?, output_tokens = ?, compaction_count = ?, + memory_flush_compaction_count = ?, memory_flush_at = ?, + label = ?, spawned_by = ?, spawn_depth = ?, + agent_id = ?, user_id = ?, metadata = ?, updated_at = ?, + team_id = ? + WHERE session_key = ? AND tenant_id = ?`, + msgsJSON, nilStr(snapshot.Summary), nilStr(snapshot.Model), nilStr(snapshot.Provider), nilStr(snapshot.Channel), + snapshot.InputTokens, snapshot.OutputTokens, snapshot.CompactionCount, + snapshot.MemoryFlushCompactionCount, snapshot.MemoryFlushAt, + nilStr(snapshot.Label), nilStr(snapshot.SpawnedBy), snapshot.SpawnDepth, + nilSessionUUID(snapshot.AgentUUID), nilStr(snapshot.UserID), metaJSON, snapshot.Updated, + snapshot.TeamID, + key, tenantIDForInsert(ctx), + ) + return err +} + +func (s *SQLiteSessionStore) TruncateHistory(ctx context.Context, key string, keepLast int) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + if keepLast <= 0 { + data.Messages = []providers.Message{} + } else if len(data.Messages) > keepLast { + data.Messages = data.Messages[len(data.Messages)-keepLast:] + } + data.Updated = time.Now() + } +} + +func (s *SQLiteSessionStore) SetHistory(ctx context.Context, key string, msgs []providers.Message) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.Messages = msgs + data.Updated = time.Now() + } +} + +func (s *SQLiteSessionStore) Reset(ctx context.Context, key string) { + s.mu.Lock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.Messages = []providers.Message{} + data.Summary = "" + data.Updated = time.Now() + s.mu.Unlock() + return + } + s.mu.Unlock() + + // Session not in cache — clear directly in DB. + tid := tenantIDForInsert(ctx) + if _, err := s.db.ExecContext(ctx, + `UPDATE sessions SET messages = '[]', summary = '', updated_at = ? + WHERE session_key = ? AND tenant_id = ?`, + time.Now(), key, tid, + ); err != nil { + slog.Warn("sessions.reset_db_fallback_failed", "key", key, "error", err) + } +} + +func (s *SQLiteSessionStore) Delete(ctx context.Context, key string) error { + s.mu.Lock() + delete(s.cache, sessionCacheKey(ctx, key)) + s.mu.Unlock() + + if s.OnDelete != nil { + s.OnDelete(key) + } + + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE session_key = ? AND tenant_id = ?", key, tid) + return err +} + +func (s *SQLiteSessionStore) LastUsedChannel(ctx context.Context, agentID string) (string, string) { + prefix := "agent:" + agentID + ":%" + tid := tenantIDForInsert(ctx) + var sessionKey string + err := s.db.QueryRowContext(ctx, + `SELECT session_key FROM sessions + WHERE session_key LIKE ? + AND session_key NOT LIKE ? + AND session_key NOT LIKE ? + AND tenant_id = ? + ORDER BY updated_at DESC LIMIT 1`, + prefix, + "agent:"+agentID+":cron:%", + "agent:"+agentID+":subagent:%", + tid, + ).Scan(&sessionKey) + if err != nil { + return "", "" + } + parts := strings.SplitN(sessionKey, ":", 5) + if len(parts) >= 5 { + return parts[2], parts[4] + } + return "", "" +} diff --git a/internal/store/sqlitestore/sessions_tokens.go b/internal/store/sqlitestore/sessions_tokens.go new file mode 100644 index 00000000..81230c47 --- /dev/null +++ b/internal/store/sqlitestore/sessions_tokens.go @@ -0,0 +1,96 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "time" +) + +func (s *SQLiteSessionStore) AccumulateTokens(ctx context.Context, key string, input, output int64) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.InputTokens += input + data.OutputTokens += output + } +} + +func (s *SQLiteSessionStore) IncrementCompaction(ctx context.Context, key string) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.CompactionCount++ + } +} + +func (s *SQLiteSessionStore) GetCompactionCount(ctx context.Context, key string) int { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return data.CompactionCount + } + return 0 +} + +func (s *SQLiteSessionStore) GetMemoryFlushCompactionCount(ctx context.Context, key string) int { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return data.MemoryFlushCompactionCount + } + return -1 +} + +func (s *SQLiteSessionStore) SetMemoryFlushDone(ctx context.Context, key string) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.MemoryFlushCompactionCount = data.CompactionCount + data.MemoryFlushAt = time.Now().UnixMilli() + } +} + +func (s *SQLiteSessionStore) SetSpawnInfo(ctx context.Context, key, spawnedBy string, depth int) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.SpawnedBy = spawnedBy + data.SpawnDepth = depth + } +} + +func (s *SQLiteSessionStore) SetContextWindow(ctx context.Context, key string, cw int) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.ContextWindow = cw + } +} + +func (s *SQLiteSessionStore) GetContextWindow(ctx context.Context, key string) int { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return data.ContextWindow + } + return 0 +} + +func (s *SQLiteSessionStore) SetLastPromptTokens(ctx context.Context, key string, tokens, msgCount int) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + data.LastPromptTokens = tokens + data.LastMessageCount = msgCount + } +} + +func (s *SQLiteSessionStore) GetLastPromptTokens(ctx context.Context, key string) (int, int) { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return data.LastPromptTokens, data.LastMessageCount + } + return 0, 0 +} diff --git a/internal/store/sqlitestore/skills.go b/internal/store/sqlitestore/skills.go new file mode 100644 index 00000000..c0327209 --- /dev/null +++ b/internal/store/sqlitestore/skills.go @@ -0,0 +1,264 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "os" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const defaultSkillsCacheTTL = 5 * time.Minute + +// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file. +var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`) + +// SQLiteSkillStore implements store.SkillStore backed by SQLite. +type SQLiteSkillStore struct { + db *sql.DB + baseDir string + mu sync.RWMutex + version atomic.Int64 + + listCache map[uuid.UUID]*skillListCacheEntry + ttl time.Duration +} + +type skillListCacheEntry struct { + skills []store.SkillInfo + ver int64 + time time.Time +} + +func NewSQLiteSkillStore(db *sql.DB, baseDir string) *SQLiteSkillStore { + return &SQLiteSkillStore{ + db: db, + baseDir: baseDir, + listCache: make(map[uuid.UUID]*skillListCacheEntry), + ttl: defaultSkillsCacheTTL, + } +} + +func (s *SQLiteSkillStore) Version() int64 { return s.version.Load() } +func (s *SQLiteSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) } +func (s *SQLiteSkillStore) Dirs() []string { return []string{s.baseDir} } + +func (s *SQLiteSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { + currentVer := s.version.Load() + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + + s.mu.RLock() + if entry := s.listCache[tid]; entry != nil && entry.ver == currentVer && time.Since(entry.time) < s.ttl { + result := entry.skills + s.mu.RUnlock() + return result + } + s.mu.RUnlock() + + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path + FROM skills WHERE (status IN ('active', 'archived') OR is_system = 1) AND (is_system = 1 OR tenant_id = ?) + ORDER BY name`, tid) + if err != nil { + return nil + } + defer rows.Close() + + var result []store.SkillInfo + for rows.Next() { + var id uuid.UUID + var name, slug, visibility, status string + var desc *string + var tagsJSON []byte + var version int + var isSystem, enabled bool + var depsRaw, fmRaw []byte + var filePath *string + if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &tagsJSON, &version, + &isSystem, &status, &enabled, &depsRaw, &fmRaw, &filePath); err != nil { + continue + } + info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath) + info.Visibility = visibility + scanJSONStringArray(tagsJSON, &info.Tags) + info.IsSystem = isSystem + info.Status = status + info.Enabled = enabled + info.MissingDeps = parseDepsColumn(depsRaw) + info.Author = parseFrontmatterAuthor(fmRaw) + result = append(result, info) + } + if err := rows.Err(); err != nil { + slog.Warn("ListSkills: rows iteration error", "error", err) + return nil + } + + s.mu.Lock() + s.listCache[tid] = &skillListCacheEntry{skills: result, ver: currentVer, time: time.Now()} + s.mu.Unlock() + + return result +} + +func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE enabled = 1 AND status != 'deleted' AND (is_system = 1 OR tenant_id = ?) + ORDER BY name`, tid) + if err != nil { + return nil + } + defer rows.Close() + return s.scanSkillInfoList(rows) +} + +func (s *SQLiteSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE is_system = 1 AND enabled = 1 AND status != 'deleted' + ORDER BY name`) + if err != nil { + return nil + } + defer rows.Close() + return s.scanSkillInfoList(rows) +} + +func (s *SQLiteSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo { + var result []store.SkillInfo + for rows.Next() { + var id uuid.UUID + var name, slug, visibility, status string + var desc *string + var tagsJSON []byte + var version int + var isSystem, enabled bool + var depsRaw []byte + var filePath *string + if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &tagsJSON, &version, + &isSystem, &status, &enabled, &depsRaw, &filePath); err != nil { + continue + } + info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath) + info.Visibility = visibility + scanJSONStringArray(tagsJSON, &info.Tags) + info.IsSystem = isSystem + info.Status = status + info.Enabled = enabled + info.MissingDeps = parseDepsColumn(depsRaw) + result = append(result, info) + } + if err := rows.Err(); err != nil { + slog.Warn("scanSkillInfoList: rows iteration error", "error", err) + } + return result +} + +func (s *SQLiteSkillStore) StoreMissingDeps(ctx context.Context, id uuid.UUID, missing []string) error { + if missing == nil { + missing = []string{} + } + encoded, err := json.Marshal(map[string]any{"missing": missing}) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `UPDATE skills SET deps = ?, updated_at = ? WHERE id = ? AND is_system = 1`, + encoded, time.Now().UTC(), id, + ) + if err == nil { + s.BumpVersion() + } + return err +} + +// --- helpers shared with other skill files --- + +func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir string, filePath *string) store.SkillInfo { + d := "" + if desc != nil { + d = *desc + } + skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version) + if filePath != nil && *filePath != "" { + skillDir = *filePath + } + return store.SkillInfo{ + ID: id, + Name: name, + Slug: slug, + Path: skillDir + "/SKILL.md", + BaseDir: skillDir, + Source: "managed", + Description: d, + Version: version, + } +} + +func readSkillFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + content := strings.ReplaceAll(string(data), "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + content = skillFrontmatterRe.ReplaceAllString(content, "") + return content, nil +} + +func parseDepsColumn(raw []byte) []string { + if len(raw) == 0 { + return nil + } + var d struct { + Missing []string `json:"missing"` + } + if err := json.Unmarshal(raw, &d); err != nil { + return nil + } + if len(d.Missing) == 0 { + return nil + } + return d.Missing +} + +func parseFrontmatterAuthor(raw []byte) string { + if len(raw) == 0 { + return "" + } + var fm map[string]string + if err := json.Unmarshal(raw, &fm); err != nil { + return "" + } + return fm["author"] +} + +func marshalFrontmatter(fm map[string]string) []byte { + if len(fm) == 0 { + return []byte("{}") + } + b, err := json.Marshal(fm) + if err != nil { + return []byte("{}") + } + return b +} diff --git a/internal/store/sqlitestore/skills_content.go b/internal/store/sqlitestore/skills_content.go new file mode 100644 index 00000000..8fc7df91 --- /dev/null +++ b/internal/store/sqlitestore/skills_content.go @@ -0,0 +1,291 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *SQLiteSkillStore) LoadSkill(ctx context.Context, name string) (string, bool) { + var slug string + var version int + var filePath *string + q := "SELECT slug, version, file_path FROM skills WHERE slug = ? AND status = 'active'" + args := []any{name} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = 1 OR tenant_id = ?)" + args = append(args, tid) + } + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&slug, &version, &filePath); err != nil { + return "", false + } + info := buildSkillInfo("", "", slug, nil, version, s.baseDir, filePath) + content, err := readSkillFile(info.Path) + if err != nil { + return "", false + } + return content, true +} + +func (s *SQLiteSkillStore) LoadForContext(ctx context.Context, allowList []string) string { + skills := s.FilterSkills(ctx, allowList) + if len(skills) == 0 { + return "" + } + var parts []string + for _, sk := range skills { + content, ok := s.LoadSkill(ctx, sk.Name) + if !ok { + continue + } + parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", sk.Name, content)) + } + if len(parts) == 0 { + return "" + } + var result strings.Builder + result.WriteString("## Available Skills\n\n") + for i, p := range parts { + if i > 0 { + result.WriteString("\n\n---\n\n") + } + result.WriteString(p) + } + return result.String() +} + +func (s *SQLiteSkillStore) BuildSummary(ctx context.Context, allowList []string) string { + skills := s.FilterSkills(ctx, allowList) + if len(skills) == 0 { + return "" + } + var result strings.Builder + result.WriteString("\n") + for _, sk := range skills { + result.WriteString(" \n") + result.WriteString(fmt.Sprintf(" %s\n", sk.Name)) + result.WriteString(fmt.Sprintf(" %s\n", sk.Description)) + result.WriteString(fmt.Sprintf(" %s\n", sk.Path)) + result.WriteString(" \n") + } + result.WriteString("") + return result.String() +} + +func (s *SQLiteSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) { + var id uuid.UUID + var skillName, slug, visibility string + var desc *string + var tagsJSON []byte + var version int + var isSystem bool + var filePath *string + q := "SELECT id, name, slug, description, visibility, tags, version, is_system, file_path FROM skills WHERE slug = ? AND status = 'active'" + args := []any{name} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = 1 OR tenant_id = ?)" + args = append(args, tid) + } + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, &tagsJSON, &version, &isSystem, &filePath); err != nil { + return nil, false + } + info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath) + info.Visibility = visibility + scanJSONStringArray(tagsJSON, &info.Tags) + info.IsSystem = isSystem + return &info, true +} + +func (s *SQLiteSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo { + all := s.ListSkills(ctx) + var filtered []store.SkillInfo + if allowList == nil { + for _, sk := range all { + if sk.Enabled { + filtered = append(filtered, sk) + } + } + return filtered + } + if len(allowList) == 0 { + return nil + } + allowed := make(map[string]bool, len(allowList)) + for _, name := range allowList { + allowed[name] = true + } + for _, sk := range all { + if sk.Enabled && allowed[sk.Slug] { + filtered = append(filtered, sk) + } + } + return filtered +} + +// GetSkillByID returns a SkillInfo for any skill by UUID regardless of status. +func (s *SQLiteSkillStore) GetSkillByID(ctx context.Context, id uuid.UUID) (store.SkillInfo, bool) { + var name, slug, visibility, status string + var desc *string + var tagsJSON, depsRaw []byte + var version int + var isSystem, enabled bool + var filePath *string + q := `SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE id = ?` + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = 1 OR tenant_id = ?)" + args = append(args, tid) + } + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&name, &slug, &desc, &visibility, &tagsJSON, + &version, &isSystem, &status, &enabled, &depsRaw, &filePath); err != nil { + return store.SkillInfo{}, false + } + info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath) + info.Visibility = visibility + scanJSONStringArray(tagsJSON, &info.Tags) + info.IsSystem = isSystem + info.Status = status + info.Enabled = enabled + info.MissingDeps = parseDepsColumn(depsRaw) + return info, true +} + +func (s *SQLiteSkillStore) GetSkillOwnerID(ctx context.Context, id uuid.UUID) (string, bool) { + q := "SELECT owner_id FROM skills WHERE id = ?" + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = 1 OR tenant_id = ?)" + args = append(args, tid) + } + var ownerID string + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil { + return "", false + } + return ownerID, true +} + +func (s *SQLiteSkillStore) GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool) { + q := "SELECT owner_id FROM skills WHERE slug = ? AND status = 'active'" + args := []any{slug} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = 1 OR tenant_id = ?)" + args = append(args, tid) + } + var ownerID string + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil { + return "", false + } + return ownerID, true +} + +// UpsertSystemSkill creates or updates a system skill. +// Returns (id, changed, actualFilePath, error). +func (s *SQLiteSkillStore) UpsertSystemSkill(ctx context.Context, p store.SkillCreateParams) (uuid.UUID, bool, string, error) { + var existingID uuid.UUID + var existingHash *string + var existingFilePath string + err := s.db.QueryRowContext(ctx, + "SELECT id, file_hash, file_path FROM skills WHERE slug = ?", p.Slug, + ).Scan(&existingID, &existingHash, &existingFilePath) + + if err == nil { + if existingHash != nil && p.FileHash != nil && *existingHash == *p.FileHash { + return existingID, false, existingFilePath, nil + } + if existingHash == nil && p.FileHash != nil { + _, _ = s.db.ExecContext(ctx, + `UPDATE skills SET file_hash = ?, updated_at = ? WHERE id = ?`, + p.FileHash, time.Now().UTC(), existingID, + ) + return existingID, false, existingFilePath, nil + } + fmJSON := marshalFrontmatter(p.Frontmatter) + _, err = s.db.ExecContext(ctx, + `UPDATE skills SET name = ?, description = ?, version = ?, frontmatter = ?, + file_path = ?, file_size = ?, file_hash = ?, is_system = 1, + visibility = 'public', status = ?, updated_at = ? + WHERE id = ?`, + p.Name, p.Description, p.Version, fmJSON, + p.FilePath, p.FileSize, p.FileHash, p.Status, time.Now().UTC(), existingID, + ) + if err != nil { + return uuid.Nil, false, "", fmt.Errorf("update system skill: %w", err) + } + s.BumpVersion() + return existingID, true, p.FilePath, nil + } + + id := store.GenNewID() + fmJSON := marshalFrontmatter(p.Frontmatter) + now := time.Now().UTC() + _, err = s.db.ExecContext(ctx, + `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, + is_system, frontmatter, file_path, file_size, file_hash, tenant_id, created_at, updated_at) + VALUES (?, ?, ?, ?, 'system', 'public', ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`, + id, p.Name, p.Slug, p.Description, p.Version, p.Status, + fmJSON, p.FilePath, p.FileSize, p.FileHash, store.MasterTenantID, now, now, + ) + if err != nil { + return uuid.Nil, false, "", fmt.Errorf("insert system skill: %w", err) + } + s.BumpVersion() + return id, true, p.FilePath, nil +} + +// ListSystemSkillDirs returns slug->file_path map for all enabled system skills. +func (s *SQLiteSkillStore) ListSystemSkillDirs(ctx context.Context) map[string]string { + rows, err := s.db.QueryContext(ctx, + `SELECT slug, file_path FROM skills WHERE is_system = 1 AND enabled = 1`) + if err != nil { + return nil + } + defer rows.Close() + dirs := make(map[string]string) + for rows.Next() { + var slug, path string + if err := rows.Scan(&slug, &path); err != nil { + continue + } + dirs[slug] = path + } + if err := rows.Err(); err != nil { + return nil + } + return dirs +} + +// IsSystemSkill checks if a skill slug belongs to a system skill. +func (s *SQLiteSkillStore) IsSystemSkill(slug string) bool { + var isSystem bool + err := s.db.QueryRow("SELECT is_system FROM skills WHERE slug = ?", slug).Scan(&isSystem) + return err == nil && isSystem +} + diff --git a/internal/store/sqlitestore/skills_crud.go b/internal/store/sqlitestore/skills_crud.go new file mode 100644 index 00000000..ba46eb2c --- /dev/null +++ b/internal/store/sqlitestore/skills_crud.go @@ -0,0 +1,212 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *SQLiteSkillStore) CreateSkill(name, slug string, description *string, ownerID, visibility string, version int, filePath string, fileSize int64, fileHash *string) error { + id := store.GenNewID() + now := time.Now().UTC() + _, err := s.db.Exec( + `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, tenant_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`, + id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash, store.MasterTenantID, now, now, + ) + if err == nil { + s.BumpVersion() + } + return err +} + +func (s *SQLiteSkillStore) UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error { + var err error + if store.IsCrossTenant(ctx) { + err = execMapUpdate(ctx, s.db, "skills", id, updates) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + err = execMapUpdateWhereTenant(ctx, s.db, "skills", updates, id, tid) + } + if err != nil { + return err + } + s.BumpVersion() + return nil +} + +func (s *SQLiteSkillStore) DeleteSkill(ctx context.Context, id uuid.UUID) error { + var isSystem bool + if err := s.db.QueryRowContext(ctx, "SELECT is_system FROM skills WHERE id = ?", id).Scan(&isSystem); err != nil { + return fmt.Errorf("check skill: %w", err) + } + if isSystem { + return fmt.Errorf("cannot delete system skill") + } + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + var skillTenantID uuid.UUID + if err := s.db.QueryRowContext(ctx, "SELECT tenant_id FROM skills WHERE id = ?", id).Scan(&skillTenantID); err != nil { + return fmt.Errorf("skill not found") + } + if skillTenantID != tid { + return fmt.Errorf("skill not found") + } + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, "DELETE FROM skill_agent_grants WHERE skill_id = ?", id); err != nil { + return fmt.Errorf("delete skill grants: %w", err) + } + if _, err := tx.ExecContext(ctx, "DELETE FROM skill_user_grants WHERE skill_id = ?", id); err != nil { + return fmt.Errorf("delete skill user grants: %w", err) + } + if _, err := tx.ExecContext(ctx, "UPDATE skills SET status = 'deleted' WHERE id = ?", id); err != nil { + return fmt.Errorf("delete skill: %w", err) + } + + if err := tx.Commit(); err != nil { + return err + } + s.BumpVersion() + return nil +} + +// CreateSkillManaged creates or updates a skill from upload parameters. +// SQLite uses a serializable transaction to avoid the race on version calculation. +func (s *SQLiteSkillStore) CreateSkillManaged(ctx context.Context, p store.SkillCreateParams) (uuid.UUID, error) { + if err := store.ValidateUserID(p.OwnerID); err != nil { + return uuid.Nil, err + } + + fmJSON := marshalFrontmatter(p.Frontmatter) + + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return uuid.Nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + // Compute next version atomically under the transaction lock. + var version int + if err := tx.QueryRowContext(ctx, + "SELECT COALESCE(MAX(version), 0) + 1 FROM skills WHERE slug = ?", p.Slug, + ).Scan(&version); err != nil { + return uuid.Nil, fmt.Errorf("get next version: %w", err) + } + + now := time.Now().UTC() + + // Check for existing skill with same tenant+slug (for upsert). + var existingID uuid.UUID + err = tx.QueryRowContext(ctx, + "SELECT id FROM skills WHERE tenant_id = ? AND slug = ?", tenantID, p.Slug, + ).Scan(&existingID) + + var returnedID uuid.UUID + if err == nil { + // Update existing. + _, err = tx.ExecContext(ctx, + `UPDATE skills SET name = ?, description = ?, version = ?, frontmatter = ?, + file_path = ?, file_size = ?, file_hash = ?, + visibility = CASE WHEN status IN ('archived', 'deleted') THEN 'private' ELSE visibility END, + status = 'active', updated_at = ? WHERE id = ?`, + p.Name, p.Description, version, fmJSON, + p.FilePath, p.FileSize, p.FileHash, now, existingID, + ) + if err != nil { + return uuid.Nil, fmt.Errorf("update skill: %w", err) + } + returnedID = existingID + } else { + // Insert new. + newID := store.GenNewID() + _, err = tx.ExecContext(ctx, + `INSERT INTO skills (id, name, slug, description, owner_id, tenant_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`, + newID, p.Name, p.Slug, p.Description, p.OwnerID, tenantID, p.Visibility, version, + fmJSON, p.FilePath, p.FileSize, p.FileHash, now, now, + ) + if err != nil { + return uuid.Nil, fmt.Errorf("insert skill: %w", err) + } + returnedID = newID + } + + if err := tx.Commit(); err != nil { + return uuid.Nil, fmt.Errorf("commit: %w", err) + } + + s.BumpVersion() + return returnedID, nil +} + +func (s *SQLiteSkillStore) GetSkillFilePath(ctx context.Context, id uuid.UUID) (filePath string, slug string, version int, isSystem bool, ok bool) { + q := "SELECT file_path, slug, version, is_system FROM skills WHERE id = ? AND status = 'active'" + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = 1 OR tenant_id = ?)" + args = append(args, tid) + } + err := s.db.QueryRowContext(ctx, q, args...).Scan(&filePath, &slug, &version, &isSystem) + return filePath, slug, version, isSystem, err == nil +} + +func (s *SQLiteSkillStore) GetNextVersion(ctx context.Context, slug string) int { + var maxVersion int + s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM skills WHERE slug = ?", slug).Scan(&maxVersion) + return maxVersion + 1 +} + +// GetNextVersionLocked returns the next version for a slug. +// SQLite has no advisory locks; returns a no-op closer. +func (s *SQLiteSkillStore) GetNextVersionLocked(ctx context.Context, slug string) (int, func() error, error) { + return s.GetNextVersion(ctx, slug), func() error { return nil }, nil +} + +func (s *SQLiteSkillStore) ToggleSkill(ctx context.Context, id uuid.UUID, enabled bool) error { + now := time.Now().UTC() + q := `UPDATE skills SET enabled = ?, updated_at = ? WHERE id = ?` + args := []any{enabled, now, id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND tenant_id = ?" + args = append(args, tid) + } + } + + _, err := s.db.ExecContext(ctx, q, args...) + if err == nil { + s.BumpVersion() + } + return err +} diff --git a/internal/store/sqlitestore/skills_grants.go b/internal/store/sqlitestore/skills_grants.go new file mode 100644 index 00000000..7ed634a2 --- /dev/null +++ b/internal/store/sqlitestore/skills_grants.go @@ -0,0 +1,234 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SkillGrantInfo is a simplified grant record for API responses. +type SkillGrantInfo struct { + SkillID uuid.UUID `json:"skill_id"` + PinnedVersion int `json:"pinned_version"` + GrantedBy string `json:"granted_by"` +} + +// GrantToAgent grants a skill to an agent with version pinning. +func (s *SQLiteSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.UUID, version int, grantedBy string) error { + if err := store.ValidateUserID(grantedBy); err != nil { + return err + } + + // Upsert grant. + id := store.GenNewID() + _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (skill_id, agent_id) DO UPDATE SET pinned_version = excluded.pinned_version`, + id, skillID, agentID, version, grantedBy, time.Now().UTC(), tenantIDForInsert(ctx), + ) + if err != nil { + return err + } + + // Auto-promote: private → internal. + _, err = s.db.ExecContext(ctx, + `UPDATE skills SET visibility = 'internal', updated_at = ? WHERE id = ? AND visibility = 'private'`, + time.Now().UTC(), skillID) + if err != nil { + slog.Warn("skill_grants: failed to auto-promote visibility", "skill_id", skillID, "error", err) + } + + s.BumpVersion() + return nil +} + +// RevokeFromAgent revokes a skill grant from an agent. +func (s *SQLiteSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uuid.UUID) error { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM skill_agent_grants WHERE skill_id = ? AND agent_id = ?"+tClause, + append([]any{skillID, agentID}, tArgs...)...) + if err != nil { + return err + } + + // Auto-demote: internal → private when no grants remain. + _, err = s.db.ExecContext(ctx, + `UPDATE skills SET visibility = 'private', updated_at = ? + WHERE id = ? AND visibility = 'internal' + AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?)`, + time.Now().UTC(), skillID, skillID) + if err != nil { + slog.Warn("skill_grants: failed to auto-demote visibility", "skill_id", skillID, "error", err) + } + + s.BumpVersion() + return nil +} + +// ListAgentGrants returns all skill grants for an agent. +func (s *SQLiteSkillStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]SkillGrantInfo, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + "SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = ?"+tClause, + append([]any{agentID}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []SkillGrantInfo + for rows.Next() { + var g SkillGrantInfo + if err := rows.Scan(&g.SkillID, &g.PinnedVersion, &g.GrantedBy); err != nil { + slog.Warn("skill_grants: scan error in ListAgentGrants", "error", err) + continue + } + result = append(result, g) + } + return result, rows.Err() +} + +// GrantToUser grants a skill to a user. +func (s *SQLiteSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, userID, grantedBy string) error { + if err := store.ValidateUserID(userID); err != nil { + return err + } + if err := store.ValidateUserID(grantedBy); err != nil { + return err + } + id := store.GenNewID() + _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (skill_id, user_id) DO NOTHING`, + id, skillID, userID, grantedBy, time.Now().UTC(), tenantIDForInsert(ctx), + ) + return err +} + +// RevokeFromUser revokes a skill grant from a user. +func (s *SQLiteSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID, userID string) error { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM skill_user_grants WHERE skill_id = ? AND user_id = ?"+tClause, + append([]any{skillID, userID}, tArgs...)...) + return err +} + +// ListAccessible returns skills accessible to a given agent+user combination. +func (s *SQLiteSkillStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.SkillInfo, error) { + tClause, tArgs, err := scopeClauseAlias(ctx, "s") + if err != nil { + return nil, err + } + tenantCond := "" + stcJoin := "" + stcFilter := "" + if len(tArgs) > 0 { + tenantCond = " AND (s.is_system = 1 OR s.tenant_id = ?)" + stcJoin = " LEFT JOIN skill_tenant_configs stc ON s.id = stc.skill_id AND stc.tenant_id = ?" + stcFilter = " AND (stc.enabled IS NULL OR stc.enabled = 1)" + } + + // Build args: agentID, userID, [tenantID for tenant cond, tenantID for stc join] + queryArgs := []any{agentID, userID} + if len(tArgs) > 0 { + queryArgs = append(queryArgs, tArgs...) // tenant cond + queryArgs = append(queryArgs, tArgs...) // stc join + } + // Remove tClause (aliased scope) — we handle it manually above. + _ = tClause + + rows, err := s.db.QueryContext(ctx, + `SELECT DISTINCT s.name, s.slug, s.description, s.version, s.file_path FROM skills s + LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = ? + LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND sug.user_id = ?`+stcJoin+` + WHERE s.status = 'active'`+tenantCond+stcFilter+` AND ( + s.is_system = 1 + OR s.visibility = 'public' + OR (s.visibility = 'private' AND s.owner_id = ?) + OR (s.visibility = 'internal' AND (sag.id IS NOT NULL OR sug.id IS NOT NULL)) + ) + ORDER BY s.name`, + append(queryArgs, userID)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.SkillInfo + for rows.Next() { + var name, slug string + var desc *string + var version int + var filePath *string + if err := rows.Scan(&name, &slug, &desc, &version, &filePath); err != nil { + slog.Warn("skill_grants: scan error in ListAccessible", "error", err) + continue + } + result = append(result, buildSkillInfo("", name, slug, desc, version, s.baseDir, filePath)) + } + return result, rows.Err() +} + +// ListWithGrantStatus returns all active skills with grant status for a specific agent. +func (s *SQLiteSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUID) ([]store.SkillWithGrantStatus, error) { + tClause, tArgs, err := scopeClauseAlias(ctx, "s") + if err != nil { + return nil, err + } + tenantCond := "" + if len(tArgs) > 0 { + tenantCond = " AND (s.is_system = 1 OR s.tenant_id = ?)" + } + _ = tClause + + queryArgs := []any{agentID} + if len(tArgs) > 0 { + queryArgs = append(queryArgs, tArgs...) + } + + rows, err := s.db.QueryContext(ctx, + `SELECT s.id, s.name, s.slug, COALESCE(s.description, ''), s.visibility, s.version, + (sag.id IS NOT NULL) AS granted, + sag.pinned_version, + s.is_system + FROM skills s + LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = ? + WHERE s.status = 'active'`+tenantCond+` + ORDER BY s.name`, queryArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.SkillWithGrantStatus + for rows.Next() { + var r store.SkillWithGrantStatus + if err := rows.Scan(&r.ID, &r.Name, &r.Slug, &r.Description, &r.Visibility, + &r.Version, &r.Granted, &r.PinnedVer, &r.IsSystem); err != nil { + slog.Warn("skill_grants: scan error in ListWithGrantStatus", "error", err) + continue + } + result = append(result, r) + } + return result, rows.Err() +} diff --git a/internal/store/sqlitestore/snapshots.go b/internal/store/sqlitestore/snapshots.go new file mode 100644 index 00000000..9f633f5f --- /dev/null +++ b/internal/store/sqlitestore/snapshots.go @@ -0,0 +1,304 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteSnapshotStore implements store.SnapshotStore backed by SQLite. +type SQLiteSnapshotStore struct { + db *sql.DB +} + +func NewSQLiteSnapshotStore(db *sql.DB) *SQLiteSnapshotStore { + return &SQLiteSnapshotStore{db: db} +} + +const snapshotFieldCount = 22 + +// sqliteSnapshotBatchSize limits each INSERT to stay under SQLite's 999-variable limit (999 / 22 ≈ 45 → use 40). +const sqliteSnapshotBatchSize = 40 + +func (s *SQLiteSnapshotStore) UpsertSnapshots(ctx context.Context, snapshots []store.UsageSnapshot) error { + if len(snapshots) == 0 { + return nil + } + for start := 0; start < len(snapshots); start += sqliteSnapshotBatchSize { + end := start + sqliteSnapshotBatchSize + if end > len(snapshots) { + end = len(snapshots) + } + if err := s.upsertBatch(ctx, snapshots[start:end]); err != nil { + return err + } + } + return nil +} + +func (s *SQLiteSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.UsageSnapshot) error { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + + placeholderRow := "(" + strings.Repeat("?, ", snapshotFieldCount-1) + "?)" + vals := make([]string, len(snapshots)) + var args []any + + for i, snap := range snapshots { + vals[i] = placeholderRow + args = append(args, + snap.BucketHour, nilUUID(snap.AgentID), snap.Provider, snap.Model, snap.Channel, + snap.InputTokens, snap.OutputTokens, snap.CacheReadTokens, snap.CacheCreateTokens, snap.ThinkingTokens, + snap.TotalCost, snap.RequestCount, snap.LLMCallCount, snap.ToolCallCount, + snap.ErrorCount, snap.UniqueUsers, snap.AvgDurationMS, + snap.MemoryDocs, snap.MemoryChunks, snap.KGEntities, snap.KGRelations, + tenantID, + ) + } + + query := `INSERT INTO usage_snapshots ( + bucket_hour, agent_id, provider, model, channel, + input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, thinking_tokens, + total_cost, request_count, llm_call_count, tool_call_count, + error_count, unique_users, avg_duration_ms, + memory_docs, memory_chunks, kg_entities, kg_relations, + tenant_id + ) VALUES ` + strings.Join(vals, ", ") + ` + ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), COALESCE(provider, ''), COALESCE(model, ''), COALESCE(channel, ''), tenant_id) + DO UPDATE SET + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + cache_read_tokens = excluded.cache_read_tokens, + cache_create_tokens = excluded.cache_create_tokens, + thinking_tokens = excluded.thinking_tokens, + total_cost = excluded.total_cost, + request_count = excluded.request_count, + llm_call_count = excluded.llm_call_count, + tool_call_count = excluded.tool_call_count, + error_count = excluded.error_count, + unique_users = excluded.unique_users, + avg_duration_ms = excluded.avg_duration_ms, + memory_docs = excluded.memory_docs, + memory_chunks = excluded.memory_chunks, + kg_entities = excluded.kg_entities, + kg_relations = excluded.kg_relations` + + _, err := s.db.ExecContext(ctx, query, args...) + return err +} + +func (s *SQLiteSnapshotStore) GetTimeSeries(ctx context.Context, q store.SnapshotQuery) ([]store.SnapshotTimeSeries, error) { + bucketExpr := "bucket_hour" + if q.GroupBy == "day" { + bucketExpr = "strftime('%Y-%m-%d 00:00:00', bucket_hour)" + } + + where, args := buildSnapshotWhere(ctx, q) + + query := fmt.Sprintf(`SELECT + bucket_time, + SUM(input_tokens), SUM(output_tokens), + SUM(cache_read_tokens), SUM(cache_create_tokens), SUM(thinking_tokens), + SUM(total_cost), + SUM(request_count), SUM(llm_call_count), SUM(tool_call_count), + SUM(error_count), SUM(unique_users), + CASE WHEN SUM(request_count) > 0 + THEN SUM(avg_duration_ms * request_count) / SUM(request_count) + ELSE 0 END, + SUM(memory_docs), SUM(memory_chunks), + SUM(kg_entities), SUM(kg_relations) + FROM ( + SELECT + %s as bucket_time, + CASE WHEN provider != '' THEN input_tokens ELSE 0 END as input_tokens, + CASE WHEN provider != '' THEN output_tokens ELSE 0 END as output_tokens, + CASE WHEN provider != '' THEN cache_read_tokens ELSE 0 END as cache_read_tokens, + CASE WHEN provider != '' THEN cache_create_tokens ELSE 0 END as cache_create_tokens, + CASE WHEN provider != '' THEN thinking_tokens ELSE 0 END as thinking_tokens, + CASE WHEN provider != '' THEN total_cost ELSE 0 END as total_cost, + CASE WHEN provider != '' THEN llm_call_count ELSE 0 END as llm_call_count, + CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END as request_count, + CASE WHEN provider = '' AND model = '' THEN tool_call_count ELSE 0 END as tool_call_count, + CASE WHEN provider = '' AND model = '' THEN error_count ELSE 0 END as error_count, + CASE WHEN provider = '' AND model = '' THEN unique_users ELSE 0 END as unique_users, + CASE WHEN provider = '' AND model = '' THEN avg_duration_ms ELSE 0 END as avg_duration_ms, + CASE WHEN provider = '' AND model = '' THEN memory_docs ELSE 0 END as memory_docs, + CASE WHEN provider = '' AND model = '' THEN memory_chunks ELSE 0 END as memory_chunks, + CASE WHEN provider = '' AND model = '' THEN kg_entities ELSE 0 END as kg_entities, + CASE WHEN provider = '' AND model = '' THEN kg_relations ELSE 0 END as kg_relations + FROM usage_snapshots + %s + ) sub + GROUP BY bucket_time + ORDER BY bucket_time`, bucketExpr, where) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("get timeseries: %w", err) + } + defer rows.Close() + + var result []store.SnapshotTimeSeries + for rows.Next() { + var ts store.SnapshotTimeSeries + if err := rows.Scan( + &ts.BucketTime, + &ts.InputTokens, &ts.OutputTokens, + &ts.CacheReadTokens, &ts.CacheCreateTokens, &ts.ThinkingTokens, + &ts.TotalCost, + &ts.RequestCount, &ts.LLMCallCount, &ts.ToolCallCount, + &ts.ErrorCount, &ts.UniqueUsers, &ts.AvgDurationMS, + &ts.MemoryDocs, &ts.MemoryChunks, + &ts.KGEntities, &ts.KGRelations, + ); err != nil { + return nil, fmt.Errorf("scan timeseries: %w", err) + } + result = append(result, ts) + } + return result, rows.Err() +} + +func (s *SQLiteSnapshotStore) GetBreakdown(ctx context.Context, q store.SnapshotQuery) ([]store.SnapshotBreakdown, error) { + groupBy := q.GroupBy + if groupBy == "" { + groupBy = "provider" + } + + var groupCol, orderExpr, extraFilter string + switch groupBy { + case "provider": + groupCol = "provider" + orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC" + extraFilter = " AND provider != '' AND model != ''" + case "model": + groupCol = "model" + orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC" + extraFilter = " AND provider != '' AND model != ''" + case "channel": + groupCol = "channel" + orderExpr = "SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) DESC" + extraFilter = " AND channel != ''" + case "agent": + groupCol = "agent_id" // already TEXT in SQLite — no ::TEXT cast needed + orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC" + extraFilter = "" + default: + groupCol = "provider" + orderExpr = "SUM(input_tokens) DESC" + extraFilter = " AND provider != '' AND model != ''" + } + + where, args := buildSnapshotWhere(ctx, q) + if where == "" { + where = " WHERE 1=1" + } + where += extraFilter + + query := fmt.Sprintf(`SELECT + %s as key, + SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END), + SUM(CASE WHEN provider != '' THEN output_tokens ELSE 0 END), + SUM(CASE WHEN provider != '' THEN cache_read_tokens ELSE 0 END), + SUM(CASE WHEN provider != '' THEN cache_create_tokens ELSE 0 END), + SUM(CASE WHEN provider != '' THEN total_cost ELSE 0 END), + SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END), + SUM(CASE WHEN provider != '' THEN llm_call_count ELSE 0 END), + SUM(CASE WHEN provider = '' AND model = '' THEN tool_call_count ELSE 0 END), + SUM(CASE WHEN provider = '' AND model = '' THEN error_count ELSE 0 END), + CASE WHEN SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) > 0 + THEN SUM(CASE WHEN provider = '' AND model = '' THEN avg_duration_ms * request_count ELSE 0 END) / + SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) + ELSE 0 END + FROM usage_snapshots + %s + GROUP BY %s + ORDER BY %s`, groupCol, where, groupCol, orderExpr) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("get breakdown: %w", err) + } + defer rows.Close() + + var result []store.SnapshotBreakdown + for rows.Next() { + var b store.SnapshotBreakdown + if err := rows.Scan( + &b.Key, + &b.InputTokens, &b.OutputTokens, + &b.CacheReadTokens, &b.CacheCreateTokens, + &b.TotalCost, + &b.RequestCount, &b.LLMCallCount, &b.ToolCallCount, + &b.ErrorCount, &b.AvgDurationMS, + ); err != nil { + return nil, fmt.Errorf("scan breakdown: %w", err) + } + result = append(result, b) + } + return result, rows.Err() +} + +func (s *SQLiteSnapshotStore) GetLatestBucket(ctx context.Context) (*time.Time, error) { + var nt nullSqliteTime + err := s.db.QueryRowContext(ctx, `SELECT MAX(bucket_hour) FROM usage_snapshots`).Scan(&nt) + if err != nil { + return nil, fmt.Errorf("get latest bucket: %w", err) + } + if !nt.Valid { + return nil, nil + } + return &nt.Time, nil +} + +// buildSnapshotWhere builds a WHERE clause with ? placeholders for SQLite. +func buildSnapshotWhere(ctx context.Context, q store.SnapshotQuery) (string, []any) { + var conds []string + var args []any + + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conds = append(conds, "tenant_id = ?") + args = append(args, tenantID) + } + } + if !q.From.IsZero() { + conds = append(conds, "bucket_hour >= ?") + args = append(args, q.From) + } + if !q.To.IsZero() { + conds = append(conds, "bucket_hour < ?") + args = append(args, q.To) + } + if q.AgentID != nil { + conds = append(conds, "agent_id = ?") + args = append(args, *q.AgentID) + } + if q.Provider != "" { + conds = append(conds, "provider = ?") + args = append(args, q.Provider) + } + if q.Model != "" { + conds = append(conds, "model = ?") + args = append(args, q.Model) + } + if q.Channel != "" { + conds = append(conds, "channel = ?") + args = append(args, q.Channel) + } + + if len(conds) == 0 { + return "", nil + } + return " WHERE " + strings.Join(conds, " AND "), args +} diff --git a/internal/store/sqlitestore/system-configs.go b/internal/store/sqlitestore/system-configs.go new file mode 100644 index 00000000..774ab19e --- /dev/null +++ b/internal/store/sqlitestore/system-configs.go @@ -0,0 +1,100 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "time" +) + +// SQLiteSystemConfigStore implements store.SystemConfigStore backed by SQLite. +// Strict tenant isolation — all operations require tenant_id in context (no fallback). +type SQLiteSystemConfigStore struct { + db *sql.DB +} + +func NewSQLiteSystemConfigStore(db *sql.DB) *SQLiteSystemConfigStore { + return &SQLiteSystemConfigStore{db: db} +} + +func (s *SQLiteSystemConfigStore) Get(ctx context.Context, key string) (string, error) { + tid, err := requireTenantID(ctx) + if err != nil { + return "", fmt.Errorf("system config get: %w", err) + } + + var val string + err = s.db.QueryRowContext(ctx, + "SELECT value FROM system_configs WHERE key = ? AND tenant_id = ?", + key, tid, + ).Scan(&val) + if err == nil { + return val, nil + } + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("system config not found: %s", key) + } + return "", fmt.Errorf("system config get: %w", err) +} + +func (s *SQLiteSystemConfigStore) Set(ctx context.Context, key, value string) error { + tid, err := requireTenantID(ctx) + if err != nil { + slog.Warn("system_config.set: no tenant in context", "key", key) + return fmt.Errorf("system config set: %w", err) + } + + _, err = s.db.ExecContext(ctx, + `INSERT INTO system_configs (key, value, tenant_id, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (key, tenant_id) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + key, value, tid, time.Now(), + ) + return err +} + +func (s *SQLiteSystemConfigStore) Delete(ctx context.Context, key string) error { + tid, err := requireTenantID(ctx) + if err != nil { + return fmt.Errorf("system config delete: %w", err) + } + + _, err = s.db.ExecContext(ctx, + "DELETE FROM system_configs WHERE key = ? AND tenant_id = ?", + key, tid, + ) + return err +} + +func (s *SQLiteSystemConfigStore) List(ctx context.Context) (map[string]string, error) { + tid, err := requireTenantID(ctx) + if err != nil { + return nil, fmt.Errorf("system config list: %w", err) + } + + rows, err := s.db.QueryContext(ctx, + "SELECT key, value FROM system_configs WHERE tenant_id = ? ORDER BY key", + tid, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("system config scan: %w", err) + } + result[k] = v + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("system config list: %w", err) + } + return result, nil +} diff --git a/internal/store/sqlitestore/teams.go b/internal/store/sqlitestore/teams.go new file mode 100644 index 00000000..126a7909 --- /dev/null +++ b/internal/store/sqlitestore/teams.go @@ -0,0 +1,505 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteTeamStore implements store.TeamStore backed by SQLite. +type SQLiteTeamStore struct { + db *sql.DB +} + +// SetEmbeddingProvider is a no-op for SQLite — vector search not supported. +func (s *SQLiteTeamStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {} + +func NewSQLiteTeamStore(db *sql.DB) *SQLiteTeamStore { + return &SQLiteTeamStore{db: db} +} + +const teamSelectCols = `id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at` + +// ============================================================ +// Team CRUD +// ============================================================ + +func (s *SQLiteTeamStore) CreateTeam(ctx context.Context, team *store.TeamData) error { + if team.ID == uuid.Nil { + team.ID = store.GenNewID() + } + now := time.Now() + team.CreatedAt = now + team.UpdatedAt = now + + settings := team.Settings + if len(settings) == 0 { + settings = json.RawMessage(`{}`) + } + + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + team.ID, team.Name, team.LeadAgentID, team.Description, + team.Status, settings, team.CreatedBy, now, now, tenantID, + ) + return err +} + +func (s *SQLiteTeamStore) GetTeam(ctx context.Context, teamID uuid.UUID) (*store.TeamData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ?`, teamID) + return scanTeamRow(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + row := s.db.QueryRowContext(ctx, + `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ? AND tenant_id = ?`, teamID, tenantID) + d, err := scanTeamRow(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return d, err +} + +func (s *SQLiteTeamStore) UpdateTeam(ctx context.Context, teamID uuid.UUID, updates map[string]any) error { + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "agent_teams", updates, teamID, tid) +} + +func (s *SQLiteTeamStore) DeleteTeam(ctx context.Context, teamID uuid.UUID) error { + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = ?`, teamID) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for delete") + } + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = ? AND tenant_id = ?`, teamID, tid) + return err +} + +func (s *SQLiteTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) { + var tenantFilter string + var queryArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + tenantFilter = " WHERE t.tenant_id = ?" + queryArgs = append(queryArgs, tenantID) + } + + rows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at, + COALESCE(a.agent_key, '') AS lead_agent_key, + COALESCE(a.display_name, '') AS lead_display_name + FROM agent_teams t + LEFT JOIN agents a ON a.id = t.lead_agent_id`+tenantFilter+` + ORDER BY t.created_at`, queryArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + var teams []store.TeamData + teamIndex := map[uuid.UUID]int{} + for rows.Next() { + var d store.TeamData + var desc sql.NullString + createdAt, updatedAt := scanTimePair() + if err := rows.Scan( + &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status, + &d.Settings, &d.CreatedBy, createdAt, updatedAt, + &d.LeadAgentKey, &d.LeadDisplayName, + ); err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + if desc.Valid { + d.Description = desc.String + } + teamIndex[d.ID] = len(teams) + teams = append(teams, d) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if len(teams) > 0 { + mRows, err := s.db.QueryContext(ctx, + `SELECT m.team_id, m.agent_id, m.role, m.joined_at, + COALESCE(a.agent_key, '') AS agent_key, + COALESCE(a.display_name, '') AS display_name, + COALESCE(a.frontmatter, '') AS frontmatter, + COALESCE(json_extract(a.other_config, '$.emoji'), '') AS emoji + FROM agent_team_members m + JOIN agents a ON a.id = m.agent_id + WHERE a.status = 'active' + ORDER BY m.joined_at`) + if err != nil { + return nil, err + } + defer mRows.Close() + + for mRows.Next() { + var m store.TeamMemberData + var joinedAt sqliteTime + if err := mRows.Scan(&m.TeamID, &m.AgentID, &m.Role, &joinedAt, &m.AgentKey, &m.DisplayName, &m.Frontmatter, &m.Emoji); err != nil { + return nil, err + } + m.JoinedAt = joinedAt.Time + if idx, ok := teamIndex[m.TeamID]; ok { + teams[idx].Members = append(teams[idx].Members, m) + teams[idx].MemberCount++ + } + } + if err := mRows.Err(); err != nil { + return nil, err + } + } + + return teams, nil +} + +// ============================================================ +// Members +// ============================================================ + +func (s *SQLiteTeamStore) AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO agent_team_members (team_id, agent_id, role, joined_at, tenant_id) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (team_id, agent_id) DO UPDATE SET role = excluded.role`, + teamID, agentID, role, time.Now(), tenantIDForInsert(ctx), + ) + return err +} + +func (s *SQLiteTeamStore) RemoveMember(ctx context.Context, teamID, agentID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM agent_team_members WHERE team_id = ? AND agent_id = ?`, + teamID, agentID, + ) + return err +} + +func (s *SQLiteTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) { + q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at, + COALESCE(a.agent_key, '') AS agent_key, + COALESCE(a.display_name, '') AS display_name, + COALESCE(a.frontmatter, '') AS frontmatter, + COALESCE(json_extract(a.other_config, '$.emoji'), '') AS emoji + FROM agent_team_members m + JOIN agents a ON a.id = m.agent_id + JOIN agent_teams at2 ON at2.id = m.team_id + WHERE m.team_id = ? AND a.status = 'active'` + args := []any{teamID} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND at2.tenant_id = ?" + args = append(args, tid) + } + } + q += ` ORDER BY m.joined_at` + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var members []store.TeamMemberData + for rows.Next() { + var d store.TeamMemberData + var joinedAt sqliteTime + if err := rows.Scan( + &d.TeamID, &d.AgentID, &d.Role, &joinedAt, + &d.AgentKey, &d.DisplayName, &d.Frontmatter, &d.Emoji, + ); err != nil { + return nil, err + } + d.JoinedAt = joinedAt.Time + members = append(members, d) + } + return members, rows.Err() +} + +func (s *SQLiteTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) { + q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at, + COALESCE(a.agent_key, '') AS agent_key, + COALESCE(a.display_name, '') AS display_name, + COALESCE(a.frontmatter, '') AS frontmatter, + COALESCE(json_extract(a.other_config, '$.emoji'), '') AS emoji + FROM agent_team_members m + JOIN agents a ON a.id = m.agent_id + JOIN agent_teams at2 ON at2.id = m.team_id + WHERE m.team_id = ? AND a.status = 'active' AND m.role != ? + AND NOT EXISTS ( + SELECT 1 FROM team_tasks tt + WHERE tt.owner_agent_id = m.agent_id AND tt.team_id = ? AND tt.status = ? + )` + args := []any{teamID, store.TeamRoleLead, teamID, store.TeamTaskStatusInProgress} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND at2.tenant_id = ?" + args = append(args, tid) + } + } + q += ` ORDER BY m.joined_at` + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var members []store.TeamMemberData + for rows.Next() { + var d store.TeamMemberData + var joinedAt sqliteTime + if err := rows.Scan( + &d.TeamID, &d.AgentID, &d.Role, &joinedAt, + &d.AgentKey, &d.DisplayName, &d.Frontmatter, &d.Emoji, + ); err != nil { + return nil, err + } + d.JoinedAt = joinedAt.Time + members = append(members, d) + } + return members, rows.Err() +} + +func (s *SQLiteTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*store.TeamData, error) { + q := `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at + FROM agent_teams t + WHERE ( + t.lead_agent_id = ? + OR EXISTS (SELECT 1 FROM agent_team_members m WHERE m.team_id = t.id AND m.agent_id = ?) + ) AND t.status = ?` + args := []any{agentID, agentID, store.TeamStatusActive} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND t.tenant_id = ?" + args = append(args, tid) + } + } + q += ` ORDER BY (t.lead_agent_id = ?) DESC LIMIT 1` + args = append(args, agentID) + + row := s.db.QueryRowContext(ctx, q, args...) + d, err := scanTeamRow(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return d, err +} + +func (s *SQLiteTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit int) ([]string, error) { + if limit <= 0 { + limit = 100 + } + q := `SELECT DISTINCT s.user_id + FROM sessions s + JOIN agent_team_members m ON m.agent_id = s.agent_id + JOIN agent_teams at2 ON at2.id = m.team_id + WHERE m.team_id = ? AND s.user_id != ''` + args := []any{teamID} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND at2.tenant_id = ?" + args = append(args, tid) + } + } + q += ` ORDER BY s.user_id LIMIT ?` + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []string + for rows.Next() { + var uid string + if err := rows.Scan(&uid); err != nil { + return nil, err + } + users = append(users, uid) + } + return users, rows.Err() +} + +// ============================================================ +// Team user grants +// ============================================================ + +func (s *SQLiteTeamStore) GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO team_user_grants (id, team_id, user_id, role, granted_by, created_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by`, + store.GenNewID(), teamID, userID, role, grantedBy, time.Now(), tenantIDForInsert(ctx), + ) + return err +} + +func (s *SQLiteTeamStore) RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM team_user_grants WHERE team_id = ? AND user_id = ?`+tClause, + append([]any{teamID, userID}, tArgs...)...) + return err +} + +func (s *SQLiteTeamStore) ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]store.TeamUserGrant, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, team_id, user_id, role, COALESCE(granted_by, ''), created_at + FROM team_user_grants WHERE team_id = ?`+tClause+` ORDER BY created_at DESC`, + append([]any{teamID}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.TeamUserGrant + for rows.Next() { + var g store.TeamUserGrant + var createdAt sqliteTime + if err := rows.Scan(&g.ID, &g.TeamID, &g.UserID, &g.Role, &g.GrantedBy, &createdAt); err != nil { + return nil, err + } + g.CreatedAt = createdAt.Time + result = append(result, g) + } + return result, rows.Err() +} + +func (s *SQLiteTeamStore) ListUserTeams(ctx context.Context, userID string) ([]store.TeamData, error) { + baseQuery := `SELECT ` + teamSelectCols + ` + FROM agent_teams t + WHERE t.status = ? + AND EXISTS (SELECT 1 FROM team_user_grants g WHERE g.team_id = t.id AND g.user_id = ?)` + args := []any{store.TeamStatusActive, userID} + + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + baseQuery += ` AND t.tenant_id = ?` + args = append(args, tenantID) + } + baseQuery += ` ORDER BY t.created_at DESC` + + rows, err := s.db.QueryContext(ctx, baseQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var teams []store.TeamData + for rows.Next() { + var d store.TeamData + var desc sql.NullString + createdAt, updatedAt := scanTimePair() + if err := rows.Scan( + &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status, + &d.Settings, &d.CreatedBy, createdAt, updatedAt, + ); err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + if desc.Valid { + d.Description = desc.String + } + teams = append(teams, d) + } + return teams, rows.Err() +} + +func (s *SQLiteTeamStore) HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return false, err + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM team_user_grants WHERE team_id = ? AND user_id = ?`+tClause+`)`, + append([]any{teamID, userID}, tArgs...)..., + ).Scan(&exists) + return exists, err +} + +// GetTeamUnscoped returns a team by ID without tenant filtering. Server-internal only. +func (s *SQLiteTeamStore) GetTeamUnscoped(ctx context.Context, id uuid.UUID) (*store.TeamData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ?`, id) + return scanTeamRow(row) +} + +// ============================================================ +// Scan helpers +// ============================================================ + +func scanTeamRow(row *sql.Row) (*store.TeamData, error) { + var d store.TeamData + var desc sql.NullString + createdAt, updatedAt := scanTimePair() + err := row.Scan( + &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status, + &d.Settings, &d.CreatedBy, createdAt, updatedAt, + ) + if err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + if desc.Valid { + d.Description = desc.String + } + return &d, nil +} diff --git a/internal/store/sqlitestore/teams_tasks.go b/internal/store/sqlitestore/teams_tasks.go new file mode 100644 index 00000000..b0e0a86a --- /dev/null +++ b/internal/store/sqlitestore/teams_tasks.go @@ -0,0 +1,558 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + "unicode" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// taskLockDuration is how long a claimed task stays locked before stale recovery resets it. +const taskLockDuration = 30 * time.Minute + +// maxListTasksRows caps ListTasks results to prevent unbounded queries. +const maxListTasksRows = 30 + +// taskSelectCols is the shared SELECT column list for task queries. +const taskSelectCols = `t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.user_id, t.channel, + t.task_type, t.task_number, COALESCE(t.identifier,''), t.created_by_agent_id, COALESCE(t.assignee_user_id,''), t.parent_id, + COALESCE(t.chat_id,''), t.metadata, t.locked_at, t.lock_expires_at, COALESCE(t.progress_percent,0), COALESCE(t.progress_step,''), + t.followup_at, COALESCE(t.followup_count,0), COALESCE(t.followup_max,0), COALESCE(t.followup_message,''), COALESCE(t.followup_channel,''), COALESCE(t.followup_chat_id,''), + COALESCE(t.comment_count,0), COALESCE(t.attachment_count,0), + t.created_at, t.updated_at, + COALESCE(a.agent_key, '') AS owner_agent_key, + COALESCE(ca.agent_key, '') AS created_by_agent_key` + +// taskJoinClause is the shared JOIN clause for task queries. +const taskJoinClause = `FROM team_tasks t + LEFT JOIN agents a ON a.id = t.owner_agent_id + LEFT JOIN agents ca ON ca.id = t.created_by_agent_id` + +// ============================================================ +// Scopes +// ============================================================ + +func (s *SQLiteTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]store.ScopeEntry, error) { + args := []any{teamID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = " AND tenant_id = ?" + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, + `SELECT DISTINCT channel, chat_id FROM team_tasks + WHERE team_id = ? AND channel IS NOT NULL AND channel != ''`+tenantWhere+` + ORDER BY channel, chat_id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var scopes []store.ScopeEntry + for rows.Next() { + var sc store.ScopeEntry + if err := rows.Scan(&sc.Channel, &sc.ChatID); err != nil { + return nil, err + } + scopes = append(scopes, sc) + } + return scopes, rows.Err() +} + +// ============================================================ +// Tasks CRUD +// ============================================================ + +func (s *SQLiteTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) error { + if task.ID == uuid.Nil { + task.ID = store.GenNewID() + } + now := time.Now() + task.CreatedAt = now + task.UpdatedAt = now + + if task.TaskType == "" { + task.TaskType = "general" + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Scope task_number per (team_id, chat_id). + var taskNumber int + err = tx.QueryRowContext(ctx, + `SELECT COALESCE(MAX(task_number), 0) + 1 FROM team_tasks WHERE team_id = ? AND COALESCE(chat_id, '') = ?`, + task.TeamID, task.ChatID, + ).Scan(&taskNumber) + if err != nil { + return fmt.Errorf("compute task_number: %w", err) + } + task.TaskNumber = taskNumber + + hex := strings.ReplaceAll(task.ID.String(), "-", "") + task.Identifier = fmt.Sprintf("T-%03d-%s", taskNumber, hex[len(hex)-4:]) + + var metaJSON []byte + if len(task.Metadata) > 0 { + metaJSON, _ = json.Marshal(task.Metadata) + } + + blockedByJSON := jsonStringArray(uuidSliceToStrings(task.BlockedBy)) + + _, err = tx.ExecContext(ctx, + `INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, user_id, channel, + task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, metadata, locked_at, lock_expires_at, created_at, updated_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + task.ID, task.TeamID, task.Subject, task.Description, + task.Status, task.OwnerAgentID, blockedByJSON, + task.Priority, task.Result, + nilStr(task.UserID), nilStr(task.Channel), + task.TaskType, taskNumber, task.Identifier, + task.CreatedByAgentID, task.ParentID, + nilStr(task.ChatID), + metaJSON, + task.LockedAt, task.LockExpiresAt, + now, now, tenantIDForInsert(ctx), + ) + if err != nil { + return err + } + + return tx.Commit() +} + +// allowedTaskUpdateCols is the whitelist of columns that UpdateTask accepts. +var allowedTaskUpdateCols = map[string]bool{ + "subject": true, + "description": true, + "priority": true, + "assignee_user_id": true, + "metadata": true, + "blocked_by": true, + "updated_at": true, +} + +func (s *SQLiteTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + for col := range updates { + if !allowedTaskUpdateCols[col] { + return fmt.Errorf("column %q is not allowed in task updates", col) + } + } + // Convert blocked_by []uuid.UUID to JSON string array for SQLite. + if v, ok := updates["blocked_by"]; ok { + switch tv := v.(type) { + case []uuid.UUID: + updates["blocked_by"] = jsonStringArray(uuidSliceToStrings(tv)) + case []string: + updates["blocked_by"] = jsonStringArray(tv) + } + } + updates["updated_at"] = time.Now() + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "team_tasks", taskID, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "team_tasks", updates, taskID, tid) +} + +func (s *SQLiteTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string, limit int, offset int) ([]store.TeamTaskData, error) { + orderClause := "t.priority DESC, t.created_at" + if orderBy == "newest" { + orderClause = "t.created_at DESC" + } + + statusWhere := "" + switch statusFilter { + case store.TeamTaskFilterActive: + statusWhere = "AND t.status NOT IN ('completed','cancelled')" + case store.TeamTaskFilterInReview: + statusWhere = "AND t.status = 'in_review'" + case store.TeamTaskFilterCompleted: + statusWhere = "AND t.status IN ('completed','cancelled')" + } + + if limit <= 0 { + limit = maxListTasksRows + } + + // Scope filter using COALESCE for optional channel/chatID. + scopeWhere := "AND (? = '' OR COALESCE(t.channel,'') = ?) AND (? = '' OR COALESCE(t.chat_id,'') = ?)" + + args := []any{teamID, userID, userID, channel, channel, chatID, chatID} + + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = " AND t.tenant_id = ?" + args = append(args, tid) + } + args = append(args, limit+1, offset) + + rows, err := s.db.QueryContext(ctx, + `SELECT `+taskSelectCols+` + `+taskJoinClause+` + WHERE t.team_id = ? AND (? = '' OR t.user_id = ?) `+statusWhere+` `+scopeWhere+tenantWhere+` + ORDER BY `+orderClause+` + LIMIT ? OFFSET ?`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskRowsJoined(rows) +} + +func (s *SQLiteTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) { + args := []any{taskID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = " AND t.tenant_id = ?" + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+taskSelectCols+` + `+taskJoinClause+` + WHERE t.id = ?`+tenantWhere, args...) + if err != nil { + return nil, err + } + defer rows.Close() + tasks, err := scanTaskRowsJoined(rows) + if err != nil { + return nil, err + } + if len(tasks) == 0 { + return nil, store.ErrTaskNotFound + } + return &tasks[0], nil +} + +func (s *SQLiteTeamStore) GetTasksByIDs(ctx context.Context, ids []uuid.UUID) ([]store.TeamTaskData, error) { + if len(ids) == 0 { + return nil, nil + } + placeholders := strings.Repeat("?,", len(ids)) + placeholders = placeholders[:len(placeholders)-1] + args := make([]any, len(ids)) + for i, id := range ids { + args[i] = id + } + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = " AND t.tenant_id = ?" + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+taskSelectCols+` + `+taskJoinClause+` + WHERE t.id IN (`+placeholders+`)`+tenantWhere, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskRowsJoined(rows) +} + +// SearchTasks uses LIKE-based search (SQLite has no tsvector/FTS without extension). +func (s *SQLiteTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query string, limit int, userID string) ([]store.TeamTaskData, error) { + if limit <= 0 { + limit = 20 + } + words := strings.Fields(query) + if len(words) == 0 { + return nil, nil + } + // Sanitize words. + var sanitized []string + for _, w := range words { + w = strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' { + return r + } + return -1 + }, w) + w = strings.TrimSpace(w) + if w != "" { + sanitized = append(sanitized, "%"+w+"%") + } + } + if len(sanitized) == 0 { + return nil, nil + } + + // Build AND-chained LIKE conditions. + likeClauses := make([]string, len(sanitized)) + args := []any{teamID} + for i, pat := range sanitized { + likeClauses[i] = "(t.subject LIKE ? OR t.description LIKE ?)" + args = append(args, pat, pat) + } + args = append(args, userID, userID) + + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = " AND t.tenant_id = ?" + args = append(args, tid) + } + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, + `SELECT `+taskSelectCols+` + `+taskJoinClause+` + WHERE t.team_id = ? AND `+strings.Join(likeClauses, " AND ")+` + AND (? = '' OR t.user_id = ?)`+tenantWhere+` + ORDER BY t.created_at DESC + LIMIT ?`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskRowsJoined(rows) +} + +func (s *SQLiteTeamStore) DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error { + args := []any{taskID, teamID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + tenantWhere = " AND tenant_id = ?" + args = append(args, tid) + } + res, err := s.db.ExecContext(ctx, + `DELETE FROM team_tasks WHERE id = ? AND team_id = ? AND status IN ('completed','failed','cancelled')`+tenantWhere, + args...) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return store.ErrTaskNotFound + } + return nil +} + +func (s *SQLiteTeamStore) DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, teamID uuid.UUID) ([]uuid.UUID, error) { + if len(taskIDs) == 0 { + return nil, nil + } + // SQLite doesn't support RETURNING in older versions — do select then delete. + placeholders := strings.Repeat("?,", len(taskIDs)) + placeholders = placeholders[:len(placeholders)-1] + args := make([]any, len(taskIDs)) + for i, id := range taskIDs { + args[i] = id + } + args = append(args, teamID) + + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = " AND tenant_id = ?" + args = append(args, tid) + } + + cond := `id IN (` + placeholders + `) AND team_id = ? AND status IN ('completed','failed','cancelled')` + tenantWhere + + // Fetch IDs to delete first. + selectRows, err := s.db.QueryContext(ctx, `SELECT id FROM team_tasks WHERE `+cond, args...) + if err != nil { + return nil, err + } + var deleted []uuid.UUID + for selectRows.Next() { + var id uuid.UUID + if err := selectRows.Scan(&id); err != nil { + selectRows.Close() + return deleted, err + } + deleted = append(deleted, id) + } + selectRows.Close() + if err := selectRows.Err(); err != nil { + return nil, err + } + + if len(deleted) > 0 { + _, err = s.db.ExecContext(ctx, `DELETE FROM team_tasks WHERE `+cond, args...) + if err != nil { + return nil, err + } + } + return deleted, nil +} + +func (s *SQLiteTeamStore) ListActiveTasksByChatID(ctx context.Context, chatID string) ([]store.TeamTaskData, error) { + if chatID == "" { + return nil, nil + } + args := []any{chatID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = " AND t.tenant_id = ?" + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+taskSelectCols+` + `+taskJoinClause+` + WHERE COALESCE(t.chat_id,'') = ? + AND t.status IN ('pending','in_progress','blocked','in_review')`+tenantWhere+` + ORDER BY t.task_number ASC + LIMIT 50`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskRowsJoined(rows) +} + +// ============================================================ +// Scan helpers +// ============================================================ + +func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) { + var tasks []store.TeamTaskData + for rows.Next() { + var d store.TeamTaskData + var desc, result, userID, channel sql.NullString + var ownerID, createdByAgentID, parentID *uuid.UUID + var blockedByJSON []byte + var assigneeUserID, chatID, progressStep, identifier string + var metadataJSON []byte + var lockedAt, lockExpiresAt, followupAt nullSqliteTime + var followupCount, followupMax int + var followupMessage, followupChannel, followupChatID string + createdAt, updatedAt := scanTimePair() + if err := rows.Scan( + &d.ID, &d.TeamID, &d.Subject, &desc, &d.Status, + &ownerID, &blockedByJSON, &d.Priority, &result, + &userID, &channel, + &d.TaskType, &d.TaskNumber, &identifier, &createdByAgentID, &assigneeUserID, &parentID, + &chatID, &metadataJSON, &lockedAt, &lockExpiresAt, &d.ProgressPercent, &progressStep, + &followupAt, &followupCount, &followupMax, &followupMessage, &followupChannel, &followupChatID, + &d.CommentCount, &d.AttachmentCount, + createdAt, updatedAt, + &d.OwnerAgentKey, + &d.CreatedByAgentKey, + ); err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + if desc.Valid { + d.Description = desc.String + } + if result.Valid { + d.Result = &result.String + } + if userID.Valid { + d.UserID = userID.String + } + if channel.Valid { + d.Channel = channel.String + } + d.OwnerAgentID = ownerID + d.Identifier = identifier + d.CreatedByAgentID = createdByAgentID + d.AssigneeUserID = assigneeUserID + d.ParentID = parentID + d.ChatID = chatID + + // Decode blocked_by JSON string array → []uuid.UUID. + var blockedByStrs []string + scanJSONStringArray(blockedByJSON, &blockedByStrs) + d.BlockedBy = stringsToUUIDSlice(blockedByStrs) + + if len(metadataJSON) > 0 { + _ = json.Unmarshal(metadataJSON, &d.Metadata) + } + if lockedAt.Valid { + d.LockedAt = &lockedAt.Time + } + if lockExpiresAt.Valid { + d.LockExpiresAt = &lockExpiresAt.Time + } + d.ProgressStep = progressStep + if followupAt.Valid { + d.FollowupAt = &followupAt.Time + } + d.FollowupCount = followupCount + d.FollowupMax = followupMax + d.FollowupMessage = followupMessage + d.FollowupChannel = followupChannel + d.FollowupChatID = followupChatID + tasks = append(tasks, d) + } + return tasks, rows.Err() +} + +// uuidSliceToStrings converts []uuid.UUID to []string for JSON storage. +func uuidSliceToStrings(ids []uuid.UUID) []string { + if ids == nil { + return nil + } + out := make([]string, len(ids)) + for i, id := range ids { + out[i] = id.String() + } + return out +} + +// stringsToUUIDSlice converts []string back to []uuid.UUID. +func stringsToUUIDSlice(strs []string) []uuid.UUID { + if strs == nil { + return nil + } + out := make([]uuid.UUID, 0, len(strs)) + for _, s := range strs { + if id, err := uuid.Parse(s); err == nil { + out = append(out, id) + } + } + return out +} diff --git a/internal/store/sqlitestore/teams_tasks_activity.go b/internal/store/sqlitestore/teams_tasks_activity.go new file mode 100644 index 00000000..e5ac69a5 --- /dev/null +++ b/internal/store/sqlitestore/teams_tasks_activity.go @@ -0,0 +1,679 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ============================================================ +// Task comments +// ============================================================ + +func (s *SQLiteTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTaskCommentData) error { + if comment.ID == uuid.Nil { + comment.ID = store.GenNewID() + } + comment.CreatedAt = time.Now() + commentType := comment.CommentType + if commentType == "" { + commentType = "note" + } + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, + `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, comment_type, created_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + comment.ID, comment.TaskID, comment.AgentID, + nilStr(comment.UserID), + comment.Content, commentType, comment.CreatedAt, tid, + ) + if err != nil { + return err + } + _, _ = s.db.ExecContext(ctx, + `UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = ? AND tenant_id = ?`, comment.TaskID, tid) + return nil +} + +func (s *SQLiteTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) { + tid := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at, + COALESCE(a.agent_key, '') AS agent_key + FROM team_task_comments c + LEFT JOIN agents a ON a.id = c.agent_id + WHERE c.task_id = ? AND c.tenant_id = ? + ORDER BY c.created_at ASC`, taskID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskCommentRows(rows) +} + +func (s *SQLiteTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) { + tid := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at, + COALESCE(a.agent_key, '') AS agent_key + FROM team_task_comments c + LEFT JOIN agents a ON a.id = c.agent_id + WHERE c.task_id = ? AND c.tenant_id = ? + ORDER BY c.created_at DESC + LIMIT ?`, taskID, tid, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + comments, err := scanTaskCommentRows(rows) + if err != nil { + return nil, err + } + // Reverse to chronological order (ASC) for display. + for i, j := 0, len(comments)-1; i < j; i, j = i+1, j-1 { + comments[i], comments[j] = comments[j], comments[i] + } + return comments, nil +} + +func scanTaskCommentRows(rows *sql.Rows) ([]store.TeamTaskCommentData, error) { + var comments []store.TeamTaskCommentData + for rows.Next() { + var c store.TeamTaskCommentData + var agentID *uuid.UUID + var userID sql.NullString + var createdAt sqliteTime + if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &createdAt, &c.AgentKey); err != nil { + return nil, err + } + c.CreatedAt = createdAt.Time + c.AgentID = agentID + if userID.Valid { + c.UserID = userID.String + } + comments = append(comments, c) + } + return comments, rows.Err() +} + +// ============================================================ +// Audit events +// ============================================================ + +func (s *SQLiteTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTaskEventData) error { + if event.ID == uuid.Nil { + event.ID = store.GenNewID() + } + event.CreatedAt = time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt, tenantIDForInsert(ctx), + ) + return err +} + +func (s *SQLiteTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) { + tid := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, task_id, event_type, actor_type, actor_id, data, created_at + FROM team_task_events + WHERE task_id = ? AND tenant_id = ? + ORDER BY created_at ASC`, taskID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskEventRows(rows) +} + +func (s *SQLiteTeamStore) ListTeamEvents(ctx context.Context, teamID uuid.UUID, limit, offset int) ([]store.TeamTaskEventData, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + tid := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT e.id, e.task_id, e.event_type, e.actor_type, e.actor_id, e.data, e.created_at + FROM team_task_events e + JOIN team_tasks t ON t.id = e.task_id + WHERE t.team_id = ? AND t.tenant_id = ? + ORDER BY e.created_at DESC + LIMIT ? OFFSET ?`, + teamID, tid, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskEventRows(rows) +} + +func scanTaskEventRows(rows *sql.Rows) ([]store.TeamTaskEventData, error) { + var events []store.TeamTaskEventData + for rows.Next() { + var e store.TeamTaskEventData + var data json.RawMessage + var createdAt sqliteTime + if err := rows.Scan(&e.ID, &e.TaskID, &e.EventType, &e.ActorType, &e.ActorID, &data, &createdAt); err != nil { + return nil, err + } + e.CreatedAt = createdAt.Time + e.Data = data + events = append(events, e) + } + return events, rows.Err() +} + +// ============================================================ +// Attachments +// ============================================================ + +func (s *SQLiteTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskAttachmentData) error { + if att.ID == uuid.Nil { + att.ID = store.GenNewID() + } + att.CreatedAt = time.Now() + if len(att.Metadata) == 0 { + att.Metadata = json.RawMessage(`{}`) + } + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (task_id, path) DO NOTHING`, + att.ID, att.TaskID, att.TeamID, att.ChatID, att.Path, + att.FileSize, att.MimeType, att.CreatedByAgentID, + nilStr(att.CreatedBySenderID), + att.Metadata, att.CreatedAt, tid, + ) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n > 0 { + _, _ = s.db.ExecContext(ctx, + `UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = ? AND tenant_id = ?`, att.TaskID, tid) + } + return nil +} + +func (s *SQLiteTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID) (*store.TeamTaskAttachmentData, error) { + var a store.TeamTaskAttachmentData + var agentID *uuid.UUID + var senderID sql.NullString + var metadata json.RawMessage + var createdAt sqliteTime + tid := tenantIDForInsert(ctx) + err := s.db.QueryRowContext(ctx, + `SELECT id, task_id, team_id, chat_id, path, file_size, mime_type, + created_by_agent_id, created_by_sender_id, metadata, created_at + FROM team_task_attachments WHERE id = ? AND tenant_id = ?`, attachmentID, tid, + ).Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType, + &agentID, &senderID, &metadata, &createdAt) + if err != nil { + return nil, err + } + a.CreatedAt = createdAt.Time + a.CreatedByAgentID = agentID + if senderID.Valid { + a.CreatedBySenderID = senderID.String + } + a.Metadata = metadata + return &a, nil +} + +func (s *SQLiteTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) { + tid := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, task_id, team_id, chat_id, path, file_size, mime_type, + created_by_agent_id, created_by_sender_id, metadata, created_at + FROM team_task_attachments + WHERE task_id = ? AND tenant_id = ? + ORDER BY created_at ASC`, taskID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + var atts []store.TeamTaskAttachmentData + for rows.Next() { + var a store.TeamTaskAttachmentData + var agentID *uuid.UUID + var senderID sql.NullString + var metadata json.RawMessage + var createdAt sqliteTime + if err := rows.Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType, + &agentID, &senderID, &metadata, &createdAt); err != nil { + return nil, err + } + a.CreatedAt = createdAt.Time + a.CreatedByAgentID = agentID + if senderID.Valid { + a.CreatedBySenderID = senderID.String + } + a.Metadata = metadata + atts = append(atts, a) + } + return atts, rows.Err() +} + +func (s *SQLiteTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID, path string) error { + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `DELETE FROM team_task_attachments WHERE task_id = ? AND path = ? AND tenant_id = ?`, + taskID, path, tid, + ) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n > 0 { + _, _ = s.db.ExecContext(ctx, + `UPDATE team_tasks SET attachment_count = MAX(attachment_count - 1, 0) WHERE id = ? AND tenant_id = ?`, taskID, tid) + } + return nil +} + +// ============================================================ +// Progress +// ============================================================ + +func (s *SQLiteTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uuid.UUID, percent int, step string) error { + if percent < 0 || percent > 100 { + return fmt.Errorf("progress percent must be 0-100, got %d", percent) + } + now := time.Now() + lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET progress_percent = ?, progress_step = ?, lock_expires_at = ?, updated_at = ? + WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`, + percent, step, lockExpires, now, + taskID, store.TeamTaskStatusInProgress, teamID, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not in progress or not found") + } + return nil +} + +func (s *SQLiteTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error { + now := time.Now() + lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET lock_expires_at = ?, updated_at = ? + WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`, + lockExpires, now, + taskID, teamID, store.TeamTaskStatusInProgress, tid, + ) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("task not in progress or not found") + } + return nil +} + +// ============================================================ +// Stale recovery +// ============================================================ + +// v2ActiveTeamJoin is the JOIN that filters to v2 active teams. +// SQLite uses json_extract instead of PG's ->> operator. +const v2ActiveTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id + AND tm.status = 'active' + AND COALESCE(CAST(json_extract(tm.settings, '$.version') AS INTEGER), 0) >= 2` + +func (s *SQLiteTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) { + now := time.Now() + // SQLite: UPDATE ... FROM not supported in older versions; use subquery in WHERE. + _, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL, + followup_at = NULL, followup_count = 0, followup_message = NULL, + followup_channel = NULL, followup_chat_id = NULL, updated_at = ? + WHERE status = ? AND lock_expires_at IS NOT NULL AND lock_expires_at < ? + AND team_id IN ( + SELECT id FROM agent_teams WHERE status = 'active' + AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2 + )`, + store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, now, + ) + if err != nil { + return nil, err + } + return s.queryRecoveredTasks(ctx, store.TeamTaskStatusPending) +} + +func (s *SQLiteTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) { + now := time.Now() + _, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL, + followup_at = NULL, followup_count = 0, followup_message = NULL, + followup_channel = NULL, followup_chat_id = NULL, updated_at = ? + WHERE status = ? + AND team_id IN ( + SELECT id FROM agent_teams WHERE status = 'active' + AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2 + )`, + store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, + ) + if err != nil { + return nil, err + } + return s.queryRecoveredTasks(ctx, store.TeamTaskStatusPending) +} + +func (s *SQLiteTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) { + now := time.Now() + tid := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT `+taskSelectCols+` + `+taskJoinClause+` + WHERE t.team_id = ? AND t.tenant_id = ? + AND ( + t.status = ? + OR (t.status = ? AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < ?) + ) + ORDER BY t.priority DESC, t.created_at + LIMIT ?`, + teamID, tid, + store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskRowsJoined(rows) +} + +func (s *SQLiteTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) { + now := time.Now() + _, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, updated_at = ? + WHERE status = ? AND updated_at < ? + AND team_id IN ( + SELECT id FROM agent_teams WHERE status = 'active' + AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2 + )`, + store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan, + ) + if err != nil { + return nil, err + } + return s.queryRecoveredTasks(ctx, store.TeamTaskStatusStale) +} + +func (s *SQLiteTeamStore) MarkInReviewStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) { + now := time.Now() + _, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, updated_at = ? + WHERE status = ? AND updated_at < ? + AND team_id IN ( + SELECT id FROM agent_teams WHERE status = 'active' + AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2 + )`, + store.TeamTaskStatusStale, now, store.TeamTaskStatusInReview, olderThan, + ) + if err != nil { + return nil, err + } + return s.queryRecoveredTasks(ctx, store.TeamTaskStatusStale) +} + +// FixOrphanedBlockedTasks unblocks blocked tasks where all blockers have reached terminal status. +func (s *SQLiteTeamStore) FixOrphanedBlockedTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) { + now := time.Now() + // Find blocked tasks where every entry in blocked_by is in terminal status. + rows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.team_id, t.tenant_id, t.task_number, t.subject, + COALESCE(t.channel, ''), COALESCE(t.chat_id, '') + FROM team_tasks t + JOIN agent_teams tm ON tm.id = t.team_id AND tm.status = 'active' + AND COALESCE(CAST(json_extract(tm.settings, '$.version') AS INTEGER), 0) >= 2 + WHERE t.status = 'blocked' + AND t.blocked_by IS NOT NULL AND t.blocked_by != '[]' + AND NOT EXISTS ( + SELECT 1 FROM json_each(t.blocked_by) AS je + JOIN team_tasks bt ON bt.id = je.value AND bt.tenant_id = t.tenant_id + WHERE bt.status NOT IN (?, ?, ?) + )`, + store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled, + ) + if err != nil { + return nil, err + } + infos, err := scanRecoveredTaskInfoRows(rows) + rows.Close() + if err != nil { + return nil, err + } + + // Unblock each — clear blocked_by and set pending. + for _, info := range infos { + _, _ = s.db.ExecContext(ctx, + `UPDATE team_tasks SET blocked_by = '[]', status = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`, + store.TeamTaskStatusPending, now, info.ID, info.TenantID, + ) + } + return infos, nil +} + +// queryRecoveredTasks fetches recently-updated tasks matching the given status for recovery reporting. +func (s *SQLiteTeamStore) queryRecoveredTasks(ctx context.Context, status string) ([]store.RecoveredTaskInfo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.team_id, t.tenant_id, t.task_number, t.subject, + COALESCE(t.channel, ''), COALESCE(t.chat_id, '') + FROM team_tasks t + WHERE t.status = ? + AND t.updated_at >= ? + AND t.team_id IN ( + SELECT id FROM agent_teams WHERE status = 'active' + AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2 + ) + ORDER BY t.updated_at DESC + LIMIT 200`, + status, time.Now().Add(-5*time.Second), + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRecoveredTaskInfoRows(rows) +} + +func scanRecoveredTaskInfoRows(rows interface { + Next() bool + Scan(...any) error + Err() error +}) ([]store.RecoveredTaskInfo, error) { + var out []store.RecoveredTaskInfo + for rows.Next() { + var info store.RecoveredTaskInfo + if err := rows.Scan(&info.ID, &info.TeamID, &info.TenantID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil { + return nil, err + } + out = append(out, info) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *SQLiteTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error { + now := time.Now() + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL, result = NULL, + progress_percent = NULL, progress_step = NULL, updated_at = ? + WHERE id = ? AND team_id = ? AND status IN (?, ?, ?, ?) AND tenant_id = ?`, + store.TeamTaskStatusPending, now, + taskID, teamID, + store.TeamTaskStatusStale, store.TeamTaskStatusFailed, + store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not available for reset (not stale/failed/cancelled/in_review or wrong team)") + } + return nil +} + +// ============================================================ +// Follow-up reminders +// ============================================================ + +func (s *SQLiteTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error { + now := time.Now() + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET followup_at = ?, followup_max = ?, followup_message = ?, followup_channel = ?, followup_chat_id = ?, updated_at = ? + WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`, + followupAt, max, message, channel, chatID, now, + taskID, teamID, store.TeamTaskStatusInProgress, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not in progress or not found") + } + return nil +} + +func (s *SQLiteTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error { + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = ? + WHERE id = ? AND tenant_id = ?`, + time.Now(), taskID, tid, + ) + return err +} + +func (s *SQLiteTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.TeamTaskData, error) { + now := time.Now() + rows, err := s.db.QueryContext(ctx, + `SELECT `+taskSelectCols+` + `+taskJoinClause+` + `+v2ActiveTeamJoin+` + WHERE t.followup_at IS NOT NULL + AND t.followup_at <= ? + AND t.status = ? + AND (t.followup_max = 0 OR t.followup_count < t.followup_max) + ORDER BY t.followup_at + LIMIT 100`, + now, store.TeamTaskStatusInProgress, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskRowsJoined(rows) +} + +func (s *SQLiteTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error { + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = ?, updated_at = ? + WHERE id = ? AND tenant_id = ?`, + nextAt, time.Now(), taskID, tid, + ) + return err +} + +func (s *SQLiteTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) { + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks + SET followup_at = NULL, followup_count = 0, followup_message = NULL, + followup_channel = NULL, followup_chat_id = NULL, updated_at = ? + WHERE followup_channel = ? AND followup_chat_id = ? + AND followup_at IS NOT NULL AND status = ? AND tenant_id = ?`, + time.Now(), channel, chatID, store.TeamTaskStatusInProgress, tid, + ) + if err != nil { + return 0, err + } + n, err := res.RowsAffected() + return int(n), err +} + +func (s *SQLiteTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) { + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks + SET followup_at = ?, followup_max = ?, followup_message = ?, + followup_channel = ?, followup_chat_id = ?, updated_at = ? + WHERE team_id = ? + AND status = ? + AND followup_at IS NULL + AND tenant_id = ? + AND ( + (COALESCE(channel,'') = ? AND COALESCE(chat_id,'') = ?) + OR followup_channel = ? + OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '') + )`, + followupAt, max, message, channel, chatID, time.Now(), + teamID, store.TeamTaskStatusInProgress, tid, + channel, chatID, channel, + ) + if err != nil { + return 0, err + } + n, err := res.RowsAffected() + return int(n), err +} + +func (s *SQLiteTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error) { + tid := tenantIDForInsert(ctx) + var exists bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS( + SELECT 1 FROM team_tasks + WHERE team_id = ? + AND owner_agent_id IS NOT NULL + AND owner_agent_id != ? + AND status IN (?, ?, ?) + AND tenant_id = ? + )`, + teamID, excludeAgentID, + store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked, tid, + ).Scan(&exists) + return exists, err +} + +// BackfillTaskEmbeddings is a no-op for SQLite — vector search not supported. +func (s *SQLiteTeamStore) BackfillTaskEmbeddings(_ context.Context) (int, error) { + return 0, nil +} + +// SearchTasksByEmbedding is a no-op for SQLite — falls back to LIKE search. +func (s *SQLiteTeamStore) SearchTasksByEmbedding(_ context.Context, _ uuid.UUID, _ []float32, _ int, _ string) ([]store.TeamTaskData, error) { + return nil, nil +} diff --git a/internal/store/sqlitestore/teams_tasks_lifecycle.go b/internal/store/sqlitestore/teams_tasks_lifecycle.go new file mode 100644 index 00000000..c53051e8 --- /dev/null +++ b/internal/store/sqlitestore/teams_tasks_lifecycle.go @@ -0,0 +1,349 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *SQLiteTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error { + now := time.Now() + lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, owner_agent_id = ?, locked_at = ?, lock_expires_at = ?, updated_at = ? + WHERE id = ? AND status = ? AND owner_agent_id IS NULL AND team_id = ? AND tenant_id = ?`, + store.TeamTaskStatusInProgress, agentID, now, lockExpires, now, + taskID, store.TeamTaskStatusPending, teamID, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not available for claiming (already claimed or not pending)") + } + return nil +} + +func (s *SQLiteTeamStore) AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error { + now := time.Now() + lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, owner_agent_id = ?, locked_at = ?, lock_expires_at = ?, updated_at = ? + WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`, + store.TeamTaskStatusInProgress, agentID, now, lockExpires, now, + taskID, teamID, store.TeamTaskStatusPending, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not available for assignment (not pending or wrong team)") + } + return nil +} + +func (s *SQLiteTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID, result string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + tid := tenantIDForInsert(ctx) + res, err := tx.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL, + followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, + progress_percent = NULL, updated_at = ? + WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`, + store.TeamTaskStatusCompleted, result, time.Now(), + taskID, store.TeamTaskStatusInProgress, teamID, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not in progress or not found") + } + + if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLiteTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + now := time.Now() + tid := tenantIDForInsert(ctx) + res, err := tx.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL, + followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, + progress_percent = NULL, updated_at = ? + WHERE id = ? AND status NOT IN (?, ?) AND team_id = ? AND tenant_id = ?`, + store.TeamTaskStatusCancelled, reason, now, + taskID, store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, teamID, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not found, already completed/cancelled, or wrong team") + } + + if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLiteTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + now := time.Now() + tid := tenantIDForInsert(ctx) + res, err := tx.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL, + followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, + progress_percent = NULL, updated_at = ? + WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`, + store.TeamTaskStatusFailed, "FAILED: "+errMsg, now, + taskID, store.TeamTaskStatusInProgress, teamID, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not in progress or not found") + } + + if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLiteTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + now := time.Now() + tid := tenantIDForInsert(ctx) + res, err := tx.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL, + progress_percent = NULL, updated_at = ? + WHERE id = ? AND status IN (?, ?) AND team_id = ? AND tenant_id = ?`, + store.TeamTaskStatusFailed, "FAILED: "+errMsg, now, + taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not pending/blocked or not found") + } + + if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLiteTeamStore) ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error { + tid := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, updated_at = ? + WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`, + store.TeamTaskStatusInReview, time.Now(), + taskID, store.TeamTaskStatusInProgress, teamID, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not in progress or not found") + } + return nil +} + +func (s *SQLiteTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, comment string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + now := time.Now() + tid := tenantIDForInsert(ctx) + res, err := tx.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL, + followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, + progress_percent = NULL, updated_at = ? + WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`, + store.TeamTaskStatusCompleted, now, + taskID, store.TeamTaskStatusInReview, teamID, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not in review or not found") + } + + if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLiteTeamStore) RejectTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + now := time.Now() + tid := tenantIDForInsert(ctx) + res, err := tx.ExecContext(ctx, + `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL, + followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, + progress_percent = NULL, updated_at = ? + WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`, + store.TeamTaskStatusCancelled, reason, now, + taskID, store.TeamTaskStatusInReview, teamID, tid, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not in review or not found") + } + + if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil { + return err + } + return tx.Commit() +} + +// unblockDependentTasksSQLite removes taskID from blocked_by JSON arrays and +// transitions blocked→pending when all blockers are resolved. +// SQLite has no array_remove — we do it in Go: fetch affected rows, update in-process. +func unblockDependentTasksSQLite(ctx context.Context, tx *sql.Tx, taskID uuid.UUID) error { + tid := tenantIDForInsert(ctx) + taskIDStr := taskID.String() + + // Find all blocked tasks that reference this taskID in their blocked_by JSON array. + rows, err := tx.QueryContext(ctx, + `SELECT id, blocked_by FROM team_tasks + WHERE status IN ('blocked', 'pending') AND tenant_id = ? + AND EXISTS (SELECT 1 FROM json_each(blocked_by) WHERE json_each.value = ?)`, + tid, taskIDStr, + ) + if err != nil { + return err + } + + type toUpdate struct { + id uuid.UUID + blockedBy []string + } + var pending []toUpdate + for rows.Next() { + var id uuid.UUID + var rawJSON []byte + if err := rows.Scan(&id, &rawJSON); err != nil { + rows.Close() + return err + } + var blockedBy []string + if len(rawJSON) > 0 { + _ = json.Unmarshal(rawJSON, &blockedBy) + } + // Remove taskID from the slice. + filtered := blockedBy[:0] + for _, s := range blockedBy { + if s != taskIDStr { + filtered = append(filtered, s) + } + } + pending = append(pending, toUpdate{id: id, blockedBy: filtered}) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + now := time.Now() + for _, u := range pending { + newJSON := jsonStringArray(u.blockedBy) + newStatus := "pending" + if len(u.blockedBy) > 0 { + newStatus = "blocked" + } + if _, err := tx.ExecContext(ctx, + `UPDATE team_tasks SET blocked_by = ?, status = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`, + newJSON, newStatus, now, u.id, tid, + ); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/sqlitestore/tenant-configs.go b/internal/store/sqlitestore/tenant-configs.go new file mode 100644 index 00000000..38715d04 --- /dev/null +++ b/internal/store/sqlitestore/tenant-configs.go @@ -0,0 +1,151 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +// SQLiteBuiltinToolTenantConfigStore implements store.BuiltinToolTenantConfigStore. +type SQLiteBuiltinToolTenantConfigStore struct { + db *sql.DB +} + +func NewSQLiteBuiltinToolTenantConfigStore(db *sql.DB) *SQLiteBuiltinToolTenantConfigStore { + return &SQLiteBuiltinToolTenantConfigStore{db: db} +} + +func (s *SQLiteBuiltinToolTenantConfigStore) ListDisabled(ctx context.Context, tenantID uuid.UUID) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT tool_name FROM builtin_tool_tenant_configs WHERE tenant_id = ? AND enabled = 0`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +func (s *SQLiteBuiltinToolTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[string]bool, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT tool_name, enabled FROM builtin_tool_tenant_configs WHERE tenant_id = ?`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]bool) + for rows.Next() { + var name string + var enabled bool + if err := rows.Scan(&name, &enabled); err != nil { + return nil, err + } + result[name] = enabled + } + return result, rows.Err() +} + +func (s *SQLiteBuiltinToolTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, toolName string, enabled bool) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO builtin_tool_tenant_configs (tool_name, tenant_id, enabled, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (tool_name, tenant_id) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at`, + toolName, tenantID, enabled, time.Now().UTC(), + ) + return err +} + +func (s *SQLiteBuiltinToolTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, toolName string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM builtin_tool_tenant_configs WHERE tool_name = ? AND tenant_id = ?`, + toolName, tenantID, + ) + return err +} + +// SQLiteSkillTenantConfigStore implements store.SkillTenantConfigStore. +type SQLiteSkillTenantConfigStore struct { + db *sql.DB +} + +func NewSQLiteSkillTenantConfigStore(db *sql.DB) *SQLiteSkillTenantConfigStore { + return &SQLiteSkillTenantConfigStore{db: db} +} + +func (s *SQLiteSkillTenantConfigStore) ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT skill_id FROM skill_tenant_configs WHERE tenant_id = ? AND enabled = 0`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (s *SQLiteSkillTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[uuid.UUID]bool, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT skill_id, enabled FROM skill_tenant_configs WHERE tenant_id = ?`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[uuid.UUID]bool) + for rows.Next() { + var id uuid.UUID + var enabled bool + if err := rows.Scan(&id, &enabled); err != nil { + return nil, err + } + result[id] = enabled + } + return result, rows.Err() +} + +func (s *SQLiteSkillTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_tenant_configs (skill_id, tenant_id, enabled, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (skill_id, tenant_id) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at`, + skillID, tenantID, enabled, time.Now().UTC(), + ) + return err +} + +func (s *SQLiteSkillTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM skill_tenant_configs WHERE skill_id = ? AND tenant_id = ?`, + skillID, tenantID, + ) + return err +} diff --git a/internal/store/sqlitestore/tenants.go b/internal/store/sqlitestore/tenants.go new file mode 100644 index 00000000..f6d26077 --- /dev/null +++ b/internal/store/sqlitestore/tenants.go @@ -0,0 +1,240 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteTenantStore implements store.TenantStore backed by SQLite. +type SQLiteTenantStore struct { + db *sql.DB +} + +func NewSQLiteTenantStore(db *sql.DB) *SQLiteTenantStore { + return &SQLiteTenantStore{db: db} +} + +// ============================================================ +// Tenant CRUD +// ============================================================ + +func (s *SQLiteTenantStore) CreateTenant(ctx context.Context, tenant *store.TenantData) error { + if tenant.ID == uuid.Nil { + tenant.ID = store.GenNewID() + } + now := time.Now() + tenant.CreatedAt = now + tenant.UpdatedAt = now + + settings := tenant.Settings + if len(settings) == 0 { + settings = json.RawMessage(`{}`) + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO tenants (id, name, slug, status, settings, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + tenant.ID, tenant.Name, tenant.Slug, tenant.Status, settings, now, now, + ) + return err +} + +func (s *SQLiteTenantStore) GetTenant(ctx context.Context, id uuid.UUID) (*store.TenantData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, slug, status, settings, created_at, updated_at + FROM tenants WHERE id = ?`, id) + return scanTenantRow(row) +} + +func (s *SQLiteTenantStore) GetTenantBySlug(ctx context.Context, slug string) (*store.TenantData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, slug, status, settings, created_at, updated_at + FROM tenants WHERE slug = ?`, slug) + return scanTenantRow(row) +} + +func (s *SQLiteTenantStore) ListTenants(ctx context.Context) ([]store.TenantData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, slug, status, settings, created_at, updated_at + FROM tenants ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + var tenants []store.TenantData + for rows.Next() { + d, err := scanTenantRowScanner(rows) + if err != nil { + return nil, err + } + tenants = append(tenants, *d) + } + return tenants, rows.Err() +} + +func (s *SQLiteTenantStore) UpdateTenant(ctx context.Context, id uuid.UUID, updates map[string]any) error { + return execMapUpdate(ctx, s.db, "tenants", id, updates) +} + +// ============================================================ +// Tenant-user membership +// ============================================================ + +func (s *SQLiteTenantStore) AddUser(ctx context.Context, tenantID uuid.UUID, userID, role string) error { + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO tenant_users (id, tenant_id, user_id, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`, + store.GenNewID(), tenantID, userID, role, now, now, + ) + return err +} + +func (s *SQLiteTenantStore) GetTenantUser(ctx context.Context, id uuid.UUID) (*store.TenantUserData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at + FROM tenant_users WHERE id = ?`, id) + var d store.TenantUserData + createdAt, updatedAt := scanTimePair() + if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, createdAt, updatedAt); err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + return &d, nil +} + +func (s *SQLiteTenantStore) CreateTenantUserReturning(ctx context.Context, tenantID uuid.UUID, userID, displayName, role string) (*store.TenantUserData, error) { + now := time.Now() + var dn *string + if displayName != "" { + dn = &displayName + } + // SQLite 3.35+ supports RETURNING. + row := s.db.QueryRowContext(ctx, + `INSERT INTO tenant_users (id, tenant_id, user_id, display_name, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET + display_name = COALESCE(excluded.display_name, tenant_users.display_name), + updated_at = excluded.updated_at + RETURNING id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at`, + store.GenNewID(), tenantID, userID, dn, role, now, now, + ) + var d store.TenantUserData + createdAt, updatedAt := scanTimePair() + if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, createdAt, updatedAt); err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + return &d, nil +} + +func (s *SQLiteTenantStore) RemoveUser(ctx context.Context, tenantID uuid.UUID, userID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM tenant_users WHERE tenant_id = ? AND user_id = ?`, + tenantID, userID, + ) + return err +} + +func (s *SQLiteTenantStore) GetUserRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) { + var role string + err := s.db.QueryRowContext(ctx, + `SELECT role FROM tenant_users WHERE tenant_id = ? AND user_id = ?`, + tenantID, userID, + ).Scan(&role) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return role, err +} + +func (s *SQLiteTenantStore) ListUsers(ctx context.Context, tenantID uuid.UUID) ([]store.TenantUserData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at + FROM tenant_users WHERE tenant_id = ? ORDER BY created_at`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTenantUserRows(rows) +} + +func (s *SQLiteTenantStore) ListUserTenants(ctx context.Context, userID string) ([]store.TenantUserData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at + FROM tenant_users WHERE user_id = ? ORDER BY created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTenantUserRows(rows) +} + +func (s *SQLiteTenantStore) ResolveUserTenant(ctx context.Context, userID string) (uuid.UUID, error) { + var tenantID uuid.UUID + err := s.db.QueryRowContext(ctx, + `SELECT tenant_id FROM tenant_users WHERE user_id = ? ORDER BY created_at LIMIT 1`, + userID, + ).Scan(&tenantID) + if errors.Is(err, sql.ErrNoRows) { + return store.MasterTenantID, nil + } + if err != nil { + return uuid.Nil, err + } + return tenantID, nil +} + +// ============================================================ +// Scan helpers +// ============================================================ + +func scanTenantRow(row *sql.Row) (*store.TenantData, error) { + var d store.TenantData + createdAt, updatedAt := scanTimePair() + if err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, createdAt, updatedAt); err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + return &d, nil +} + +func scanTenantRowScanner(row interface{ Scan(...any) error }) (*store.TenantData, error) { + var d store.TenantData + createdAt, updatedAt := scanTimePair() + if err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, createdAt, updatedAt); err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + return &d, nil +} + +func scanTenantUserRows(rows *sql.Rows) ([]store.TenantUserData, error) { + var result []store.TenantUserData + for rows.Next() { + var d store.TenantUserData + createdAt, updatedAt := scanTimePair() + if err := rows.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, createdAt, updatedAt); err != nil { + return nil, err + } + d.CreatedAt = createdAt.Time + d.UpdatedAt = updatedAt.Time + result = append(result, d) + } + return result, rows.Err() +} diff --git a/internal/store/sqlitestore/tracing.go b/internal/store/sqlitestore/tracing.go new file mode 100644 index 00000000..3e2d6bd1 --- /dev/null +++ b/internal/store/sqlitestore/tracing.go @@ -0,0 +1,252 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteTracingStore implements store.TracingStore backed by SQLite. +type SQLiteTracingStore struct { + db *sql.DB +} + +func NewSQLiteTracingStore(db *sql.DB) *SQLiteTracingStore { + return &SQLiteTracingStore{db: db} +} + +func (s *SQLiteTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData) error { + if trace.ID == uuid.Nil { + trace.ID = store.GenNewID() + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, + duration_ms, name, channel, input_preview, output_preview, + total_input_tokens, total_output_tokens, total_cost, span_count, llm_call_count, tool_call_count, + status, error, metadata, tags, team_id, created_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey), + nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime), + nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel), + nilStr(trace.InputPreview), nilStr(trace.OutputPreview), + trace.TotalInputTokens, trace.TotalOutputTokens, trace.TotalCost, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount, + trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), jsonStringArray(trace.Tags), nilUUID(trace.TeamID), trace.CreatedAt, tenantID, + ) + return err +} + +func (s *SQLiteTracingStore) UpdateTrace(ctx context.Context, traceID uuid.UUID, updates map[string]any) error { + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "traces", traceID, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "traces", updates, traceID, tid) +} + +func (s *SQLiteTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*store.TraceData, error) { + query := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, + duration_ms, name, channel, input_preview, output_preview, + total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count, + status, error, metadata, tags, team_id, created_at + FROM traces WHERE id = ?` + qArgs := []any{traceID} + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } + query += ` AND tenant_id = ?` + qArgs = append(qArgs, tenantID) + } + return scanTraceRow(s.db.QueryRowContext(ctx, query, qArgs...)) +} + +func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []any) { + var conditions []string + var args []any + + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return " WHERE 1=0", nil + } + conditions = append(conditions, "tenant_id = ?") + args = append(args, tenantID) + } + if opts.AgentID != nil { + conditions = append(conditions, "agent_id = ?") + args = append(args, *opts.AgentID) + } + if opts.UserID != "" { + conditions = append(conditions, "user_id = ?") + args = append(args, opts.UserID) + } + if opts.SessionKey != "" { + conditions = append(conditions, "session_key = ?") + args = append(args, opts.SessionKey) + } + if opts.Status != "" { + conditions = append(conditions, "status = ?") + args = append(args, opts.Status) + } + if opts.Channel != "" { + conditions = append(conditions, "channel = ?") + args = append(args, opts.Channel) + } + + if len(conditions) == 0 { + return "", nil + } + return " WHERE " + strings.Join(conditions, " AND "), args +} + +func (s *SQLiteTracingStore) CountTraces(ctx context.Context, opts store.TraceListOpts) (int, error) { + where, args := buildTraceWhere(ctx, opts) + var count int + err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM traces"+where, args...).Scan(&count) + return count, err +} + +func (s *SQLiteTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpts) ([]store.TraceData, error) { + where, args := buildTraceWhere(ctx, opts) + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, + duration_ms, name, channel, input_preview, output_preview, + total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count, + status, error, metadata, tags, team_id, created_at + FROM traces` + where + + fmt.Sprintf(" ORDER BY created_at DESC LIMIT %d OFFSET %d", limit, opts.Offset) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTraceRows(rows) +} + +func (s *SQLiteTracingStore) ListChildTraces(ctx context.Context, parentTraceID uuid.UUID) ([]store.TraceData, error) { + q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, + duration_ms, name, channel, input_preview, output_preview, + total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count, + status, error, metadata, tags, team_id, created_at + FROM traces WHERE parent_trace_id = ?` + qArgs := []any{parentTraceID} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + q += " AND tenant_id = ?" + qArgs = append(qArgs, tid) + } + q += " ORDER BY created_at" + rows, err := s.db.QueryContext(ctx, q, qArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTraceRows(rows) +} + +func (s *SQLiteTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) { + start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + q := `SELECT COALESCE(SUM(total_cost), 0) FROM traces + WHERE agent_id = ? AND created_at >= ? AND created_at < ? AND parent_trace_id IS NULL` + qArgs := []any{agentID, start, end} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND tenant_id = ?" + qArgs = append(qArgs, tid) + } + } + var cost float64 + err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&cost) + return cost, err +} + +func (s *SQLiteTracingStore) GetCostSummary(ctx context.Context, opts store.CostSummaryOpts) ([]store.CostSummaryRow, error) { + var conditions []string + var args []any + + conditions = append(conditions, "parent_trace_id IS NULL") + + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, "tenant_id = ?") + args = append(args, tenantID) + } + } + if opts.AgentID != nil { + conditions = append(conditions, "agent_id = ?") + args = append(args, *opts.AgentID) + } + if opts.From != nil { + conditions = append(conditions, "created_at >= ?") + args = append(args, *opts.From) + } + if opts.To != nil { + conditions = append(conditions, "created_at < ?") + args = append(args, *opts.To) + } + + where := " WHERE " + strings.Join(conditions, " AND ") + q := `SELECT agent_id, COALESCE(SUM(total_cost), 0), COALESCE(SUM(total_input_tokens), 0), + COALESCE(SUM(total_output_tokens), 0), COUNT(*) + FROM traces` + where + ` GROUP BY agent_id ORDER BY SUM(total_cost) DESC` + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.CostSummaryRow + for rows.Next() { + var r store.CostSummaryRow + var agentID *uuid.UUID + if err := rows.Scan(&agentID, &r.TotalCost, &r.TotalInputTokens, &r.TotalOutputTokens, &r.TraceCount); err != nil { + continue + } + r.AgentID = agentID + result = append(result, r) + } + return result, rows.Err() +} + +// DeleteTracesOlderThan deletes traces and their spans older than cutoff. +func (s *SQLiteTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { + // Delete spans belonging to old traces. + _, err := s.db.ExecContext(ctx, + `DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < ?)`, cutoff) + if err != nil { + return 0, fmt.Errorf("delete old spans: %w", err) + } + + res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < ?`, cutoff) + if err != nil { + return 0, fmt.Errorf("delete old traces: %w", err) + } + return res.RowsAffected() +} diff --git a/internal/store/sqlitestore/tracing_scan.go b/internal/store/sqlitestore/tracing_scan.go new file mode 100644 index 00000000..6e0aa86f --- /dev/null +++ b/internal/store/sqlitestore/tracing_scan.go @@ -0,0 +1,97 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "database/sql" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func scanTraceRow(row *sql.Row) (*store.TraceData, error) { + var d store.TraceData + var parentTraceID, agentID, teamID *uuid.UUID + var userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string + var endTime nullSqliteTime + var durationMS *int + var metadata *[]byte + var tags []byte + var startTime, createdAt sqliteTime + + err := row.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &startTime, &endTime, + &durationMS, &name, &channel, &inputPreview, &outputPreview, + &d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, + &d.Status, &errStr, &metadata, &tags, &teamID, &createdAt) + if err != nil { + return nil, err + } + d.StartTime = startTime.Time + d.CreatedAt = createdAt.Time + var endTimePtr *time.Time + if endTime.Valid { + endTimePtr = &endTime.Time + } + applyTraceNullables(&d, parentTraceID, agentID, teamID, userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr, endTimePtr, durationMS, metadata, tags) + return &d, nil +} + +func scanTraceRows(rows *sql.Rows) ([]store.TraceData, error) { + var result []store.TraceData + for rows.Next() { + var d store.TraceData + var parentTraceID, agentID, teamID *uuid.UUID + var userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string + var endTime nullSqliteTime + var durationMS *int + var metadata *[]byte + var tags []byte + var startTime, createdAt sqliteTime + + if err := rows.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &startTime, &endTime, + &durationMS, &name, &channel, &inputPreview, &outputPreview, + &d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, + &d.Status, &errStr, &metadata, &tags, &teamID, &createdAt); err != nil { + slog.Warn("tracing: trace scan failed", "error", err) + continue + } + d.StartTime = startTime.Time + d.CreatedAt = createdAt.Time + var endTimePtr *time.Time + if endTime.Valid { + endTimePtr = &endTime.Time + } + applyTraceNullables(&d, parentTraceID, agentID, teamID, userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr, endTimePtr, durationMS, metadata, tags) + result = append(result, d) + } + return result, rows.Err() +} + +func applyTraceNullables(d *store.TraceData, + parentTraceID, agentID, teamID *uuid.UUID, + userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string, + endTime *time.Time, durationMS *int, metadata *[]byte, tags []byte, +) { + d.ParentTraceID = parentTraceID + d.AgentID = agentID + d.TeamID = teamID + d.UserID = derefStr(userID) + d.SessionKey = derefStr(sessionKey) + d.RunID = derefStr(runID) + d.EndTime = endTime + if durationMS != nil { + d.DurationMS = *durationMS + } + d.Name = derefStr(name) + d.Channel = derefStr(channel) + d.InputPreview = derefStr(inputPreview) + d.OutputPreview = derefStr(outputPreview) + d.Error = derefStr(errStr) + if metadata != nil { + d.Metadata = *metadata + } + scanJSONStringArray(tags, &d.Tags) +} diff --git a/internal/store/sqlitestore/tracing_spans.go b/internal/store/sqlitestore/tracing_spans.go new file mode 100644 index 00000000..f5d0ac29 --- /dev/null +++ b/internal/store/sqlitestore/tracing_spans.go @@ -0,0 +1,208 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "log/slog" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *SQLiteTracingStore) CreateSpan(ctx context.Context, span *store.SpanData) error { + if span.ID == uuid.Nil { + span.ID = store.GenNewID() + } + tenantID := span.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO spans (id, trace_id, parent_span_id, agent_id, span_type, name, + start_time, end_time, duration_ms, status, error, level, + model, provider, input_tokens, output_tokens, finish_reason, + model_params, tool_name, tool_call_id, input_preview, output_preview, + metadata, team_id, created_at, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + span.ID, span.TraceID, span.ParentSpanID, span.AgentID, span.SpanType, nilStr(span.Name), + span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level, + nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason), + jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview), + jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID, + ) + return err +} + +func (s *SQLiteTracingStore) UpdateSpan(ctx context.Context, spanID uuid.UUID, updates map[string]any) error { + return execMapUpdate(ctx, s.db, "spans", spanID, updates) +} + +func (s *SQLiteTracingStore) GetTraceSpans(ctx context.Context, traceID uuid.UUID) ([]store.SpanData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, trace_id, parent_span_id, agent_id, span_type, name, + start_time, end_time, duration_ms, status, error, level, + model, provider, input_tokens, output_tokens, finish_reason, + model_params, tool_name, tool_call_id, input_preview, output_preview, + metadata, team_id, created_at + FROM spans WHERE trace_id = ? ORDER BY start_time`, traceID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.SpanData + for rows.Next() { + var d store.SpanData + var parentSpanID, agentID, teamID *uuid.UUID + var name, errStr, level, model, provider, finishReason, toolName, toolCallID, inputPreview, outputPreview *string + var status *string + var endTimeSt nullSqliteTime + var durationMS, inputTokens, outputTokens *int + var modelParams, metadata *[]byte + var startTime, createdAt sqliteTime + + if err := rows.Scan(&d.ID, &d.TraceID, &parentSpanID, &agentID, &d.SpanType, &name, + &startTime, &endTimeSt, &durationMS, &status, &errStr, &level, + &model, &provider, &inputTokens, &outputTokens, &finishReason, + &modelParams, &toolName, &toolCallID, &inputPreview, &outputPreview, + &metadata, &teamID, &createdAt); err != nil { + slog.Warn("tracing: span scan failed", "trace_id", traceID, "error", err) + continue + } + d.StartTime = startTime.Time + d.CreatedAt = createdAt.Time + + d.ParentSpanID = parentSpanID + d.AgentID = agentID + d.TeamID = teamID + d.Name = derefStr(name) + if endTimeSt.Valid { + d.EndTime = &endTimeSt.Time + } + d.Status = derefStr(status) + d.Level = derefStr(level) + if modelParams != nil { + d.ModelParams = *modelParams + } + if metadata != nil { + d.Metadata = *metadata + } + if durationMS != nil { + d.DurationMS = *durationMS + } + d.Error = derefStr(errStr) + d.Model = derefStr(model) + d.Provider = derefStr(provider) + if inputTokens != nil { + d.InputTokens = *inputTokens + } + if outputTokens != nil { + d.OutputTokens = *outputTokens + } + d.FinishReason = derefStr(finishReason) + d.ToolName = derefStr(toolName) + d.ToolCallID = derefStr(toolCallID) + d.InputPreview = derefStr(inputPreview) + d.OutputPreview = derefStr(outputPreview) + result = append(result, d) + } + return result, rows.Err() +} + +// sqliteSpanCols is the number of columns in the spans INSERT. +const sqliteSpanCols = 26 + +// sqliteSpanBatchSize limits rows per batch: 999 / 26 ≈ 38. +const sqliteSpanBatchSize = 38 + +// BatchCreateSpans inserts spans in batches to stay under SQLite's 999-variable limit. +func (s *SQLiteTracingStore) BatchCreateSpans(ctx context.Context, spans []store.SpanData) error { + if len(spans) == 0 { + return nil + } + for start := 0; start < len(spans); start += sqliteSpanBatchSize { + end := start + sqliteSpanBatchSize + if end > len(spans) { + end = len(spans) + } + if err := s.batchInsertSpans(ctx, spans[start:end]); err != nil { + return err + } + } + return nil +} + +func (s *SQLiteTracingStore) batchInsertSpans(ctx context.Context, spans []store.SpanData) error { + placeholderRow := "(" + strings.Repeat("?, ", sqliteSpanCols-1) + "?)" + valueGroups := make([]string, len(spans)) + args := make([]any, 0, len(spans)*sqliteSpanCols) + + for i, span := range spans { + if span.ID == uuid.Nil { + span.ID = store.GenNewID() + spans[i].ID = span.ID + } + tenantID := span.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + valueGroups[i] = placeholderRow + args = append(args, + span.ID, span.TraceID, span.ParentSpanID, span.AgentID, span.SpanType, nilStr(span.Name), + span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level, + nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason), + jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview), + jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID, + ) + } + + q := `INSERT INTO spans (id, trace_id, parent_span_id, agent_id, span_type, name, + start_time, end_time, duration_ms, status, error, level, + model, provider, input_tokens, output_tokens, finish_reason, + model_params, tool_name, tool_call_id, input_preview, output_preview, + metadata, team_id, created_at, tenant_id) + VALUES ` + strings.Join(valueGroups, ", ") + + _, err := s.db.ExecContext(ctx, q, args...) + if err == nil { + return nil + } + + // Fallback to individual inserts on batch failure. + slog.Warn("tracing: batch span insert failed, falling back", "count", len(spans), "error", err) + var firstErr error + for i := range spans { + if e := s.CreateSpan(ctx, &spans[i]); e != nil { + slog.Warn("tracing: individual span insert failed", "span_id", spans[i].ID, "error", e) + if firstErr == nil { + firstErr = e + } + } + } + return firstErr +} + +func (s *SQLiteTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE traces SET + span_count = (SELECT COUNT(*) FROM spans WHERE trace_id = ?), + llm_call_count = (SELECT COUNT(*) FROM spans WHERE trace_id = ? AND span_type = 'llm_call'), + tool_call_count = (SELECT COUNT(*) FROM spans WHERE trace_id = ? AND span_type = 'tool_call'), + total_input_tokens = COALESCE((SELECT SUM(input_tokens) FROM spans WHERE trace_id = ? AND span_type = 'llm_call' AND input_tokens IS NOT NULL), 0), + total_output_tokens = COALESCE((SELECT SUM(output_tokens) FROM spans WHERE trace_id = ? AND span_type = 'llm_call' AND output_tokens IS NOT NULL), 0), + total_cost = COALESCE((SELECT SUM(total_cost) FROM spans WHERE trace_id = ? AND total_cost IS NOT NULL), 0), + metadata = ( + SELECT json_object( + 'total_cache_read_tokens', COALESCE(SUM(json_extract(metadata, '$.cache_read_tokens')), 0), + 'total_cache_creation_tokens', COALESCE(SUM(json_extract(metadata, '$.cache_creation_tokens')), 0) + ) + FROM spans WHERE trace_id = ? AND span_type = 'llm_call' AND metadata IS NOT NULL + ) + WHERE id = ?`, + traceID, traceID, traceID, traceID, traceID, traceID, traceID, traceID) + return err +} + diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go index 1f97a80a..97e7e221 100644 --- a/internal/store/tracing_store.go +++ b/internal/store/tracing_store.go @@ -144,4 +144,7 @@ type TracingStore interface { // Cost aggregation GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) GetCostSummary(ctx context.Context, opts CostSummaryOpts) ([]CostSummaryRow, error) + + // Maintenance + DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) } diff --git a/internal/store/types.go b/internal/store/types.go index 6fbc88e0..4f4c4863 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -20,9 +20,15 @@ func GenNewID() uuid.UUID { // StoreConfig configures the store layer. type StoreConfig struct { - // PostgresDSN is the Postgres connection string (required). + // PostgresDSN is the Postgres connection string (required for postgres backend). PostgresDSN string + // SQLitePath is the path to the SQLite database file (required for sqlite backend). + SQLitePath string + + // StorageBackend selects the database backend: "postgres" (default) or "sqlite". + StorageBackend string + // SkillsStorageDir is the directory for skill file content (default: dataDir/skills-store/). SkillsStorageDir string diff --git a/internal/tools/create_audio.go b/internal/tools/create_audio.go index 407b93ee..fd9b5d60 100644 --- a/internal/tools/create_audio.go +++ b/internal/tools/create_audio.go @@ -171,7 +171,7 @@ func (t *CreateAudioTool) Execute(ctx context.Context, args map[string]any) *Res slog.Info("create_audio: file saved", "path", audioPath, "size", fi.Size(), "data_len", len(audioBytes), "provider", providerName, "type", audioType) } - result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", audioPath)} + result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s\nUse the EXACT filename when referencing: %s", audioPath, filepath.Base(audioPath))} result.Media = []bus.MediaFile{{Path: audioPath, MimeType: "audio/mpeg"}} result.Deliverable = fmt.Sprintf("[Generated audio: %s]\nPrompt: %s", filepath.Base(audioPath), prompt) result.Provider = providerName diff --git a/internal/tools/create_image.go b/internal/tools/create_image.go index 9d51e9e4..e18db98e 100644 --- a/internal/tools/create_image.go +++ b/internal/tools/create_image.go @@ -122,7 +122,7 @@ func (t *CreateImageTool) Execute(ctx context.Context, args map[string]any) *Res slog.Info("create_image: file saved", "path", imagePath, "size", fi.Size(), "data_len", len(chainResult.Data)) } - result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", imagePath)} + result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s\nUse the EXACT filename when referencing: %s", imagePath, filepath.Base(imagePath))} result.Media = []bus.MediaFile{{Path: imagePath, MimeType: "image/png"}} result.Deliverable = fmt.Sprintf("[Generated image: %s]\nPrompt: %s", filepath.Base(imagePath), prompt) result.Provider = chainResult.Provider diff --git a/internal/tools/create_video.go b/internal/tools/create_video.go index 5b8acc0c..ea6ccaf1 100644 --- a/internal/tools/create_video.go +++ b/internal/tools/create_video.go @@ -140,7 +140,7 @@ func (t *CreateVideoTool) Execute(ctx context.Context, args map[string]any) *Res slog.Info("create_video: file saved", "path", videoPath, "size", fi.Size(), "data_len", len(chainResult.Data)) } - result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", videoPath)} + result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s\nUse the EXACT filename when referencing: %s", videoPath, filepath.Base(videoPath))} result.Media = []bus.MediaFile{{Path: videoPath, MimeType: "video/mp4"}} result.Deliverable = fmt.Sprintf("[Generated video: %s]\nPrompt: %s", filepath.Base(videoPath), prompt) result.Provider = chainResult.Provider diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 8611a327..0325a464 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -17,6 +17,7 @@ import ( type Registry struct { tools map[string]Tool aliases map[string]string // alias name → canonical tool name + disabled map[string]bool // tools disabled via admin UI (kept in registry, excluded from List) mu sync.RWMutex rateLimiter *ToolRateLimiter // nil = no rate limiting scrubbing bool // scrub credentials from output (default true) @@ -30,6 +31,7 @@ func NewRegistry() *Registry { return &Registry{ tools: make(map[string]Tool), aliases: make(map[string]string), + disabled: make(map[string]bool), scrubbing: true, // enabled by default } } @@ -93,11 +95,18 @@ func (r *Registry) Aliases() map[string]string { } // resolve looks up a tool by name, checking real tools first, then aliases. +// Disabled tools are excluded. func (r *Registry) resolve(name string) (Tool, bool) { if t, ok := r.tools[name]; ok { + if r.disabled[name] { + return nil, false + } return t, true } if canonical, ok := r.aliases[name]; ok { + if r.disabled[canonical] { + return nil, false + } t, ok := r.tools[canonical] return t, ok } @@ -218,10 +227,16 @@ func (r *Registry) ProviderDefs() []providers.ToolDefinition { defer r.mu.RUnlock() defs := make([]providers.ToolDefinition, 0, len(r.tools)+len(r.aliases)) - for _, tool := range r.tools { + for name, tool := range r.tools { + if r.disabled[name] { + continue + } defs = append(defs, ToProviderDef(tool)) } for alias, canonical := range r.aliases { + if r.disabled[canonical] { + continue + } tool, ok := r.tools[canonical] if !ok { continue @@ -244,11 +259,28 @@ func (r *Registry) List() []string { defer r.mu.RUnlock() names := make([]string, 0, len(r.tools)) for name := range r.tools { - names = append(names, name) + if !r.disabled[name] { + names = append(names, name) + } } return names } +// Disable marks a tool as disabled (excluded from List and policy evaluation) +// without removing it from the registry. Can be re-enabled later. +func (r *Registry) Disable(name string) { + r.mu.Lock() + defer r.mu.Unlock() + r.disabled[name] = true +} + +// Enable re-enables a previously disabled tool. +func (r *Registry) Enable(name string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.disabled, name) +} + // Count returns the number of registered tools. func (r *Registry) Count() int { r.mu.RLock() @@ -265,10 +297,12 @@ func (r *Registry) Clone() *Registry { clone := &Registry{ tools: make(map[string]Tool, len(r.tools)), aliases: make(map[string]string, len(r.aliases)), + disabled: make(map[string]bool, len(r.disabled)), rateLimiter: r.rateLimiter, scrubbing: r.scrubbing, } maps.Copy(clone.tools, r.tools) maps.Copy(clone.aliases, r.aliases) + maps.Copy(clone.disabled, r.disabled) return clone } diff --git a/internal/tools/team_action_policy.go b/internal/tools/team_action_policy.go new file mode 100644 index 00000000..5f7bd695 --- /dev/null +++ b/internal/tools/team_action_policy.go @@ -0,0 +1,49 @@ +package tools + +// TeamActionPolicy controls which team_tasks actions are available. +// Injected into TeamTasksTool at construction — no scattered if/else. +type TeamActionPolicy interface { + // IsAllowed returns true if the action is permitted in this edition. + IsAllowed(action string) bool + // AllowedActions returns the list of permitted action names (for Schema enum). + AllowedActions() []string + // MemberGuidance returns system-prompt text for team members. + MemberGuidance() string +} + +// FullTeamPolicy allows all 18 team task actions (standard/PG edition). +type FullTeamPolicy struct{} + +var fullActions = []string{ + "list", "get", "create", "claim", "complete", "cancel", + "approve", "reject", "search", "review", "comment", + "progress", "attach", "update", "ask_user", "clear_ask_user", "retry", +} + +func (FullTeamPolicy) IsAllowed(string) bool { return true } +func (FullTeamPolicy) AllowedActions() []string { return fullActions } +func (FullTeamPolicy) MemberGuidance() string { + return "Use comment(type='blocker') to escalate blockers to the leader. " + + "Use review to submit work for approval. " + + "Use progress to report incremental status updates." +} + +// LiteTeamPolicy allows core lifecycle actions only (desktop/lite edition). +// Blocked: comment, review, approve, reject, attach, ask_user, clear_ask_user. +type LiteTeamPolicy struct{} + +var liteActions = []string{ + "list", "get", "create", "claim", "complete", "cancel", + "progress", "search", "update", "retry", +} + +var liteBlocked = map[string]bool{ + "comment": true, "review": true, "approve": true, "reject": true, + "attach": true, "ask_user": true, "clear_ask_user": true, +} + +func (LiteTeamPolicy) IsAllowed(action string) bool { return !liteBlocked[action] } +func (LiteTeamPolicy) AllowedActions() []string { return liteActions } +func (LiteTeamPolicy) MemberGuidance() string { + return "Use progress to update status. Use complete when finished." +} diff --git a/internal/tools/team_tasks_tool.go b/internal/tools/team_tasks_tool.go index cb0c771f..7f7b276d 100644 --- a/internal/tools/team_tasks_tool.go +++ b/internal/tools/team_tasks_tool.go @@ -3,16 +3,18 @@ package tools import ( "context" "fmt" + "strings" ) // TeamTasksTool exposes the shared team task list to agents. -// Actions: list, get, create, claim, complete, cancel, search, review, comment, progress, attach, update. +// Actions are filtered by TeamActionPolicy (full in standard, limited in lite). type TeamTasksTool struct { manager *TeamToolManager + policy TeamActionPolicy } -func NewTeamTasksTool(manager *TeamToolManager) *TeamTasksTool { - return &TeamTasksTool{manager: manager} +func NewTeamTasksTool(manager *TeamToolManager, policy TeamActionPolicy) *TeamTasksTool { + return &TeamTasksTool{manager: manager, policy: policy} } func (t *TeamTasksTool) Name() string { return "team_tasks" } @@ -27,10 +29,8 @@ func (t *TeamTasksTool) Parameters() map[string]any { "properties": map[string]any{ "action": map[string]any{ "type": "string", - "description": "'list', 'get', 'create', 'claim', 'complete', 'cancel', 'approve', 'reject', 'search', 'review', 'comment', 'progress', 'attach', 'update', 'ask_user', or 'clear_ask_user'. " + - "ask_user: set a periodic reminder that is sent to the USER (not the team) when you need the user's input/decision to continue (e.g. 'Which design do you prefer?'). ONLY use when you have a question for the user. Do NOT use for status updates, waiting for teammates, or notifications — use 'progress' instead. " + - "clear_ask_user: cancel a previously set ask_user reminder. " + - "retry: re-dispatch a stale or failed task.", + "enum": t.policy.AllowedActions(), + "description": t.buildActionDescription(), }, "task_id": map[string]any{ "type": "string", @@ -98,9 +98,26 @@ func (t *TeamTasksTool) Parameters() map[string]any { } } +// buildActionDescription returns the action parameter description based on policy. +func (t *TeamTasksTool) buildActionDescription() string { + base := "Available actions: " + strings.Join(t.policy.AllowedActions(), ", ") + "." + if t.policy.IsAllowed("ask_user") { + base += " ask_user: set a periodic reminder sent to the USER when you need input. clear_ask_user: cancel a previously set reminder." + } + if t.policy.IsAllowed("retry") { + base += " retry: re-dispatch a stale or failed task." + } + return base +} + func (t *TeamTasksTool) Execute(ctx context.Context, args map[string]any) *Result { action, _ := args["action"].(string) + // Edition policy guard — reject actions not allowed in this edition. + if !t.policy.IsAllowed(action) { + return ErrorResult(fmt.Sprintf("action %q is not available in this edition", action)) + } + // Block mutations during notification runs — leader may only relay status. if RunKindFromCtx(ctx) == RunKindNotification { switch action { diff --git a/internal/tracing/collector.go b/internal/tracing/collector.go index 184843e1..a6ba4ec2 100644 --- a/internal/tracing/collector.go +++ b/internal/tracing/collector.go @@ -4,10 +4,8 @@ import ( "context" "log/slog" "os" - "strings" "sync" "time" - "unicode/utf8" "github.com/google/uuid" @@ -17,7 +15,9 @@ import ( const ( defaultFlushInterval = 5 * time.Second defaultBufferSize = 1000 - previewMaxLen = 500 + previewMaxLen = 40_000 + traceRetention = 7 * 24 * time.Hour // auto-delete traces older than 7 days + pruneInterval = 8 * time.Hour ) // SpanExporter is implemented by backends that receive span data alongside @@ -205,18 +205,41 @@ func (c *Collector) flushLoop() { ticker := time.NewTicker(defaultFlushInterval) defer ticker.Stop() + pruneTicker := time.NewTicker(pruneInterval) + defer pruneTicker.Stop() + + // Run initial prune shortly after startup. + go c.pruneOldTraces() + for { select { case <-ticker.C: c.flush() + case <-pruneTicker.C: + c.pruneOldTraces() case <-c.stopCh: - // Drain remaining spans c.flush() return } } } +// pruneOldTraces deletes traces and spans older than traceRetention. +func (c *Collector) pruneOldTraces() { + cutoff := time.Now().UTC().Add(-traceRetention) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + deleted, err := c.store.DeleteTracesOlderThan(ctx, cutoff) + if err != nil { + slog.Warn("tracing: prune old traces failed", "error", err) + return + } + if deleted > 0 { + slog.Info("tracing: pruned old traces", "deleted", deleted, "older_than", cutoff.Format(time.RFC3339)) + } +} + func (c *Collector) flush() { // Drain span channel var spans []store.SpanData @@ -296,15 +319,7 @@ doneUpdates: } } -// truncatePreviewStr sanitizes and truncates a string based on verbose mode. +// truncatePreviewStr sanitizes and truncates a string by removing the middle. func (c *Collector) truncatePreviewStr(s string) string { - limit := c.PreviewMaxLen() - s = strings.ToValidUTF8(s, "") - if len(s) <= limit { - return s - } - for limit > 0 && !utf8.RuneStart(s[limit]) { - limit-- - } - return s[:limit] + "..." + return TruncateMid(s, c.PreviewMaxLen()) } diff --git a/internal/tracing/snapshot_worker.go b/internal/tracing/snapshot_worker.go index 79519b11..a5c14791 100644 --- a/internal/tracing/snapshot_worker.go +++ b/internal/tracing/snapshot_worker.go @@ -191,13 +191,13 @@ func queryTraceAggregates(ctx context.Context, db *sql.DB, from, to time.Time) ( agent_id, COALESCE(channel, '') as channel, COUNT(*) as request_count, - COUNT(*) FILTER (WHERE status = 'error') as error_count, + SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count, COUNT(DISTINCT user_id) as unique_users, COALESCE(SUM(total_input_tokens), 0) as input_tokens, COALESCE(SUM(total_output_tokens), 0) as output_tokens, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(tool_call_count), 0) as tool_call_count, - COALESCE(AVG(duration_ms), 0)::INTEGER as avg_duration_ms + CAST(COALESCE(AVG(duration_ms), 0) AS INTEGER) as avg_duration_ms FROM traces WHERE start_time >= $1 AND start_time < $2 AND parent_trace_id IS NULL @@ -249,9 +249,9 @@ func querySpanAggregates(ctx context.Context, db *sql.DB, from, to time.Time) ([ COALESCE(SUM(s.input_tokens), 0) as span_input_tokens, COALESCE(SUM(s.output_tokens), 0) as span_output_tokens, COALESCE(SUM(s.total_cost), 0) as span_cost, - COALESCE(SUM((s.metadata->>'cache_read_tokens')::BIGINT), 0) as cache_read_tokens, - COALESCE(SUM((s.metadata->>'cache_creation_tokens')::BIGINT), 0) as cache_create_tokens, - COALESCE(SUM((s.metadata->>'thinking_tokens')::BIGINT), 0) as thinking_tokens + COALESCE(SUM(CAST(s.metadata->>'cache_read_tokens' AS INTEGER)), 0) as cache_read_tokens, + COALESCE(SUM(CAST(s.metadata->>'cache_creation_tokens' AS INTEGER)), 0) as cache_create_tokens, + COALESCE(SUM(CAST(s.metadata->>'thinking_tokens' AS INTEGER)), 0) as thinking_tokens FROM traces t JOIN spans s ON s.trace_id = t.id AND s.span_type = 'llm_call' WHERE t.start_time >= $1 AND t.start_time < $2 diff --git a/internal/tracing/truncate.go b/internal/tracing/truncate.go new file mode 100644 index 00000000..b86c5a34 --- /dev/null +++ b/internal/tracing/truncate.go @@ -0,0 +1,158 @@ +package tracing + +import ( + "encoding/json" + "fmt" + "strings" + "unicode/utf8" +) + +const truncateMarker = "\n\n... [truncated %d chars] ...\n\n" + +// TruncateMid truncates s by removing the middle portion, keeping head + tail. +// Returns s unchanged if it fits within maxLen. +func TruncateMid(s string, maxLen int) string { + s = strings.ToValidUTF8(s, "") + if len(s) <= maxLen { + return s + } + + marker := fmt.Sprintf(truncateMarker, len(s)-maxLen) + usable := maxLen - len(marker) + if usable <= 0 { + return s[len(s)-maxLen:] + } + + head := usable * 2 / 3 // 2/3 head, 1/3 tail + tail := usable - head + + // Align to rune boundaries. + for head > 0 && !utf8.RuneStart(s[head]) { + head-- + } + tailStart := len(s) - tail + for tailStart < len(s) && !utf8.RuneStart(s[tailStart]) { + tailStart++ + } + + return s[:head] + marker + s[tailStart:] +} + +// TruncateJSON truncates a JSON string by removing middle array elements, +// preserving valid JSON structure. Falls back to TruncateMid if the input +// is not a JSON array. +func TruncateJSON(s string, maxLen int) string { + s = strings.ToValidUTF8(s, "") + if len(s) <= maxLen { + return s + } + + // Only handle top-level JSON arrays (the common case: messages array). + var arr []json.RawMessage + if err := json.Unmarshal([]byte(s), &arr); err != nil || len(arr) < 3 { + return TruncateMid(s, maxLen) + } + + // Pre-compute each element's serialized length to avoid repeated string builds. + elemLens := make([]int, len(arr)) + for i, raw := range arr { + elemLens[i] = len(raw) + } + + // Find max elements to keep via binary search on total size. + // We keep `keep` elements split as ceil(keep/2) head + floor(keep/2) tail. + maxKeep := len(arr) - 1 // must drop at least 1 to have a placeholder + bestKeep := 0 + + lo, hi := 2, maxKeep // minimum 2 (1 head + 1 tail) + if lo > hi { + lo = hi + } + for lo <= hi { + mid := (lo + hi) / 2 + size := estimateArraySize(arr, elemLens, mid) + if size <= maxLen { + bestKeep = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + + // Even 2 elements too big — truncate individual elements. + if bestKeep < 2 { + return truncateArrayElements(arr, maxLen) + } + + keepHead := (bestKeep + 1) / 2 + keepTail := bestKeep - keepHead + return buildTruncatedArray(arr, keepHead, keepTail) +} + +// estimateArraySize returns the byte length of a JSON array keeping `keep` elements +// (split head/tail) plus a placeholder, without building the actual string. +func estimateArraySize(arr []json.RawMessage, elemLens []int, keep int) int { + keepHead := (keep + 1) / 2 + keepTail := keep - keepHead + dropped := len(arr) - keep + + // placeholder: {"__truncated__":"N elements omitted"} + placeholderLen := 22 + digitCount(dropped) + 17 // key + number + " elements omitted"} + + total := 2 // [ and ] + for i := 0; i < keepHead; i++ { + total += elemLens[i] + } + total += placeholderLen + for i := len(arr) - keepTail; i < len(arr); i++ { + total += elemLens[i] + } + total += keep // commas between elements (keep elements + 1 placeholder - 1) + return total +} + +// digitCount returns the number of decimal digits in n. +func digitCount(n int) int { + if n <= 0 { + return 1 + } + d := 0 + for n > 0 { + d++ + n /= 10 + } + return d +} + +// buildTruncatedArray constructs a JSON array string keeping head and tail elements, +// with a placeholder object in the middle showing how many elements were omitted. +func buildTruncatedArray(arr []json.RawMessage, keepHead, keepTail int) string { + dropped := len(arr) - keepHead - keepTail + placeholder := fmt.Sprintf(`{"__truncated__":"%d elements omitted"}`, dropped) + + parts := make([]string, 0, keepHead+1+keepTail) + for i := 0; i < keepHead; i++ { + parts = append(parts, string(arr[i])) + } + parts = append(parts, placeholder) + for i := len(arr) - keepTail; i < len(arr); i++ { + parts = append(parts, string(arr[i])) + } + + return "[" + strings.Join(parts, ",") + "]" +} + +// truncateArrayElements keeps first + last element but truncates their content. +func truncateArrayElements(arr []json.RawMessage, maxLen int) string { + dropped := len(arr) - 2 + placeholder := fmt.Sprintf(`{"__truncated__":"%d elements omitted"}`, dropped) + overhead := len("[,,]") + len(placeholder) + perElem := (maxLen - overhead) / 2 + if perElem < 50 { + perElem = 50 + } + + first := TruncateMid(string(arr[0]), perElem) + last := TruncateMid(string(arr[len(arr)-1]), perElem) + return "[" + first + "," + placeholder + "," + last + "]" +} diff --git a/ui/desktop/app.go b/ui/desktop/app.go new file mode 100644 index 00000000..a0cbc2e1 --- /dev/null +++ b/ui/desktop/app.go @@ -0,0 +1,216 @@ +//go:build sqliteonly + +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/cmd" + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// App holds Wails application state and embedded gateway lifecycle. +type App struct { + ctx context.Context + cancelGw context.CancelFunc + gatewayToken string + gatewayPort int +} + +// NewApp creates a new App instance with default port. +func NewApp() *App { + return &App{ + gatewayPort: 18790, + } +} + +// startup is called by Wails when the application starts. +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + + // Resolve port from env or use default. + if p := os.Getenv("GOCLAW_PORT"); p != "" { + fmt.Sscanf(p, "%d", &a.gatewayPort) + } + + // Ensure secrets (encryption key + gateway token) via OS keyring or file fallback. + encKey, gwToken, err := EnsureSecrets() + if err != nil { + slog.Error("failed to setup secrets", "error", err) + return + } + a.gatewayToken = gwToken + + // Set env vars consumed by the embedded gateway. + os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey) + os.Setenv("GOCLAW_GATEWAY_TOKEN", gwToken) + os.Setenv("GOCLAW_STORAGE_BACKEND", "sqlite") + os.Setenv("GOCLAW_DESKTOP", "1") + // Bind to localhost only — desktop has no reason to expose on LAN. + if os.Getenv("GOCLAW_HOST") == "" { + os.Setenv("GOCLAW_HOST", "127.0.0.1") + } + slog.Info("desktop secrets configured", "token_len", len(gwToken), "token_prefix", gwToken[:min(8, len(gwToken))]) + + // Ensure data directory exists. + dataDir := os.Getenv("GOCLAW_DATA_DIR") + if dataDir == "" { + home, _ := os.UserHomeDir() + dataDir = home + "/.goclaw/data" + os.Setenv("GOCLAW_DATA_DIR", dataDir) + } + os.MkdirAll(dataDir, 0755) + + // Start gateway in background with a cancel context for graceful shutdown. + gwCtx, cancel := context.WithCancel(context.Background()) + a.cancelGw = cancel + + go func() { + // When context is cancelled, interrupt the process to trigger gateway shutdown. + go func() { + <-gwCtx.Done() + p, _ := os.FindProcess(os.Getpid()) + p.Signal(os.Interrupt) + }() + slog.Info("starting embedded gateway", "port", a.gatewayPort) + cmd.RunGateway() + }() + + a.waitForGateway() +} + +// waitForGateway polls the health endpoint until the gateway is ready or times out. +func (a *App) waitForGateway() { + url := fmt.Sprintf("http://localhost:%d/health", a.gatewayPort) + for i := 0; i < 30; i++ { + resp, err := http.Get(url) + if err == nil && resp.StatusCode == 200 { + resp.Body.Close() + slog.Info("gateway ready", "port", a.gatewayPort) + return + } + if resp != nil { + resp.Body.Close() + } + time.Sleep(500 * time.Millisecond) + } + slog.Warn("gateway not responding after 15s") +} + +// shutdown is called by Wails when the application is closing. +func (a *App) shutdown(ctx context.Context) { + if a.cancelGw != nil { + a.cancelGw() + } +} + +// GetGatewayURL returns the base URL of the embedded gateway. +func (a *App) GetGatewayURL() string { + return fmt.Sprintf("http://localhost:%d", a.gatewayPort) +} + +// GetGatewayToken returns the token for WebSocket authentication. +func (a *App) GetGatewayToken() string { + return a.gatewayToken +} + +// GetGatewayPort returns the gateway port number. +func (a *App) GetGatewayPort() int { + return a.gatewayPort +} + +// IsGatewayReady checks if the gateway health endpoint is responding. +func (a *App) IsGatewayReady() bool { + url := fmt.Sprintf("http://localhost:%d/health", a.gatewayPort) + resp, err := http.Get(url) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == 200 +} + +// GetVersion returns the embedded gateway version string. +func (a *App) GetVersion() string { + return cmd.Version +} + +// OpenFile opens a local file with the system's default application. +func (a *App) OpenFile(path string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", path).Start() + case "linux": + return exec.Command("xdg-open", path).Start() + default: // windows + return exec.Command("cmd", "/c", "start", "", path).Start() + } +} + +// SaveFile opens a Save As dialog and copies the source file to the chosen location. +func (a *App) SaveFile(srcPath string) error { + dest, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{ + DefaultFilename: filepath.Base(srcPath), + Title: "Save File", + }) + if err != nil || dest == "" { + return err + } + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer src.Close() + dst, err := os.Create(dest) + if err != nil { + return err + } + defer dst.Close() + _, err = io.Copy(dst, src) + return err +} + +// DownloadURL fetches a URL with Bearer auth and opens a Save As dialog. +func (a *App) DownloadURL(url, defaultFilename string) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + // Use gateway token for local API calls + if strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1") { + req.Header.Set("Authorization", "Bearer "+a.gatewayToken) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + dest, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{ + DefaultFilename: defaultFilename, + Title: "Save File", + }) + if err != nil || dest == "" { + return err + } + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, resp.Body) + return err +} diff --git a/ui/desktop/build/appicon.png b/ui/desktop/build/appicon.png new file mode 100644 index 00000000..dc9525c4 Binary files /dev/null and b/ui/desktop/build/appicon.png differ diff --git a/ui/desktop/build/darwin/Info.dev.plist b/ui/desktop/build/darwin/Info.dev.plist new file mode 100644 index 00000000..14121ef7 --- /dev/null +++ b/ui/desktop/build/darwin/Info.dev.plist @@ -0,0 +1,68 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/ui/desktop/build/darwin/iconfile.icns b/ui/desktop/build/darwin/iconfile.icns new file mode 100644 index 00000000..96182702 Binary files /dev/null and b/ui/desktop/build/darwin/iconfile.icns differ diff --git a/ui/desktop/frontend/.npmrc b/ui/desktop/frontend/.npmrc new file mode 100644 index 00000000..00369fee --- /dev/null +++ b/ui/desktop/frontend/.npmrc @@ -0,0 +1 @@ +{"onlyBuiltDependencies":["esbuild"]} diff --git a/ui/desktop/frontend/dist/goclaw-icon.svg b/ui/desktop/frontend/dist/goclaw-icon.svg new file mode 100644 index 00000000..797941f5 --- /dev/null +++ b/ui/desktop/frontend/dist/goclaw-icon.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/desktop/frontend/dist/index.html b/ui/desktop/frontend/dist/index.html new file mode 100644 index 00000000..479c4f31 --- /dev/null +++ b/ui/desktop/frontend/dist/index.html @@ -0,0 +1,22 @@ + + + + + + GoClaw + + + + + +
+ + diff --git a/ui/desktop/frontend/index.html b/ui/desktop/frontend/index.html new file mode 100644 index 00000000..faf22865 --- /dev/null +++ b/ui/desktop/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + GoClaw + + + +
+ + + diff --git a/ui/desktop/frontend/package.json b/ui/desktop/frontend/package.json new file mode 100644 index 00000000..e3d7c286 --- /dev/null +++ b/ui/desktop/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "goclaw-desktop", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "clsx": "^2.1.1", + "framer-motion": "^12.38.0", + "i18next": "^25.10.10", + "immer": "^10.1.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-i18next": "^16.6.6", + "react-markdown": "^9.0.3", + "react-syntax-highlighter": "^15.6.1", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/react-syntax-highlighter": "^15.5.13", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^6.0.0" + } +} diff --git a/ui/desktop/frontend/pnpm-lock.yaml b/ui/desktop/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..dea8db4c --- /dev/null +++ b/ui/desktop/frontend/pnpm-lock.yaml @@ -0,0 +1,2703 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + clsx: + specifier: ^2.1.1 + version: 2.1.1 + framer-motion: + specifier: ^12.38.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + i18next: + specifier: ^25.10.10 + version: 25.10.10(typescript@5.9.3) + immer: + specifier: ^10.1.1 + version: 10.2.0 + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^16.6.6 + version: 16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + react-markdown: + specifier: ^9.0.3 + version: 9.1.0(@types/react@19.2.14)(react@19.2.4) + react-syntax-highlighter: + specifier: ^15.6.1 + version: 15.6.6(react@19.2.4) + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + remark-gfm: + specifier: ^4.0.0 + version: 4.0.1 + zustand: + specifier: ^5.0.3 + version: 5.0.12(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.2(vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0)) + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + '@types/react-syntax-highlighter': + specifier: ^15.5.13 + version: 15.5.13 + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0)) + tailwindcss: + specifier: ^4.0.0 + version: 4.2.2 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1(jiti@2.6.1)(lightningcss@1.32.0) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@2.3.10': + resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-syntax-highlighter@15.5.13': + resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + baseline-browser-mapping@2.10.10: + resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@1.1.4: + resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@1.2.4: + resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@1.1.4: + resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@1.0.8: + resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + electron-to-chromium@1.5.325: + resolution: {integrity: sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fault@1.0.4: + resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hast-util-parse-selector@2.2.5: + resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@6.0.0: + resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + highlightjs-vue@1.0.0: + resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + i18next@25.10.10: + resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@1.0.4: + resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@1.0.4: + resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@1.0.4: + resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-hexadecimal@1.0.4: + resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lowlight@1.20.0: + resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + parse-entities@2.0.0: + resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prismjs@1.27.0: + resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==} + engines: {node: '>=6'} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + property-information@5.6.0: + resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-i18next@16.6.6: + resolution: {integrity: sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==} + peerDependencies: + i18next: '>= 25.10.9' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-markdown@9.1.0: + resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-syntax-highlighter@15.6.6: + resolution: {integrity: sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==} + peerDependencies: + react: '>= 0.14.0' + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + refractor@3.6.0: + resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@1.1.5: + resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 6.4.1(jiti@2.6.1)(lightningcss@1.32.0) + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/hast@2.3.10': + dependencies: + '@types/unist': 2.0.11 + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-syntax-highlighter@15.5.13': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-react@4.7.0(vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(jiti@2.6.1)(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color + + bail@2.0.2: {} + + baseline-browser-mapping@2.10.10: {} + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.10 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.325 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + caniuse-lite@1.0.30001781: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@1.1.4: {} + + character-entities-legacy@3.0.0: {} + + character-entities@1.2.4: {} + + character-entities@2.0.2: {} + + character-reference-invalid@1.1.4: {} + + character-reference-invalid@2.0.1: {} + + clsx@2.1.1: {} + + comma-separated-tokens@1.0.8: {} + + comma-separated-tokens@2.0.3: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + electron-to-chromium@1.5.325: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-string-regexp@5.0.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + extend@3.0.2: {} + + fault@1.0.4: + dependencies: + format: 0.2.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + format@0.2.2: {} + + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + graceful-fs@4.2.11: {} + + hast-util-parse-selector@2.2.5: {} + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@6.0.0: + dependencies: + '@types/hast': 2.3.10 + comma-separated-tokens: 1.0.8 + hast-util-parse-selector: 2.2.5 + property-information: 5.6.0 + space-separated-tokens: 1.1.5 + + highlight.js@10.7.3: {} + + highlightjs-vue@1.0.0: {} + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + html-url-attributes@3.0.1: {} + + i18next@25.10.10(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + optionalDependencies: + typescript: 5.9.3 + + immer@10.2.0: {} + + inline-style-parser@0.2.7: {} + + is-alphabetical@1.0.4: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@1.0.4: + dependencies: + is-alphabetical: 1.0.4 + is-decimal: 1.0.4 + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@1.0.4: {} + + is-decimal@2.0.1: {} + + is-hexadecimal@1.0.4: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + longest-streak@3.1.0: {} + + lowlight@1.20.0: + dependencies: + fault: 1.0.4 + highlight.js: 10.7.3 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-releases@2.0.36: {} + + parse-entities@2.0.0: + dependencies: + character-entities: 1.2.4 + character-entities-legacy: 1.1.4 + character-reference-invalid: 1.1.4 + is-alphanumerical: 1.0.4 + is-decimal: 1.0.4 + is-hexadecimal: 1.0.4 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prismjs@1.27.0: {} + + prismjs@1.30.0: {} + + property-information@5.6.0: + dependencies: + xtend: 4.0.2 + + property-information@7.1.0: {} + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 25.10.10(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + + react-markdown@9.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-refresh@0.17.0: {} + + react-syntax-highlighter@15.6.6(react@19.2.4): + dependencies: + '@babel/runtime': 7.29.2 + highlight.js: 10.7.3 + highlightjs-vue: 1.0.0 + lowlight: 1.20.0 + prismjs: 1.30.0 + react: 19.2.4 + refractor: 3.6.0 + + react@19.2.4: {} + + refractor@3.6.0: + dependencies: + hastscript: 6.0.0 + parse-entities: 2.0.0 + prismjs: 1.27.0 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@1.1.5: {} + + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + + void-elements@3.1.0: {} + + xtend@4.0.2: {} + + yallist@3.1.1: {} + + zustand@5.0.12(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + immer: 10.2.0 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + + zwitch@2.0.4: {} diff --git a/ui/desktop/frontend/public/goclaw-icon.svg b/ui/desktop/frontend/public/goclaw-icon.svg new file mode 100644 index 00000000..797941f5 --- /dev/null +++ b/ui/desktop/frontend/public/goclaw-icon.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/desktop/frontend/src/App.tsx b/ui/desktop/frontend/src/App.tsx new file mode 100644 index 00000000..24b2b252 --- /dev/null +++ b/ui/desktop/frontend/src/App.tsx @@ -0,0 +1,119 @@ +import './i18n' +import { useEffect, useState } from 'react' +import { useUiStore } from './stores/ui-store' +import { AppShell } from './components/layout/AppShell' +import { ChatCanvas } from './components/chat/ChatCanvas' +import { OnboardingWizard } from './components/onboarding/OnboardingWizard' +import { wails } from './lib/wails' +import { initWsClient } from './lib/ws' +import { initApiClient } from './lib/api' +import { useSessionStore } from './stores/session-store' +import { useChatStore } from './stores/chat-store' +import { ErrorBoundary } from './components/common/ErrorBoundary' +import { Toaster } from './components/common/Toaster' +import { SplashScreen } from './components/common/SplashScreen' + +function AppReady() { + const toggleSidebar = useUiStore((s) => s.toggleSidebar) + const openSettings = useUiStore((s) => s.openSettings) + const closeSettings = useUiStore((s) => s.closeSettings) + const activeView = useUiStore((s) => s.activeView) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey + if (mod && e.key === 'b') { e.preventDefault(); toggleSidebar() } + if (mod && e.key === 'n') { + e.preventDefault() + // "New Chat" — clear session + chat directly (avoids duplicate useSessions instance) + useSessionStore.getState().setActiveSession(null) + useChatStore.getState().clear() + } + if (mod && e.key === ',') { e.preventDefault(); openSettings() } + if (e.key === 'Escape' && activeView === 'settings') { closeSettings() } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [toggleSidebar, openSettings, closeSettings, activeView]) + + return ( + <> + + + + + + ) +} + +function App() { + const theme = useUiStore((s) => s.theme) + const onboarded = useUiStore((s) => s.onboarded) + const completeOnboarding = useUiStore((s) => s.completeOnboarding) + const [ready, setReady] = useState(false) + const [splashDone, setSplashDone] = useState(false) + + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark') + }, [theme]) + + useEffect(() => { + const splashMin = new Promise((r) => setTimeout(r, 2500)) + + const init = async () => { + let attempts = 0 + while (attempts < 30) { + try { + const isReady = await wails.isGatewayReady() + if (isReady) break + } catch { /* not ready yet */ } + await new Promise((r) => setTimeout(r, 500)) + attempts++ + } + + let token = '' + try { token = await wails.getGatewayToken() } catch (e) { + console.warn('[app] failed to get token:', e) + } + + const gatewayUrl = await wails.getGatewayURL() + const wsUrl = gatewayUrl.replace(/^http/, 'ws') + '/ws' + + initWsClient(wsUrl, token) + const api = initApiClient(gatewayUrl, token) + setReady(true) + + // Auto-detect empty DB → reset onboarded flag (handles DB deletion) + try { + const [pRes, aRes] = await Promise.allSettled([ + api.get<{ providers?: unknown[] | null }>('/v1/providers'), + api.get<{ agents?: unknown[] | null }>('/v1/agents'), + ]) + const hasProviders = pRes.status === 'fulfilled' && (pRes.value.providers?.length ?? 0) > 0 + const hasAgents = aRes.status === 'fulfilled' && (aRes.value.agents?.length ?? 0) > 0 + if (!hasProviders && !hasAgents) { + useUiStore.getState().resetOnboarding() + } + } catch { /* ignore — onboarding wizard will handle */ } + } + + // Wait for both gateway init AND minimum splash duration + Promise.all([init(), splashMin]).then(() => setSplashDone(true)) + }, []) + + if (!splashDone) { + return + } + + if (!onboarded) { + return + } + + return ( + + + + ) +} + +export default App diff --git a/ui/desktop/frontend/src/components/agents/AgentAvatar.tsx b/ui/desktop/frontend/src/components/agents/AgentAvatar.tsx new file mode 100644 index 00000000..57973fb0 --- /dev/null +++ b/ui/desktop/frontend/src/components/agents/AgentAvatar.tsx @@ -0,0 +1,34 @@ +import type { AgentStatus } from '../../stores/agent-store' + +interface AgentAvatarProps { + name: string + status: AgentStatus + size?: 'sm' | 'md' + emoji?: string +} + +const STATUS_CLASSES: Record = { + online: 'bg-success', + busy: 'bg-warning animate-pulse', + offline: 'bg-error', + idle: 'border-2 border-idle bg-transparent', +} + +export function AgentAvatar({ name, status, size = 'sm', emoji }: AgentAvatarProps) { + const dim = size === 'sm' ? 28 : 36 + const textSize = size === 'sm' ? 'text-[11px]' : 'text-sm' + + return ( +
+
+ {emoji || name.charAt(0).toUpperCase()} +
+ +
+ ) +} diff --git a/ui/desktop/frontend/src/components/chat/ActivityIndicator.tsx b/ui/desktop/frontend/src/components/chat/ActivityIndicator.tsx new file mode 100644 index 00000000..8294b96f --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/ActivityIndicator.tsx @@ -0,0 +1,100 @@ +import { useTranslation } from 'react-i18next' + +interface ActivityIndicatorProps { + phase: string + tool?: string + iteration?: number +} + +const PHASE_COLOR: Record = { + thinking: 'text-amber-500', + tool_exec: 'text-blue-500', + compacting: 'text-warning', + streaming: 'text-text-secondary', + retrying: 'text-amber-500', + leader_processing: 'text-emerald-500', +} + +export function ActivityIndicator({ phase, tool, iteration }: ActivityIndicatorProps) { + const { t } = useTranslation('desktop') + + const color = PHASE_COLOR[phase] ?? 'text-text-muted' + + let label: string + switch (phase) { + case 'thinking': + label = t('activity.thinking') + break + case 'tool_exec': + label = tool ? t('activity.runningTool', { tool }) : t('activity.executingTool') + break + case 'compacting': + label = t('activity.compacting') + break + case 'streaming': + label = t('activity.streaming') + break + case 'retrying': + label = iteration ? t('activity.retryingAttempt', { n: iteration }) : t('activity.retrying') + break + case 'leader_processing': + label = t('activity.processing') + break + default: + label = phase + } + + return ( +
+ + {label} + {phase !== 'retrying' && iteration && iteration > 1 && ( + · {t('activity.step', { n: iteration })} + )} +
+ ) +} + +function PhaseIcon({ phase, color }: { phase: string; color: string }) { + const cls = `w-3.5 h-3.5 ${color}` + + switch (phase) { + case 'thinking': + // Brain icon + return ( + + + + + + ) + case 'tool_exec': + // Wrench icon + return ( + + + + ) + case 'retrying': + // RefreshCw icon + return ( + + + + + ) + default: + // Bouncing dots fallback + return ( + + {[0, 150, 300].map((delay) => ( + + ))} + + ) + } +} diff --git a/ui/desktop/frontend/src/components/chat/AuthImage.tsx b/ui/desktop/frontend/src/components/chat/AuthImage.tsx new file mode 100644 index 00000000..a31f0528 --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/AuthImage.tsx @@ -0,0 +1,30 @@ +import { useMediaUrl } from '../../hooks/use-media-url' +import { SaveFile } from '../../../wailsjs/go/main/App' + +interface AuthImageProps { + src: string + alt?: string + className?: string + onClick?: () => void +} + +/** Image with blob-cached src to prevent flickering on session switch. */ +export function AuthImage({ src, alt, className, onClick }: AuthImageProps) { + const cachedSrc = useMediaUrl(src) + + if (!cachedSrc) { + return
+ } + + return {alt +} + +/** Save file via native Save As dialog. Desktop — files are local on disk. + * Extracts absolute path from /v1/files/{path} URL and opens Save dialog via Go binding. */ +export function downloadFile(url: string, _filename: string) { + const match = url.match(/\/v1\/files\/(.+?)(?:\?|$)/) + if (match) { + const absPath = '/' + decodeURIComponent(match[1]) + SaveFile(absPath) + } +} diff --git a/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx b/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx new file mode 100644 index 00000000..ba4e896d --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useChat } from '../../hooks/use-chat' +import { useAgents } from '../../hooks/use-agents' +import { useSessionStore } from '../../stores/session-store' +import { ChatTopBar } from './ChatTopBar' +import { MessageBubble } from './MessageBubble' +import { ActivityIndicator } from './ActivityIndicator' +import { InputBar, type AttachedFile } from './InputBar' +import { TaskPanel } from './TaskPanel' + +export function ChatCanvas() { + const { t } = useTranslation('common') + const { messages, isRunning, activity, sendMessage } = useChat() + const { selectedAgent } = useAgents() + const activeSessionKey = useSessionStore((s) => s.activeSessionKey) + const messagesEndRef = useRef(null) + const scrollAreaRef = useRef(null) + const userScrolledUp = useRef(false) + + // Find last assistant message ID for streaming cursor + const lastAssistantId = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'assistant') return messages[i].id + } + return null + }, [messages]) + + useEffect(() => { + if (!userScrolledUp.current) { + messagesEndRef.current?.scrollIntoView({ behavior: isRunning ? 'smooth' : 'instant' }) + } + }, [messages, isRunning]) + + const handleScroll = useCallback(() => { + const el = scrollAreaRef.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50 + userScrolledUp.current = !atBottom + }, []) + + const handleSend = useCallback((text: string, files?: AttachedFile[]) => { + if (!selectedAgent) return + userScrolledUp.current = false + sendMessage(text, selectedAgent.id, files) + }, [selectedAgent, sendMessage]) + + const hasMessages = messages.length > 0 + + return ( +
+ + + {/* Chat body — dots background covers messages + input */} +
+ {/* Messages area */} +
+
+ {!selectedAgent && ( +
+
+

{t('loading')}

+
+ )} + {selectedAgent && !hasMessages && } + + {messages.map((msg) => ( + + ))} + + {isRunning && activity && ( + + )} + +
+
+
+ + {/* Team task panel */} + + + {/* Input bar */} + +
+
+ ) +} + +/** Empty state with logo and clickable suggested prompts */ +function EmptyState({ agentName, onSuggestion }: { agentName?: string; onSuggestion?: (text: string) => void }) { + const { t } = useTranslation('desktop') + const suggestions = t('chat.suggestions', { returnObjects: true }) as string[] + return ( +
+ GoClaw +

+ {agentName + ? t('chat.emptyTitle', { name: agentName }) + : t('chat.emptyTitleDefault')} +

+

+ {agentName ? t('chat.emptyDescAgent') : t('chat.emptyDescNoAgent')} +

+ {agentName && ( +
+ {suggestions.map((prompt) => ( + + ))} +
+ )} +

+ Press ⌘N {t('sidebar.newChat').toLowerCase()} +

+
+ ) +} diff --git a/ui/desktop/frontend/src/components/chat/ChatTopBar.tsx b/ui/desktop/frontend/src/components/chat/ChatTopBar.tsx new file mode 100644 index 00000000..78a84e97 --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/ChatTopBar.tsx @@ -0,0 +1,178 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useAgents } from '../../hooks/use-agents' +import { useUiStore } from '../../stores/ui-store' +import { LANGUAGES, getAllTimezones } from '../../lib/constants' + +function LanguagePicker() { + const locale = useUiStore((s) => s.locale) + const setLocale = useUiStore((s) => s.setLocale) + const { i18n } = useTranslation() + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + const current = LANGUAGES.find((l) => l.value === locale) ?? LANGUAGES[0] + + function handleSelect(lang: string) { + setLocale(lang) + i18n.changeLanguage(lang) + setOpen(false) + } + + return ( +
+ + {open && ( +
+ {LANGUAGES.map((lang) => ( + + ))} +
+ )} +
+ ) +} + +function TimezonePicker() { + const timezone = useUiStore((s) => s.timezone) + const setTimezone = useUiStore((s) => s.setTimezone) + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + const allTimezones = getAllTimezones() + const filtered = search + ? allTimezones.filter((tz: string) => tz.toLowerCase().includes(search.toLowerCase())) + : allTimezones + + // Short display: "Asia/Saigon" → "Saigon" + const shortTz = timezone.split('/').pop() ?? timezone + + return ( +
+ + {open && ( +
+
+ setSearch(e.target.value)} + placeholder="Search timezone..." + className="w-full px-2 py-1 text-xs bg-surface-tertiary border border-border rounded text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" + autoFocus + /> +
+
+ {filtered.slice(0, 50).map((tz) => ( + + ))} +
+
+ )} +
+ ) +} + +export function ChatTopBar() { + const { selectedAgent } = useAgents() + const toggleSidebar = useUiStore((s) => s.toggleSidebar) + const sidebarOpen = useUiStore((s) => s.sidebarOpen) + + return ( +
+ {/* Sidebar toggle */} + {!sidebarOpen && ( + + )} + + {/* Agent info */} +
+ {selectedAgent ? ( +
+ {selectedAgent.name} + + {selectedAgent.model} + +
+ ) : ( + Select an agent to start chatting + )} +
+ + {/* Top right pickers */} +
+ + +
+
+ ) +} diff --git a/ui/desktop/frontend/src/components/chat/CodeBlock.tsx b/ui/desktop/frontend/src/components/chat/CodeBlock.tsx new file mode 100644 index 00000000..8a5942cf --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/CodeBlock.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' + +interface CodeBlockProps { + language: string + code: string +} + +export function CodeBlock({ language, code }: CodeBlockProps) { + const { t } = useTranslation('common') + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
+ {language} + +
+ + {code} + +
+ ) +} diff --git a/ui/desktop/frontend/src/components/chat/FileButton.tsx b/ui/desktop/frontend/src/components/chat/FileButton.tsx new file mode 100644 index 00000000..dfaa7c49 --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/FileButton.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { FilePreviewDialog } from './FilePreviewDialog' +import { downloadFile } from './AuthImage' + +export interface FileButtonProps { + url: string + filename: string + mimeType?: string + size?: number +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function fileIcon(name: string): string { + if (/\.(md|markdown)$/i.test(name)) return '📝' + if (/\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml)$/i.test(name)) return '💻' + if (/\.(zip|tar|gz|rar|7z)$/i.test(name)) return '📦' + if (/\.(csv|xls|xlsx)$/i.test(name)) return '📊' + if (/\.(pdf)$/i.test(name)) return '📕' + if (/\.(json|xml|html|css)$/i.test(name)) return '📋' + return '📄' +} + +function truncateName(name: string, max = 30): string { + if (name.length <= max) return name + const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '' + return name.slice(0, max - ext.length - 1) + '…' + ext +} + +export function FileButton({ url, filename, mimeType, size }: FileButtonProps) { + const { t } = useTranslation('common') + const [previewOpen, setPreviewOpen] = useState(false) + + // Strip timestamp from filename for display: "file.1774537056.md" → "file.md" + const displayName = filename.split('?')[0].replace(/\.\d{9,}(\.\w+)$/, '$1') + + return ( + <> + + + + + + {previewOpen && createPortal( + setPreviewOpen(false)} + />, + document.body, + )} + + ) +} diff --git a/ui/desktop/frontend/src/components/chat/FilePreviewDialog.tsx b/ui/desktop/frontend/src/components/chat/FilePreviewDialog.tsx new file mode 100644 index 00000000..54145820 --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/FilePreviewDialog.tsx @@ -0,0 +1,225 @@ +import { useEffect, useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' +import { MarkdownRenderer } from './MarkdownRenderer' +import { AuthImage, downloadFile } from './AuthImage' +import { getApiClient, isApiClientReady } from '../../lib/api' + +// Strip ?ft= token and timestamps from filename for display +function cleanFilename(name: string): string { + // Remove query params + const base = name.split('?')[0] + // Remove timestamp suffix: "file.1774537056.md" → "file.md" + return base.replace(/\.\d{9,}(\.\w+)$/, '$1') +} + +interface FilePreviewDialogProps { + url: string + filename: string + mimeType?: string + onClose: () => void +} + +function getLanguage(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() ?? '' + const map: Record = { + ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', + py: 'python', go: 'go', rs: 'rust', java: 'java', + c: 'c', cpp: 'cpp', h: 'c', rb: 'ruby', sh: 'bash', + yaml: 'yaml', yml: 'yaml', toml: 'toml', json: 'json', + xml: 'xml', html: 'html', css: 'css', + } + return map[ext] ?? 'text' +} + +function isImage(filename: string, mimeType?: string): boolean { + if (mimeType?.startsWith('image/')) return true + return /\.(jpe?g|png|gif|webp|svg|bmp|ico)$/i.test(filename) +} + +function isVideo(filename: string, mimeType?: string): boolean { + if (mimeType?.startsWith('video/')) return true + return /\.(mp4|webm|ogg|mov)$/i.test(filename) +} + +function isAudio(filename: string, mimeType?: string): boolean { + if (mimeType?.startsWith('audio/')) return true + return /\.(mp3|wav|ogg|flac|aac|m4a)$/i.test(filename) +} + +function isMarkdown(filename: string): boolean { + return /\.(md|markdown)$/i.test(filename) +} + +function isCode(filename: string): boolean { + return /\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml|json|xml|html|css)$/i.test(filename) +} + +function isText(filename: string, mimeType?: string): boolean { + if (mimeType?.startsWith('text/')) return true + return /\.(txt|log|csv|conf|ini|env)$/i.test(filename) +} + +export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClose }: FilePreviewDialogProps) { + const { t } = useTranslation('common') + const [textContent, setTextContent] = useState(null) + const [loadError, setLoadError] = useState(false) + + // Normalize: if filename is a full URL/path, extract the basename + const filename = (rawFilename.includes('/') ? rawFilename.split('/').pop() : rawFilename)?.split('?')[0] ?? rawFilename + const needsTextFetch = isMarkdown(filename) || isCode(filename) || isText(filename, mimeType) + + useEffect(() => { + if (!needsTextFetch) return + let cancelled = false + setTextContent(null) + setLoadError(false) + // For URLs without ?ft= token, sign them first to get a valid ft= token. + // This avoids tenant header issues with direct Bearer auth fetch. + // Fetch with Bearer auth via ApiClient.fetchFile (handles base URL + auth header). + const doFetch = isApiClientReady() + ? getApiClient().fetchFile(url) + : fetch(url) + doFetch + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.text() + }) + .then((text) => { if (!cancelled) setTextContent(text) }) + .catch(() => { if (!cancelled) setLoadError(true) }) + return () => { cancelled = true } + }, [url, needsTextFetch]) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, + [onClose], + ) + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown]) + + const displayName = cleanFilename(filename) + + function renderContent() { + if (isImage(filename, mimeType)) { + return ( + + ) + } + if (isVideo(filename, mimeType)) { + return