Changeset 0.32.0 (#206)

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

View File

@@ -1,5 +1,70 @@
# Changelog # 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 (015s) 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 ## [0.31.2] — 2026-03-19
### Summary ### Summary

View File

@@ -1 +1 @@
0.31.2 0.32.0

View File

@@ -22,6 +22,20 @@ spec:
imagePullSecrets: imagePullSecrets:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- 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: containers:
- name: backend - name: backend
image: {{ include "switchboard.backend.image" . }} image: {{ include "switchboard.backend.image" . }}
@@ -45,18 +59,21 @@ spec:
- name: data - name: data
mountPath: /data mountPath: /data
{{- end }} {{- end }}
# v0.32.0: Liveness — process alive (no dependency checks).
livenessProbe: livenessProbe:
httpGet: httpGet:
path: {{ .Values.basePath }}/api/v1/health path: {{ .Values.basePath }}/healthz/live
port: http port: http
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 30 periodSeconds: 30
# v0.32.0: Readiness — PG reachable. Failure pulls pod from Service.
readinessProbe: readinessProbe:
httpGet: httpGet:
path: {{ .Values.basePath }}/api/v1/health path: {{ .Values.basePath }}/healthz/ready
port: http port: http
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
failureThreshold: 3
{{- if .Values.persistence.enabled }} {{- if .Values.persistence.enabled }}
volumes: volumes:
- name: data - name: data

View File

@@ -7,7 +7,7 @@ backend:
repository: git.gobha.me/xcaliber/chat-switchboard repository: git.gobha.me/xcaliber/chat-switchboard
tag: "" # defaults to Chart.appVersion tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
replicaCount: 1 replicaCount: 2 # v0.32.0: multi-replica HA
resources: resources:
requests: requests:
cpu: 100m cpu: 100m

810
docs/DESIGN-0.32.0.md Normal file
View File

@@ -0,0 +1,810 @@
# DESIGN-0.32.0 — Multi-Replica HA
Run 23 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 <taskColumns>
```
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 <taskColumns> 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 (015s) 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.

View File

@@ -30,7 +30,7 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ │ │ │
Extension Track Operations Track 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.1 API Extensions ✅ v0.33.0 Observability
v0.29.2 DB Extensions ✅ v0.34.0 Data Portability v0.29.2 DB Extensions ✅ v0.34.0 Data Portability
v0.29.3 Workflow Forms ✅ │ v0.29.3 Workflow Forms ✅ │
@@ -39,7 +39,7 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
v0.30.2 Workflow Packages ✅ │ v0.30.2 Workflow Packages ✅ │
v0.31.0 Editor + SDK ✅ │ v0.31.0 Editor + SDK ✅ │
v0.31.1 SDK Exercise ✅ │ v0.31.1 SDK Exercise ✅ │
v0.31.2 Team Workflows │ v0.31.2 Team Workflows
│ │ │ │
│ v0.35.0 Workflow Product │ v0.35.0 Workflow Product
│ │ │ │
@@ -350,43 +350,39 @@ Depends on: v0.31.1.
Parallel to extension track. No Starlark dependency. Delivers Parallel to extension track. No Starlark dependency. Delivers
production readiness for the target multi-team deployment. production readiness for the target multi-team deployment.
### v0.32.0 — Multi-Replica HA ### v0.32.0 — Multi-Replica HA
Run 23 backend replicas across nodes for node-level availability. Run 23 backend replicas across nodes for node-level availability.
Depends on: v0.28.8. 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:** **What already works multi-replica:**
- REST API (stateless, JWT auth) ✅ - REST API (stateless, JWT auth) ✅
- PG + S3 + CephFS (shared infrastructure) ✅ - PG + S3 + CephFS (shared infrastructure) ✅
- `pg_broadcast` LISTEN/NOTIFY (cross-pod event bus) ✅ - `pg_broadcast` LISTEN/NOTIFY (cross-pod event bus) ✅
**What needs work:** **Delivered (5 changesets):**
- [ ] WebSocket fan-out: wire `Bus.Subscribe` to `pg_broadcast` listener - [x] Task scheduler: `FOR UPDATE SKIP LOCKED` atomic claim replaces
so events from other pods reach local WebSocket connections. `ListDue`. `CreateRunExclusive` conditional insert for
LISTEN/NOTIFY plumbing exists — bridge inbound NOTIFY into per-pod belt-and-suspenders uniqueness. Startup jitter (015s).
hub for local delivery. - [x] WebSocket cross-pod delivery: `PublishToUser` routes through
- [ ] Task scheduler leader election: Kubernetes `Lease`-based. Only bus → `pg_broadcast` → remote pod. 18 `SendToUser` call sites
leader runs cron scheduler. Prevents duplicate execution. migrated. `tool.result.*` routing changed to `DirBoth` for
- [ ] Shared ticket store: `TicketStore` from `sync.Map` → PG table cross-pod `WaitFor`. `TargetUserID` field on `Event`.
with 30s TTL. Ensures WS ticket from pod-1 validates on pod-2. - [x] Shared ticket store: `ws_tickets` PG table with 30s TTL,
- [ ] Shared rate limiter: in-memory → PG or Redis counter. Without atomic `DELETE ... RETURNING` validation. Replaces `sync.Map`.
this, effective rate limit = N × configured across N replicas. - [x] Shared rate limiter: `rate_limit_counters` PG table with
- [ ] Health check refinement: readiness probe fails fast on PG fixed-window counters. Fail-open policy on DB errors.
connection loss. - [x] Health probes: `/healthz/ready` (PG ping, 2s timeout),
- [ ] Helm: `backend.replicaCount` > 1 tested. Pod anti-affinity `/healthz/live` (process alive). Helm: `replicaCount: 2`,
to spread across nodes. pod anti-affinity, readiness/liveness probes.
**Sizing (measured on v0.28.8):** **Schema:** `020_ha.sql` — `ws_tickets`, `rate_limit_counters`.
| Metric | Value |
|--------|-------|
| Idle memory | 17Mi |
| Peak (569 reqs, 4 users, SSE) | 216Mi |
| Settled after GC | 34Mi |
| Per-SSE stream | ~500KB1MB |
| Per-WebSocket | ~64KB |
| DB pool | 25 max (shared) |
### v0.33.0 — Observability ### v0.33.0 — Observability

View File

@@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
type Mode string type Mode string

View File

@@ -9,8 +9,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
const bcryptCost = 12 const bcryptCost = 12

View File

@@ -8,8 +8,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// MTLSConfig holds mTLS-specific configuration. // MTLSConfig holds mTLS-specific configuration.

View File

@@ -3,7 +3,7 @@ package auth
import ( import (
"testing" "testing"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
func TestParseDN(t *testing.T) { func TestParseDN(t *testing.T) {

View File

@@ -18,8 +18,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// OIDCConfig holds OIDC-specific configuration from env vars. // OIDCConfig holds OIDC-specific configuration from env vars.

View File

@@ -3,7 +3,7 @@ package auth_test
import ( import (
"testing" "testing"
"git.gobha.me/xcaliber/chat-switchboard/auth" "chat-switchboard/auth"
) )
func TestOIDCProvider_Discovery(t *testing.T) { func TestOIDCProvider_Discovery(t *testing.T) {

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"time" "time"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in // EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in

View File

@@ -9,9 +9,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
const sessionCookieName = "sb_session" const sessionCookieName = "sb_session"

View File

@@ -3,7 +3,7 @@ package capabilities
import ( import (
"testing" "testing"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
) )
func TestLookupKnownModel_AlwaysFalse(t *testing.T) { func TestLookupKnownModel_AlwaysFalse(t *testing.T) {

View File

@@ -4,7 +4,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
) )
// ── Known Model Defaults ──────────────────── // ── Known Model Defaults ────────────────────

View File

@@ -4,8 +4,8 @@ import (
"context" "context"
"log" "log"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ModelsForUser returns all models and Personas visible to a user. // ModelsForUser returns all models and Personas visible to a user.

View File

@@ -8,11 +8,11 @@ import (
"strings" "strings"
"time" "time"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles" "chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/treepath" "chat-switchboard/treepath"
) )
// ── Request / Result ──────────────────────── // ── Request / Result ────────────────────────

View File

@@ -6,9 +6,9 @@ import (
"testing" "testing"
"time" "time"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
) )
// ── Helpers ───────────────────────────────── // ── Helpers ─────────────────────────────────

View File

@@ -1,7 +1,7 @@
package compaction package compaction
import ( import (
"git.gobha.me/xcaliber/chat-switchboard/treepath" "chat-switchboard/treepath"
) )
// ── Token Estimation ──────────────────────── // ── Token Estimation ────────────────────────

View File

@@ -3,7 +3,7 @@ package compaction
import ( import (
"testing" "testing"
"git.gobha.me/xcaliber/chat-switchboard/treepath" "chat-switchboard/treepath"
) )
func TestEstimateTokens(t *testing.T) { func TestEstimateTokens(t *testing.T) {

View File

@@ -6,10 +6,10 @@ import (
"sync" "sync"
"time" "time"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/roles" "chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/treepath" "chat-switchboard/treepath"
) )
// ── Defaults ──────────────────────────────── // ── Defaults ────────────────────────────────

View File

@@ -4,7 +4,7 @@ import (
"os" "os"
"testing" "testing"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {

View File

@@ -12,7 +12,7 @@ import (
_ "github.com/lib/pq" _ "github.com/lib/pq"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
) )
// DB is the application-wide database connection pool. // DB is the application-wide database connection pool.

View File

@@ -13,7 +13,6 @@
CREATE TABLE IF NOT EXISTS teams ( CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL UNIQUE,
slug VARCHAR(200) NOT NULL DEFAULT '' UNIQUE,
description TEXT DEFAULT '', description TEXT DEFAULT '',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,

View File

@@ -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);

View File

@@ -4,7 +4,6 @@
CREATE TABLE IF NOT EXISTS teams ( CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL DEFAULT '' UNIQUE,
description TEXT DEFAULT '', description TEXT DEFAULT '',
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,

View File

@@ -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);

View File

@@ -115,7 +115,7 @@ func TestRouteFor(t *testing.T) {
{"pong", DirToClient}, {"pong", DirToClient},
// Tool bridge routes // Tool bridge routes
{"tool.call.abc123", DirToClient}, {"tool.call.abc123", DirToClient},
{"tool.result.abc123", DirFromClient}, {"tool.result.abc123", DirBoth}, // v0.32.0: DirBoth for cross-pod WaitFor
// Extension lifecycle // Extension lifecycle
{"extension.loaded", DirLocal}, {"extension.loaded", DirLocal},
{"extension.error", 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") { if !ShouldAcceptFromClient("tool.result.abc123") {
t.Error("tool.result.* should be accepted from client") t.Error("tool.result.* should be accepted from client")
} }
if ShouldSendToClient("tool.result.abc123") { if !ShouldSendToClient("tool.result.abc123") {
t.Error("tool.result.* should NOT be sent to client") t.Error("tool.result.* should route DirBoth (filtered by WS subscriber, not routing table)")
} }
} }

View File

@@ -23,7 +23,7 @@ import (
"github.com/lib/pq" "github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
) )
const pgNotifyChannel = "switchboard_events" const pgNotifyChannel = "switchboard_events"

View File

@@ -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)
}

View File

@@ -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": "<opaque>"}.
// 3. Client connects WebSocket with ?ticket=<opaque>.
// 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
}
}
}

View File

@@ -9,6 +9,11 @@ type Event struct {
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
Ts int64 `json:"ts"` 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) // Server-side metadata (not serialized to clients)
SenderID string `json:"-"` // user ID of sender, empty for system events SenderID string `json:"-"` // user ID of sender, empty for system events
ConnID string `json:"-"` // WebSocket connection ID, empty for server-origin ConnID string `json:"-"` // WebSocket connection ID, empty for server-origin
@@ -60,7 +65,7 @@ var routeTable = map[string]Direction{
"role.fallback": DirToClient, "role.fallback": DirToClient,
// Notifications (v0.20.0) // 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 "notification.read": DirToClient, // badge sync across tabs
// Workspace (v0.21.5) // Workspace (v0.21.5)
@@ -79,7 +84,7 @@ var routeTable = map[string]Direction{
// Tool execution (browser tools bridge) // Tool execution (browser tools bridge)
"tool.call.": DirToClient, // Server → specific client (browser tool invocation) "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 lifecycle
"extension.loaded": DirLocal, // Client-only "extension.loaded": DirLocal, // Client-only

View File

@@ -170,9 +170,17 @@ func (h *Hub) IsConnected(userID string) bool {
return false return false
} }
// SendToUser sends an event directly to all WebSocket connections for a user, // PublishToUser sends an event to a specific user via the bus.
// bypassing room filtering. Used for targeted tool.call delivery. // v0.32.0: Cross-pod safe — the bus broadcast hook fans out via pg_notify.
func (h *Hub) SendToUser(userID string, event Event) { // 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) data, err := json.Marshal(event)
if err != nil { if err != nil {
return return
@@ -226,6 +234,18 @@ func (c *Conn) subscribeToBus() {
return 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 // Don't echo typing events back to the sender
if e.Label == "chat.typing" || e.ConnID == c.id { if e.Label == "chat.typing" || e.ConnID == c.id {
if e.SenderID == c.userID && e.ConnID == c.id { if e.SenderID == c.userID && e.ConnID == c.id {

View File

@@ -9,9 +9,9 @@ import (
"context" "context"
"log" "log"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/sandbox" "chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// DiscoverStarlarkFilters scans the package registry for active starlark // DiscoverStarlarkFilters scans the package registry for active starlark

View File

@@ -13,7 +13,7 @@ import (
"fmt" "fmt"
"log" "log"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// KBInjectFilter injects a system prompt listing active knowledge bases // KBInjectFilter injects a system prompt listing active knowledge bases

View File

@@ -24,8 +24,8 @@ import (
"go.starlark.net/starlark" "go.starlark.net/starlark"
"git.gobha.me/xcaliber/chat-switchboard/sandbox" "chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// StarlarkFilter runs a Starlark package's on_pre_completion entry point. // StarlarkFilter runs a Starlark package's on_pre_completion entry point.

View File

@@ -1,4 +1,4 @@
module git.gobha.me/xcaliber/chat-switchboard module chat-switchboard
go 1.22 go 1.22

View File

@@ -12,14 +12,14 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/auth" "chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/storage" "chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools/search" "chat-switchboard/tools/search"
) )
type AdminHandler struct { type AdminHandler struct {

View File

@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/notifications" "chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// AdminEmailHandler handles admin SMTP configuration and test endpoints. // AdminEmailHandler handles admin SMTP configuration and test endpoints.

View File

@@ -7,9 +7,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ProviderConfigHandler handles user-facing provider config endpoints. // ProviderConfigHandler handles user-facing provider config endpoints.

View File

@@ -8,8 +8,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// AuditLog writes an audit entry via the store interface. // AuditLog writes an audit entry via the store interface.

View File

@@ -17,11 +17,11 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/auth" "chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// Claims represents the JWT payload. // Claims represents the JWT payload.

View File

@@ -14,9 +14,9 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/auth" "chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
func testConfig() *config.Config { func testConfig() *config.Config {

View File

@@ -15,8 +15,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
const avatarSize = 128 const avatarSize = 128

View File

@@ -8,11 +8,11 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" capspkg "chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/auth" "chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/health" "chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ModelHandler provides the unified models endpoint. // ModelHandler provides the unified models endpoint.

View File

@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Channel Models Handler ────────────────── // ── Channel Models Handler ──────────────────

View File

@@ -9,8 +9,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Request / Response types ──────────────── // ── Request / Response types ────────────────

View File

@@ -13,22 +13,22 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/auth" "chat-switchboard/auth"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" capspkg "chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events" "chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/filters" "chat-switchboard/filters"
"git.gobha.me/xcaliber/chat-switchboard/health" "chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/knowledge" "chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications" "chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/routing" "chat-switchboard/routing"
"git.gobha.me/xcaliber/chat-switchboard/sandbox" "chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/storage" "chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools" "chat-switchboard/tools"
) )
// ── Request Types ─────────────────────────── // ── Request Types ───────────────────────────
@@ -280,7 +280,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),
} }
// Send to sender // Send to sender
h.hub.SendToUser(userID, evt) h.hub.PublishToUser(userID, evt)
// Send to other user participants in the channel // Send to other user participants in the channel
pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(` pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT participant_id FROM channel_participants SELECT participant_id FROM channel_participants
@@ -291,7 +291,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
for pRows.Next() { for pRows.Next() {
var pid string var pid string
if pRows.Scan(&pid) == nil { 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, "from_user": userID,
"content": truncateContent(req.Content, 120), "content": truncateContent(req.Content, 120),
}) })
h.hub.SendToUser(mentionedUserID, events.Event{ h.hub.PublishToUser(mentionedUserID, events.Event{
Label: "user.mentioned", Label: "user.mentioned",
Payload: payload, Payload: payload,
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),

View File

@@ -7,11 +7,11 @@ import (
"log" "log"
"time" "time"
"git.gobha.me/xcaliber/chat-switchboard/events" "chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "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. // maxChainDepth limits AI-to-AI chaining to prevent runaway loops.
@@ -175,7 +175,7 @@ func (h *CompletionHandler) chainIfMentioned(
"tokens_used": resp.InputTokens + resp.OutputTokens, "tokens_used": resp.InputTokens + resp.OutputTokens,
"chain_depth": depth + 1, "chain_depth": depth + 1,
}) })
h.hub.SendToUser(userID, events.Event{ h.hub.PublishToUser(userID, events.Event{
Label: "message.created", Label: "message.created",
Payload: payload, Payload: payload,
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),
@@ -314,7 +314,7 @@ func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, trigger
"tokens_used": resp.InputTokens + resp.OutputTokens, "tokens_used": resp.InputTokens + resp.OutputTokens,
"chain_depth": depth, "chain_depth": depth,
}) })
h.hub.SendToUser(userID, events.Event{ h.hub.PublishToUser(userID, events.Event{
Label: "message.created", Label: "message.created",
Payload: payload, Payload: payload,
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),
@@ -350,7 +350,7 @@ func (h *CompletionHandler) emitTyping(userID, channelID, participantID, display
"participant_type": "persona", "participant_type": "persona",
"display_name": displayName, "display_name": displayName,
}) })
h.hub.SendToUser(userID, events.Event{ h.hub.PublishToUser(userID, events.Event{
Label: eventType, Label: eventType,
Payload: payload, Payload: payload,
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),

View File

@@ -42,9 +42,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.starlark.net/starlark" "go.starlark.net/starlark"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/sandbox" "chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ExtAPIHandler serves extension API routes via Starlark. // ExtAPIHandler serves extension API routes via Starlark.

View File

@@ -15,7 +15,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// validSchemaIdentifier matches safe SQL identifiers for table and column names. // validSchemaIdentifier matches safe SQL identifiers for table and column names.

View File

@@ -8,7 +8,7 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── mock ExtDataStore ──────────────────────────────────────────────────────── // ── mock ExtDataStore ────────────────────────────────────────────────────────

View File

@@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ExtPermHandler serves extension permission management endpoints. // ExtPermHandler serves extension permission management endpoints.

View File

@@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ExtSecretsHandler serves extension secret management endpoints. // ExtSecretsHandler serves extension secret management endpoints.

View File

@@ -11,13 +11,13 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" authpkg "chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "chat-switchboard/store/sqlite"
) )
// ── Extension Test Harness ────────────────── // ── Extension Test Harness ──────────────────

View File

@@ -7,9 +7,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ExtensionHandler serves extension management endpoints. // ExtensionHandler serves extension management endpoints.

View File

@@ -12,10 +12,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/extraction" "chat-switchboard/extraction"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/storage" "chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Default Limits ───────────────────────── // ── Default Limits ─────────────────────────

View File

@@ -10,8 +10,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
type FolderHandler struct { type FolderHandler struct {

View File

@@ -11,10 +11,10 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/workspace" "chat-switchboard/workspace"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -7,13 +7,13 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "chat-switchboard/store/sqlite"
) )
// ── Git Credentials Test Harness ────────── // ── Git Credentials Test Harness ──────────

View File

@@ -7,11 +7,11 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/auth" "chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications" "chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Request types ─────────────────────────── // ── Request types ───────────────────────────

View File

@@ -6,11 +6,11 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" capspkg "chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/health" "chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Health Admin Handler ──────────────────── // ── Health Admin Handler ────────────────────

View File

@@ -15,16 +15,16 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" authpkg "chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/roles" "chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "chat-switchboard/store/sqlite"
"git.gobha.me/xcaliber/chat-switchboard/treepath" "chat-switchboard/treepath"
) )
// ── Test Harness ──────────────────────────── // ── Test Harness ────────────────────────────

