feat(webapp): enforce watch plan limits - #4556
Conversation
Refuse a watch whose window exceeds the plan's agentWatchMaxHours, or that would push the org past its agentWatchers count, with a new watch_limit_reached result carrying an upgrade hint. Plan limits are a floor below the existing code ceilings (min(plan, WATCH_MAX_HOURS=24) and the per-chat cap of 3, which still apply independently). Fails open: an absent limit resolves to unlimited, so self-hosted is unaffected and the upgrade nudge is gated on billing presence. TRI-12863
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…63' into feat/agent-watch-limits-tri-12863
| return { | ||
| ok: false, | ||
| code: "watch_limit_reached", | ||
| error: hint("That watch window is longer than your plan allows."), | ||
| }; | ||
| } |
There was a problem hiding this comment.
🟡 Dashboard watch card refusals from plan limits return a server error instead of a normal rejection
The new plan-limit refusal is returned with a code the dashboard's watch-submit endpoint doesn't recognise (code: "watch_limit_reached" at apps/webapp/app/services/dashboardAgentWatches.server.ts:345), so a perfectly ordinary "your plan doesn't allow this" answer is sent back as an internal server error.
Impact: Users hitting their plan's watch limit from the dashboard card trigger 500 responses, which pollute error monitoring and can be treated as outages rather than expected rejections.
Status mapping in the dashboard route lacks the new refusal code
createDashboardAgentWatch now returns watch_limit_reached for both the window floor (apps/webapp/app/services/dashboardAgentWatches.server.ts:342-348) and the watcher-count floor (apps/webapp/app/services/dashboardAgentWatches.server.ts:367-374). This code propagates through submitDashboardAgentWatch (SubmitWatchErrorCode = CreateWatchErrorCode | "request_conflict").
The MCP route was updated (apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts:113-118 maps it to 409), but the dashboard resource route's ladder at apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts:542-551 only lists limit_reached, duplicate, request_conflict, invalid_target, chat_not_found, not_configured, and otherwise falls through to 500. A plan-limit refusal therefore returns HTTP 500 with the upgrade message in the body.
Prompt for agents
The new refusal code `watch_limit_reached` produced by createDashboardAgentWatch is not handled by the dashboard watch-submit route's HTTP status ladder in apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts (around lines 542-551), so plan-limit refusals fall through to 500. The MCP route api.v1.dashboard-agent.watches.ts was updated to map it to 409. Update the dashboard route's mapping to treat watch_limit_reached the same way (409), keeping the two routes consistent.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // Plan floors sit below the code ceilings (min(plan, ceiling)). Fails open: an absent | ||
| // limit resolves to unlimited, so neither floor bites on self-hosted. | ||
| const planLimits = await resolveLimits(environment.organizationId); | ||
| if (spec.maxHours > effectiveWatchMaxHours(planLimits.maxHours)) { | ||
| return { | ||
| ok: false, | ||
| code: "watch_limit_reached", | ||
| error: hint("That watch window is longer than your plan allows."), | ||
| }; | ||
| } |
There was a problem hiding this comment.
🔍 Window floor also refuses one-shots, unlike the watcher-count floor
The window check runs before the immediate check, while the watcher-count check deliberately runs after it (apps/webapp/app/services/dashboardAgentWatches.server.ts:364-374) so that a one-shot consumes no slot. Consequence: a request whose condition is already satisfied — which would create no row at all — is still refused purely because the requested window exceeds the plan's agentWatchMaxHours. If the intent is "plan limits constrain what is actually persisted", the window check should arguably also sit after the immediate check, or the card should clamp the window instead of refusing.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted, so | ||
| // the fallback applies and the plan floor is off. | ||
| const WATCH_MAX_HOURS_LIMIT_KEY = "agentWatchMaxHours" as keyof Limits; | ||
| const WATCH_COUNT_LIMIT_KEY = "agentWatchers" as keyof Limits; |
There was a problem hiding this comment.
🔍 Limit keys are cast to keyof Limits before they exist in the platform type
agentWatchMaxHours and agentWatchers are asserted into keyof Limits. Until the cloud billing side ships those keys, getLimit will never find them and both resolve to the unlimited sentinel — the documented fail-open behaviour. Worth tracking that the string names here match exactly what billing emits; a typo would silently keep limits disabled forever with no signal, since the fallback path is indistinguishable from "not configured".
Was this helpful? React with 👍 or 👎 to provide feedback.
| return { | ||
| ok: false, | ||
| code: "watch_limit_reached", | ||
| error: hint("That watch window is longer than your plan allows."), | ||
| }; |
There was a problem hiding this comment.
🟡 Dashboard watch refusals caused by plan limits are returned as server errors
A watch turned down for exceeding the plan's allowance is reported through the new refusal code (code: "watch_limit_reached" at apps/webapp/app/services/dashboardAgentWatches.server.ts:345) that the dashboard endpoint doesn't recognise, so the refusal is sent back as an internal server error instead of a normal "you've hit your limit" answer.
Impact: Ordinary plan-limit refusals from the dashboard watch card look like server outages in logs and monitoring, and any client that keys off the response status treats a routine limit as a failure.
Status mapping in the dashboard resource route was not extended alongside the API route
apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts:113-117 was updated to map watch_limit_reached to 409, but the card-submit route at apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts:541-551 still only lists limit_reached, duplicate, request_conflict (409), invalid_target/chat_not_found (404) and not_configured (501); anything else falls through to 500. submitDashboardAgentWatch propagates the create result verbatim through refuse(result) (apps/webapp/app/services/dashboardAgentWatches.server.ts:977), so the new code reaches that mapping. The panel renders data.error regardless of status, so the user-visible text is right, but the HTTP status is wrong (5xx for a user-level refusal).
Prompt for agents
The new refusal code `watch_limit_reached` (returned from createDashboardAgentWatch in apps/webapp/app/services/dashboardAgentWatches.server.ts) is mapped to HTTP 409 in apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts, but the dashboard card-submit route (apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts, the `watch-create` intent branch around the `submitDashboardAgentWatch` result handling) still maps only the older codes, so `watch_limit_reached` falls through to the 500 default. Extend that route's status mapping so plan-limit refusals are returned as 409 like the API route does, ideally by sharing one code->status helper between the two routes so they cannot drift again.
Was this helpful? React with 👍 or 👎 to provide feedback.
| async function readLimit(organizationId: string, key: keyof Limits): Promise<number> { | ||
| const cached = await getCachedLimit(organizationId, key, UNLIMITED_WATCH_LIMIT); | ||
| // A cache error leaves `val` empty; fall open to unlimited. | ||
| return cached.val ?? UNLIMITED_WATCH_LIMIT; | ||
| } |
There was a problem hiding this comment.
🔍 A plan limit configured as 0 is treated as unlimited
readLimit delegates to getCachedLimit -> getLimit, which does if (!result) return fallback (apps/webapp/app/services/platform.v3.server.ts:426). A plan that legitimately sets agentWatchers: 0 or agentWatchMaxHours: 0 is therefore indistinguishable from an absent limit and falls open to the unlimited sentinel. Worth knowing before the cloud side (TRI-12863 P0) starts publishing these keys — a "no watches" tier cannot be expressed with 0.
Was this helpful? React with 👍 or 👎 to provide feedback.
What & why. Watches now honour a plan's watch limits. A watch whose window exceeds the plan's
agentWatchMaxHours, or that would push the org past itsagentWatcherscount, is refused with a newwatch_limit_reachedresult and an upgrade hint (a chat line on the card, HTTP 409 on the API).Key decisions.
min(plan, WATCH_MAX_HOURS=24)for the window, and the per-chat cap of 3 still applies independently. Plans only tighten, never loosen.isBillingConfigured().TRI-12863