Skip to content

Commit 883dc3f

Browse files
authored
feat(loops): Surface billing pauses and gate manual runs (#3779)
1 parent 3b560b4 commit 883dc3f

4 files changed

Lines changed: 213 additions & 22 deletions

File tree

packages/api-client/src/loops.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export namespace LoopSchemas {
3939
| "rate_capped"
4040
| "team_rate_capped"
4141
| "disabled"
42-
| "gate_blocked";
42+
| "gate_blocked"
43+
| "owner_inactive"
44+
| "owner_changed";
4345
export type LoopRunStatusEnum =
4446
| "not_started"
4547
| "queued"
@@ -180,8 +182,9 @@ export namespace LoopSchemas {
180182
sandbox_environment_id: string | null;
181183
enabled: boolean;
182184
/** Why the loop was paused when it wasn't the owner who paused it (e.g.
183-
* "owner_deactivated", "github_integration_disconnected"), or null for a normal pause.
184-
* Cleared when the loop is re-enabled. Read-only. */
185+
* "owner_deactivated", "github_integration_disconnected", "usage_limited",
186+
* "repeated_failures"), or null for a normal pause. Cleared when the loop is
187+
* re-enabled. Read-only. */
185188
disabled_reason: string | null;
186189
overlap_policy: LoopOverlapPolicyEnum;
187190
behaviors: LoopBehaviors;

packages/ui/src/features/loops/components/LoopDetailView.tsx

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
Textarea,
1515
} from "@posthog/quill";
1616
import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
17+
import { assertCloudUsageAvailable } from "@posthog/ui/features/billing/preflightCloudUsage";
18+
import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore";
1719
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
1820
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
1921
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
@@ -36,6 +38,8 @@ import {
3638
import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns";
3739
import {
3840
describeTrigger,
41+
loopFireBlockedMessage,
42+
loopPausedDescription,
3943
loopStatusColor,
4044
loopStatusLabel,
4145
nextScheduleRun,
@@ -50,6 +54,7 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
5054
const deleteLoop = useDeleteLoop();
5155
const runLoop = useRunLoop(loopId);
5256
const [deleteOpen, setDeleteOpen] = useState(false);
57+
const [runNowPending, setRunNowPending] = useState(false);
5358

5459
const runsQuery = useLoopRuns(loopId);
5560
const runs = runsQuery.data ?? [];
@@ -78,18 +83,28 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
7883
);
7984
};
8085

81-
const handleRunNow = () => {
82-
runLoop.mutate(undefined, {
83-
onSuccess: (result) => {
84-
if (result.created) {
85-
toast.success("Loop run started");
86-
} else {
87-
toast.error(`Run not started: ${result.reason}`);
88-
}
89-
},
90-
onError: (error) =>
91-
toast.error("Failed to start run", { description: error.message }),
92-
});
86+
const handleRunNow = async () => {
87+
if (runNowPending) return;
88+
setRunNowPending(true);
89+
try {
90+
if (!(await assertCloudUsageAvailable())) return;
91+
const result = await runLoop.mutateAsync();
92+
if (result.created) {
93+
toast.success("Loop run started");
94+
} else if (result.reason === "gate_blocked") {
95+
useUsageLimitStore.getState().show({ cause: "org_limit" });
96+
} else {
97+
toast.error("Run not started", {
98+
description: loopFireBlockedMessage(result.reason),
99+
});
100+
}
101+
} catch (error) {
102+
toast.error("Failed to start run", {
103+
description: error instanceof Error ? error.message : String(error),
104+
});
105+
} finally {
106+
setRunNowPending(false);
107+
}
93108
};
94109

95110
const handleDelete = () => {
@@ -153,9 +168,9 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
153168
<Button
154169
variant="outline"
155170
size="sm"
156-
loading={runLoop.isPending}
157-
disabled={runLoop.isPending}
158-
onClick={handleRunNow}
171+
loading={runNowPending}
172+
disabled={runNowPending}
173+
onClick={() => void handleRunNow()}
159174
>
160175
Run now
161176
</Button>
@@ -181,6 +196,8 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
181196
{loop.description}
182197
</Text>
183198
) : null}
199+
200+
<PausedNotice loop={loop} />
184201
</Flex>
185202

186203
<ConfigSummarySection loop={loop} />
@@ -272,6 +289,36 @@ function loopStatusBadgeVariant(
272289
return "default";
273290
}
274291

292+
function PausedNotice({ loop }: { loop: LoopSchemas.Loop }) {
293+
const description = loopPausedDescription(loop);
294+
if (!description) return null;
295+
296+
return (
297+
<Flex
298+
align="center"
299+
justify="between"
300+
gap="3"
301+
wrap="wrap"
302+
className="rounded-(--radius-2) border border-(--red-6) bg-(--red-2) px-3 py-2"
303+
>
304+
<Text className="text-(--red-11) text-[12.5px] leading-snug">
305+
{description}
306+
</Text>
307+
{loop.disabled_reason === "usage_limited" ? (
308+
<Button
309+
variant="outline"
310+
size="sm"
311+
onClick={() =>
312+
useUsageLimitStore.getState().show({ cause: "org_limit" })
313+
}
314+
>
315+
Manage plan
316+
</Button>
317+
) : null}
318+
</Flex>
319+
);
320+
}
321+
275322
function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
276323
const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model);
277324
const {

packages/ui/src/features/loops/loopDisplay.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,107 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
33
describeTrigger,
4+
loopFireBlockedMessage,
5+
loopPausedDescription,
6+
loopStatusColor,
7+
loopStatusLabel,
48
nextScheduleRun,
59
summarizeNotificationDestinations,
610
summarizeTrigger,
711
} from "./loopDisplay";
812

