Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/api-client/src/loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down
77 changes: 62 additions & 15 deletions packages/ui/src/features/loops/components/LoopDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,6 +38,8 @@ import {
import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns";
import {
describeTrigger,
loopFireBlockedMessage,
loopPausedDescription,
loopStatusColor,
loopStatusLabel,
nextScheduleRun,
Expand All @@ -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 ?? [];
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -153,9 +168,9 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
<Button
variant="outline"
size="sm"
loading={runLoop.isPending}
disabled={runLoop.isPending}
onClick={handleRunNow}
loading={runNowPending}
disabled={runNowPending}
onClick={() => void handleRunNow()}
>
Run now
</Button>
Expand All @@ -181,6 +196,8 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
{loop.description}
</Text>
) : null}

<PausedNotice loop={loop} />
</Flex>

<ConfigSummarySection loop={loop} />
Expand Down Expand Up @@ -272,6 +289,36 @@ function loopStatusBadgeVariant(
return "default";
}

function PausedNotice({ loop }: { loop: LoopSchemas.Loop }) {
const description = loopPausedDescription(loop);
if (!description) return null;

return (
<Flex
align="center"
justify="between"
gap="3"
wrap="wrap"
className="rounded-(--radius-2) border border-(--red-6) bg-(--red-2) px-3 py-2"
>
<Text className="text-(--red-11) text-[12.5px] leading-snug">
{description}
</Text>
{loop.disabled_reason === "usage_limited" ? (
<Button
variant="outline"
size="sm"
onClick={() =>
useUsageLimitStore.getState().show({ cause: "org_limit" })
}
>
Manage plan
</Button>
) : null}
</Flex>
);
}

function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model);
const {
Expand Down
96 changes: 96 additions & 0 deletions packages/ui/src/features/loops/loopDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -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());
Expand Down
53 changes: 49 additions & 4 deletions packages/ui/src/features/loops/loopDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<string, string> = {
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;
Expand Down
Loading