Skip to content

Commit e449743

Browse files
authored
feat(webapp): enforce watch plan limits (#4556)
**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
1 parent c7c7245 commit e449743

15 files changed

Lines changed: 2357 additions & 1 deletion
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
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.

apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55

66
import {
77
listStaleOpenInvestigations,
8+
recordInvestigationSweepAttempt,
89
settleInvestigationAndCloseCard,
10+
settleInvestigationAsInconclusive,
911
type Investigation,
12+
type SettledInvestigation,
1013
type SettledInvestigationCard,
1114
} from "@internal/dashboard-agent-db";
1215
import { UNSETTLED_INVESTIGATION_NOTE } from "@internal/dashboard-agent-contracts";
@@ -22,6 +25,13 @@ export const INVESTIGATION_STALE_MS = 30 * 60 * 1000;
2225
/** Per-run cap. Oldest first, so the rest land next run. */
2326
const SWEEP_BATCH_LIMIT = 100;
2427

28+
/**
29+
* After this many failed settle attempts a row is force-abandoned: settled `inconclusive`
30+
* WITHOUT the closing card, so a card that never renders leaves the queue instead of
31+
* looping forever. The rare stuck spinner is the price of not starving every other row.
32+
*/
33+
export const MAX_SWEEP_ATTEMPTS = 5;
34+
2535
export type InvestigationSweepResult = {
2636
/** Stale `in_progress` rows seen. */
2737
stale: number;
@@ -30,6 +40,8 @@ export type InvestigationSweepResult = {
3040
closed: number;
3141
/** A turn (or another sweep) settled it first. */
3242
alreadySettled: number;
43+
/** Rows past the attempt cap, force-settled without a card so they leave the queue. */
44+
abandoned: number;
3345
failed: number;
3446
};
3547

@@ -46,6 +58,10 @@ export type InvestigationSweepDeps = {
4658
chatId: string;
4759
note: string;
4860
}) => Promise<SettledInvestigationCard | null>;
61+
/** Record a failed settle out-of-band; returns the new attempt count, or null if gone. */
62+
recordAttempt?: (params: { id: string }) => Promise<number | null>;
63+
/** Force a poison row terminal without the failing render path. */
64+
forceAbandon?: (params: { id: string; note: string }) => Promise<SettledInvestigation | null>;
4965
};
5066