13+
const statusFields = (
14+
overrides: Partial<{
15+
enabled: boolean;
16+
disabled_reason: string | null;
17+
last_run_status: string | null;
18+
}> = {},
19+
) => ({
20+
enabled: true,
21+
disabled_reason: null,
22+
last_run_status: null,
23+
...overrides,
24+
});
25+
26+
describe("loopStatusLabel and loopStatusColor", () => {
27+
it.each([
28+
[statusFields(), "Active", "green"],
29+
[statusFields({ last_run_status: "failed" }), "Failing", "red"],
30+
[statusFields({ enabled: false }), "Paused", "gray"],
31+
[
32+
statusFields({ enabled: false, disabled_reason: "usage_limited" }),
33+
"Paused: usage limit",
34+
"red",
35+
],
36+
[
37+
statusFields({ enabled: false, disabled_reason: "repeated_failures" }),
38+
"Auto-paused",
39+
"red",
40+
],
41+
[
42+
statusFields({ enabled: false, disabled_reason: "owner_deactivated" }),
43+
"Auto-paused",
44+
"red",
45+
],
46+
])("derives label and color (%#)", (loop, label, color) => {
47+
expect(loopStatusLabel(loop)).toBe(label);
48+
expect(loopStatusColor(loop)).toBe(color);
49+
});
50+
51+
it("ignores disabled_reason while the loop is enabled", () => {
52+
const loop = statusFields({ disabled_reason: "usage_limited" });
53+
expect(loopStatusLabel(loop)).toBe("Active");
54+
expect(loopStatusColor(loop)).toBe("green");
55+
});
56+
});
57+
58+
describe("loopPausedDescription", () => {
59+
it.each([
60+
["usage_limited", "usage limit"],
61+
["repeated_failures", "failed runs in a row"],
62+
["owner_deactivated", "deactivated"],
63+
["owner_removed_from_org", "left the organization"],
64+
["github_integration_disconnected", "GitHub connection"],
65+
])("explains a %s pause", (reason, expected) => {
66+
expect(
67+
loopPausedDescription(
68+
statusFields({ enabled: false, disabled_reason: reason }),
69+
),
70+
).toContain(expected);
71+
});
72+
73+
it("falls back to a generic sentence for unknown reasons", () => {
74+
expect(
75+
loopPausedDescription(
76+
statusFields({ enabled: false, disabled_reason: "something_new" }),
77+
),
78+
).toBe("Paused automatically.");
79+
});
80+
81+
it.each([
82+
[statusFields()],
83+
[statusFields({ enabled: false })],
84+
[statusFields({ disabled_reason: "usage_limited" })],
85+
])("returns null without a backend-driven pause (%#)", (loop) => {
86+
expect(loopPausedDescription(loop)).toBeNull();
87+
});
88+
});
89+
90+
describe("loopFireBlockedMessage", () => {
91+
it.each([
92+
["gate_blocked", "usage limit"],
93+
["overlap_skipped", "still in progress"],
94+
["rate_capped", "daily run cap"],
95+
["team_rate_capped", "daily loop run cap"],
96+
["deduped", "already started"],
97+
["disabled", "disabled"],
98+
["owner_inactive", "no longer start runs"],
99+
["owner_changed", "owner changed"],
100+
] as const)("describes %s", (reason, expected) => {
101+
expect(loopFireBlockedMessage(reason)).toContain(expected);
102+
});
103+
});
104+
9105
describe("describeTrigger", () => {
10106
beforeEach(() => vi.useFakeTimers());
11107
afterEach(() => vi.useRealTimers());

packages/ui/src/features/loops/loopDisplay.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,65 @@ function describeNextRun(
6767
return ` · Next run ${formatted}`;
6868
}
6969

70+
type LoopStatusFields = Pick<
71+
LoopSchemas.Loop,
72+
"enabled" | "disabled_reason" | "last_run_status"
73+
>;
74+
7075
export function loopStatusColor(
71-
loop: LoopSchemas.Loop,
76+
loop: LoopStatusFields,
7277
): "gray" | "green" | "red" {
73-
if (!loop.enabled) return "gray";
78+
if (!loop.enabled) return loop.disabled_reason ? "red" : "gray";
7479
if (loop.last_run_status === "failed") return "red";
7580
return "green";
7681
}
7782

78-
export function loopStatusLabel(loop: LoopSchemas.Loop): string {
79-
if (!loop.enabled) return "Paused";
83+
export function loopStatusLabel(loop: LoopStatusFields): string {
84+
if (!loop.enabled) {
85+
if (loop.disabled_reason === "usage_limited") return "Paused: usage limit";
86+
if (loop.disabled_reason) return "Auto-paused";
87+
return "Paused";
88+
}
8089
if (loop.last_run_status === "failed") return "Failing";
8190
return "Active";
8291
}
8392

93+
const PAUSED_DESCRIPTIONS: Record<string, string> = {
94+
usage_limited:
95+
"Paused automatically: your organization reached its usage limit. Upgrade or wait for the limit to reset, then re-enable the loop.",
96+
repeated_failures:
97+
"Paused automatically after too many failed runs in a row. Check the last run's error, then re-enable the loop.",
98+
owner_deactivated: "Paused because its owner's account was deactivated.",
99+
owner_removed_from_org: "Paused because its owner left the organization.",
100+
github_integration_disconnected:
101+
"Paused because its GitHub connection was removed.",
102+
};
103+
104+
/** Sentence explaining a backend-driven pause, or null for an enabled loop or a
105+
* normal owner pause. */
106+
export function loopPausedDescription(loop: LoopStatusFields): string | null {
107+
if (loop.enabled || !loop.disabled_reason) return null;
108+
return PAUSED_DESCRIPTIONS[loop.disabled_reason] ?? "Paused automatically.";
109+
}
110+
111+
const FIRE_BLOCKED_MESSAGES: Record<string, string> = {
112+
deduped: "An identical run was already started for this trigger.",
113+
overlap_skipped: "The previous run is still in progress.",
114+
rate_capped: "This loop reached its daily run cap.",
115+
team_rate_capped: "Your team reached its daily loop run cap.",
116+
disabled: "This loop or its trigger is disabled.",
117+
gate_blocked: "Your organization reached its usage limit.",
118+
owner_inactive: "The loop owner's account can no longer start runs.",
119+
owner_changed:
120+
"The loop's owner changed while the run was starting. Try again.",
121+
};
122+
123+
export function loopFireBlockedMessage(
124+
reason: LoopSchemas.LoopFireReasonEnum,
125+
): string {
126+
return FIRE_BLOCKED_MESSAGES[reason] ?? `Run not started: ${reason}`;
127+
}
128+
84129
interface TriggerLike {
85130
type: LoopSchemas.LoopTriggerTypeEnum;
86131
config: LoopSchemas.LoopTriggerConfig;

0 commit comments

Comments
 (0)