Changeset 0.32.0 (#206)
This commit is contained in:
65
CHANGELOG.md
65
CHANGELOG.md
@@ -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 (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
|
## [0.31.2] — 2026-03-19
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
810
docs/DESIGN-0.32.0.md
Normal file
@@ -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 <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 (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.
|
||||||
@@ -30,7 +30,7 @@ v0.9.x–v0.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.x–v0.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 2–3 backend replicas across nodes for node-level availability.
|
Run 2–3 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 (0–15s).
|
||||||
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 | ~500KB–1MB |
|
|
||||||
| Per-WebSocket | ~64KB |
|
|
||||||
| DB pool | 25 max (shared) |
|
|
||||||
|
|
||||||
### v0.33.0 — Observability
|
### v0.33.0 — Observability
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 ────────────────────
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ────────────────────────
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package compaction
|
package compaction
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
"chat-switchboard/treepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Token Estimation ────────────────────────
|
// ── Token Estimation ────────────────────────
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 ────────────────────────────────
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
37
server/database/migrations/020_ha.sql
Normal file
37
server/database/migrations/020_ha.sql
Normal 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);
|
||||||
@@ -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,
|
||||||
|
|||||||
34
server/database/migrations/sqlite/020_ha.sql
Normal file
34
server/database/migrations/sqlite/020_ha.sql
Normal 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);
|
||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
25
server/events/ticket_adapter.go
Normal file
25
server/events/ticket_adapter.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module git.gobha.me/xcaliber/chat-switchboard
|
module chat-switchboard
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ──────────────────
|
||||||
|
|||||||
@@ -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 ────────────────
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ──────────────────
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ─────────────────────────
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ──────────
|
||||||
|
|||||||
@@ -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 ───────────────────────────
|
||||||
|
|||||||
@@ -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 ────────────────────
|
||||||
|
|||||||
@@ -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 ────────────────────────────
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ────────────────
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ──────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ────────────────
|
||||||
|
|||||||
@@ -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 ──────────────
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ──────────────
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ──────────────────
|
||||||
|
|||||||
@@ -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 ────────────────
|
||||||
|
|||||||
@@ -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 ──────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ────────────────────────
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ───────────────────
|
||||||
|
|||||||
@@ -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 ────────────────────────────────
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ───────────────────────────
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user