5167
/**
@@ -61,12 +77,17 @@ export async function sweepDashboardAgentInvestigations(
6177
deps.listStale ?? ((params) => listStaleOpenInvestigations(dashboardAgentDb, params));
6278
const settleAndClose =
6379
deps.settleAndClose ?? ((params) => settleInvestigationAndCloseCard(dashboardAgentDb, params));
80+
const recordAttempt =
81+
deps.recordAttempt ?? ((params) => recordInvestigationSweepAttempt(dashboardAgentDb, params));
82+
const forceAbandon =
83+
deps.forceAbandon ?? ((params) => settleInvestigationAsInconclusive(dashboardAgentDb, params));
6484

6585
const result: InvestigationSweepResult = {
6686
stale: 0,
6787
settled: 0,
6888
closed: 0,
6989
alreadySettled: 0,
90+
abandoned: 0,
7091
failed: 0,
7192
};
7293

@@ -93,10 +114,49 @@ export async function sweepDashboardAgentInvestigations(
93114
result.settled++;
94115
if (outcome.closed) result.closed++;
95116
} catch (error) {
117+
// The settle rolled back, so the row is still `in_progress`. Record the attempt in
118+
// its own write — this rotates the row to the back of the sweep order (see
119+
// `listStaleOpenInvestigations`) so it can't pin the head and starve newer rows.
120+
let attempts: number | null = null;
121+
try {
122+
attempts = await recordAttempt({ id: investigation.id });
123+
} catch (recordError) {
124+
logger.error("Dashboard agent investigation sweep: failed to record a sweep attempt", {
125+
investigationId: investigation.id,
126+
chatId: investigation.chatId,
127+
error: recordError,
128+
});
129+
}
130+
131+
// Past the cap the card will never render; force it terminal without the render
132+
// path so it leaves the queue instead of looping forever.
133+
if (attempts !== null && attempts >= MAX_SWEEP_ATTEMPTS) {
134+
try {
135+
await forceAbandon({ id: investigation.id, note: UNSETTLED_INVESTIGATION_NOTE });
136+
result.abandoned++;
137+
logger.warn(
138+
"Dashboard agent investigation sweep: abandoned a card past the attempt cap",
139+
{
140+
investigationId: investigation.id,
141+
chatId: investigation.chatId,
142+
attempts,
143+
}
144+
);
145+
continue;
146+
} catch (abandonError) {
147+
logger.error("Dashboard agent investigation sweep: failed to abandon a poison card", {
148+
investigationId: investigation.id,
149+
chatId: investigation.chatId,
150+
error: abandonError,
151+
});
152+
}
153+
}
154+
96155
result.failed++;
97156
logger.error("Dashboard agent investigation sweep: failed to settle an investigation", {
98157
investigationId: investigation.id,
99158
chatId: investigation.chatId,
159+
attempts,
100160
error,
101161
});
102162
}

apps/webapp/app/services/dashboardAgentWatchErrorStatus.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { SubmitWatchErrorCode } from "./dashboardAgentWatches.server";
66
*/
77
const STATUS_BY_CODE: Record<SubmitWatchErrorCode, number> = {
88
limit_reached: 409,
9+
watch_limit_reached: 409,
910
duplicate: 409,
1011
request_conflict: 409,
1112
invalid_target: 404,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Limits } from "@trigger.dev/platform";
2+
import { WATCH_MAX_HOURS } from "@internal/dashboard-agent-contracts";
3+
import { getCachedLimitAllowingZero, isBillingConfigured } from "./platform.v3.server";
4+
5+
// The unlimited sentinel, matching the message quota (TRI-12863 P1). Never Infinity: it
6+
// serializes to null in the limit cache.
7+
export const UNLIMITED_WATCH_LIMIT = 100_000_000;
8+
9+
// Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted, so
10+
// the fallback applies and the plan floor is off.
11+
const WATCH_MAX_HOURS_LIMIT_KEY = "agentWatchMaxHours" as keyof Limits;
12+
const WATCH_COUNT_LIMIT_KEY = "agentWatchers" as keyof Limits;
13+
14+
export type WatchPlanLimits = {
15+
/** Longest window one watch may run for, in hours. */
16+
maxHours: number;
17+
/** How many active watches the org may run at once. */
18+
watchers: number;
19+
};
20+
21+
async function readLimit(organizationId: string, key: keyof Limits): Promise<number> {
22+
// A plan of 0 means zero, not absent: an org with watches switched off must not read as
23+
// unlimited. Only a missing limit falls open.
24+
const cached = await getCachedLimitAllowingZero(organizationId, key, UNLIMITED_WATCH_LIMIT);
25+
// A cache error leaves `val` empty; fall open to unlimited.
26+
return cached.val ?? UNLIMITED_WATCH_LIMIT;
27+
}
28+
29+
/**
30+
* The org's plan floors for watches. Fails open: an absent limit (self-hosted, or before the
31+
* cloud side ships) resolves to the unlimited sentinel, so neither floor bites. `read` is the
32+
* plan-limit seam: tests pass their own reader instead of the cached platform one.
33+
*/
34+
export async function resolveWatchPlanLimits(
35+
organizationId: string,
36+
read: (organizationId: string, key: keyof Limits) => Promise<number> = readLimit
37+
): Promise<WatchPlanLimits> {
38+
const [maxHours, watchers] = await Promise.all([
39+
read(organizationId, WATCH_MAX_HOURS_LIMIT_KEY),
40+
read(organizationId, WATCH_COUNT_LIMIT_KEY),
41+
]);
42+
return { maxHours, watchers };
43+
}
44+
45+
/**
46+
* The window ceiling actually in force: the plan floor under the code ceiling. A plan that
47+
* allows 100 hours still caps at {@link WATCH_MAX_HOURS}.
48+
*/
49+
export function effectiveWatchMaxHours(planMaxHours: number): number {
50+
return Math.min(planMaxHours, WATCH_MAX_HOURS);
51+
}
52+
53+
/**
54+
* A watch-limit refusal, plus an upgrade nudge when billing is present. Self-hosted never
55+
* hits this (fails open above), and the nudge is gated so a stray refusal stays quiet there.
56+
*/
57+
export function watchLimitHint(base: string, billingConfigured = isBillingConfigured()): string {
58+
return billingConfigured ? `${base} Upgrade your plan for more.` : base;
59+
}

