diff --git a/packages/api-client/src/loops.ts b/packages/api-client/src/loops.ts
index fb3ecb50f3..0acfdf3ff3 100644
--- a/packages/api-client/src/loops.ts
+++ b/packages/api-client/src/loops.ts
@@ -39,7 +39,9 @@ export namespace LoopSchemas {
| "rate_capped"
| "team_rate_capped"
| "disabled"
- | "gate_blocked";
+ | "gate_blocked"
+ | "owner_inactive"
+ | "owner_changed";
export type LoopRunStatusEnum =
| "not_started"
| "queued"
@@ -180,8 +182,9 @@ export namespace LoopSchemas {
sandbox_environment_id: string | null;
enabled: boolean;
/** Why the loop was paused when it wasn't the owner who paused it (e.g.
- * "owner_deactivated", "github_integration_disconnected"), or null for a normal pause.
- * Cleared when the loop is re-enabled. Read-only. */
+ * "owner_deactivated", "github_integration_disconnected", "usage_limited",
+ * "repeated_failures"), or null for a normal pause. Cleared when the loop is
+ * re-enabled. Read-only. */
disabled_reason: string | null;
overlap_policy: LoopOverlapPolicyEnum;
behaviors: LoopBehaviors;
diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx
index 8e3fdcb8f1..2d7bae6ff8 100644
--- a/packages/ui/src/features/loops/components/LoopDetailView.tsx
+++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx
@@ -14,6 +14,8 @@ import {
Textarea,
} from "@posthog/quill";
import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
+import { assertCloudUsageAvailable } from "@posthog/ui/features/billing/preflightCloudUsage";
+import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore";
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
@@ -36,6 +38,8 @@ import {
import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns";
import {
describeTrigger,
+ loopFireBlockedMessage,
+ loopPausedDescription,
loopStatusColor,
loopStatusLabel,
nextScheduleRun,
@@ -50,6 +54,7 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
const deleteLoop = useDeleteLoop();
const runLoop = useRunLoop(loopId);
const [deleteOpen, setDeleteOpen] = useState(false);
+ const [runNowPending, setRunNowPending] = useState(false);
const runsQuery = useLoopRuns(loopId);
const runs = runsQuery.data ?? [];
@@ -78,18 +83,28 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
);
};
- const handleRunNow = () => {
- runLoop.mutate(undefined, {
- onSuccess: (result) => {
- if (result.created) {
- toast.success("Loop run started");
- } else {
- toast.error(`Run not started: ${result.reason}`);
- }
- },
- onError: (error) =>
- toast.error("Failed to start run", { description: error.message }),
- });
+ const handleRunNow = async () => {
+ if (runNowPending) return;
+ setRunNowPending(true);
+ try {
+ if (!(await assertCloudUsageAvailable())) return;
+ const result = await runLoop.mutateAsync();
+ if (result.created) {
+ toast.success("Loop run started");
+ } else if (result.reason === "gate_blocked") {
+ useUsageLimitStore.getState().show({ cause: "org_limit" });
+ } else {
+ toast.error("Run not started", {
+ description: loopFireBlockedMessage(result.reason),
+ });
+ }
+ } catch (error) {
+ toast.error("Failed to start run", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setRunNowPending(false);
+ }
};
const handleDelete = () => {
@@ -153,9 +168,9 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
@@ -181,6 +196,8 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
{loop.description}
) : null}
+
+
@@ -272,6 +289,36 @@ function loopStatusBadgeVariant(
return "default";
}
+function PausedNotice({ loop }: { loop: LoopSchemas.Loop }) {
+ const description = loopPausedDescription(loop);
+ if (!description) return null;
+
+ return (
+
+
+ {description}
+
+ {loop.disabled_reason === "usage_limited" ? (
+
+ ) : null}
+
+ );
+}
+
function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model);
const {
diff --git a/packages/ui/src/features/loops/loopDisplay.test.ts b/packages/ui/src/features/loops/loopDisplay.test.ts
index 1d7ced1e3a..97e84a1197 100644
--- a/packages/ui/src/features/loops/loopDisplay.test.ts
+++ b/packages/ui/src/features/loops/loopDisplay.test.ts
@@ -1,11 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
describeTrigger,
+ loopFireBlockedMessage,
+ loopPausedDescription,
+ loopStatusColor,
+ loopStatusLabel,
nextScheduleRun,
summarizeNotificationDestinations,
summarizeTrigger,
} from "./loopDisplay";
+const statusFields = (
+ overrides: Partial<{
+ enabled: boolean;
+ disabled_reason: string | null;
+ last_run_status: string | null;
+ }> = {},
+) => ({
+ enabled: true,
+ disabled_reason: null,
+ last_run_status: null,
+ ...overrides,
+});
+
+describe("loopStatusLabel and loopStatusColor", () => {
+ it.each([
+ [statusFields(), "Active", "green"],
+ [statusFields({ last_run_status: "failed" }), "Failing", "red"],
+ [statusFields({ enabled: false }), "Paused", "gray"],
+ [
+ statusFields({ enabled: false, disabled_reason: "usage_limited" }),
+ "Paused: usage limit",
+ "red",
+ ],
+ [
+ statusFields({ enabled: false, disabled_reason: "repeated_failures" }),
+ "Auto-paused",
+ "red",
+ ],
+ [
+ statusFields({ enabled: false, disabled_reason: "owner_deactivated" }),
+ "Auto-paused",
+ "red",
+ ],
+ ])("derives label and color (%#)", (loop, label, color) => {
+ expect(loopStatusLabel(loop)).toBe(label);
+ expect(loopStatusColor(loop)).toBe(color);
+ });
+
+ it("ignores disabled_reason while the loop is enabled", () => {
+ const loop = statusFields({ disabled_reason: "usage_limited" });
+ expect(loopStatusLabel(loop)).toBe("Active");
+ expect(loopStatusColor(loop)).toBe("green");
+ });
+});
+
+describe("loopPausedDescription", () => {
+ it.each([
+ ["usage_limited", "usage limit"],
+ ["repeated_failures", "failed runs in a row"],
+ ["owner_deactivated", "deactivated"],
+ ["owner_removed_from_org", "left the organization"],
+ ["github_integration_disconnected", "GitHub connection"],
+ ])("explains a %s pause", (reason, expected) => {
+ expect(
+ loopPausedDescription(
+ statusFields({ enabled: false, disabled_reason: reason }),
+ ),
+ ).toContain(expected);
+ });
+
+ it("falls back to a generic sentence for unknown reasons", () => {
+ expect(
+ loopPausedDescription(
+ statusFields({ enabled: false, disabled_reason: "something_new" }),
+ ),
+ ).toBe("Paused automatically.");
+ });
+
+ it.each([
+ [statusFields()],
+ [statusFields({ enabled: false })],
+ [statusFields({ disabled_reason: "usage_limited" })],
+ ])("returns null without a backend-driven pause (%#)", (loop) => {
+ expect(loopPausedDescription(loop)).toBeNull();
+ });
+});
+
+describe("loopFireBlockedMessage", () => {
+ it.each([
+ ["gate_blocked", "usage limit"],
+ ["overlap_skipped", "still in progress"],
+ ["rate_capped", "daily run cap"],
+ ["team_rate_capped", "daily loop run cap"],
+ ["deduped", "already started"],
+ ["disabled", "disabled"],
+ ["owner_inactive", "no longer start runs"],
+ ["owner_changed", "owner changed"],
+ ] as const)("describes %s", (reason, expected) => {
+ expect(loopFireBlockedMessage(reason)).toContain(expected);
+ });
+});
+
describe("describeTrigger", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
diff --git a/packages/ui/src/features/loops/loopDisplay.ts b/packages/ui/src/features/loops/loopDisplay.ts
index 3795d701d9..b9bf5730d4 100644
--- a/packages/ui/src/features/loops/loopDisplay.ts
+++ b/packages/ui/src/features/loops/loopDisplay.ts
@@ -67,20 +67,65 @@ function describeNextRun(
return ` ยท Next run ${formatted}`;
}
+type LoopStatusFields = Pick<
+ LoopSchemas.Loop,
+ "enabled" | "disabled_reason" | "last_run_status"
+>;
+
export function loopStatusColor(
- loop: LoopSchemas.Loop,
+ loop: LoopStatusFields,
): "gray" | "green" | "red" {
- if (!loop.enabled) return "gray";
+ if (!loop.enabled) return loop.disabled_reason ? "red" : "gray";
if (loop.last_run_status === "failed") return "red";
return "green";
}
-export function loopStatusLabel(loop: LoopSchemas.Loop): string {
- if (!loop.enabled) return "Paused";
+export function loopStatusLabel(loop: LoopStatusFields): string {
+ if (!loop.enabled) {
+ if (loop.disabled_reason === "usage_limited") return "Paused: usage limit";
+ if (loop.disabled_reason) return "Auto-paused";
+ return "Paused";
+ }
if (loop.last_run_status === "failed") return "Failing";
return "Active";
}
+const PAUSED_DESCRIPTIONS: Record = {
+ usage_limited:
+ "Paused automatically: your organization reached its usage limit. Upgrade or wait for the limit to reset, then re-enable the loop.",
+ repeated_failures:
+ "Paused automatically after too many failed runs in a row. Check the last run's error, then re-enable the loop.",
+ owner_deactivated: "Paused because its owner's account was deactivated.",
+ owner_removed_from_org: "Paused because its owner left the organization.",
+ github_integration_disconnected:
+ "Paused because its GitHub connection was removed.",
+};
+
+/** Sentence explaining a backend-driven pause, or null for an enabled loop or a
+ * normal owner pause. */
+export function loopPausedDescription(loop: LoopStatusFields): string | null {
+ if (loop.enabled || !loop.disabled_reason) return null;
+ return PAUSED_DESCRIPTIONS[loop.disabled_reason] ?? "Paused automatically.";
+}
+
+const FIRE_BLOCKED_MESSAGES: Record = {
+ deduped: "An identical run was already started for this trigger.",
+ overlap_skipped: "The previous run is still in progress.",
+ rate_capped: "This loop reached its daily run cap.",
+ team_rate_capped: "Your team reached its daily loop run cap.",
+ disabled: "This loop or its trigger is disabled.",
+ gate_blocked: "Your organization reached its usage limit.",
+ owner_inactive: "The loop owner's account can no longer start runs.",
+ owner_changed:
+ "The loop's owner changed while the run was starting. Try again.",
+};
+
+export function loopFireBlockedMessage(
+ reason: LoopSchemas.LoopFireReasonEnum,
+): string {
+ return FIRE_BLOCKED_MESSAGES[reason] ?? `Run not started: ${reason}`;
+}
+
interface TriggerLike {
type: LoopSchemas.LoopTriggerTypeEnum;
config: LoopSchemas.LoopTriggerConfig;