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;