Changeset 0.32.0 (#206)

This commit is contained in:
2026-03-19 18:50:27 +00:00
parent 6668e546fe
commit b1266b0d7c
283 changed files with 2187 additions and 1055 deletions

View File

@@ -3,7 +3,7 @@ package store
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// ExtensionPermissionStore manages declared and granted permissions for packages.

View File

@@ -6,7 +6,7 @@ import (
"errors"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// ── Sentinel Errors ─────────────────────────
@@ -62,6 +62,8 @@ type Stores struct {
Health HealthStore // v0.29.0: Provider health window management
ExtPermissions ExtensionPermissionStore // v0.29.0: Extension declared/granted capabilities
ExtData ExtDataStore // v0.29.2: Extension namespaced table catalog
Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod)
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
}
// =========================================
@@ -340,7 +342,6 @@ type UserSearchResult struct {
type TeamStore interface {
Create(ctx context.Context, t *models.Team) error
GetByID(ctx context.Context, id string) (*models.Team, error)
GetBySlug(ctx context.Context, slug string) (*models.Team, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context) ([]models.Team, error)

View File

@@ -5,8 +5,8 @@ import (
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type AuditStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// CapOverrideStore implements store.CapabilityOverrideStore for Postgres.

View File

@@ -7,8 +7,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type CatalogStore struct{}

View File

@@ -10,8 +10,8 @@ import (
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type ChannelStore struct{}

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type ExtensionPermissionStore struct {

View File

@@ -6,7 +6,7 @@ import (
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// FileStore implements store.FileStore against the `files` table.

View File

@@ -3,7 +3,7 @@ package postgres
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type FolderStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// GitCredentialStore implements store.GitCredentialStore for PostgreSQL.

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type GlobalConfigStore struct{}

View File

@@ -7,8 +7,8 @@ import (
"errors"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── GroupStore ──────────────────────────────

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// HealthStore implements health.Store for Postgres.

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
// DB is the shared database connection pool.

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
"github.com/lib/pq"
)

View File

@@ -6,8 +6,8 @@ import (
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type MemoryStore struct{}

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// RecallHybrid returns active memories using vector similarity + keyword search.

View File

@@ -7,8 +7,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type MessageStore struct{}

View File

@@ -6,8 +6,8 @@ import (
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── Tree Operations (v0.29.0) ───────────────────────────────────────────

View File

@@ -7,8 +7,8 @@ import (
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
"github.com/lib/pq"
)

View File

@@ -3,7 +3,7 @@ package postgres
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
"github.com/lib/pq"
)

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type NotificationStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type NotificationPreferenceStore struct{}

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
type PackageStore struct{}

View File

@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type PersonaStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type PersonaGroupStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
// ── Mention resolution + display info (v0.29.0) ────────────────────────

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type PolicyStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type PricingStore struct{}

View File

@@ -7,8 +7,8 @@ import (
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── ProjectStore ───────────────────────────

View File

@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type ProviderStore struct{}

View File

@@ -0,0 +1,36 @@
package postgres
import (
"context"
"fmt"
"time"
)
// RateLimitStore manages distributed rate limit counters in Postgres.
// v0.32.0: fixed-window counter — upsert per-second bucket, check burst.
type RateLimitStore struct{}
func NewRateLimitStore() *RateLimitStore { return &RateLimitStore{} }
func (s *RateLimitStore) Allow(ctx context.Context, key string, rate float64, burst int) (bool, error) {
// Upsert the current-second window counter and return the new token count.
var tokens float64
err := DB.QueryRowContext(ctx, `
INSERT INTO rate_limit_counters (key, window_start, tokens)
VALUES ($1, date_trunc('second', NOW()), 1)
ON CONFLICT (key, window_start)
DO UPDATE SET tokens = rate_limit_counters.tokens + 1
RETURNING tokens`, key).Scan(&tokens)
if err != nil {
return false, fmt.Errorf("rate limit upsert: %w", err)
}
return tokens <= float64(burst), nil
}
func (s *RateLimitStore) Cleanup(ctx context.Context, maxAge time.Duration) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM rate_limit_counters
WHERE window_start < NOW() - $1 * INTERVAL '1 second'`,
int(maxAge.Seconds()))
return err
}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
"github.com/lib/pq"
)

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// RoutingPolicyStore implements store.RoutingPolicyStore for Postgres.

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// ── SessionStore ───────────────────────────

View File

@@ -3,7 +3,7 @@ package postgres
import (
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
// NewStores creates all Postgres store implementations and wires them
@@ -48,5 +48,7 @@ func NewStores(db *sql.DB) store.Stores {
Health: NewHealthStore(db),
ExtPermissions: NewExtensionPermissionStore(db),
ExtData: NewExtDataStore(db),
Tickets: NewTicketStore(),
RateLimits: NewRateLimitStore(),
}
}

View File

@@ -4,7 +4,7 @@ import (
"context"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type TaskStore struct{}
@@ -149,8 +149,22 @@ func (s *TaskStore) ListAll(ctx context.Context) ([]models.Task, error) {
return s.list(ctx, `ORDER BY created_at DESC`)
}
func (s *TaskStore) ListDue(ctx context.Context, limit int) ([]models.Task, error) {
return s.list(ctx, `WHERE is_active = true AND next_run_at <= NOW() ORDER BY next_run_at ASC LIMIT $1`, limit)
func (s *TaskStore) ClaimDueTask(ctx context.Context) (*models.Task, error) {
t := &models.Task{}
row := DB.QueryRowContext(ctx, `
UPDATE tasks SET next_run_at = NULL
WHERE id = (
SELECT id FROM tasks
WHERE is_active = true AND next_run_at <= NOW()
ORDER BY next_run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING `+taskColumns)
if err := scanTask(row, t); err != nil {
return nil, err
}
return t, nil
}
// ── Scheduler bookkeeping ──────────────────────
@@ -181,6 +195,25 @@ func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
).Scan(&r.ID, &r.StartedAt)
}
func (s *TaskStore) CreateRunExclusive(ctx context.Context, taskID string) (*models.TaskRun, error) {
r := &models.TaskRun{}
err := DB.QueryRowContext(ctx, `
INSERT INTO task_runs (task_id, status)
SELECT $1, 'running'
WHERE NOT EXISTS (
SELECT 1 FROM task_runs
WHERE task_id = $1 AND status IN ('running', 'queued')
)
RETURNING id, started_at`, taskID,
).Scan(&r.ID, &r.StartedAt)
if err != nil {
return nil, err
}
r.TaskID = taskID
r.Status = "running"
return r, nil
}
func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed, toolCalls, wallClock int, errMsg string) error {
_, err := DB.ExecContext(ctx, `
UPDATE task_runs SET status = $1, tokens_used = $2, tool_calls = $3,

View File

@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type TeamStore struct{}
@@ -16,10 +16,10 @@ func NewTeamStore() *TeamStore { return &TeamStore{} }
func (s *TeamStore) Create(ctx context.Context, t *models.Team) error {
settingsJSON := ToJSON(t.Settings)
return DB.QueryRowContext(ctx, `
INSERT INTO teams (name, slug, description, created_by, is_active, settings)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO teams (name, description, created_by, is_active, settings)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at`,
t.Name, t.Slug, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
t.Name, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
}
@@ -27,27 +27,10 @@ func (s *TeamStore) GetByID(ctx context.Context, id string) (*models.Team, error
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE id = $1`, id).Scan(
&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount,
)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
return &t, nil
}
func (s *TeamStore) GetBySlug(ctx context.Context, slug string) (*models.Team, error) {
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE slug = $1`, slug).Scan(
&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount,
)
if err != nil {
@@ -81,7 +64,7 @@ func (s *TeamStore) Delete(ctx context.Context, id string) error {
func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams ORDER BY name`)
if err != nil {
@@ -93,7 +76,7 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount)
if err != nil {
return nil, err
@@ -106,7 +89,7 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT t.id, t.name, t.slug, t.description, t.created_by, t.is_active,
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
tm.role AS my_role,
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
@@ -123,7 +106,7 @@ func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Te
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MyRole, &t.MemberCount)
if err != nil {
return nil, err

View File

@@ -0,0 +1,53 @@
package postgres
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
)
// TicketStore manages WS auth tickets in Postgres.
// v0.32.0: replaces in-memory sync.Map for cross-pod validation.
type TicketStore struct{}
func NewTicketStore() *TicketStore { return &TicketStore{} }
func (s *TicketStore) Issue(ctx context.Context, userID string) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
id := hex.EncodeToString(b)
_, err := DB.ExecContext(ctx, `
INSERT INTO ws_tickets (id, user_id, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '30 seconds')`, id, userID)
if err != nil {
return "", err
}
return id, nil
}
func (s *TicketStore) Validate(ctx context.Context, ticketID string) (string, bool) {
var userID string
err := DB.QueryRowContext(ctx, `
DELETE FROM ws_tickets
WHERE id = $1 AND expires_at > NOW()
RETURNING user_id`, ticketID).Scan(&userID)
if err != nil {
if err == sql.ErrNoRows {
return "", false
}
return "", false
}
return userID, true
}
func (s *TicketStore) Reap(ctx context.Context) (int, error) {
res, err := DB.ExecContext(ctx, `DELETE FROM ws_tickets WHERE expires_at <= NOW()`)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type UsageStore struct{}

View File

@@ -7,8 +7,8 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type UserStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
// ── Mention resolution + display info (v0.29.0) ────────────────────────

View File

@@ -4,7 +4,7 @@ import (
"context"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type UserModelSettingsStore struct{}

View File

@@ -7,8 +7,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// WorkflowStore implements store.WorkflowStore for Postgres.

View File

@@ -7,7 +7,7 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type WorkspaceStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// =========================================

View File

@@ -0,0 +1,18 @@
package store
import (
"context"
"time"
)
// RateLimitStore manages distributed rate limit counters.
// v0.32.0: replaces in-memory token bucket for cross-pod rate limiting.
type RateLimitStore interface {
// Allow checks if a request is within the rate limit.
// key is "{scope}:{identifier}" e.g. "auth:192.168.1.1".
// Atomically increments the counter if allowed.
Allow(ctx context.Context, key string, rate float64, burst int) (bool, error)
// Cleanup removes expired windows older than maxAge.
Cleanup(ctx context.Context, maxAge time.Duration) error
}

View File

@@ -6,8 +6,8 @@ import (
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type AuditStore struct{}

View File

@@ -3,8 +3,8 @@ package sqlite
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type CapOverrideStore struct{}

View File

@@ -7,8 +7,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type CatalogStore struct{}

View File

@@ -8,8 +8,8 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type ChannelStore struct{}

View File

@@ -6,8 +6,8 @@ import (
"github.com/google/uuid"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/database"
"chat-switchboard/models"
)
type ExtensionPermissionStore struct{}

View File

@@ -6,8 +6,8 @@ import (
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// FileStore implements store.FileStore against the `files` table.

View File

@@ -3,8 +3,8 @@ package sqlite
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type FolderStore struct{}

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"encoding/base64"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
"github.com/google/uuid"
)

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type GlobalConfigStore struct{}

View File

@@ -8,8 +8,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── GroupStore ──────────────────────────────

View File

@@ -5,8 +5,8 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type HealthStore struct{}

View File

@@ -7,7 +7,7 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
// DB is the shared database connection pool.

View File

@@ -10,8 +10,8 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── KnowledgeBaseStore ──────────────────────────

View File

@@ -6,8 +6,8 @@ import (
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type MemoryStore struct{}

View File

@@ -5,7 +5,7 @@ import (
"encoding/json"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// RecallHybrid returns active memories using vector similarity + keyword search.

View File

@@ -7,8 +7,8 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type MessageStore struct{}

View File

@@ -7,8 +7,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── Tree Operations (v0.29.0) ───────────────────────────────────────────

View File

@@ -8,8 +8,8 @@ import (
"time"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type NoteStore struct{}

View File

@@ -3,7 +3,7 @@ package sqlite
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type NoteLinkStore struct{}

View File

@@ -5,8 +5,8 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type NotificationStore struct{}

View File

@@ -4,8 +4,8 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type NotificationPreferenceStore struct{}

View File

@@ -6,7 +6,7 @@ import (
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
type PackageStore struct{}

View File

@@ -7,8 +7,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type PersonaStore struct{}

View File

@@ -5,8 +5,8 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type PersonaGroupStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
// ── Mention resolution + display info (v0.29.0) ────────────────────────

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
type PolicyStore struct{}

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
// PresenceStore manages user_presence table.

View File

@@ -4,8 +4,8 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type PricingStore struct{}

View File

@@ -6,8 +6,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── ProjectStore ───────────────────────────

View File

@@ -7,8 +7,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type ProviderStore struct{}

View File

@@ -0,0 +1,46 @@
package sqlite
import (
"context"
"fmt"
"time"
)
// RateLimitStore manages rate limit counters in SQLite.
// v0.32.0: functional parity for single-process test coverage.
type RateLimitStore struct{}
func NewRateLimitStore() *RateLimitStore { return &RateLimitStore{} }
func (s *RateLimitStore) Allow(ctx context.Context, key string, rate float64, burst int) (bool, error) {
// SQLite: strftime truncates to the second for window bucketing.
window := time.Now().UTC().Truncate(time.Second).Format("2006-01-02 15:04:05")
// Upsert via INSERT OR REPLACE pattern. SQLite has no ON CONFLICT ... DO UPDATE
// with RETURNING, so read-then-write in a single connection (single-writer safe).
var tokens float64
err := DB.QueryRowContext(ctx, `
SELECT COALESCE(tokens, 0) FROM rate_limit_counters
WHERE key = ? AND window_start = ?`, key, window).Scan(&tokens)
if err != nil {
// No row yet — insert fresh
tokens = 0
}
tokens++
_, err = DB.ExecContext(ctx, `
INSERT INTO rate_limit_counters (key, window_start, tokens)
VALUES (?, ?, ?)
ON CONFLICT (key, window_start) DO UPDATE SET tokens = ?`,
key, window, tokens, tokens)
if err != nil {
return false, fmt.Errorf("rate limit upsert: %w", err)
}
return tokens <= float64(burst), nil
}
func (s *RateLimitStore) Cleanup(ctx context.Context, maxAge time.Duration) error {
cutoff := time.Now().UTC().Add(-maxAge).Format("2006-01-02 15:04:05")
_, err := DB.ExecContext(ctx, `
DELETE FROM rate_limit_counters WHERE window_start < ?`, cutoff)
return err
}

View File

@@ -5,8 +5,8 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── ResourceGrantStore ──────────────────────

View File

@@ -5,8 +5,8 @@ import (
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// RoutingPolicyStore implements store.RoutingPolicyStore for SQLite.

View File

@@ -5,8 +5,8 @@ import (
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── SessionStore ───────────────────────────

View File

@@ -3,7 +3,7 @@ package sqlite
import (
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
// NewStores creates all SQLite store implementations and wires them
@@ -48,5 +48,7 @@ func NewStores(db *sql.DB) store.Stores {
Health: NewHealthStore(),
ExtPermissions: NewExtensionPermissionStore(),
ExtData: NewExtDataStore(),
Tickets: NewTicketStore(),
RateLimits: NewRateLimitStore(),
}
}

View File

@@ -2,11 +2,12 @@ package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type TaskStore struct{}
@@ -159,8 +160,20 @@ func (s *TaskStore) ListAll(ctx context.Context) ([]models.Task, error) {
return s.list(ctx, `ORDER BY created_at DESC`)
}
func (s *TaskStore) ListDue(ctx context.Context, limit int) ([]models.Task, error) {
return s.list(ctx, `WHERE is_active = 1 AND next_run_at <= datetime('now') ORDER BY next_run_at ASC LIMIT ?`, limit)
func (s *TaskStore) ClaimDueTask(ctx context.Context) (*models.Task, error) {
// SQLite: single-process, no contention. Select the oldest due task,
// then atomically clear next_run_at to mark it claimed.
t := &models.Task{}
row := DB.QueryRowContext(ctx,
`SELECT `+taskColumns+` FROM tasks
WHERE is_active = 1 AND next_run_at <= datetime('now')
ORDER BY next_run_at ASC LIMIT 1`)
if err := scanTask(row, t); err != nil {
return nil, err
}
_, _ = DB.ExecContext(ctx,
`UPDATE tasks SET next_run_at = NULL WHERE id = ?`, t.ID)
return t, nil
}
// ── Scheduler bookkeeping ──────────────────────
@@ -194,6 +207,34 @@ func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
return err
}
func (s *TaskStore) CreateRunExclusive(ctx context.Context, taskID string) (*models.TaskRun, error) {
// SQLite: check-then-insert. Single-process, no race.
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM task_runs
WHERE task_id = ? AND status IN ('running', 'queued')`, taskID).Scan(&count)
if err != nil {
return nil, err
}
if count > 0 {
return nil, sql.ErrNoRows
}
r := &models.TaskRun{
ID: store.NewID(),
TaskID: taskID,
Status: "running",
StartedAt: time.Now().UTC(),
}
_, err = DB.ExecContext(ctx, `
INSERT INTO task_runs (id, task_id, status, started_at)
VALUES (?, ?, 'running', ?)`,
r.ID, taskID, r.StartedAt.UTC().Format("2006-01-02 15:04:05"))
if err != nil {
return nil, err
}
return r, nil
}
func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed, toolCalls, wallClock int, errMsg string) error {
_, err := DB.ExecContext(ctx, `
UPDATE task_runs SET status = ?, tokens_used = ?, tool_calls = ?,

View File

@@ -7,8 +7,8 @@ import (
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type TeamStore struct{}
@@ -22,9 +22,9 @@ func (s *TeamStore) Create(ctx context.Context, t *models.Team) error {
t.CreatedAt = now
t.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO teams (id, name, slug, description, created_by, is_active, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Slug, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
INSERT INTO teams (id, name, description, created_by, is_active, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
@@ -34,27 +34,10 @@ func (s *TeamStore) GetByID(ctx context.Context, id string) (*models.Team, error
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE id = ?`, id).Scan(
&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount,
)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
return &t, nil
}
func (s *TeamStore) GetBySlug(ctx context.Context, slug string) (*models.Team, error) {
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE slug = ?`, slug).Scan(
&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount,
)
if err != nil {
@@ -88,7 +71,7 @@ func (s *TeamStore) Delete(ctx context.Context, id string) error {
func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams ORDER BY name`)
if err != nil {
@@ -100,7 +83,7 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount)
if err != nil {
return nil, err
@@ -113,7 +96,7 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT t.id, t.name, t.slug, t.description, t.created_by, t.is_active,
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
tm.role AS my_role,
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
@@ -130,7 +113,7 @@ func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Te
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MyRole, &t.MemberCount)
if err != nil {
return nil, err

View File

@@ -0,0 +1,54 @@
package sqlite
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
)
// TicketStore manages WS auth tickets in SQLite.
// v0.32.0: functional parity for single-process test coverage.
type TicketStore struct{}
func NewTicketStore() *TicketStore { return &TicketStore{} }
func (s *TicketStore) Issue(ctx context.Context, userID string) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
id := hex.EncodeToString(b)
_, err := DB.ExecContext(ctx, `
INSERT INTO ws_tickets (id, user_id, expires_at)
VALUES (?, ?, datetime('now', '+30 seconds'))`, id, userID)
if err != nil {
return "", err
}
return id, nil
}
func (s *TicketStore) Validate(ctx context.Context, ticketID string) (string, bool) {
// SQLite has no DELETE ... RETURNING. Two-step: select then delete.
var userID string
err := DB.QueryRowContext(ctx, `
SELECT user_id FROM ws_tickets
WHERE id = ? AND expires_at > datetime('now')`, ticketID).Scan(&userID)
if err != nil {
if err == sql.ErrNoRows {
return "", false
}
return "", false
}
DB.ExecContext(ctx, `DELETE FROM ws_tickets WHERE id = ?`, ticketID)
return userID, true
}
func (s *TicketStore) Reap(ctx context.Context) (int, error) {
res, err := DB.ExecContext(ctx, `DELETE FROM ws_tickets WHERE expires_at <= datetime('now')`)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type UsageStore struct{}

View File

@@ -7,8 +7,8 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type UserStore struct{}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/store"
)
// ── Mention resolution + display info (v0.29.0) ────────────────────────

View File

@@ -4,8 +4,8 @@ import (
"context"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type UserModelSettingsStore struct{}

View File

@@ -6,8 +6,8 @@ import (
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
// WorkflowStore implements store.WorkflowStore for SQLite.

View File

@@ -10,8 +10,8 @@ import (
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"chat-switchboard/models"
"chat-switchboard/store"
)
type WorkspaceStore struct{}

View File

@@ -9,7 +9,7 @@ package store
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// =========================================

View File

@@ -3,7 +3,7 @@ package store
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// TaskStore manages task definitions and run history.
@@ -21,13 +21,14 @@ type TaskStore interface {
GetByTriggerToken(ctx context.Context, token string) (*models.Task, error)
// Scheduler queries
ListDue(ctx context.Context, limit int) ([]models.Task, error)
ClaimDueTask(ctx context.Context) (*models.Task, error)
SetNextRun(ctx context.Context, id string, nextRun interface{}) error
SetLastRun(ctx context.Context, id string) error
IncrementRunCount(ctx context.Context, id string) error
// Run history
CreateRun(ctx context.Context, r *models.TaskRun) error
CreateRunExclusive(ctx context.Context, taskID string) (*models.TaskRun, error)
UpdateRun(ctx context.Context, id string, status string, tokensUsed, toolCalls, wallClock int, errMsg string) error
TransitionRunStatus(ctx context.Context, id string, status string) error
GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error)

View File

@@ -0,0 +1,18 @@
package store
import "context"
// TicketStore manages short-lived, single-use WebSocket auth tickets.
// v0.32.0: replaces in-memory sync.Map for cross-pod ticket validation.
type TicketStore interface {
// Issue creates a single-use ticket for the given user.
// Returns the opaque ticket ID (128-bit hex).
Issue(ctx context.Context, userID string) (string, error)
// Validate atomically consumes a ticket and returns the user ID.
// Returns ("", false) if the ticket is invalid, expired, or already consumed.
Validate(ctx context.Context, ticketID string) (string, bool)
// Reap removes expired tickets. Returns the count deleted.
Reap(ctx context.Context) (int, error)
}

View File

@@ -3,7 +3,7 @@ package store
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"chat-switchboard/models"
)
// WorkflowStore manages workflow definitions, stages, and version snapshots.