You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 9f67130
Browse filesBrowse the repository at this point in the historyBrowse files
## What & why
The Free-plan agent-message allowance was only a client-side hint
(`useIsFreePlan()` was hardcoded to `undefined`, so nothing was
enforced) and the running count was a live `COUNT(*)` over
`chat_messages` — which meant deleting a chat silently freed quota. This
PR makes the allowance a real server-side limit with a durable counter,
so the cap holds regardless of the client and a deleted chat can't
reclaim messages within the period.
The cloud side that fills the actual per-plan number is a separate PR
(TRI-12863 P0). Until it deploys the `agentMessages` limit key is
absent, which resolves to the repo's unlimited sentinel — the correct
fail-open default, and why this ships independently.
## What's inside
- **New counter table** `trigger_dashboard_agent.agent_message_usage`,
keyed `(organization_id, period)` where `period` is a UTC calendar month
`"YYYY-MM"`. FK-free (`pgSchema` convention), plus a drizzle migration.
Deliberately **not** joined to chats — that closes the delete-a-chat
hole. Distinct from TRI-13068's `AiUsageEvent`; neither is derived from
the other.
- **New service** `dashboardAgentQuota.server.ts`: a pure
`checkAgentMessageQuota({ used, limit })` (so the MCP send path can
reuse the rule later) plus an org-scoped resolver that reads the period
counter and the cached plan limit, and a `recordAgentMessageSent`
increment.
- **Counting on send**: the create path (head start) and the `.in`
append path each increment one user message. The append path counts only
after the existing `trigger === "action"` 403, so **wakes never count**.
- **Enforcement**: at/over the limit both send routes return `403 {
error: "message_quota_reached", limit }`, which the client renders as
`AgentUpgradeBlock` rather than a generic failure. Never a silent drop.
- **Client**: `?quota=1` now reads the period counter; `useIsFreePlan()`
is a real read gated on **billing presence** (no subscription →
self-hosted → no cap, no upgrade UI), not on the plan value.
## Key decisions
- **Fails open.** The cap is a nudge, not a security boundary. An absent
limit (self-hosted, or pre-P0) resolves to the unlimited sentinel
`100_000_000` (never `Infinity` — that serializes to `null` in the Redis
limit cache), and a counter read that throws returns "no cap".
Self-hosted needs zero extra branching — it falls out of the fallback,
with a test to prove it.
- **Billing-presence gate.** The upgrade UI keys off whether a
subscription exists, so a self-hosted install with no billing shows no
cap and no upsell label.
- **The `(org, period)` table closes the delete hole.** A standalone
counter, not a count over chat rows, so deleting a chat can't free quota
inside the period.
## Testing
`apps/webapp/test/dashboardAgentQuota.test.ts` (testcontainers, no
mocks):
- pure `checkAgentMessageQuota` under/at/over/unlimited (control-breaks
the `>=`);
- `agentTurnCountsAgainstQuota` counts a message but not a wake
(control-breaks the wake exclusion);
- the counter accumulates and a **deleted chat can't free quota** within
the period; other periods/orgs start fresh;
- the resolver reports reached over the limit and never reached when
unlimited;
- fails open when the limit is absent (self-hosted) and when the counter
read throws.
`TRI-12863`
Watches now respect your plan's limits: free plans can run a limited number of watches at once and for a shorter window, with a prompt to upgrade for more.
0 commit comments