View File

@@ -11,10 +11,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/knowledge" "chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/storage" "chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Limits ─────────────────────────────────── // ── Limits ───────────────────────────────────

View File

@@ -7,14 +7,14 @@ import (
"strings" "strings"
"testing" "testing"
"git.gobha.me/xcaliber/chat-switchboard/compaction" "chat-switchboard/compaction"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/roles" "chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "chat-switchboard/store/sqlite"
"git.gobha.me/xcaliber/chat-switchboard/treepath" "chat-switchboard/treepath"
) )
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════

View File

@@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
) )
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════

View File

@@ -5,9 +5,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/memory" "chat-switchboard/memory"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// MemoryHandler provides REST endpoints for memory management. // MemoryHandler provides REST endpoints for memory management.

View File

@@ -6,9 +6,9 @@ import (
"log" "log"
"strings" "strings"
"git.gobha.me/xcaliber/chat-switchboard/knowledge" "chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// maxMemoryChars is the approximate character budget for injected memories. // maxMemoryChars is the approximate character budget for injected memories.

View File

@@ -4,8 +4,8 @@ import (
"net/http" "net/http"
"testing" "testing"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
) )
// seedMemory inserts a memory directly into the DB for testing. // seedMemory inserts a memory directly into the DB for testing.

View File

@@ -10,15 +10,15 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" capspkg "chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/events" "chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/storage" "chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools" "chat-switchboard/tools"
"git.gobha.me/xcaliber/chat-switchboard/treepath" "chat-switchboard/treepath"
) )
// ── Request / Response types ──────────────── // ── Request / Response types ────────────────

View File

@@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ModelPrefsHandler handles user model preference endpoints. // ModelPrefsHandler handles user model preference endpoints.

View File

@@ -8,12 +8,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "chat-switchboard/store/sqlite"
) )
// ── Model Prefs Test Harness ────────────── // ── Model Prefs Test Harness ──────────────

View File

@@ -10,9 +10,9 @@ import (
"strconv" "strconv"
"time" "time"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// providerSyncTimeout is the maximum time allowed for an outbound provider // providerSyncTimeout is the maximum time allowed for an outbound provider

View File

@@ -7,9 +7,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notelinks" "chat-switchboard/notelinks"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Request / Response Types ──────────────── // ── Request / Response Types ────────────────

View File

@@ -8,14 +8,14 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications" "chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "chat-switchboard/store/sqlite"
) )
// ── Notification Test Harness ────────────── // ── Notification Test Harness ──────────────

View File

@@ -10,10 +10,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/events" "chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications" "chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Handler ───────────────────────────────── // ── Handler ─────────────────────────────────
@@ -111,7 +111,7 @@ func (h *NotificationHandler) MarkRead(c *gin.Context) {
// Sync badge across tabs // Sync badge across tabs
if h.hub != nil { if h.hub != nil {
payload, _ := json.Marshal(map[string]string{"id": id}) payload, _ := json.Marshal(map[string]string{"id": id})
h.hub.SendToUser(userID, events.Event{ h.hub.PublishToUser(userID, events.Event{
Label: "notification.read", Label: "notification.read",
Payload: payload, Payload: payload,
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),
@@ -138,7 +138,7 @@ func (h *NotificationHandler) MarkAllRead(c *gin.Context) {
// Sync badge across tabs // Sync badge across tabs
if h.hub != nil { if h.hub != nil {
payload, _ := json.Marshal(map[string]string{"action": "mark_all_read"}) 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", Label: "notification.read",
Payload: payload, Payload: payload,
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),
@@ -341,7 +341,7 @@ func (h *NotificationHandler) Broadcast(c *gin.Context) {
"level": level, "level": level,
}) })
for _, uid := range userIDs { for _, uid := range userIDs {
h.hub.SendToUser(uid, events.Event{ h.hub.PublishToUser(uid, events.Event{
Label: "system.broadcast", Label: "system.broadcast",
Payload: payload, Payload: payload,
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),

View File

@@ -18,7 +18,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// PackageExportHandler handles package export operations. // PackageExportHandler handles package export operations.

View File

@@ -28,8 +28,8 @@ import (
"go.starlark.net/starlark" "go.starlark.net/starlark"
"git.gobha.me/xcaliber/chat-switchboard/sandbox" "chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ParseSchemaVersion extracts the "schema_version" integer from a manifest. // ParseSchemaVersion extracts the "schema_version" integer from a manifest.

View File

@@ -29,7 +29,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// RegistryEntry represents a single package in the registry. // RegistryEntry represents a single package in the registry.

View File

@@ -14,9 +14,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/sandbox" "chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// validPackageID matches lowercase alphanumeric slugs with optional hyphens. // validPackageID matches lowercase alphanumeric slugs with optional hyphens.

View File

@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Channel Participants Handler ────────────── // ── Channel Participants Handler ──────────────

View File

@@ -10,8 +10,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
type PersonaGroupHandler struct { type PersonaGroupHandler struct {

View File

@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// PersonaHandler handles persona endpoints. // PersonaHandler handles persona endpoints.

View File

@@ -15,7 +15,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
const presenceOnlineThreshold = 90 * time.Second const presenceOnlineThreshold = 90 * time.Second

View File

@@ -8,12 +8,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "chat-switchboard/store/sqlite"
) )
// ── Profile Test Harness ────────────────── // ── Profile Test Harness ──────────────────

View File

@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Request / Response Types ──────────────── // ── Request / Response Types ────────────────

View File

@@ -8,13 +8,13 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "chat-switchboard/store/sqlite"
) )
// ── Project Test Harness ────────────────── // ── Project Test Harness ──────────────────

View File

@@ -10,10 +10,10 @@ import (
"context" "context"
"fmt" "fmt"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/sandbox" "chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ProviderResolverAdapter implements sandbox.ProviderResolver using // ProviderResolverAdapter implements sandbox.ProviderResolver using

View File

@@ -17,10 +17,10 @@ import (
"fmt" "fmt"
"log" "log"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools" "chat-switchboard/tools"
) )
// ── Provider Resolution ──────────────────────── // ── Provider Resolution ────────────────────────

View File

@@ -5,10 +5,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles" "chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// RolesHandler manages model role configuration. // RolesHandler manages model role configuration.

View File

@@ -7,14 +7,14 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" authpkg "chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/pages" "chat-switchboard/pages"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "chat-switchboard/store/sqlite"
) )
// TestRouteRegistration verifies that ALL production routes can be // TestRouteRegistration verifies that ALL production routes can be

View File

@@ -6,10 +6,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/health" "chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/routing" "chat-switchboard/routing"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Routing Admin Handler ─────────────────── // ── Routing Admin Handler ───────────────────

View File

@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/lib/pq" "github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database" "chat-switchboard/database"
) )
// ── SafeJSON ──────────────────────────────── // ── SafeJSON ────────────────────────────────

View File

@@ -7,7 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// builtinManifest is the on-disk manifest.json shape. // builtinManifest is the on-disk manifest.json shape.

View File

@@ -5,10 +5,10 @@ import (
"log" "log"
"strings" "strings"
"git.gobha.me/xcaliber/chat-switchboard/config" "chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/models" "chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// Known provider default endpoints for seeding. // Known provider default endpoints for seeding.

View File

@@ -15,8 +15,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// ── Request Types ─────────────────────────── // ── Request Types ───────────────────────────

View File

@@ -5,7 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/storage" "chat-switchboard/storage"
) )
// storageConfigured is a package-level flag set during init. // storageConfigured is a package-level flag set during init.

View File

@@ -11,11 +11,11 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/events" "chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/providers" "chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/sandbox" "chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools" "chat-switchboard/tools"
) )
// recordHealthFn records provider health from standalone streaming functions. // 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), "arguments": json.RawMessage(call.Arguments),
}) })
hub.SendToUser(userID, events.Event{ hub.PublishToUser(userID, events.Event{
Label: "tool.call." + callID, Label: "tool.call." + callID,
Payload: payload, Payload: payload,
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),

View File

@@ -9,9 +9,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/compaction" "chat-switchboard/compaction"
"git.gobha.me/xcaliber/chat-switchboard/roles" "chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store" "chat-switchboard/store"
) )
// SummarizeHandler handles manual conversation summarization via HTTP. // SummarizeHandler handles manual conversation summarization via HTTP.

Some files were not shown because too many files have changed in this diff Show More