-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): enforce watch plan limits #4556
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/agent-message-quota-tri-12863
Are you sure you want to change the base?
Changes from all commits
f9d674e
00933ab
063e41e
21f5461
2dd410d
7d2efc9
bce03cb
2db7c39
3fbc04a
743644b
f695267
f64f238
f080779
3fd5cf4
946831b
47139f6
49f64a6
526c3fc
26ab506
0cbe4c0
e4b02df
d7c7fb7
cf74d85
372a4eb
d4bda0a
8f93f80
b9656c0
aced730
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
|
|
||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import type { Limits } from "@trigger.dev/platform"; | ||
| import { WATCH_MAX_HOURS } from "@internal/dashboard-agent-contracts"; | ||
| import { getCachedLimitAllowingZero, isBillingConfigured } from "./platform.v3.server"; | ||
|
|
||
| // The unlimited sentinel, matching the message quota (TRI-12863 P1). Never Infinity: it | ||
| // serializes to null in the limit cache. | ||
| export const UNLIMITED_WATCH_LIMIT = 100_000_000; | ||
|
|
||
| // 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; | ||
|
kathiekiwi marked this conversation as resolved.
|
||
|
|
||
| export type WatchPlanLimits = { | ||
| /** Longest window one watch may run for, in hours. */ | ||
| maxHours: number; | ||
| /** How many active watches the org may run at once. */ | ||
| watchers: number; | ||
| }; | ||
|
|
||
| async function readLimit(organizationId: string, key: keyof Limits): Promise<number> { | ||
| // A plan of 0 means zero, not absent: an org with watches switched off must not read as | ||
| // unlimited. Only a missing limit falls open. | ||
| const cached = await getCachedLimitAllowingZero(organizationId, key, UNLIMITED_WATCH_LIMIT); | ||
| // A cache error leaves `val` empty; fall open to unlimited. | ||
| return cached.val ?? UNLIMITED_WATCH_LIMIT; | ||
| } | ||
|
kathiekiwi marked this conversation as resolved.
kathiekiwi marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * The org's plan floors for watches. Fails open: an absent limit (self-hosted, or before the | ||
| * cloud side ships) resolves to the unlimited sentinel, so neither floor bites. `read` is the | ||
| * plan-limit seam: tests pass their own reader instead of the cached platform one. | ||
| */ | ||
| export async function resolveWatchPlanLimits( | ||
| organizationId: string, | ||
| read: (organizationId: string, key: keyof Limits) => Promise<number> = readLimit | ||
| ): Promise<WatchPlanLimits> { | ||
| const [maxHours, watchers] = await Promise.all([ | ||
| read(organizationId, WATCH_MAX_HOURS_LIMIT_KEY), | ||
| read(organizationId, WATCH_COUNT_LIMIT_KEY), | ||
| ]); | ||
| return { maxHours, watchers }; | ||
| } | ||
|
kathiekiwi marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * The window ceiling actually in force: the plan floor under the code ceiling. A plan that | ||
| * allows 100 hours still caps at {@link WATCH_MAX_HOURS}. | ||
| */ | ||
| export function effectiveWatchMaxHours(planMaxHours: number): number { | ||
| return Math.min(planMaxHours, WATCH_MAX_HOURS); | ||
| } | ||
|
|
||
| /** | ||
| * A watch-limit refusal, plus an upgrade nudge when billing is present. Self-hosted never | ||
| * hits this (fails open above), and the nudge is gated so a stray refusal stays quiet there. | ||
| */ | ||
| export function watchLimitHint(base: string, billingConfigured = isBillingConfigured()): string { | ||
| return billingConfigured ? `${base} Upgrade your plan for more.` : base; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -482,6 +482,37 @@ export async function getCachedLimit(orgId: string, limit: keyof Limits, fallbac | |
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Reads one plan limit, treating 0 as zero rather than absent: only a missing limit falls back. | ||
| * {@link getLimit} keeps its `!result` fallback, which its callers depend on. | ||
| */ | ||
| export function limitValueAllowingZero( | ||
| limits: Limits | undefined, | ||
| limit: keyof Limits, | ||
| fallback: number | ||
| ): number { | ||
| const result = limits?.[limit]; | ||
|
|
||
| if (result === undefined || result === null) return fallback; | ||
| if (typeof result === "number") return result; | ||
| if (typeof result === "object" && "number" in result) return result.number; | ||
| return fallback; | ||
| } | ||
|
|
||
| /** | ||
| * Like {@link getCachedLimit}, but a plan value of 0 means zero. Cached under its own key so it | ||
| * never crosses with {@link getCachedLimit}. | ||
| */ | ||
| export async function getCachedLimitAllowingZero( | ||
| orgId: string, | ||
| limit: keyof Limits, | ||
| fallback: number | ||
| ) { | ||
| return platformCache.limits.swr(`${orgId}:${limit}:allow-zero`, async () => | ||
| limitValueAllowingZero(await getLimits(orgId), limit, fallback) | ||
| ); | ||
| } | ||
|
Comment on lines
+506
to
+514
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Users who upgrade are still told to upgrade before creating a watch, for up to ten minutes The org's watch allowance is read from a cache that is never cleared when the plan changes ( Why the plan change doesn't reach the watch limit read
Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| export async function customerPortalUrl(orgId: string, orgSlug: string) { | ||
| if (!client) return undefined; | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.