diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd7061..95193b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,70 @@ # Changelog +## [0.32.0] — 2026-03-19 + +### Summary + +Multi-Replica HA: run 2+ backend replicas across nodes for node-level +availability. All shared mutable state moves from in-process memory to +Postgres — task scheduler uses `FOR UPDATE SKIP LOCKED` for atomic +claim (no leader election), WebSocket tickets and rate limit counters +become PG tables, and `SendToUser` is replaced by bus-routed +`PublishToUser` for cross-pod event delivery. New `/healthz/ready` +and `/healthz/live` probe endpoints. Helm defaults to 2 replicas with +pod anti-affinity. + +### New + +- **`FOR UPDATE SKIP LOCKED` task scheduler** — every replica polls; + PG serializes task handoff atomically. `ClaimDueTask` replaces + `ListDue`. `CreateRunExclusive` conditional insert prevents + double-execution. Startup jitter (0–15s) staggers replicas. +- **PG ticket store** — `ws_tickets` table replaces in-memory + `sync.Map`. Atomic `DELETE ... RETURNING` for single-use validation. + 30s TTL, reaper piggybacked on scheduler poll tick. +- **PG rate limiter** — `rate_limit_counters` table with fixed-window + `INSERT ON CONFLICT DO UPDATE`. Fail-open policy: DB errors allow + the request rather than blocking auth. Cleanup piggybacked on + scheduler poll tick. +- **Cross-pod WebSocket delivery** — `Hub.PublishToUser` routes events + through the bus → `pg_broadcast` → remote pod `publishLocal`. + `TargetUserID` field on `Event` for targeted filtering. + `tool.result.*` routing changed to `DirBoth` so browser tool results + cross pods for `WaitFor`. +- **Health probes** — `/healthz/ready` (PG ping, 2s timeout) and + `/healthz/live` (process alive, no deps). +- **Helm HA** — `backend.replicaCount: 2`, pod anti-affinity + (`preferredDuringScheduling`, `kubernetes.io/hostname`), readiness + and liveness probes pointed at new healthz endpoints. +- **Schema `020_ha.sql`** — `ws_tickets` and `rate_limit_counters` + tables (PG + SQLite). + +### Changed + +- **`SendToUser` → `PublishToUser`** — 18 call sites across 10 files + migrated to bus-routed delivery for cross-pod reach. +- **`ListDue` removed from `TaskStore`** — replaced by `ClaimDueTask` + (atomic claim) + `CreateRunExclusive` (belt-and-suspenders guard). +- **Rate limiter middleware** — rewritten to accept `store.RateLimitStore` + instead of managing in-memory state. Constructor signature changed: + `NewRateLimiter(store, rate, burst)`. +- **`tool.result.*` routing** — `DirFromClient` → `DirBoth` to enable + cross-pod `WaitFor`. WS subscriber explicitly filters `tool.result` + to prevent re-sending to clients. + +### Removed + +- **`events/tickets.go`** — in-memory `TicketStore` replaced by + `store.TicketStore` (PG/SQLite) + `TicketValidatorAdapter`. +- **In-memory rate limiter** — visitor map and cleanup goroutine + replaced by PG-backed store. + +### Fixed + +- **Team workflow route registration** — v0.31.2 team-scoped workflow + CRUD routes (`/teams/:teamId/workflows`) were missing from `main.go` + route registration. 12 routes added to `teamScoped` group. + ## [0.31.2] — 2026-03-19 ### Summary diff --git a/VERSION b/VERSION index c415e1c..8a0d6d4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.31.2 +0.32.0 \ No newline at end of file diff --git a/chart/templates/deployment-backend.yaml b/chart/templates/deployment-backend.yaml index 90a2168..c5f194e 100644 --- a/chart/templates/deployment-backend.yaml +++ b/chart/templates/deployment-backend.yaml @@ -22,6 +22,20 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + # v0.32.0: Spread replicas across nodes for node-level HA. + # preferred (not required) — if fewer nodes than replicas, pods + # still schedule on the same node (degraded HA > no scheduling). + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: backend + topologyKey: kubernetes.io/hostname containers: - name: backend image: {{ include "switchboard.backend.image" . }} @@ -45,18 +59,21 @@ spec: - name: data mountPath: /data {{- end }} + # v0.32.0: Liveness — process alive (no dependency checks). livenessProbe: httpGet: - path: {{ .Values.basePath }}/api/v1/health + path: {{ .Values.basePath }}/healthz/live port: http initialDelaySeconds: 10 periodSeconds: 30 + # v0.32.0: Readiness — PG reachable. Failure pulls pod from Service. readinessProbe: httpGet: - path: {{ .Values.basePath }}/api/v1/health + path: {{ .Values.basePath }}/healthz/ready port: http initialDelaySeconds: 5 periodSeconds: 10 + failureThreshold: 3 {{- if .Values.persistence.enabled }} volumes: - name: data diff --git a/chart/values.yaml b/chart/values.yaml index 79e0522..ef2a9fb 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -7,7 +7,7 @@ backend: repository: git.gobha.me/xcaliber/chat-switchboard tag: "" # defaults to Chart.appVersion pullPolicy: IfNotPresent - replicaCount: 1 + replicaCount: 2 # v0.32.0: multi-replica HA resources: requests: cpu: 100m diff --git a/docs/DESIGN-0.32.0.md b/docs/DESIGN-0.32.0.md new file mode 100644 index 0000000..54bc173 --- /dev/null +++ b/docs/DESIGN-0.32.0.md @@ -0,0 +1,810 @@ +# DESIGN-0.32.0 — Multi-Replica HA + +Run 2–3 backend replicas across nodes for node-level availability. +Five changesets. No sub-versions — the work is tightly coupled and +each CS builds on the previous. + +Depends on: v0.31.2 (current HEAD). + +**Design decision:** PG `SKIP LOCKED` replaces Kubernetes Lease-based +leader election for the task scheduler. Every replica polls; PG +serializes the claims. Extends to all shared mutable state — tickets, +rate counters — keeping PG as the sole coordination layer. No Redis, +no K8s API dependency, no new infrastructure. + +--- + +## What Already Works Multi-Replica + +These require zero changes: + +- **REST API** — stateless, JWT auth, any replica serves any request. +- **PG + S3 + CephFS** — shared storage infrastructure. +- **`pg_broadcast` LISTEN/NOTIFY** — cross-pod event bus. `Publish()` + → `broadcastHook` → `pg_notify` → remote pod `publishLocal()` → + local WS subscribers. Fully wired, just never tested at N>1. + +--- + +## What Needs Work + +Five areas of in-process mutable state that break at replica count > 1: + +| State | Current | Problem at N>1 | Fix | +|-------|---------|----------------|-----| +| Task scheduler | Single goroutine polls `ListDue` | All replicas fire same tasks | `SKIP LOCKED` atomic claim | +| Task run guard | `GetActiveRun` check → `CreateRun` | TOCTOU race window | Conditional `INSERT ... WHERE NOT EXISTS` | +| WS ticket store | `sync.Map` per-pod | Ticket from pod-1 invalid on pod-2 | PG table with TTL reaper | +| Rate limiter | In-memory token bucket per-pod | Effective limit = N × configured | PG counter with time bucket | +| `SendToUser` | Local hub lookup only | User on pod-2 never reached | Route through bus → `pg_broadcast` | + +--- + +## CS0 — Schema: `020_ha.sql` + +New migration for both PG and SQLite. No changes to existing 019 +task schema — `next_run_at` nullable already supports the claim +mechanism. + +### PG: `server/database/migrations/020_ha.sql` + +```sql +-- ========================================== +-- Chat Switchboard — 020 Multi-Replica HA +-- ========================================== +-- Shared state tables for multi-replica operation. +-- v0.32.0: ws_tickets, rate_limit_counters. +-- ========================================== + +-- ========================================= +-- WEBSOCKET TICKETS (replaces sync.Map) +-- ========================================= + +CREATE TABLE IF NOT EXISTS ws_tickets ( + id TEXT PRIMARY KEY, -- 128-bit hex token + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ws_tickets_expires + ON ws_tickets (expires_at); + +-- ========================================= +-- RATE LIMIT COUNTERS (replaces in-memory) +-- ========================================= +-- Token bucket approximation using time-windowed counters. +-- Key format: "{scope}:{identifier}" e.g. "auth:192.168.1.1" +-- Window is truncated to the second for the configured rate. + +CREATE TABLE IF NOT EXISTS rate_limit_counters ( + key TEXT NOT NULL, + window TIMESTAMPTZ NOT NULL, -- truncated timestamp (window start) + tokens REAL NOT NULL DEFAULT 0, -- tokens consumed in this window + PRIMARY KEY (key, window) +); + +CREATE INDEX IF NOT EXISTS idx_rate_limit_counters_window + ON rate_limit_counters (window); +``` + +### SQLite: `server/database/migrations/sqlite/020_ha.sql` + +SQLite parity — structurally identical but with dialect adjustments. +These tables are functional in SQLite for single-process test parity, +though multi-replica is PG-only in production. + +```sql +CREATE TABLE IF NOT EXISTS ws_tickets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ws_tickets_expires + ON ws_tickets (expires_at); + +CREATE TABLE IF NOT EXISTS rate_limit_counters ( + key TEXT NOT NULL, + window TEXT NOT NULL, + tokens REAL NOT NULL DEFAULT 0, + PRIMARY KEY (key, window) +); + +CREATE INDEX IF NOT EXISTS idx_rate_limit_counters_window + ON rate_limit_counters (window); +``` + +### Deliverables + +- [x] `020_ha.sql` (PG) +- [x] `020_ha.sql` (SQLite) +- [x] CI green on both pipelines (migration runs, tables exist) + +--- + +## CS1 — Task Scheduler: SKIP LOCKED + +Replace the read-then-execute scheduler with atomic claim semantics. +Every replica runs the poll loop. PG serializes task handoff. + +### 1.1 — Atomic `ClaimDueTask` (replaces `ListDue`) + +New store method. Single atomic statement — SELECT + UPDATE in one +round trip. Returns at most one task per call. + +**PG implementation:** + +```sql +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 +``` + +Mechanics: +- `FOR UPDATE SKIP LOCKED` — if another replica holds a lock on a + candidate row, skip it instantly (no wait, no deadlock). +- `SET next_run_at = NULL` — claimed task disappears from future + polls. `advanceNextRun` restores it after execution. +- Returns zero rows if nothing is due → `sql.ErrNoRows` → no-op. +- The existing partial index `idx_tasks_next_run` covers the WHERE + clause (`is_active = true AND next_run_at IS NOT NULL` by + implication — NULL rows won't satisfy `<= NOW()`). + +**SQLite implementation:** + +SQLite has no `SKIP LOCKED`. Single-process, so not needed. Keep +the current `ListDue` behavior unchanged for SQLite. The store +interface accommodates both: + +```go +// TaskStore additions +ClaimDueTask(ctx context.Context) (*models.Task, error) +``` + +SQLite `ClaimDueTask` just does: +```sql +SELECT FROM tasks +WHERE is_active = 1 AND next_run_at <= datetime('now') +ORDER BY next_run_at ASC LIMIT 1 +``` +Then sets `next_run_at = NULL` in a second statement (single writer, +no contention). + +### 1.2 — Conditional `CreateRunExclusive` + +Belt-and-suspenders: prevent double-execution even if two replicas +somehow both claim the same task (shouldn't happen with SKIP LOCKED, +but defense in depth). + +```sql +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 +``` + +Returns `sql.ErrNoRows` if a run already exists → skip execution. + +New store method: +```go +CreateRunExclusive(ctx context.Context, taskID string) (*models.TaskRun, error) +``` + +### 1.3 — Scheduler Loop Rewrite + +`scheduler.go` changes: + +```go +func (s *Scheduler) poll() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + cfg := taskutil.LoadTaskConfig(ctx, s.stores.GlobalConfig) + if !cfg.Enabled { + return + } + + // Claim tasks one at a time until none remain or budget exhausted. + // Each replica claims independently — PG SKIP LOCKED serializes. + claimed := 0 + for claimed < cfg.MaxConcurrent { + task, err := s.stores.Tasks.ClaimDueTask(ctx) + if err != nil { + break // sql.ErrNoRows or real error — either way, done + } + claimed++ + go s.execute(ctx, task) + } +} +``` + +`execute()` changes: +- Remove the `GetActiveRun` TOCTOU check — replaced by + `CreateRunExclusive`. +- `GetQueuedRun` stays — webhook-triggered runs still need adoption. + But adoption also uses `CreateRunExclusive` semantics (transition + only if still `queued`). +- `advanceNextRun` still computes and sets `next_run_at`, making the + task visible to future polls again. + +### 1.4 — Remove `ListDue` from Interface + +`ListDue` removed from `TaskStore` interface. Replaced by +`ClaimDueTask`. `CreateRun` remains for webhook trigger path; +`CreateRunExclusive` added alongside it. + +Updated interface: +```go +type TaskStore interface { + // ... existing CRUD ... + + // Scheduler queries + 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, ...) error + TransitionRunStatus(ctx context.Context, id string, status string) error + GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error) + GetQueuedRun(ctx context.Context, taskID string) (*models.TaskRun, error) + ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error) +} +``` + +### Deliverables + +- [ ] `ClaimDueTask` — PG (`FOR UPDATE SKIP LOCKED`) + SQLite (simple select) +- [ ] `CreateRunExclusive` — PG + SQLite +- [ ] Scheduler loop rewrite — claim-per-iteration, no TOCTOU +- [ ] Remove `ListDue` from interface + both stores +- [ ] Unit tests: concurrent claim (PG only — two goroutines, verify + disjoint task sets) +- [ ] CI green + +--- + +## CS2 — PG Ticket Store + +Replace `events.TicketStore` (in-memory `sync.Map`) with a PG-backed +implementation. The middleware already programs to the `TicketValidator` +interface — the swap is clean. + +### 2.1 — Store Interface + +New interface in `store/`: + +```go +// TicketStore manages short-lived single-use WebSocket auth tickets. +type TicketStore interface { + Issue(ctx context.Context, userID string) (string, error) + Validate(ctx context.Context, ticketID string) (string, bool) + Reap(ctx context.Context) (int, error) +} +``` + +### 2.2 — PG Implementation + +**Issue:** +```sql +INSERT INTO ws_tickets (id, user_id, expires_at) +VALUES ($1, $2, NOW() + INTERVAL '30 seconds') +``` + +Token generation stays in Go (`crypto/rand`, 16 bytes, hex-encoded). + +**Validate (atomic consume):** +```sql +DELETE FROM ws_tickets +WHERE id = $1 AND expires_at > NOW() +RETURNING user_id +``` + +Single statement — delete + return. If expired or already consumed, +zero rows → `("", false)`. + +**Reap (TTL cleanup):** +```sql +DELETE FROM ws_tickets WHERE expires_at <= NOW() +``` + +Called by a system task or inline during poll. No dedicated goroutine +needed — the scheduler's 30s tick can piggyback, or register as a +lightweight system function. + +### 2.3 — SQLite Implementation + +Identical SQL with dialect adjustments (`datetime('now', '+30 seconds')` +instead of `NOW() + INTERVAL`). + +### 2.4 — Adapter for Middleware Interface + +The middleware `TicketValidator` interface is: +```go +type TicketValidator interface { + Validate(ticketID string) (string, bool) +} +``` + +The store's `Validate` takes a `context.Context`. Thin adapter: + +```go +// TicketValidatorAdapter bridges store.TicketStore → middleware.TicketValidator. +type TicketValidatorAdapter struct { + Store store.TicketStore +} + +func (a *TicketValidatorAdapter) Validate(ticketID string) (string, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return a.Store.Validate(ctx, ticketID) +} +``` + +### 2.5 — Wiring in `main.go` + +Replace: +```go +ticketStore := events.NewTicketStore() +defer ticketStore.Stop() +``` + +With: +```go +ticketAdapter := &events.TicketValidatorAdapter{Store: stores.Tickets} +``` + +The `POST /ws/ticket` endpoint calls `stores.Tickets.Issue(ctx, userID)` +directly. The WS auth middleware receives `ticketAdapter` (satisfies +`TicketValidator`). No reaper goroutine — use system function or +scheduler piggyback. + +### 2.6 — Reap Strategy + +Option A: Register `ticket_reap` as a system function +(`taskutil.SystemRegistry`), add a system task with `schedule: "@hourly"` +or `"*/5 * * * *"`. Pros: visible in admin UI, uses existing +infrastructure. Cons: requires task to exist. + +Option B: Inline reap — call `stores.Tickets.Reap(ctx)` at the top +of the scheduler `poll()` loop (every 30s, cheap no-op when table is +empty). Pros: zero config. Cons: slightly couples scheduler to tickets. + +**Recommendation: Option B.** At 50 users, the table will have at most +a few dozen rows. A `DELETE WHERE expires_at <= NOW()` every 30s is +free. Revisit if ticket volume grows. + +### 2.7 — Delete `events/tickets.go` + +The in-memory `TicketStore` struct, `NewTicketStore()`, and reaper +goroutine are fully replaced. Remove the file. The `TicketValidatorAdapter` +lives in `events/` (or alongside the middleware — whichever avoids +import cycles). + +### Deliverables + +- [ ] `store.TicketStore` interface +- [ ] PG implementation + SQLite implementation +- [ ] `TicketValidatorAdapter` — bridges `context`-aware store to + `TicketValidator` interface +- [ ] `main.go` wiring — swap in PG store, remove `events.NewTicketStore()` +- [ ] Inline reap in scheduler poll +- [ ] Delete `events/tickets.go` +- [ ] Add `Tickets` field to `store.Stores` +- [ ] CI green + +--- + +## CS3 — PG Rate Limiter + +Replace the in-memory token bucket (`middleware.RateLimiter`) with a +PG-backed counter. Currently only applied to `/auth` routes (5 req/s, +burst 8), so transaction volume is trivially low. + +### 3.1 — Store Interface + +```go +// RateLimitStore manages distributed rate limit counters. +type RateLimitStore interface { + // Allow checks if a request is within the rate limit. + // Returns (allowed bool, tokensRemaining float64). + // Atomically increments the counter if allowed. + Allow(ctx context.Context, key string, rate float64, burst int) (bool, error) + + // Cleanup removes expired windows. + Cleanup(ctx context.Context, maxAge time.Duration) error +} +``` + +### 3.2 — PG Implementation: Sliding-Window Token Bucket + +The in-memory implementation uses a classic token bucket (refill based +on elapsed time). The PG version approximates this with time-bucketed +counters. + +**`Allow` — atomic check-and-increment:** + +```sql +-- Upsert the current window's counter and check burst limit. +-- Window = current second (truncated). +INSERT INTO rate_limit_counters (key, window, tokens) +VALUES ($1, date_trunc('second', NOW()), 1) +ON CONFLICT (key, window) +DO UPDATE SET tokens = rate_limit_counters.tokens + 1 +RETURNING tokens +``` + +Go logic after the RETURNING: +1. Query returns `tokens` (count in the current 1-second window). +2. If `tokens > burst` → denied, return `(false, nil)`. +3. Else → allowed. + +This is a fixed-window approximation, not a sliding window. For auth +rate limiting at 5 req/s burst 8, the practical difference is +negligible — worst case allows 2× burst at window boundaries, which +is acceptable for this use case. A true sliding window would require +reading adjacent windows and interpolating, adding complexity for +near-zero benefit at this scale. + +**Cleanup:** +```sql +DELETE FROM rate_limit_counters +WHERE window < NOW() - $1::interval +``` + +Called alongside ticket reap in the scheduler poll loop. + +### 3.3 — SQLite Implementation + +Same logic, `datetime('now')` for window truncation. Single-process, +so the PG atomicity guarantees are naturally satisfied. + +### 3.4 — Middleware Swap + +Replace `middleware.RateLimiter` struct with one backed by the store: + +```go +type RateLimiter struct { + store store.RateLimitStore + rate float64 + burst int +} + +func NewRateLimiter(store store.RateLimitStore, rate float64, burst int) *RateLimiter { + return &RateLimiter{store: store, rate: rate, burst: burst} +} + +func (rl *RateLimiter) Limit() gin.HandlerFunc { + return func(c *gin.Context) { + key := "auth:" + c.ClientIP() + allowed, err := rl.store.Allow(c.Request.Context(), key, rl.rate, rl.burst) + if err != nil { + // DB error — fail open (don't block auth on rate limit DB failure) + c.Next() + return + } + if !allowed { + c.Header("Retry-After", "1") + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded", + }) + return + } + c.Next() + } +} +``` + +**Fail-open policy:** if PG is down, allow the request. The auth +endpoints have their own protections (bcrypt, lockout). Blocking +legitimate logins because the rate limit table is unreachable is worse +than allowing a brief burst. + +### 3.5 — Wiring in `main.go` + +Replace: +```go +authLimiter := middleware.NewRateLimiter(5, 8) +``` + +With: +```go +authLimiter := middleware.NewRateLimiter(stores.RateLimits, 5, 8) +``` + +Delete the in-memory cleanup goroutine (replaced by scheduler reap). + +### Deliverables + +- [ ] `store.RateLimitStore` interface +- [ ] PG implementation + SQLite implementation +- [ ] `middleware.RateLimiter` rewrite — backed by store, fail-open +- [ ] `main.go` wiring +- [ ] Add `RateLimits` field to `store.Stores` +- [ ] Scheduler poll cleanup (rate_limit_counters + ws_tickets) +- [ ] Delete in-memory visitor map + cleanup goroutine +- [ ] CI green + +--- + +## CS4 — WebSocket Cross-Pod Delivery + +The event bus → `pg_broadcast` → remote `publishLocal` pipeline is +already implemented and handles room-scoped events correctly. The gap: +**`hub.SendToUser()` bypasses the bus entirely** — it writes directly +to local connection send channels. Users on other pods are never reached. + +### Affected call sites (7 total) + +| File | Context | +|------|---------| +| `main.go` | DM typing indicators | +| `handlers/completion.go` (×3) | Streaming chunks, tool results, mentions | +| `handlers/stream_loop.go` | SSE stream events | +| `handlers/tool_loop.go` | Browser tool call delivery | +| `notifications/service.go` | Push notification delivery | + +### 4.1 — Add `TargetUserID` to Event + +```go +type Event struct { + Label string `json:"event"` + Room string `json:"room,omitempty"` + Payload json.RawMessage `json:"payload"` + Ts int64 `json:"ts"` + + // Server-side metadata (not serialized to clients) + SenderID string `json:"-"` + ConnID string `json:"-"` + TargetUserID string `json:"-"` // v0.32.0: cross-pod targeted delivery +} +``` + +`TargetUserID` is included in the JSON serialization for `pg_broadcast` +(so the remote pod knows who to deliver to) but stripped before sending +to WebSocket clients. Add a custom `MarshalJSON` for the broadcast +path, or use a separate internal envelope. + +**Simpler approach:** Use `json:"target_user_id,omitempty"` — it's +harmless if clients see it (they already ignore unknown fields), and +avoids a second serialization path. The field is empty for room-scoped +events and only populated for targeted delivery. + +### 4.2 — Update WS Subscriber Filter + +In `subscribeToBus`, add target filtering: + +```go +// Targeted delivery: if event has a target user, only deliver to that user +if e.TargetUserID != "" && e.TargetUserID != c.userID { + return +} +``` + +This goes before the room filter. Targeted events skip room filtering +entirely (they're user-scoped, not room-scoped). + +### 4.3 — Rewrite `SendToUser` → `PublishToUser` + +New method on `Hub`: + +```go +// PublishToUser sends an event to a specific user via the bus. +// Cross-pod safe: the bus broadcast hook fans out via pg_notify. +func (h *Hub) PublishToUser(userID string, event Event) { + event.TargetUserID = userID + h.bus.Publish(event) +} +``` + +This replaces all 7 `hub.SendToUser` call sites. The event flows: + +1. `hub.PublishToUser(userID, evt)` +2. `bus.Publish(evt)` → local subscribers + `broadcastHook` +3. Local pod: `subscribeToBus` filter matches `TargetUserID` → deliver +4. `broadcastHook` → `pg_notify` → remote pod +5. Remote pod: `publishLocal(evt)` → `subscribeToBus` filter → deliver + +### 4.4 — Keep `SendToUser` as Local-Only Optimization + +Don't delete `SendToUser` — rename to `sendToUserLocal` (unexported). +`PublishToUser` can optionally try local delivery first (fast path) +and only broadcast if the user isn't connected locally. But this +optimization is premature at 50 users — just always go through the +bus. Revisit post-MVP if latency matters. + +### 4.5 — Audit: `tool.call` + `WaitFor` + +`tool.call.*` events use `DirToClient` routing and are delivered via +`SendToUser`. After the rewrite, they go through the bus and will +broadcast to all pods. The `WaitFor` on the originating pod still +works — `tool.result.*` comes back from the client on the same pod +(the WS connection is sticky to a pod). + +Verify: if the user's browser tool is connected to pod-2, but the +completion handler calling `WaitFor` is on pod-1, the tool call event +needs to reach pod-2 (via pg_broadcast), and the result needs to come +back to pod-1 (via pg_broadcast of `tool.result.*`). Check +`RouteFor("tool.result.*")` — currently `DirFromClient`, which means +the broadcast hook skips it. **This needs to change to `DirBoth`** so +tool results cross pod boundaries for `WaitFor` to work. + +### Deliverables + +- [ ] `TargetUserID` field on `Event` +- [ ] `subscribeToBus` target filter +- [ ] `Hub.PublishToUser` — replaces all `SendToUser` call sites +- [ ] Update `tool.result.*` routing to `DirBoth` +- [ ] Rename `SendToUser` → `sendToUserLocal` (unexported) +- [ ] Verify `WaitFor` works cross-pod (tool bridge scenario) +- [ ] CI green + +--- + +## CS5 — Health Probes + Helm Validation + +### 5.1 — Readiness Probe Refinement + +Current health endpoint: basic HTTP 200. Add a PG ping: + +```go +func (h *HealthHandler) Readiness(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + if err := database.DB.PingContext(ctx); err != nil { + c.JSON(503, gin.H{"error": "database unavailable"}) + return + } + c.JSON(200, gin.H{"status": "ok"}) +} +``` + +Wire as `/healthz/ready` (separate from the existing `/healthz/live` +liveness probe). Kubernetes pulls the pod from the service on readiness +failure — new requests route to healthy replicas. + +### 5.2 — Helm Changes + +`chart/values.yaml`: +```yaml +backend: + replicaCount: 2 # was 1 +``` + +`chart/templates/deployment-backend.yaml` additions: + +**Pod anti-affinity** — spread replicas across nodes: +```yaml +affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: switchboard-backend + topologyKey: kubernetes.io/hostname +``` + +`preferredDuring` (not `requiredDuring`) — if fewer nodes than +replicas, pods still schedule on the same node (degraded HA is better +than no scheduling). + +**Readiness probe:** +```yaml +readinessProbe: + httpGet: + path: /healthz/ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 3 +``` + +### 5.3 — Startup Jitter + +Add random jitter (0–15s) to the scheduler's initial tick to stagger +replica polling: + +```go +func (s *Scheduler) Run() { + // ... + jitter := time.Duration(rand.Intn(15000)) * time.Millisecond + time.Sleep(jitter) + log.Printf("[scheduler] Started (jitter=%s, interval=%s)", jitter, s.interval) + // ... +} +``` + +Not strictly necessary with `SKIP LOCKED` (contention is harmless), +but reduces unnecessary lock acquisition at startup. + +### 5.4 — Validation Checklist + +Manual validation on the `gobha-ai-chat` cluster: + +- [ ] Scale to 2 replicas: `kubectl scale deploy switchboard-backend --replicas=2` +- [ ] Verify pods land on different nodes (`kubectl get pods -o wide`) +- [ ] Create a cron task (every 1 min), confirm only one run per tick +- [ ] WebSocket: connect on pod-1, send message that completes on + pod-2, verify streaming events arrive +- [ ] Issue WS ticket on pod-1, connect WS on pod-2 — ticket validates +- [ ] Rate limit: hit `/auth/login` 10× rapidly — confirm 429 after + burst regardless of which replica serves each request +- [ ] Kill one pod (`kubectl delete pod ...`), confirm the other + continues serving + picks up scheduler duties immediately +- [ ] `kubectl top pod` — memory baseline at 2 replicas under light load + +### Deliverables + +- [ ] `/healthz/ready` endpoint with PG ping +- [ ] Helm: `replicaCount: 2`, pod anti-affinity, readiness probe +- [ ] Scheduler startup jitter +- [ ] Validation checklist executed and documented +- [ ] CI green, tag `v0.32.0` + +--- + +## Summary: Store Interface Additions + +```go +type Stores struct { + // ... existing fields ... + Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed) + RateLimits RateLimitStore // v0.32.0: Distributed rate limiting +} +``` + +`TaskStore` changes: +- **Add:** `ClaimDueTask(ctx) (*Task, error)` +- **Add:** `CreateRunExclusive(ctx, taskID) (*TaskRun, error)` +- **Remove:** `ListDue(ctx, limit) ([]Task, error)` + +--- + +## Changeset Sequence + +| CS | Scope | Key Files | Gate | +|----|-------|-----------|------| +| CS0 | Schema | `020_ha.sql` (PG + SQLite) | Migrations run, tables exist | +| CS1 | Task scheduler | `store/task_iface.go`, `store/postgres/tasks.go`, `store/sqlite/tasks.go`, `scheduler/scheduler.go` | Concurrent claim test passes | +| CS2 | Ticket store | `store/ticket_iface.go`, `store/postgres/tickets.go`, `store/sqlite/tickets.go`, `events/tickets.go` (delete), `main.go` | WS auth works with PG tickets | +| CS3 | Rate limiter | `store/ratelimit_iface.go`, `store/postgres/ratelimit.go`, `store/sqlite/ratelimit.go`, `middleware/ratelimit.go`, `main.go` | Rate limit shared across replicas | +| CS4 | WS fan-out | `events/types.go`, `events/ws.go`, all `SendToUser` call sites | Cross-pod event delivery | +| CS5 | Health + Helm | `handlers/health.go`, `chart/`, `scheduler/scheduler.go` | 2-replica cluster validated | + +--- + +## Risk Assessment + +**Low risk:** CS0 (schema), CS2 (ticket store — simple CRUD), CS3 +(rate limiter — auth-only, fail-open). + +**Medium risk:** CS1 (scheduler rewrite — core behavior change, but +well-scoped and testable), CS5 (Helm — infra changes, but rollback is +`--replicas=1`). + +**Higher risk:** CS4 (WS fan-out — touches 7 call sites across +handlers, completion, notifications). The `tool.result` routing change +(`DirFromClient` → `DirBoth`) needs careful testing — if tool results +broadcast to all pods, the non-originating pod's `WaitFor` must not +accidentally consume the result. Verify: `WaitFor` subscribes to +`tool.result.{specific-id}` (exact label match), so only the waiting +goroutine receives it. Safe. + +**Fallback:** If any CS breaks multi-replica, `replicaCount: 1` is +always a safe rollback. Each CS should leave CI green at single-replica. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 67615e8..d6fa373 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,7 +30,7 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ │ Extension Track Operations Track │ │ - v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA + v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA ✅ v0.29.1 API Extensions ✅ v0.33.0 Observability v0.29.2 DB Extensions ✅ v0.34.0 Data Portability v0.29.3 Workflow Forms ✅ │ @@ -39,7 +39,7 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ v0.30.2 Workflow Packages ✅ │ v0.31.0 Editor + SDK ✅ │ v0.31.1 SDK Exercise ✅ │ - v0.31.2 Team Workflows │ + v0.31.2 Team Workflows ✅ │ │ │ │ v0.35.0 Workflow Product │ │ @@ -350,43 +350,39 @@ Depends on: v0.31.1. Parallel to extension track. No Starlark dependency. Delivers production readiness for the target multi-team deployment. -### v0.32.0 — Multi-Replica HA +### v0.32.0 — Multi-Replica HA ✅ Run 2–3 backend replicas across nodes for node-level availability. Depends on: v0.28.8. +**Design decision:** PG `SKIP LOCKED` replaces Kubernetes Lease-based +leader election. Every replica runs the scheduler poll loop; PG +serializes task claims atomically. No K8s API dependency, no Redis, +no new coordination infrastructure. See `docs/DESIGN-0.32.0.md`. + **What already works multi-replica:** - REST API (stateless, JWT auth) ✅ - PG + S3 + CephFS (shared infrastructure) ✅ - `pg_broadcast` LISTEN/NOTIFY (cross-pod event bus) ✅ -**What needs work:** -- [ ] WebSocket fan-out: wire `Bus.Subscribe` to `pg_broadcast` listener - so events from other pods reach local WebSocket connections. - LISTEN/NOTIFY plumbing exists — bridge inbound NOTIFY into per-pod - hub for local delivery. -- [ ] Task scheduler leader election: Kubernetes `Lease`-based. Only - leader runs cron scheduler. Prevents duplicate execution. -- [ ] Shared ticket store: `TicketStore` from `sync.Map` → PG table - with 30s TTL. Ensures WS ticket from pod-1 validates on pod-2. -- [ ] Shared rate limiter: in-memory → PG or Redis counter. Without - this, effective rate limit = N × configured across N replicas. -- [ ] Health check refinement: readiness probe fails fast on PG - connection loss. -- [ ] Helm: `backend.replicaCount` > 1 tested. Pod anti-affinity - to spread across nodes. +**Delivered (5 changesets):** +- [x] Task scheduler: `FOR UPDATE SKIP LOCKED` atomic claim replaces + `ListDue`. `CreateRunExclusive` conditional insert for + belt-and-suspenders uniqueness. Startup jitter (0–15s). +- [x] WebSocket cross-pod delivery: `PublishToUser` routes through + bus → `pg_broadcast` → remote pod. 18 `SendToUser` call sites + migrated. `tool.result.*` routing changed to `DirBoth` for + cross-pod `WaitFor`. `TargetUserID` field on `Event`. +- [x] Shared ticket store: `ws_tickets` PG table with 30s TTL, + atomic `DELETE ... RETURNING` validation. Replaces `sync.Map`. +- [x] Shared rate limiter: `rate_limit_counters` PG table with + fixed-window counters. Fail-open policy on DB errors. +- [x] Health probes: `/healthz/ready` (PG ping, 2s timeout), + `/healthz/live` (process alive). Helm: `replicaCount: 2`, + pod anti-affinity, readiness/liveness probes. -**Sizing (measured on v0.28.8):** - -| Metric | Value | -|--------|-------| -| Idle memory | 17Mi | -| Peak (569 reqs, 4 users, SSE) | 216Mi | -| Settled after GC | 34Mi | -| Per-SSE stream | ~500KB–1MB | -| Per-WebSocket | ~64KB | -| DB pool | 25 max (shared) | +**Schema:** `020_ha.sql` — `ws_tickets`, `rate_limit_counters`. ### v0.33.0 — Observability diff --git a/server/auth/auth.go b/server/auth/auth.go index 1e7af27..ab2f6a1 100644 --- a/server/auth/auth.go +++ b/server/auth/auth.go @@ -5,8 +5,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) type Mode string diff --git a/server/auth/builtin.go b/server/auth/builtin.go index c172851..8b9a6c4 100644 --- a/server/auth/builtin.go +++ b/server/auth/builtin.go @@ -9,8 +9,8 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) const bcryptCost = 12 diff --git a/server/auth/mtls.go b/server/auth/mtls.go index 53fc7d5..f097765 100644 --- a/server/auth/mtls.go +++ b/server/auth/mtls.go @@ -8,8 +8,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // MTLSConfig holds mTLS-specific configuration. diff --git a/server/auth/mtls_test.go b/server/auth/mtls_test.go index 9de1691..b27117e 100644 --- a/server/auth/mtls_test.go +++ b/server/auth/mtls_test.go @@ -3,7 +3,7 @@ package auth import ( "testing" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) func TestParseDN(t *testing.T) { diff --git a/server/auth/oidc.go b/server/auth/oidc.go index d661124..17f6a06 100644 --- a/server/auth/oidc.go +++ b/server/auth/oidc.go @@ -18,8 +18,8 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // OIDCConfig holds OIDC-specific configuration from env vars. diff --git a/server/auth/oidc_test.go b/server/auth/oidc_test.go index 6f89cac..97f8db1 100644 --- a/server/auth/oidc_test.go +++ b/server/auth/oidc_test.go @@ -3,7 +3,7 @@ package auth_test import ( "testing" - "git.gobha.me/xcaliber/chat-switchboard/auth" + "chat-switchboard/auth" ) func TestOIDCProvider_Discovery(t *testing.T) { diff --git a/server/auth/permissions.go b/server/auth/permissions.go index cb04bbe..8adfff3 100644 --- a/server/auth/permissions.go +++ b/server/auth/permissions.go @@ -4,7 +4,7 @@ import ( "context" "time" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in diff --git a/server/auth/session.go b/server/auth/session.go index 52fcea8..efb1c13 100644 --- a/server/auth/session.go +++ b/server/auth/session.go @@ -9,9 +9,9 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/config" + "chat-switchboard/models" + "chat-switchboard/store" ) const sessionCookieName = "sb_session" diff --git a/server/capabilities/capabilities_test.go b/server/capabilities/capabilities_test.go index 0e0e955..dc47a6d 100644 --- a/server/capabilities/capabilities_test.go +++ b/server/capabilities/capabilities_test.go @@ -3,7 +3,7 @@ package capabilities import ( "testing" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) func TestLookupKnownModel_AlwaysFalse(t *testing.T) { diff --git a/server/capabilities/intrinsic.go b/server/capabilities/intrinsic.go index 5bb0319..57cb948 100644 --- a/server/capabilities/intrinsic.go +++ b/server/capabilities/intrinsic.go @@ -4,7 +4,7 @@ import ( "regexp" "strings" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) // ── Known Model Defaults ──────────────────── diff --git a/server/capabilities/resolver.go b/server/capabilities/resolver.go index 1c13183..90e25cf 100644 --- a/server/capabilities/resolver.go +++ b/server/capabilities/resolver.go @@ -4,8 +4,8 @@ import ( "context" "log" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ModelsForUser returns all models and Personas visible to a user. diff --git a/server/compaction/compaction.go b/server/compaction/compaction.go index 34f8b32..3b04b61 100644 --- a/server/compaction/compaction.go +++ b/server/compaction/compaction.go @@ -8,11 +8,11 @@ import ( "strings" "time" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/treepath" + "chat-switchboard/models" + "chat-switchboard/providers" + "chat-switchboard/roles" + "chat-switchboard/store" + "chat-switchboard/treepath" ) // ── Request / Result ──────────────────────── diff --git a/server/compaction/compaction_test.go b/server/compaction/compaction_test.go index 718859f..f28d7d9 100644 --- a/server/compaction/compaction_test.go +++ b/server/compaction/compaction_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/models" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" + "chat-switchboard/database" + "chat-switchboard/models" + postgres "chat-switchboard/store/postgres" ) // ── Helpers ───────────────────────────────── diff --git a/server/compaction/estimator.go b/server/compaction/estimator.go index 8bd7579..bad265d 100644 --- a/server/compaction/estimator.go +++ b/server/compaction/estimator.go @@ -1,7 +1,7 @@ package compaction import ( - "git.gobha.me/xcaliber/chat-switchboard/treepath" + "chat-switchboard/treepath" ) // ── Token Estimation ──────────────────────── diff --git a/server/compaction/estimator_test.go b/server/compaction/estimator_test.go index 20c5236..45388f3 100644 --- a/server/compaction/estimator_test.go +++ b/server/compaction/estimator_test.go @@ -3,7 +3,7 @@ package compaction import ( "testing" - "git.gobha.me/xcaliber/chat-switchboard/treepath" + "chat-switchboard/treepath" ) func TestEstimateTokens(t *testing.T) { diff --git a/server/compaction/scanner.go b/server/compaction/scanner.go index a4f43c0..9343b9c 100644 --- a/server/compaction/scanner.go +++ b/server/compaction/scanner.go @@ -6,10 +6,10 @@ import ( "sync" "time" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/treepath" + "chat-switchboard/models" + "chat-switchboard/roles" + "chat-switchboard/store" + "chat-switchboard/treepath" ) // ── Defaults ──────────────────────────────── diff --git a/server/compaction/testmain_test.go b/server/compaction/testmain_test.go index 9a52e15..cc67b37 100644 --- a/server/compaction/testmain_test.go +++ b/server/compaction/testmain_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "git.gobha.me/xcaliber/chat-switchboard/database" + "chat-switchboard/database" ) func TestMain(m *testing.M) { diff --git a/server/database/database.go b/server/database/database.go index 14f2927..7eda278 100644 --- a/server/database/database.go +++ b/server/database/database.go @@ -12,7 +12,7 @@ import ( _ "github.com/lib/pq" _ "modernc.org/sqlite" - "git.gobha.me/xcaliber/chat-switchboard/config" + "chat-switchboard/config" ) // DB is the application-wide database connection pool. diff --git a/server/database/migrations/002_teams.sql b/server/database/migrations/002_teams.sql index aa6036f..759cd26 100644 --- a/server/database/migrations/002_teams.sql +++ b/server/database/migrations/002_teams.sql @@ -13,7 +13,6 @@ CREATE TABLE IF NOT EXISTS teams ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(200) NOT NULL UNIQUE, - slug VARCHAR(200) NOT NULL DEFAULT '' UNIQUE, description TEXT DEFAULT '', created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, is_active BOOLEAN DEFAULT true, diff --git a/server/database/migrations/020_ha.sql b/server/database/migrations/020_ha.sql new file mode 100644 index 0000000..54fc90d --- /dev/null +++ b/server/database/migrations/020_ha.sql @@ -0,0 +1,37 @@ +-- ========================================== +-- Chat Switchboard — 020 Multi-Replica HA +-- ========================================== +-- Shared state tables for multi-replica operation. +-- v0.32.0: ws_tickets, rate_limit_counters. +-- ========================================== + +-- ========================================= +-- WEBSOCKET TICKETS (replaces in-memory sync.Map) +-- ========================================= +-- Short-lived, single-use tokens for WebSocket authentication. +-- Ticket issued on pod-1 must validate on pod-2. + +CREATE TABLE IF NOT EXISTS ws_tickets ( + id TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ws_tickets_expires + ON ws_tickets (expires_at); + +-- ========================================= +-- RATE LIMIT COUNTERS (replaces in-memory token bucket) +-- ========================================= +-- Fixed-window counters keyed by scope:identifier and time bucket. +-- Key format: "{scope}:{identifier}" e.g. "auth:192.168.1.1" + +CREATE TABLE IF NOT EXISTS rate_limit_counters ( + key TEXT NOT NULL, + window_start TIMESTAMPTZ NOT NULL, + tokens REAL NOT NULL DEFAULT 0, + PRIMARY KEY (key, window_start) +); + +CREATE INDEX IF NOT EXISTS idx_rate_limit_counters_window + ON rate_limit_counters (window_start); diff --git a/server/database/migrations/sqlite/002_teams.sql b/server/database/migrations/sqlite/002_teams.sql index d157e38..1edd9e3 100644 --- a/server/database/migrations/sqlite/002_teams.sql +++ b/server/database/migrations/sqlite/002_teams.sql @@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS teams ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL DEFAULT '' UNIQUE, description TEXT DEFAULT '', created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, is_active INTEGER DEFAULT 1, diff --git a/server/database/migrations/sqlite/020_ha.sql b/server/database/migrations/sqlite/020_ha.sql new file mode 100644 index 0000000..37f2d26 --- /dev/null +++ b/server/database/migrations/sqlite/020_ha.sql @@ -0,0 +1,34 @@ +-- ========================================== +-- Chat Switchboard — 020 Multi-Replica HA +-- ========================================== +-- Shared state tables for multi-replica operation. +-- v0.32.0: ws_tickets, rate_limit_counters. +-- SQLite parity — functional for single-process test coverage. +-- ========================================== + +-- ========================================= +-- WEBSOCKET TICKETS +-- ========================================= + +CREATE TABLE IF NOT EXISTS ws_tickets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ws_tickets_expires + ON ws_tickets (expires_at); + +-- ========================================= +-- RATE LIMIT COUNTERS +-- ========================================= + +CREATE TABLE IF NOT EXISTS rate_limit_counters ( + key TEXT NOT NULL, + window_start TEXT NOT NULL, + tokens REAL NOT NULL DEFAULT 0, + PRIMARY KEY (key, window_start) +); + +CREATE INDEX IF NOT EXISTS idx_rate_limit_counters_window + ON rate_limit_counters (window_start); diff --git a/server/events/bus_test.go b/server/events/bus_test.go index 43462bf..0db568a 100644 --- a/server/events/bus_test.go +++ b/server/events/bus_test.go @@ -115,7 +115,7 @@ func TestRouteFor(t *testing.T) { {"pong", DirToClient}, // Tool bridge routes {"tool.call.abc123", DirToClient}, - {"tool.result.abc123", DirFromClient}, + {"tool.result.abc123", DirBoth}, // v0.32.0: DirBoth for cross-pod WaitFor // Extension lifecycle {"extension.loaded", DirLocal}, {"extension.error", DirLocal}, @@ -201,11 +201,14 @@ func TestToolCallRouteToClient(t *testing.T) { } } -func TestToolResultRouteFromClient(t *testing.T) { +func TestToolResultRouteBoth(t *testing.T) { + // v0.32.0: tool.result is DirBoth so results cross pods for WaitFor. + // The WS subscriber explicitly filters out tool.result events to + // prevent re-sending to clients (see subscribeToBus in ws.go). if !ShouldAcceptFromClient("tool.result.abc123") { t.Error("tool.result.* should be accepted from client") } - if ShouldSendToClient("tool.result.abc123") { - t.Error("tool.result.* should NOT be sent to client") + if !ShouldSendToClient("tool.result.abc123") { + t.Error("tool.result.* should route DirBoth (filtered by WS subscriber, not routing table)") } } diff --git a/server/events/pg_broadcast.go b/server/events/pg_broadcast.go index 83ab059..95cb5bb 100644 --- a/server/events/pg_broadcast.go +++ b/server/events/pg_broadcast.go @@ -23,7 +23,7 @@ import ( "github.com/lib/pq" - "git.gobha.me/xcaliber/chat-switchboard/database" + "chat-switchboard/database" ) const pgNotifyChannel = "switchboard_events" diff --git a/server/events/ticket_adapter.go b/server/events/ticket_adapter.go new file mode 100644 index 0000000..1c1af10 --- /dev/null +++ b/server/events/ticket_adapter.go @@ -0,0 +1,25 @@ +package events + +// v0.32.0: TicketValidatorAdapter bridges the context-aware store.TicketStore +// to the middleware.TicketValidator interface (which has no context parameter). +// Replaces the in-memory TicketStore that lived in this file previously. + +import ( + "context" + "time" + + "chat-switchboard/store" +) + +// TicketValidatorAdapter satisfies middleware.TicketValidator using a +// store.TicketStore backend (PG or SQLite). +type TicketValidatorAdapter struct { + Store store.TicketStore +} + +// Validate atomically consumes a ticket. Satisfies middleware.TicketValidator. +func (a *TicketValidatorAdapter) Validate(ticketID string) (string, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return a.Store.Validate(ctx, ticketID) +} diff --git a/server/events/tickets.go b/server/events/tickets.go deleted file mode 100644 index ac893f5..0000000 --- a/server/events/tickets.go +++ /dev/null @@ -1,118 +0,0 @@ -package events - -import ( - "crypto/rand" - "encoding/hex" - "log" - "sync" - "time" -) - -const ( - // ticketTTL is how long a ticket remains valid after issuance. - ticketTTL = 30 * time.Second - - // ticketReapInterval is how often the background reaper runs. - ticketReapInterval = 15 * time.Second -) - -// ticket is a single-use opaque token that maps to a user ID. -type ticket struct { - userID string - expiresAt time.Time -} - -// TicketStore manages short-lived, single-use WebSocket authentication tickets. -// -// Flow: -// 1. Client POSTs to /api/v1/ws/ticket (authenticated via JWT). -// 2. Server returns {"ticket": ""}. -// 3. Client connects WebSocket with ?ticket=. -// 4. Server validates and deletes the ticket atomically (single-use). -// -// This replaces passing the JWT as ?token= in the WebSocket URL, which -// exposed the token in server logs, proxy logs, and browser history. -type TicketStore struct { - mu sync.Mutex - tickets map[string]ticket - stopCh chan struct{} -} - -// NewTicketStore creates a store and starts the background reaper. -func NewTicketStore() *TicketStore { - ts := &TicketStore{ - tickets: make(map[string]ticket), - stopCh: make(chan struct{}), - } - go ts.reaper() - return ts -} - -// Issue creates a new single-use ticket for the given user. -// Returns the opaque ticket string. -func (ts *TicketStore) Issue(userID string) (string, error) { - b := make([]byte, 16) // 128-bit random - if _, err := rand.Read(b); err != nil { - return "", err - } - id := hex.EncodeToString(b) - - ts.mu.Lock() - ts.tickets[id] = ticket{ - userID: userID, - expiresAt: time.Now().Add(ticketTTL), - } - ts.mu.Unlock() - - return id, nil -} - -// Validate checks a ticket, deletes it (single-use), and returns the user ID. -// Returns ("", false) if the ticket is invalid, expired, or already consumed. -func (ts *TicketStore) Validate(ticketID string) (string, bool) { - ts.mu.Lock() - defer ts.mu.Unlock() - - t, ok := ts.tickets[ticketID] - if !ok { - return "", false - } - delete(ts.tickets, ticketID) // single-use: delete immediately - - if time.Now().After(t.expiresAt) { - return "", false - } - return t.userID, true -} - -// Stop shuts down the background reaper. -func (ts *TicketStore) Stop() { - close(ts.stopCh) -} - -// reaper periodically removes expired tickets that were never validated. -func (ts *TicketStore) reaper() { - ticker := time.NewTicker(ticketReapInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - ts.mu.Lock() - now := time.Now() - reaped := 0 - for id, t := range ts.tickets { - if now.After(t.expiresAt) { - delete(ts.tickets, id) - reaped++ - } - } - ts.mu.Unlock() - if reaped > 0 { - log.Printf("[ws-tickets] reaped %d expired tickets", reaped) - } - case <-ts.stopCh: - return - } - } -} diff --git a/server/events/types.go b/server/events/types.go index 74cd455..d4c8053 100644 --- a/server/events/types.go +++ b/server/events/types.go @@ -9,6 +9,11 @@ type Event struct { Payload json.RawMessage `json:"payload"` Ts int64 `json:"ts"` + // v0.32.0: Cross-pod targeted delivery. When set, only connections + // belonging to this user receive the event. Serialized for pg_broadcast + // (harmless if clients see it — they ignore unknown fields). + TargetUserID string `json:"target_user_id,omitempty"` + // Server-side metadata (not serialized to clients) SenderID string `json:"-"` // user ID of sender, empty for system events ConnID string `json:"-"` // WebSocket connection ID, empty for server-origin @@ -60,7 +65,7 @@ var routeTable = map[string]Direction{ "role.fallback": DirToClient, // Notifications (v0.20.0) - "notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based + "notification.new": DirToClient, // targeted via Hub.PublishToUser, not room-based "notification.read": DirToClient, // badge sync across tabs // Workspace (v0.21.5) @@ -79,7 +84,7 @@ var routeTable = map[string]Direction{ // Tool execution (browser tools bridge) "tool.call.": DirToClient, // Server → specific client (browser tool invocation) - "tool.result.": DirFromClient, // Client → server (browser tool result) + "tool.result.": DirBoth, // v0.32.0: DirBoth — result must cross pods for WaitFor // Extension lifecycle "extension.loaded": DirLocal, // Client-only diff --git a/server/events/ws.go b/server/events/ws.go index 980a23c..3a83393 100644 --- a/server/events/ws.go +++ b/server/events/ws.go @@ -170,9 +170,17 @@ func (h *Hub) IsConnected(userID string) bool { return false } -// SendToUser sends an event directly to all WebSocket connections for a user, -// bypassing room filtering. Used for targeted tool.call delivery. -func (h *Hub) SendToUser(userID string, event Event) { +// PublishToUser sends an event to a specific user via the bus. +// v0.32.0: Cross-pod safe — the bus broadcast hook fans out via pg_notify. +// All replicas receive the event; only the one with the user's connection delivers it. +func (h *Hub) PublishToUser(userID string, event Event) { + event.TargetUserID = userID + h.bus.Publish(event) +} + +// sendToUserLocal sends an event directly to local WebSocket connections for a user. +// Unexported — callers should use PublishToUser for cross-pod delivery. +func (h *Hub) sendToUserLocal(userID string, event Event) { data, err := json.Marshal(event) if err != nil { return @@ -226,6 +234,18 @@ func (c *Conn) subscribeToBus() { return } + // v0.32.0: Targeted delivery — only deliver to the intended user. + // Targeted events skip room filtering (user-scoped, not room-scoped). + if e.TargetUserID != "" && e.TargetUserID != c.userID { + return + } + + // v0.32.0: tool.result travels cross-pod for WaitFor (DirBoth) but + // must not be forwarded to WebSocket clients — they sent it. + if strings.HasPrefix(e.Label, "tool.result.") { + return + } + // Don't echo typing events back to the sender if e.Label == "chat.typing" || e.ConnID == c.id { if e.SenderID == c.userID && e.ConnID == c.id { diff --git a/server/filters/discover.go b/server/filters/discover.go index 16def74..ceff8c6 100644 --- a/server/filters/discover.go +++ b/server/filters/discover.go @@ -9,9 +9,9 @@ import ( "context" "log" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/sandbox" + "chat-switchboard/store" ) // DiscoverStarlarkFilters scans the package registry for active starlark diff --git a/server/filters/kb_inject.go b/server/filters/kb_inject.go index 8fa5e1c..d52db8c 100644 --- a/server/filters/kb_inject.go +++ b/server/filters/kb_inject.go @@ -13,7 +13,7 @@ import ( "fmt" "log" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // KBInjectFilter injects a system prompt listing active knowledge bases diff --git a/server/filters/starlark_filter.go b/server/filters/starlark_filter.go index f83975e..0b5a136 100644 --- a/server/filters/starlark_filter.go +++ b/server/filters/starlark_filter.go @@ -24,8 +24,8 @@ import ( "go.starlark.net/starlark" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/sandbox" + "chat-switchboard/store" ) // StarlarkFilter runs a Starlark package's on_pre_completion entry point. diff --git a/server/go.mod b/server/go.mod index 1721abe..b4d3abf 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,4 +1,4 @@ -module git.gobha.me/xcaliber/chat-switchboard +module chat-switchboard go 1.22 diff --git a/server/handlers/admin.go b/server/handlers/admin.go index b62ca22..b8c2b16 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -12,14 +12,14 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" - "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/storage" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/tools/search" + "chat-switchboard/auth" + "chat-switchboard/crypto" + "chat-switchboard/database" + "chat-switchboard/models" + "chat-switchboard/providers" + "chat-switchboard/storage" + "chat-switchboard/store" + "chat-switchboard/tools/search" ) type AdminHandler struct { diff --git a/server/handlers/admin_email.go b/server/handlers/admin_email.go index 6ed6ffd..0cf6631 100644 --- a/server/handlers/admin_email.go +++ b/server/handlers/admin_email.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/notifications" + "chat-switchboard/store" ) // AdminEmailHandler handles admin SMTP configuration and test endpoints. diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index 770fc6b..680cfb1 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -7,9 +7,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/crypto" + "chat-switchboard/models" + "chat-switchboard/store" ) // ProviderConfigHandler handles user-facing provider config endpoints. diff --git a/server/handlers/audit.go b/server/handlers/audit.go index 00d85b8..e729ccd 100644 --- a/server/handlers/audit.go +++ b/server/handlers/audit.go @@ -8,8 +8,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // AuditLog writes an audit entry via the store interface. diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 750cc48..fb3158a 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -17,11 +17,11 @@ import ( "github.com/google/uuid" "golang.org/x/crypto/bcrypt" - "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/auth" + "chat-switchboard/config" + "chat-switchboard/crypto" + "chat-switchboard/models" + "chat-switchboard/store" ) // Claims represents the JWT payload. diff --git a/server/handlers/auth_test.go b/server/handlers/auth_test.go index f3059d7..ad7f8a9 100644 --- a/server/handlers/auth_test.go +++ b/server/handlers/auth_test.go @@ -14,9 +14,9 @@ import ( "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" - "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/auth" + "chat-switchboard/config" + "chat-switchboard/store" ) func testConfig() *config.Config { diff --git a/server/handlers/avatar.go b/server/handlers/avatar.go index 7c3eaa9..ca308f9 100644 --- a/server/handlers/avatar.go +++ b/server/handlers/avatar.go @@ -15,8 +15,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) const avatarSize = 128 diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go index a7a8c1b..87ef393 100644 --- a/server/handlers/capabilities.go +++ b/server/handlers/capabilities.go @@ -8,11 +8,11 @@ import ( "github.com/gin-gonic/gin" - capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" - "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/health" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + capspkg "chat-switchboard/capabilities" + "chat-switchboard/auth" + "chat-switchboard/health" + "chat-switchboard/models" + "chat-switchboard/store" ) // ModelHandler provides the unified models endpoint. diff --git a/server/handlers/channel_models.go b/server/handlers/channel_models.go index 30be966..a441cd9 100644 --- a/server/handlers/channel_models.go +++ b/server/handlers/channel_models.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ── Channel Models Handler ────────────────── diff --git a/server/handlers/channels.go b/server/handlers/channels.go index 6adca2f..cd93724 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -9,8 +9,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ── Request / Response types ──────────────── diff --git a/server/handlers/completion.go b/server/handlers/completion.go index e0f8ea5..b4cf52c 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -13,22 +13,22 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/auth" - capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/filters" - "git.gobha.me/xcaliber/chat-switchboard/health" - "git.gobha.me/xcaliber/chat-switchboard/knowledge" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/routing" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/storage" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/tools" + "chat-switchboard/auth" + capspkg "chat-switchboard/capabilities" + "chat-switchboard/crypto" + "chat-switchboard/database" + "chat-switchboard/events" + "chat-switchboard/filters" + "chat-switchboard/health" + "chat-switchboard/knowledge" + "chat-switchboard/models" + "chat-switchboard/notifications" + "chat-switchboard/providers" + "chat-switchboard/routing" + "chat-switchboard/sandbox" + "chat-switchboard/storage" + "chat-switchboard/store" + "chat-switchboard/tools" ) // ── Request Types ─────────────────────────── @@ -280,7 +280,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { Ts: time.Now().UnixMilli(), } // Send to sender - h.hub.SendToUser(userID, evt) + h.hub.PublishToUser(userID, evt) // Send to other user participants in the channel pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(` SELECT participant_id FROM channel_participants @@ -291,7 +291,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { for pRows.Next() { var pid string if pRows.Scan(&pid) == nil { - h.hub.SendToUser(pid, evt) + h.hub.PublishToUser(pid, evt) } } } @@ -598,7 +598,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { "from_user": userID, "content": truncateContent(req.Content, 120), }) - h.hub.SendToUser(mentionedUserID, events.Event{ + h.hub.PublishToUser(mentionedUserID, events.Event{ Label: "user.mentioned", Payload: payload, Ts: time.Now().UnixMilli(), diff --git a/server/handlers/completion_chain.go b/server/handlers/completion_chain.go index 8425f02..ec8fcab 100644 --- a/server/handlers/completion_chain.go +++ b/server/handlers/completion_chain.go @@ -7,11 +7,11 @@ import ( "log" "time" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" + "chat-switchboard/events" + "chat-switchboard/models" + "chat-switchboard/providers" - capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" + capspkg "chat-switchboard/capabilities" ) // maxChainDepth limits AI-to-AI chaining to prevent runaway loops. @@ -175,7 +175,7 @@ func (h *CompletionHandler) chainIfMentioned( "tokens_used": resp.InputTokens + resp.OutputTokens, "chain_depth": depth + 1, }) - h.hub.SendToUser(userID, events.Event{ + h.hub.PublishToUser(userID, events.Event{ Label: "message.created", Payload: payload, Ts: time.Now().UnixMilli(), @@ -314,7 +314,7 @@ func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, trigger "tokens_used": resp.InputTokens + resp.OutputTokens, "chain_depth": depth, }) - h.hub.SendToUser(userID, events.Event{ + h.hub.PublishToUser(userID, events.Event{ Label: "message.created", Payload: payload, Ts: time.Now().UnixMilli(), @@ -350,7 +350,7 @@ func (h *CompletionHandler) emitTyping(userID, channelID, participantID, display "participant_type": "persona", "display_name": displayName, }) - h.hub.SendToUser(userID, events.Event{ + h.hub.PublishToUser(userID, events.Event{ Label: eventType, Payload: payload, Ts: time.Now().UnixMilli(), diff --git a/server/handlers/ext_api.go b/server/handlers/ext_api.go index ecc6f2f..8565900 100644 --- a/server/handlers/ext_api.go +++ b/server/handlers/ext_api.go @@ -42,9 +42,9 @@ import ( "github.com/gin-gonic/gin" "go.starlark.net/starlark" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/sandbox" + "chat-switchboard/store" ) // ExtAPIHandler serves extension API routes via Starlark. diff --git a/server/handlers/ext_db_schema.go b/server/handlers/ext_db_schema.go index 709b1b1..72c8a97 100644 --- a/server/handlers/ext_db_schema.go +++ b/server/handlers/ext_db_schema.go @@ -15,7 +15,7 @@ import ( "regexp" "strings" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // validSchemaIdentifier matches safe SQL identifiers for table and column names. diff --git a/server/handlers/ext_db_schema_test.go b/server/handlers/ext_db_schema_test.go index 7be3d55..8ed649b 100644 --- a/server/handlers/ext_db_schema_test.go +++ b/server/handlers/ext_db_schema_test.go @@ -8,7 +8,7 @@ import ( _ "modernc.org/sqlite" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // ── mock ExtDataStore ──────────────────────────────────────────────────────── diff --git a/server/handlers/extension_permissions.go b/server/handlers/extension_permissions.go index e1ce81e..95cbb4e 100644 --- a/server/handlers/extension_permissions.go +++ b/server/handlers/extension_permissions.go @@ -5,8 +5,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ExtPermHandler serves extension permission management endpoints. diff --git a/server/handlers/extension_secrets.go b/server/handlers/extension_secrets.go index a1ed2e0..c566585 100644 --- a/server/handlers/extension_secrets.go +++ b/server/handlers/extension_secrets.go @@ -5,8 +5,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ExtSecretsHandler serves extension secret management endpoints. diff --git a/server/handlers/extension_test.go b/server/handlers/extension_test.go index 151e871..c62a4b3 100644 --- a/server/handlers/extension_test.go +++ b/server/handlers/extension_test.go @@ -11,13 +11,13 @@ import ( "github.com/gin-gonic/gin" - authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + authpkg "chat-switchboard/auth" + "chat-switchboard/config" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // ── Extension Test Harness ────────────────── diff --git a/server/handlers/extensions.go b/server/handlers/extensions.go index 61be842..a863488 100644 --- a/server/handlers/extensions.go +++ b/server/handlers/extensions.go @@ -7,9 +7,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/database" + "chat-switchboard/models" + "chat-switchboard/store" ) // ExtensionHandler serves extension management endpoints. diff --git a/server/handlers/files.go b/server/handlers/files.go index 9578854..89ae753 100644 --- a/server/handlers/files.go +++ b/server/handlers/files.go @@ -12,10 +12,10 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/extraction" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/storage" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/extraction" + "chat-switchboard/models" + "chat-switchboard/storage" + "chat-switchboard/store" ) // ── Default Limits ───────────────────────── diff --git a/server/handlers/folders.go b/server/handlers/folders.go index 85a1f81..5b19850 100644 --- a/server/handlers/folders.go +++ b/server/handlers/folders.go @@ -10,8 +10,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) type FolderHandler struct { diff --git a/server/handlers/git.go b/server/handlers/git.go index 6b91fc1..9f34339 100644 --- a/server/handlers/git.go +++ b/server/handlers/git.go @@ -11,10 +11,10 @@ import ( "golang.org/x/crypto/ssh" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/workspace" + "chat-switchboard/crypto" + "chat-switchboard/models" + "chat-switchboard/store" + "chat-switchboard/workspace" "github.com/gin-gonic/gin" ) diff --git a/server/handlers/git_credentials_test.go b/server/handlers/git_credentials_test.go index 33ef820..dcdde60 100644 --- a/server/handlers/git_credentials_test.go +++ b/server/handlers/git_credentials_test.go @@ -7,13 +7,13 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "chat-switchboard/config" + "chat-switchboard/crypto" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // ── Git Credentials Test Harness ────────── diff --git a/server/handlers/groups.go b/server/handlers/groups.go index a651900..344597c 100644 --- a/server/handlers/groups.go +++ b/server/handlers/groups.go @@ -7,11 +7,11 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/auth" + "chat-switchboard/database" + "chat-switchboard/models" + "chat-switchboard/notifications" + "chat-switchboard/store" ) // ── Request types ─────────────────────────── diff --git a/server/handlers/health_admin.go b/server/handlers/health_admin.go index bbf3dbe..b7b371c 100644 --- a/server/handlers/health_admin.go +++ b/server/handlers/health_admin.go @@ -6,11 +6,11 @@ import ( "github.com/gin-gonic/gin" - capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" - "git.gobha.me/xcaliber/chat-switchboard/health" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/store" + capspkg "chat-switchboard/capabilities" + "chat-switchboard/health" + "chat-switchboard/models" + "chat-switchboard/providers" + "chat-switchboard/store" ) // ── Health Admin Handler ──────────────────── diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index eecda50..573445a 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -15,16 +15,16 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "git.gobha.me/xcaliber/chat-switchboard/config" - authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" - "git.gobha.me/xcaliber/chat-switchboard/treepath" + "chat-switchboard/config" + authpkg "chat-switchboard/auth" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/models" + "chat-switchboard/roles" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" + "chat-switchboard/treepath" ) // ── Test Harness ──────────────────────────── diff --git a/server/handlers/knowledge_bases.go b/server/handlers/knowledge_bases.go index 4f33f8c..45a9bf8 100644 --- a/server/handlers/knowledge_bases.go +++ b/server/handlers/knowledge_bases.go @@ -11,10 +11,10 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/knowledge" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/storage" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/knowledge" + "chat-switchboard/models" + "chat-switchboard/storage" + "chat-switchboard/store" ) // ── Limits ─────────────────────────────────── diff --git a/server/handlers/live_compaction_test.go b/server/handlers/live_compaction_test.go index c874bf3..93a3552 100644 --- a/server/handlers/live_compaction_test.go +++ b/server/handlers/live_compaction_test.go @@ -7,14 +7,14 @@ import ( "strings" "testing" - "git.gobha.me/xcaliber/chat-switchboard/compaction" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" - "git.gobha.me/xcaliber/chat-switchboard/treepath" + "chat-switchboard/compaction" + "chat-switchboard/database" + "chat-switchboard/models" + "chat-switchboard/roles" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" + "chat-switchboard/treepath" ) // ═══════════════════════════════════════════ diff --git a/server/handlers/live_provider_test.go b/server/handlers/live_provider_test.go index 8a209a3..c02bd8e 100644 --- a/server/handlers/live_provider_test.go +++ b/server/handlers/live_provider_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/providers" + "chat-switchboard/database" + "chat-switchboard/providers" ) // ═══════════════════════════════════════════ diff --git a/server/handlers/memory.go b/server/handlers/memory.go index a675631..1b667d4 100644 --- a/server/handlers/memory.go +++ b/server/handlers/memory.go @@ -5,9 +5,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/memory" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/memory" + "chat-switchboard/models" + "chat-switchboard/store" ) // MemoryHandler provides REST endpoints for memory management. diff --git a/server/handlers/memory_inject.go b/server/handlers/memory_inject.go index 6fb430f..a197b3c 100644 --- a/server/handlers/memory_inject.go +++ b/server/handlers/memory_inject.go @@ -6,9 +6,9 @@ import ( "log" "strings" - "git.gobha.me/xcaliber/chat-switchboard/knowledge" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/knowledge" + "chat-switchboard/models" + "chat-switchboard/store" ) // maxMemoryChars is the approximate character budget for injected memories. diff --git a/server/handlers/memory_test.go b/server/handlers/memory_test.go index f70470b..1f3f2b1 100644 --- a/server/handlers/memory_test.go +++ b/server/handlers/memory_test.go @@ -4,8 +4,8 @@ import ( "net/http" "testing" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/database" + "chat-switchboard/models" ) // seedMemory inserts a memory directly into the DB for testing. diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 26c6e07..923adc9 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -10,15 +10,15 @@ import ( "github.com/gin-gonic/gin" - capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/storage" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/tools" - "git.gobha.me/xcaliber/chat-switchboard/treepath" + capspkg "chat-switchboard/capabilities" + "chat-switchboard/crypto" + "chat-switchboard/events" + "chat-switchboard/models" + "chat-switchboard/providers" + "chat-switchboard/storage" + "chat-switchboard/store" + "chat-switchboard/tools" + "chat-switchboard/treepath" ) // ── Request / Response types ──────────────── diff --git a/server/handlers/model_prefs.go b/server/handlers/model_prefs.go index a26c6cc..7d55d5d 100644 --- a/server/handlers/model_prefs.go +++ b/server/handlers/model_prefs.go @@ -5,8 +5,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ModelPrefsHandler handles user model preference endpoints. diff --git a/server/handlers/model_prefs_test.go b/server/handlers/model_prefs_test.go index db42251..19d10c6 100644 --- a/server/handlers/model_prefs_test.go +++ b/server/handlers/model_prefs_test.go @@ -8,12 +8,12 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "chat-switchboard/config" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // ── Model Prefs Test Harness ────────────── diff --git a/server/handlers/model_sync.go b/server/handlers/model_sync.go index d7b355c..d990a23 100644 --- a/server/handlers/model_sync.go +++ b/server/handlers/model_sync.go @@ -10,9 +10,9 @@ import ( "strconv" "time" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/providers" + "chat-switchboard/store" ) // providerSyncTimeout is the maximum time allowed for an outbound provider diff --git a/server/handlers/notes.go b/server/handlers/notes.go index 5b8211c..747fe69 100644 --- a/server/handlers/notes.go +++ b/server/handlers/notes.go @@ -7,9 +7,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/notelinks" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/notelinks" + "chat-switchboard/store" ) // ── Request / Response Types ──────────────── diff --git a/server/handlers/notification_test.go b/server/handlers/notification_test.go index 515c8e9..fc12859 100644 --- a/server/handlers/notification_test.go +++ b/server/handlers/notification_test.go @@ -8,14 +8,14 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "chat-switchboard/config" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/models" + "chat-switchboard/notifications" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // ── Notification Test Harness ────────────── diff --git a/server/handlers/notifications.go b/server/handlers/notifications.go index de4dd8a..9ca7ca7 100644 --- a/server/handlers/notifications.go +++ b/server/handlers/notifications.go @@ -10,10 +10,10 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/events" + "chat-switchboard/models" + "chat-switchboard/notifications" + "chat-switchboard/store" ) // ── Handler ───────────────────────────────── @@ -111,7 +111,7 @@ func (h *NotificationHandler) MarkRead(c *gin.Context) { // Sync badge across tabs if h.hub != nil { payload, _ := json.Marshal(map[string]string{"id": id}) - h.hub.SendToUser(userID, events.Event{ + h.hub.PublishToUser(userID, events.Event{ Label: "notification.read", Payload: payload, Ts: time.Now().UnixMilli(), @@ -138,7 +138,7 @@ func (h *NotificationHandler) MarkAllRead(c *gin.Context) { // Sync badge across tabs if h.hub != nil { payload, _ := json.Marshal(map[string]string{"action": "mark_all_read"}) - h.hub.SendToUser(userID, events.Event{ + h.hub.PublishToUser(userID, events.Event{ Label: "notification.read", Payload: payload, Ts: time.Now().UnixMilli(), @@ -341,7 +341,7 @@ func (h *NotificationHandler) Broadcast(c *gin.Context) { "level": level, }) for _, uid := range userIDs { - h.hub.SendToUser(uid, events.Event{ + h.hub.PublishToUser(uid, events.Event{ Label: "system.broadcast", Payload: payload, Ts: time.Now().UnixMilli(), diff --git a/server/handlers/package_export.go b/server/handlers/package_export.go index fe963ef..5e80267 100644 --- a/server/handlers/package_export.go +++ b/server/handlers/package_export.go @@ -18,7 +18,7 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // PackageExportHandler handles package export operations. diff --git a/server/handlers/package_migrations.go b/server/handlers/package_migrations.go index 1b10212..9c2878e 100644 --- a/server/handlers/package_migrations.go +++ b/server/handlers/package_migrations.go @@ -28,8 +28,8 @@ import ( "go.starlark.net/starlark" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/sandbox" + "chat-switchboard/store" ) // ParseSchemaVersion extracts the "schema_version" integer from a manifest. diff --git a/server/handlers/package_registry.go b/server/handlers/package_registry.go index e7b3abe..e80cdf6 100644 --- a/server/handlers/package_registry.go +++ b/server/handlers/package_registry.go @@ -29,7 +29,7 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // RegistryEntry represents a single package in the registry. diff --git a/server/handlers/packages.go b/server/handlers/packages.go index e9eb3e7..08dbb4a 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -14,9 +14,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/database" + "chat-switchboard/sandbox" + "chat-switchboard/store" ) // validPackageID matches lowercase alphanumeric slugs with optional hyphens. diff --git a/server/handlers/participants.go b/server/handlers/participants.go index 3dd07a7..062ab0d 100644 --- a/server/handlers/participants.go +++ b/server/handlers/participants.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ── Channel Participants Handler ────────────── diff --git a/server/handlers/persona_groups.go b/server/handlers/persona_groups.go index a02bce8..42311d5 100644 --- a/server/handlers/persona_groups.go +++ b/server/handlers/persona_groups.go @@ -10,8 +10,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) type PersonaGroupHandler struct { diff --git a/server/handlers/personas.go b/server/handlers/personas.go index 3020e41..72a66bf 100644 --- a/server/handlers/personas.go +++ b/server/handlers/personas.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // PersonaHandler handles persona endpoints. diff --git a/server/handlers/presence.go b/server/handlers/presence.go index 994538c..eb12ce9 100644 --- a/server/handlers/presence.go +++ b/server/handlers/presence.go @@ -15,7 +15,7 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) const presenceOnlineThreshold = 90 * time.Second diff --git a/server/handlers/profile_test.go b/server/handlers/profile_test.go index a7822cf..e4b8cba 100644 --- a/server/handlers/profile_test.go +++ b/server/handlers/profile_test.go @@ -8,12 +8,12 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "chat-switchboard/config" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // ── Profile Test Harness ────────────────── diff --git a/server/handlers/projects.go b/server/handlers/projects.go index 2261878..0ac04eb 100644 --- a/server/handlers/projects.go +++ b/server/handlers/projects.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ── Request / Response Types ──────────────── diff --git a/server/handlers/projects_test.go b/server/handlers/projects_test.go index 6789fc8..8e01cc6 100644 --- a/server/handlers/projects_test.go +++ b/server/handlers/projects_test.go @@ -8,13 +8,13 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "chat-switchboard/config" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/models" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // ── Project Test Harness ────────────────── diff --git a/server/handlers/provider_resolver_adapter.go b/server/handlers/provider_resolver_adapter.go index 3dff26f..55dabc3 100644 --- a/server/handlers/provider_resolver_adapter.go +++ b/server/handlers/provider_resolver_adapter.go @@ -10,10 +10,10 @@ import ( "context" "fmt" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/crypto" + "chat-switchboard/providers" + "chat-switchboard/sandbox" + "chat-switchboard/store" ) // ProviderResolverAdapter implements sandbox.ProviderResolver using diff --git a/server/handlers/resolve.go b/server/handlers/resolve.go index e58727b..4a985eb 100644 --- a/server/handlers/resolve.go +++ b/server/handlers/resolve.go @@ -17,10 +17,10 @@ import ( "fmt" "log" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/tools" + "chat-switchboard/crypto" + "chat-switchboard/providers" + "chat-switchboard/store" + "chat-switchboard/tools" ) // ── Provider Resolution ──────────────────────── diff --git a/server/handlers/roles.go b/server/handlers/roles.go index 37df14e..3e06a98 100644 --- a/server/handlers/roles.go +++ b/server/handlers/roles.go @@ -5,10 +5,10 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/providers" + "chat-switchboard/roles" + "chat-switchboard/store" ) // RolesHandler manages model role configuration. diff --git a/server/handlers/route_test.go b/server/handlers/route_test.go index 77cb5c9..576881f 100644 --- a/server/handlers/route_test.go +++ b/server/handlers/route_test.go @@ -7,14 +7,14 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/config" - authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/pages" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "chat-switchboard/config" + authpkg "chat-switchboard/auth" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/pages" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // TestRouteRegistration verifies that ALL production routes can be diff --git a/server/handlers/routing_admin.go b/server/handlers/routing_admin.go index 86c08d5..5fb3aec 100644 --- a/server/handlers/routing_admin.go +++ b/server/handlers/routing_admin.go @@ -6,10 +6,10 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/health" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/routing" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/health" + "chat-switchboard/models" + "chat-switchboard/routing" + "chat-switchboard/store" ) // ── Routing Admin Handler ─────────────────── diff --git a/server/handlers/safe_json.go b/server/handlers/safe_json.go index da935e6..ff2cd9b 100644 --- a/server/handlers/safe_json.go +++ b/server/handlers/safe_json.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/lib/pq" - "git.gobha.me/xcaliber/chat-switchboard/database" + "chat-switchboard/database" ) // ── SafeJSON ──────────────────────────────── diff --git a/server/handlers/seed_packages.go b/server/handlers/seed_packages.go index 11110c8..e5f3a21 100644 --- a/server/handlers/seed_packages.go +++ b/server/handlers/seed_packages.go @@ -7,7 +7,7 @@ import ( "os" "path/filepath" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // builtinManifest is the on-disk manifest.json shape. diff --git a/server/handlers/seed_providers.go b/server/handlers/seed_providers.go index 2a5093d..c2359a6 100644 --- a/server/handlers/seed_providers.go +++ b/server/handlers/seed_providers.go @@ -5,10 +5,10 @@ import ( "log" "strings" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/config" + "chat-switchboard/crypto" + "chat-switchboard/models" + "chat-switchboard/store" ) // Known provider default endpoints for seeding. diff --git a/server/handlers/settings.go b/server/handlers/settings.go index 64a1164..1d4dfb7 100644 --- a/server/handlers/settings.go +++ b/server/handlers/settings.go @@ -15,8 +15,8 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/crypto" + "chat-switchboard/store" ) // ── Request Types ─────────────────────────── diff --git a/server/handlers/storage.go b/server/handlers/storage.go index 2b156b4..60d9e59 100644 --- a/server/handlers/storage.go +++ b/server/handlers/storage.go @@ -5,7 +5,7 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/storage" + "chat-switchboard/storage" ) // storageConfigured is a package-level flag set during init. diff --git a/server/handlers/stream_loop.go b/server/handlers/stream_loop.go index 5b4448c..5c2a617 100644 --- a/server/handlers/stream_loop.go +++ b/server/handlers/stream_loop.go @@ -11,11 +11,11 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/tools" + "chat-switchboard/events" + "chat-switchboard/providers" + "chat-switchboard/sandbox" + "chat-switchboard/store" + "chat-switchboard/tools" ) // recordHealthFn records provider health from standalone streaming functions. @@ -141,7 +141,7 @@ func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) too "arguments": json.RawMessage(call.Arguments), }) - hub.SendToUser(userID, events.Event{ + hub.PublishToUser(userID, events.Event{ Label: "tool.call." + callID, Payload: payload, Ts: time.Now().UnixMilli(), diff --git a/server/handlers/summarize.go b/server/handlers/summarize.go index 372ea09..7cb689c 100644 --- a/server/handlers/summarize.go +++ b/server/handlers/summarize.go @@ -9,9 +9,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/compaction" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/compaction" + "chat-switchboard/roles" + "chat-switchboard/store" ) // SummarizeHandler handles manual conversation summarization via HTTP. diff --git a/server/handlers/task_test.go b/server/handlers/task_test.go index ebf7a12..e234bc4 100644 --- a/server/handlers/task_test.go +++ b/server/handlers/task_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "git.gobha.me/xcaliber/chat-switchboard/database" + "chat-switchboard/database" ) // ═══════════════════════════════════════════════ diff --git a/server/handlers/tasks.go b/server/handlers/tasks.go index 7fe5c89..e97bdca 100644 --- a/server/handlers/tasks.go +++ b/server/handlers/tasks.go @@ -7,12 +7,12 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/taskutil" - "git.gobha.me/xcaliber/chat-switchboard/webhook" + "chat-switchboard/auth" + "chat-switchboard/middleware" + "chat-switchboard/models" + "chat-switchboard/store" + "chat-switchboard/taskutil" + "chat-switchboard/webhook" ) // TaskHandler manages task CRUD and manual execution. diff --git a/server/handlers/team_providers.go b/server/handlers/team_providers.go index c5f29ac..d00ffc4 100644 --- a/server/handlers/team_providers.go +++ b/server/handlers/team_providers.go @@ -8,10 +8,10 @@ import ( "github.com/gin-gonic/gin" - capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/store" + capspkg "chat-switchboard/capabilities" + "chat-switchboard/models" + "chat-switchboard/providers" + "chat-switchboard/store" ) // ── Team Provider Handlers ────────────────── diff --git a/server/handlers/teams.go b/server/handlers/teams.go index b0c7178..a29981b 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -11,11 +11,11 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/crypto" + "chat-switchboard/database" + "chat-switchboard/models" + "chat-switchboard/providers" + "chat-switchboard/store" ) // ── Request types ─────────────────────────── @@ -105,7 +105,6 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) { team := &models.Team{ Name: req.Name, - Slug: slugify(req.Name), Description: req.Description, CreatedBy: adminID, IsActive: true, @@ -119,7 +118,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) { return } - c.JSON(http.StatusCreated, gin.H{"id": team.ID, "name": team.Name, "slug": team.Slug}) + c.JSON(http.StatusCreated, gin.H{"id": team.ID, "name": team.Name}) AuditLog(h.stores.Audit, c, "team.create", "team", team.ID, map[string]interface{}{"name": team.Name}) } @@ -156,7 +155,6 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) { fields := map[string]interface{}{} if req.Name != nil { fields["name"] = *req.Name - fields["slug"] = slugify(*req.Name) } if req.Description != nil { fields["description"] = *req.Description diff --git a/server/handlers/testmain_test.go b/server/handlers/testmain_test.go index e65fc06..9609b74 100644 --- a/server/handlers/testmain_test.go +++ b/server/handlers/testmain_test.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/providers" + "chat-switchboard/database" + "chat-switchboard/providers" _ "modernc.org/sqlite" // register sqlite driver for DB_DRIVER=sqlite ) diff --git a/server/handlers/title.go b/server/handlers/title.go index c775e12..d1f837c 100644 --- a/server/handlers/title.go +++ b/server/handlers/title.go @@ -9,9 +9,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/providers" + "chat-switchboard/roles" + "chat-switchboard/store" ) // TitleHandler generates chat titles using the utility role. diff --git a/server/handlers/tool_loop.go b/server/handlers/tool_loop.go index 29b7fc7..05f65db 100644 --- a/server/handlers/tool_loop.go +++ b/server/handlers/tool_loop.go @@ -27,11 +27,11 @@ import ( "github.com/gin-gonic/gin" "go.starlark.net/starlark" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/tools" + "chat-switchboard/events" + "chat-switchboard/providers" + "chat-switchboard/sandbox" + "chat-switchboard/store" + "chat-switchboard/tools" ) // ── Types ────────────────────────────────────── @@ -564,7 +564,7 @@ func emitWorkspaceEvent(lcfg LoopConfig, call tools.ToolCall, result tools.ToolR Path string `json:"path"` } if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" { - lcfg.Hub.SendToUser(lcfg.ExecCtx.UserID, events.Event{ + lcfg.Hub.PublishToUser(lcfg.ExecCtx.UserID, events.Event{ Label: "workspace.file.changed", Payload: events.MustJSON(map[string]string{ "workspace_id": lcfg.ExecCtx.WorkspaceID, diff --git a/server/handlers/tree.go b/server/handlers/tree.go index 5656ed1..7a33efb 100644 --- a/server/handlers/tree.go +++ b/server/handlers/tree.go @@ -8,7 +8,7 @@ package handlers // New code should import treepath directly. import ( - "git.gobha.me/xcaliber/chat-switchboard/treepath" + "chat-switchboard/treepath" ) // ── Type Aliases ──────────────────────────── diff --git a/server/handlers/usage.go b/server/handlers/usage.go index 94be647..7d63f92 100644 --- a/server/handlers/usage.go +++ b/server/handlers/usage.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // UsageHandler manages usage tracking and pricing endpoints. diff --git a/server/handlers/user_packages.go b/server/handlers/user_packages.go index 2523cba..fa91194 100644 --- a/server/handlers/user_packages.go +++ b/server/handlers/user_packages.go @@ -22,7 +22,7 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // allowedNonGlobalPermissions is the set of permissions that non-admin diff --git a/server/handlers/workflow_assignments.go b/server/handlers/workflow_assignments.go index ca971e3..8ac0a58 100644 --- a/server/handlers/workflow_assignments.go +++ b/server/handlers/workflow_assignments.go @@ -11,9 +11,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/events" + "chat-switchboard/notifications" + "chat-switchboard/store" ) // ── Workflow Assignment Handler ───────────── @@ -93,7 +93,7 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) { } pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "") for _, uid := range pids { - h.hub.SendToUser(uid, evt) + h.hub.PublishToUser(uid, evt) } } diff --git a/server/handlers/workflow_entry.go b/server/handlers/workflow_entry.go index 54df211..c8fff24 100644 --- a/server/handlers/workflow_entry.go +++ b/server/handlers/workflow_entry.go @@ -1,15 +1,14 @@ package handlers import ( - "context" "fmt" "log" "net/http" "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ── Workflow Entry Handler ────────────────── @@ -24,24 +23,6 @@ func NewWorkflowEntryHandler(stores store.Stores) *WorkflowEntryHandler { return &WorkflowEntryHandler{stores: stores} } -// resolveTeamScope converts a URL scope parameter to a team ID. -// Accepts "global" (returns nil), a UUID (passed through), or a team slug (looked up). -func resolveTeamScope(ctx context.Context, teams store.TeamStore, scope string) *string { - if scope == "global" { - return nil - } - // UUID format: 36 chars with dashes at 8,13,18,23 - if len(scope) == 36 && scope[8] == '-' && scope[13] == '-' { - return &scope - } - // Try team slug lookup - t, err := teams.GetBySlug(ctx, scope) - if err == nil && t != nil { - return &t.ID - } - return &scope // fall through — will 404 downstream -} - // StartVisitor creates an anonymous session + workflow instance for a visitor. // POST /api/v1/workflow-entry/:scope/:slug func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) { @@ -49,7 +30,10 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) { scope := c.Param("scope") slug := c.Param("slug") - teamID := resolveTeamScope(ctx, h.stores.Teams, scope) + var teamID *string + if scope != "global" { + teamID = &scope + } wf, err := h.stores.Workflows.GetBySlug(ctx, teamID, slug) if err != nil || wf == nil { diff --git a/server/handlers/workflow_forms.go b/server/handlers/workflow_forms.go index 32e5384..59077a5 100644 --- a/server/handlers/workflow_forms.go +++ b/server/handlers/workflow_forms.go @@ -8,11 +8,11 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/tools" + "chat-switchboard/events" + "chat-switchboard/models" + "chat-switchboard/sandbox" + "chat-switchboard/store" + "chat-switchboard/tools" "go.starlark.net/starlark" ) @@ -241,7 +241,7 @@ func (h *WorkflowFormHandler) emitFormEvent(channelID, label string, stage int) } evt := events.Event{Label: label, Payload: payload} for _, uid := range pids { - h.hub.SendToUser(uid, evt) + h.hub.PublishToUser(uid, evt) } } diff --git a/server/handlers/workflow_instance_test.go b/server/handlers/workflow_instance_test.go index 0df9eab..e3fba9f 100644 --- a/server/handlers/workflow_instance_test.go +++ b/server/handlers/workflow_instance_test.go @@ -8,13 +8,13 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/config" - authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "chat-switchboard/config" + authpkg "chat-switchboard/auth" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // ═══════════════════════════════════════════════════════════ diff --git a/server/handlers/workflow_instances.go b/server/handlers/workflow_instances.go index 1fa8578..dba7295 100644 --- a/server/handlers/workflow_instances.go +++ b/server/handlers/workflow_instances.go @@ -10,11 +10,11 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/tools" + "chat-switchboard/events" + "chat-switchboard/models" + "chat-switchboard/notifications" + "chat-switchboard/store" + "chat-switchboard/tools" ) // ── Workflow Instance Handler ─────────────── @@ -298,7 +298,7 @@ func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channel } // emitWorkflowEvent pushes a workflow event to all user participants in the channel. -// Uses SendToUser (not room-scoped Bus.Publish) because room subscriptions are +// Uses PublishToUser (not room-scoped Bus.Publish) because room subscriptions are // not yet wired on the client side. See websocket.md § Room Model. func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, data map[string]any) { if h.hub == nil { @@ -318,7 +318,7 @@ func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, dat return } for _, uid := range pids { - h.hub.SendToUser(uid, evt) + h.hub.PublishToUser(uid, evt) } } @@ -398,7 +398,7 @@ func (h *WorkflowInstanceHandler) notifyAssignment(ctx context.Context, teamID, "stage_name": stageName, "assigned_to": assignedTo, }) for _, m := range members { - h.hub.SendToUser(m.UserID, events.Event{ + h.hub.PublishToUser(m.UserID, events.Event{ Label: "workflow.assigned", Payload: payload, Ts: time.Now().UnixMilli(), diff --git a/server/handlers/workflow_packages.go b/server/handlers/workflow_packages.go index fc2820e..a6fc8d1 100644 --- a/server/handlers/workflow_packages.go +++ b/server/handlers/workflow_packages.go @@ -15,8 +15,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // WorkflowPackageHandler handles workflow package export and install. diff --git a/server/handlers/workflow_team.go b/server/handlers/workflow_team.go index b824996..e281cfe 100644 --- a/server/handlers/workflow_team.go +++ b/server/handlers/workflow_team.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) // ── Team-Scoped Workflow Wrappers (v0.31.2) ──────────────── diff --git a/server/handlers/workflow_test.go b/server/handlers/workflow_test.go index 16fde78..7639402 100644 --- a/server/handlers/workflow_test.go +++ b/server/handlers/workflow_test.go @@ -7,13 +7,13 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/config" - authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "chat-switchboard/config" + authpkg "chat-switchboard/auth" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // TestWorkflowCRUD exercises the full workflow lifecycle: diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index 3cf61ea..313f593 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -10,8 +10,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/store" ) // ── Workflow Handler ──────────────────────── @@ -106,10 +106,10 @@ func (h *WorkflowHandler) Create(c *gin.Context) { w.CreatedBy = c.GetString("user_id") - // v0.31.2: Team-scoped workflow creation (set by CreateTeamWorkflow) - if forceTeam, ok := c.Get("force_team_id"); ok { - teamID := forceTeam.(string) - w.TeamID = &teamID + // v0.31.2: team-scoped route injects force_team_id via CreateTeamWorkflow + if ftid, ok := c.Get("force_team_id"); ok { + tid := ftid.(string) + w.TeamID = &tid } if err := h.stores.Workflows.Create(c.Request.Context(), &w); err != nil { diff --git a/server/handlers/workspace_test.go b/server/handlers/workspace_test.go index 7f72cbe..01fa625 100644 --- a/server/handlers/workspace_test.go +++ b/server/handlers/workspace_test.go @@ -8,13 +8,13 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "chat-switchboard/config" + "chat-switchboard/database" + "chat-switchboard/middleware" + "chat-switchboard/models" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqlite "chat-switchboard/store/sqlite" ) // ── Workspace Test Harness ──────────────── diff --git a/server/handlers/workspaces.go b/server/handlers/workspaces.go index e6c9c8e..0918066 100644 --- a/server/handlers/workspaces.go +++ b/server/handlers/workspaces.go @@ -12,9 +12,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" - "git.gobha.me/xcaliber/chat-switchboard/workspace" + "chat-switchboard/models" + "chat-switchboard/store" + "chat-switchboard/workspace" ) // ── Request Types ─────────────────────────── diff --git a/server/health/accumulator.go b/server/health/accumulator.go index 2adcea4..052f178 100644 --- a/server/health/accumulator.go +++ b/server/health/accumulator.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) // ── Thresholds ────────────────────────────── diff --git a/server/health/accumulator_test.go b/server/health/accumulator_test.go index a837715..a5be29d 100644 --- a/server/health/accumulator_test.go +++ b/server/health/accumulator_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) // ── Mock Store ────────────────────────────── diff --git a/server/knowledge/embedder.go b/server/knowledge/embedder.go index 501ab1d..53d6138 100644 --- a/server/knowledge/embedder.go +++ b/server/knowledge/embedder.go @@ -5,9 +5,9 @@ import ( "fmt" "log" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/roles" + "chat-switchboard/store" ) // ── Configuration ──────────────────────────── diff --git a/server/knowledge/ingest.go b/server/knowledge/ingest.go index ef715da..d0efa4b 100644 --- a/server/knowledge/ingest.go +++ b/server/knowledge/ingest.go @@ -9,10 +9,10 @@ import ( "sync" "time" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/storage" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/notifications" + "chat-switchboard/storage" + "chat-switchboard/store" ) // ── Configuration ──────────────────────────── diff --git a/server/main.go b/server/main.go index d331a1d..87b1186 100644 --- a/server/main.go +++ b/server/main.go @@ -13,34 +13,34 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/compaction" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/extraction" - "git.gobha.me/xcaliber/chat-switchboard/filters" - "git.gobha.me/xcaliber/chat-switchboard/sandbox" - "git.gobha.me/xcaliber/chat-switchboard/handlers" - "git.gobha.me/xcaliber/chat-switchboard/health" - "git.gobha.me/xcaliber/chat-switchboard/knowledge" - "git.gobha.me/xcaliber/chat-switchboard/memory" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/pages" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/routing" - "git.gobha.me/xcaliber/chat-switchboard/scheduler" - "git.gobha.me/xcaliber/chat-switchboard/storage" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - sqliteStore "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" - "git.gobha.me/xcaliber/chat-switchboard/tools" - "git.gobha.me/xcaliber/chat-switchboard/tools/search" - "git.gobha.me/xcaliber/chat-switchboard/treepath" - "git.gobha.me/xcaliber/chat-switchboard/workspace" + "chat-switchboard/auth" + "chat-switchboard/compaction" + "chat-switchboard/config" + "chat-switchboard/crypto" + "chat-switchboard/database" + "chat-switchboard/events" + "chat-switchboard/extraction" + "chat-switchboard/filters" + "chat-switchboard/sandbox" + "chat-switchboard/handlers" + "chat-switchboard/health" + "chat-switchboard/knowledge" + "chat-switchboard/memory" + "chat-switchboard/middleware" + "chat-switchboard/notifications" + "chat-switchboard/pages" + "chat-switchboard/providers" + "chat-switchboard/roles" + "chat-switchboard/routing" + "chat-switchboard/scheduler" + "chat-switchboard/storage" + "chat-switchboard/store" + postgres "chat-switchboard/store/postgres" + sqliteStore "chat-switchboard/store/sqlite" + "chat-switchboard/tools" + "chat-switchboard/tools/search" + "chat-switchboard/treepath" + "chat-switchboard/workspace" ) func main() { @@ -400,9 +400,8 @@ func main() { // ── WebSocket Hub ───────────────────────── hub := events.NewHub(bus, middleware.GetAllowedOrigins(cfg)) - // ── WebSocket Ticket Store (v0.28.8) ───── - ticketStore := events.NewTicketStore() - defer ticketStore.Stop() + // ── WebSocket Ticket Store (v0.32.0: PG-backed for cross-pod) ───── + ticketAdapter := &events.TicketValidatorAdapter{Store: stores.Tickets} // ── Notification Service (v0.20.0) ─────── var notifSvc *notifications.Service @@ -457,8 +456,30 @@ func main() { }) }) + // v0.32.0: Kubernetes probe endpoints + // Liveness: process is alive and serving (no dependency checks). + base.GET("/healthz/live", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + // Readiness: process can serve traffic (PG reachable). + // Failing readiness pulls the pod from the Service — new requests + // route to healthy replicas. + base.GET("/healthz/ready", func(c *gin.Context) { + if database.DB == nil { + c.JSON(503, gin.H{"error": "database not initialized"}) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + if err := database.DB.PingContext(ctx); err != nil { + c.JSON(503, gin.H{"error": "database unavailable"}) + return + } + c.JSON(200, gin.H{"status": "ok"}) + }) + // WebSocket endpoint - base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketStore), hub.HandleWebSocket) + base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter), hub.HandleWebSocket) // ── Auth routes (rate limited) ────────────── authMode, err := auth.ParseMode(cfg.AuthMode) @@ -500,7 +521,7 @@ func main() { log.Printf(" 🔑 Auth mode: %s", authMode) authH := handlers.NewAuthHandler(cfg, stores, uekCache, authProvider) - authLimiter := middleware.NewRateLimiter(5, 5) + authLimiter := middleware.NewRateLimiter(stores.RateLimits, 5, 8) api := base.Group("/api/v1") { @@ -549,7 +570,7 @@ func main() { // Client fetches this, then connects with ?ticket=. protected.POST("/ws/ticket", func(c *gin.Context) { userID := c.GetString("user_id") - ticket, err := ticketStore.Issue(userID) + ticket, err := stores.Tickets.Issue(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue ticket"}) return @@ -596,7 +617,7 @@ func main() { Ts: time.Now().UnixMilli(), } for _, pid := range pids { - hub.SendToUser(pid, evt) + hub.PublishToUser(pid, evt) } } c.JSON(200, gin.H{"ok": true}) @@ -983,6 +1004,20 @@ func main() { teamAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub) teamScoped.GET("/assignments", teamAssignH.ListForTeam) + // Team workflows — self-service (v0.31.2) + teamWfH := handlers.NewWorkflowHandler(stores) + teamScoped.GET("/workflows", teamWfH.ListTeamWorkflows) + teamScoped.POST("/workflows", teamWfH.CreateTeamWorkflow) + teamScoped.GET("/workflows/:id", teamWfH.GetTeamWorkflow) + teamScoped.PATCH("/workflows/:id", teamWfH.UpdateTeamWorkflow) + teamScoped.DELETE("/workflows/:id", teamWfH.DeleteTeamWorkflow) + teamScoped.GET("/workflows/:id/stages", teamWfH.ListTeamWorkflowStages) + teamScoped.POST("/workflows/:id/stages", teamWfH.CreateTeamWorkflowStage) + teamScoped.PUT("/workflows/:id/stages/:sid", teamWfH.UpdateTeamWorkflowStage) + teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage) + teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages) + teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow) + // Team tasks — admin CRUD (v0.27.5) teamTaskH := handlers.NewTaskHandler(stores) teamScoped.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.CreateTeamTask) @@ -990,21 +1025,6 @@ func main() { teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Delete) teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.RunNow) teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.KillRun) - - // Team workflows — self-service CRUD (v0.31.2) - teamWfH := handlers.NewWorkflowHandler(stores) - teamScoped.GET("/workflows", teamWfH.ListTeamWorkflows) - teamScoped.POST("/workflows", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.CreateTeamWorkflow) - teamScoped.GET("/workflows/:id", teamWfH.GetTeamWorkflow) - teamScoped.PATCH("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.UpdateTeamWorkflow) - teamScoped.DELETE("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.DeleteTeamWorkflow) - teamScoped.GET("/workflows/:id/stages", teamWfH.ListTeamWorkflowStages) - teamScoped.POST("/workflows/:id/stages", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.CreateTeamWorkflowStage) - teamScoped.PUT("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.UpdateTeamWorkflowStage) - teamScoped.DELETE("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.DeleteTeamWorkflowStage) - teamScoped.PATCH("/workflows/:id/stages/reorder", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.ReorderTeamWorkflowStages) - teamScoped.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.PublishTeamWorkflow) - teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion) } // Team task viewing for all members (v0.27.5) diff --git a/server/memory/compactor.go b/server/memory/compactor.go index 6706925..7b94b67 100644 --- a/server/memory/compactor.go +++ b/server/memory/compactor.go @@ -7,10 +7,10 @@ import ( "log" "strings" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/models" + "chat-switchboard/providers" + "chat-switchboard/roles" + "chat-switchboard/store" ) // compactionThreshold is the minimum number of active memories before diff --git a/server/memory/extractor.go b/server/memory/extractor.go index 3176ef7..b29ecd7 100644 --- a/server/memory/extractor.go +++ b/server/memory/extractor.go @@ -7,12 +7,12 @@ import ( "log" "strings" - "git.gobha.me/xcaliber/chat-switchboard/knowledge" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/notifications" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/knowledge" + "chat-switchboard/models" + "chat-switchboard/notifications" + "chat-switchboard/providers" + "chat-switchboard/roles" + "chat-switchboard/store" ) // defaultExtractionPrompt is used when the persona has no custom prompt. diff --git a/server/memory/scanner.go b/server/memory/scanner.go index 3bd3caf..16bf684 100644 --- a/server/memory/scanner.go +++ b/server/memory/scanner.go @@ -6,8 +6,8 @@ import ( "sync" "time" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/database" + "chat-switchboard/store" ) // ScannerConfig holds tunable parameters for the background scanner. diff --git a/server/mentions/parser.go b/server/mentions/parser.go index 635a029..8aaab14 100644 --- a/server/mentions/parser.go +++ b/server/mentions/parser.go @@ -5,7 +5,7 @@ import ( "strings" "unicode" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) // Mention represents a parsed @mention token in message content. diff --git a/server/mentions/parser_test.go b/server/mentions/parser_test.go index f84e73e..0f1ab37 100644 --- a/server/mentions/parser_test.go +++ b/server/mentions/parser_test.go @@ -3,7 +3,7 @@ package mentions import ( "testing" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) func roster() []models.ChannelModel { diff --git a/server/middleware/auth.go b/server/middleware/auth.go index b8d0209..ecf11d1 100644 --- a/server/middleware/auth.go +++ b/server/middleware/auth.go @@ -11,9 +11,9 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/config" + "chat-switchboard/database" + "chat-switchboard/store" ) // Claims represents the JWT payload. Must match handlers.Claims. diff --git a/server/middleware/page_auth.go b/server/middleware/page_auth.go index b46c699..c84b426 100644 --- a/server/middleware/page_auth.go +++ b/server/middleware/page_auth.go @@ -7,9 +7,9 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/config" + "chat-switchboard/database" + "chat-switchboard/store" ) // AuthOrRedirect validates JWT tokens for page routes. diff --git a/server/middleware/permissions.go b/server/middleware/permissions.go index 82bf3a8..ff7fedd 100644 --- a/server/middleware/permissions.go +++ b/server/middleware/permissions.go @@ -5,8 +5,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/auth" + "chat-switchboard/store" ) const permCacheKey = "resolved_permissions" diff --git a/server/middleware/ratelimit.go b/server/middleware/ratelimit.go index 3c1a3ef..585b718 100644 --- a/server/middleware/ratelimit.go +++ b/server/middleware/ratelimit.go @@ -1,70 +1,46 @@ package middleware import ( + "log" "net/http" - "sync" - "time" "github.com/gin-gonic/gin" + + "chat-switchboard/store" ) -type visitor struct { - tokens float64 - lastSeen time.Time -} - -// RateLimiter implements a per-IP token bucket rate limiter. +// RateLimiter implements per-IP rate limiting backed by a shared store. +// v0.32.0: replaces in-memory token bucket for cross-pod consistency. +// +// Fail-open: if the store is unavailable (PG down), requests are allowed. +// Auth endpoints have their own protections (bcrypt, lockout); blocking +// legitimate logins due to a rate-limit DB failure is worse than a brief burst. type RateLimiter struct { - mu sync.Mutex - visitors map[string]*visitor - rate float64 // tokens per second - burst int // max tokens + store store.RateLimitStore + rate float64 + burst int } -// NewRateLimiter creates a rate limiter. -// rate is requests per second, burst is max burst size. -func NewRateLimiter(rate float64, burst int) *RateLimiter { - rl := &RateLimiter{ - visitors: make(map[string]*visitor), - rate: rate, - burst: burst, - } - - // Cleanup stale entries every 5 minutes - go func() { - for { - time.Sleep(5 * time.Minute) - rl.cleanup() - } - }() - - return rl +// NewRateLimiter creates a rate limiter backed by the given store. +// rate is requests per second, burst is max requests per window. +func NewRateLimiter(s store.RateLimitStore, rate float64, burst int) *RateLimiter { + return &RateLimiter{store: s, rate: rate, burst: burst} } // Limit returns a Gin middleware that enforces the rate limit. func (rl *RateLimiter) Limit() gin.HandlerFunc { return func(c *gin.Context) { - ip := c.ClientIP() + key := "auth:" + c.ClientIP() - rl.mu.Lock() - v, exists := rl.visitors[ip] - now := time.Now() - - if !exists { - v = &visitor{tokens: float64(rl.burst), lastSeen: now} - rl.visitors[ip] = v + allowed, err := rl.store.Allow(c.Request.Context(), key, rl.rate, rl.burst) + if err != nil { + // Fail open — don't block auth on rate limit DB failure. + log.Printf("[ratelimit] store error (allowing request): %v", err) + c.Next() + return } - // Refill tokens based on elapsed time - elapsed := now.Sub(v.lastSeen).Seconds() - v.tokens += elapsed * rl.rate - if v.tokens > float64(rl.burst) { - v.tokens = float64(rl.burst) - } - v.lastSeen = now - - if v.tokens < 1 { - rl.mu.Unlock() + if !allowed { c.Header("Retry-After", "1") c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ "error": "rate limit exceeded", @@ -72,21 +48,6 @@ func (rl *RateLimiter) Limit() gin.HandlerFunc { return } - v.tokens-- - rl.mu.Unlock() - c.Next() } } - -func (rl *RateLimiter) cleanup() { - rl.mu.Lock() - defer rl.mu.Unlock() - - cutoff := time.Now().Add(-10 * time.Minute) - for ip, v := range rl.visitors { - if v.lastSeen.Before(cutoff) { - delete(rl.visitors, ip) - } - } -} diff --git a/server/middleware/session_auth.go b/server/middleware/session_auth.go index 2c01cd9..284ce43 100644 --- a/server/middleware/session_auth.go +++ b/server/middleware/session_auth.go @@ -6,10 +6,10 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/auth" + "chat-switchboard/config" + "chat-switchboard/database" + "chat-switchboard/store" ) // AuthOrSession returns middleware that accepts either a normal JWT or a diff --git a/server/middleware/team.go b/server/middleware/team.go index 57417a2..408412a 100644 --- a/server/middleware/team.go +++ b/server/middleware/team.go @@ -5,7 +5,7 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // RequireTeamAdmin returns middleware that restricts access to team admins. diff --git a/server/models/models.go b/server/models/models.go index f7b99f7..c7ad465 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -92,7 +92,6 @@ type User struct { type Team struct { BaseModel Name string `json:"name" db:"name"` - Slug string `json:"slug" db:"slug"` Description string `json:"description,omitempty" db:"description"` CreatedBy string `json:"created_by" db:"created_by"` IsActive bool `json:"is_active" db:"is_active"` diff --git a/server/notelinks/extract.go b/server/notelinks/extract.go index 0e8971e..b32025d 100644 --- a/server/notelinks/extract.go +++ b/server/notelinks/extract.go @@ -4,7 +4,7 @@ import ( "regexp" "strings" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) // wikiRe matches [[Title]], [[Title|Display]], ![[Title]], and ![[Title|Display]]. diff --git a/server/notifications/email.go b/server/notifications/email.go index 61a66fa..a89ab7a 100644 --- a/server/notifications/email.go +++ b/server/notifications/email.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/store" ) // SMTPConfig holds SMTP connection settings, loaded from platform_settings. diff --git a/server/notifications/service.go b/server/notifications/service.go index 762b96f..d559265 100644 --- a/server/notifications/service.go +++ b/server/notifications/service.go @@ -7,9 +7,9 @@ import ( "sync" "time" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/events" + "chat-switchboard/models" + "chat-switchboard/store" ) // ── Package-level singleton ───────────────── @@ -103,7 +103,7 @@ func (s *Service) Notify(ctx context.Context, n *models.Notification) error { if s.hub != nil { payload, _ := json.Marshal(n) - s.hub.SendToUser(n.UserID, events.Event{ + s.hub.PublishToUser(n.UserID, events.Event{ Label: "notification.new", Payload: payload, Ts: time.Now().UnixMilli(), diff --git a/server/notifications/service_test.go b/server/notifications/service_test.go index 0f43f4c..b8c748d 100644 --- a/server/notifications/service_test.go +++ b/server/notifications/service_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) // mockPrefStore implements store.NotificationPreferenceStore for testing. diff --git a/server/notifications/sources.go b/server/notifications/sources.go index 3b84399..390c9a6 100644 --- a/server/notifications/sources.go +++ b/server/notifications/sources.go @@ -6,9 +6,9 @@ import ( "fmt" "log" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/events" + "chat-switchboard/models" + "chat-switchboard/store" ) // ── Role Fallback ─────────────────────────── diff --git a/server/notifications/sources_test.go b/server/notifications/sources_test.go index f783fd5..8e6c48d 100644 --- a/server/notifications/sources_test.go +++ b/server/notifications/sources_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) // mockNotifStore implements store.NotificationStore for testing. diff --git a/server/notifications/templates.go b/server/notifications/templates.go index 599ee07..f14d5ff 100644 --- a/server/notifications/templates.go +++ b/server/notifications/templates.go @@ -5,7 +5,7 @@ import ( "html/template" "strings" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) // ── Template Data ─────────────────────────── diff --git a/server/notifications/templates_test.go b/server/notifications/templates_test.go index f75849e..15e2c18 100644 --- a/server/notifications/templates_test.go +++ b/server/notifications/templates_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "git.gobha.me/xcaliber/chat-switchboard/models" + "chat-switchboard/models" ) func TestRenderHTML_Basic(t *testing.T) { diff --git a/server/pages/loaders.go b/server/pages/loaders.go index 3133977..cc838a1 100644 --- a/server/pages/loaders.go +++ b/server/pages/loaders.go @@ -7,8 +7,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/providers" + "chat-switchboard/store" ) // ── Types for template data ────────────────── diff --git a/server/pages/pages.go b/server/pages/pages.go index b834687..2a0dc19 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -25,8 +25,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/store" + "chat-switchboard/config" + "chat-switchboard/store" ) //go:embed templates/*.html templates/components/*.html templates/surfaces/*.html templates/admin/*.html @@ -700,23 +700,10 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc { scope := c.Param("id") slug := c.Param("slug") - // Resolve scope to team_id (v0.31.2: supports team slug) + // Resolve scope to team_id var teamID *string if scope != "global" { - // UUID format check: 36 chars with dashes at standard positions - if len(scope) == 36 && scope[8] == '-' && scope[13] == '-' { - teamID = &scope - } else if e.stores.Teams != nil { - // Try team slug lookup - t, err := e.stores.Teams.GetBySlug(ctx, scope) - if err == nil && t != nil { - teamID = &t.ID - } else { - teamID = &scope // fall through — will 404 downstream - } - } else { - teamID = &scope - } + teamID = &scope } if e.stores.Workflows == nil { diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html index ba82eda..bb0a696 100644 --- a/server/pages/templates/surfaces/chat.html +++ b/server/pages/templates/surfaces/chat.html @@ -374,7 +374,6 @@ window.addEventListener('unhandledrejection', function(e) { - @@ -419,9 +418,6 @@ window.addEventListener('unhandledrejection', function(e) {
- {{/* Workflows (v0.31.2) */}} - - {{/* Groups */}}