apps/webapp/app/services/dashboardAgentWatches.server.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
cancelWatch,
1111
chatExists,
1212
claimWatchSubmission,
13+
countActiveWatchesForOrg,
1314
createChat,
1415
createWatch,
1516
generateWatchId,
@@ -68,6 +69,12 @@ import {
6869
import { watchCreationCheckDeps } from "~/services/dashboardAgentWatchChecks.server";
6970
import { normalizeErrorFingerprint } from "~/services/dashboardAgentWatchErrorChecks";
7071
import { subscribeUserToWatchAlerts } from "~/services/dashboardAgentWatchAlerts.server";
72+
import {
73+
effectiveWatchMaxHours,
74+
resolveWatchPlanLimits,
75+
watchLimitHint,
76+
type WatchPlanLimits,
77+
} from "~/services/dashboardAgentWatchLimits.server";
7178
import {
7279
mintDashboardAgentWatchBatchToken,
7380
mintDashboardAgentWatchToken,
@@ -165,6 +172,7 @@ export async function authorizeWatchEnvironmentById(params: {
165172

166173
export type CreateWatchErrorCode =
167174
| "limit_reached"
175+
| "watch_limit_reached"
168176
| "duplicate"
169177
| "invalid_target"
170178
| "chat_not_found"
@@ -273,6 +281,12 @@ export async function createDashboardAgentWatch(params: {
273281
scheduleTick?: typeof scheduleWatchTick;
274282
/** Skip the real trigger-config gate when a tick scheduler is injected. */
275283
configured?: () => boolean;
284+
/** Plan floors on window and count. Fails open to unlimited when absent. */
285+
resolveLimits?: (organizationId: string) => Promise<WatchPlanLimits>;
286+
/** Org-wide active-watch count, for the watcher-count floor. */
287+
countActiveWatches?: (organizationId: string) => Promise<number>;
288+
/** Gates the upgrade nudge, so self-hosted stays quiet. */
289+
billingConfigured?: () => boolean;
276290
};
277291
}): Promise<CreateDashboardAgentWatchResult> {
278292
const { environment, userId, chatId } = params;
@@ -284,6 +298,11 @@ export async function createDashboardAgentWatch(params: {
284298
const buildCheckDeps = params.deps?.checkDeps ?? watchCreationCheckDeps;
285299
const scheduleTick = params.deps?.scheduleTick ?? scheduleWatchTick;
286300
const isDashboardAgentConfigured = params.deps?.configured ?? isDashboardAgentConfiguredDefault;
301+
const resolveLimits = params.deps?.resolveLimits ?? resolveWatchPlanLimits;
302+
const countActiveWatches =
303+
params.deps?.countActiveWatches ??
304+
((organizationId: string) => countActiveWatchesForOrg(dashboardAgentDb, { organizationId }));
305+
const hint = (base: string) => watchLimitHint(base, params.deps?.billingConfigured?.());
287306
const checkDeps = buildCheckDeps(environment, now);
288307

289308
if (!isDashboardAgentConfigured()) {
@@ -331,6 +350,29 @@ export async function createDashboardAgentWatch(params: {
331350
return { ok: true, watching: false, identity, immediate };
332351
}
333352

353+
// Both floors are read only now the immediate check didn't answer: a one-shot creates no
354+
// row, so a plan floor must not turn an answerable question into an upgrade nudge. Plan
355+
// floors sit below the code ceilings (min(plan, ceiling)) and fail open: an absent limit
356+
// resolves to unlimited, so neither bites on self-hosted.
357+
const planLimits = await resolveLimits(environment.organizationId);
358+
if (spec.maxHours > effectiveWatchMaxHours(planLimits.maxHours)) {
359+
return {
360+
ok: false,
361+
code: "watch_limit_reached",
362+
error: hint("That watch window is longer than your plan allows."),
363+
};
364+
}
365+
366+
// The per-chat cap of 3 still applies independently, in `createWatch`.
367+
const activeCount = await countActiveWatches(environment.organizationId);
368+
if (activeCount >= planLimits.watchers) {
369+
return {
370+
ok: false,
371+
code: "watch_limit_reached",
372+
error: hint("You've reached the number of active watches your plan allows."),
373+
};
374+
}
375+
334376
const expiresAt = new Date(now.getTime() + spec.maxHours * 60 * 60 * 1000);
335377

336378
const created = await createWatch(dashboardAgentDb, {

apps/webapp/app/services/platform.v3.server.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,37 @@ export async function getCachedLimit(orgId: string, limit: keyof Limits, fallbac
482482
});
483483
}
484484

485+
/**
486+
* Reads one plan limit, treating 0 as zero rather than absent: only a missing limit falls back.
487+
* {@link getLimit} keeps its `!result` fallback, which its callers depend on.
488+
*/
489+
export function limitValueAllowingZero(
490+
limits: Limits | undefined,
491+
limit: keyof Limits,
492+
fallback: number
493+
): number {
494+
const result = limits?.[limit];
495+
496+
if (result === undefined || result === null) return fallback;
497+
if (typeof result === "number") return result;
498+
if (typeof result === "object" && "number" in result) return result.number;
499+
return fallback;
500+
}
501+
502+
/**
503+
* Like {@link getCachedLimit}, but a plan value of 0 means zero. Cached under its own key so it
504+
* never crosses with {@link getCachedLimit}.
505+
*/
506+
export async function getCachedLimitAllowingZero(
507+
orgId: string,
508+
limit: keyof Limits,
509+
fallback: number
510+
) {
511+
return platformCache.limits.swr(`${orgId}:${limit}:allow-zero`, async () =>
512+
limitValueAllowingZero(await getLimits(orgId), limit, fallback)
513+
);
514+
}
515+
485516
export async function customerPortalUrl(orgId: string, orgSlug: string) {
486517
if (!client) return undefined;
487518

0 commit comments

Comments
 (0)