Skip to content

feat(webapp): enforce watch plan limits - #4556

Open
kathiekiwi wants to merge 2 commits into
feat/agent-message-quota-tri-12863from
feat/agent-watch-limits-tri-12863
Open

feat(webapp): enforce watch plan limits#4556
kathiekiwi wants to merge 2 commits into
feat/agent-message-quota-tri-12863from
feat/agent-watch-limits-tri-12863

Conversation

@kathiekiwi

Copy link
Copy Markdown
Collaborator

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 its agentWatchers count, is refused with a new watch_limit_reached result and an upgrade hint (a chat line on the card, HTTP 409 on the API).

Key decisions.

  • Plan limits are a floor below the existing code ceilings: min(plan, WATCH_MAX_HOURS=24) for the window, and the per-chat cap of 3 still applies independently. Plans only tighten, never loosen.
  • Watcher count is org-wide and checked only after the immediate check declines, so a one-shot consumes no slot.
  • Fails open: an absent limit resolves to the unlimited sentinel, so self-hosted is unaffected; the upgrade nudge is gated on isBillingConfigured().
  • Follow-up: quiet Pro-mark on the card's long-window options.

TRI-12863

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
@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 00933ab

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 04cb74c1-0223-43df-b57b-7828f5910de7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment on lines +343 to +348
return {
ok: false,
code: "watch_limit_reached",
error: hint("That watch window is longer than your plan allows."),
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +339 to +348
// 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."),
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +9 to +12
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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".

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment on lines +343 to +347
return {
ok: false,
code: "watch_limit_reached",
error: hint("That watch window is longer than your plan allows."),
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +21 to +25
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant