diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 966a0702..33b06635 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -7,9 +7,9 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/auth/clerk"; import { X } from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ApiError } from "@/api/mutator"; +import { ApiError, customFetch } from "@/api/mutator"; import { type getBoardApiV1BoardsBoardIdGetResponse, useGetBoardApiV1BoardsBoardIdGet, @@ -69,6 +69,22 @@ const slugify = (value: string) => const LEAD_AGENT_VALUE = "__lead_agent__"; +type GovernorActivityTriggerType = "A" | "B"; + +type AutoHeartbeatGovernorPolicy = { + enabled: boolean; + run_interval_seconds: number; + ladder: string[]; + lead_cap_every: string; + activity_trigger_type: GovernorActivityTriggerType; +}; + +const governorPolicyQueryKey = (boardId: string) => [ + "boards", + boardId, + "auto-heartbeat-governor-policy", +] as const; + type WebhookCardProps = { webhook: BoardWebhookRead; agents: AgentRead[]; @@ -311,6 +327,16 @@ export default function EditBoardPage() { const [webhookError, setWebhookError] = useState(null); const [copiedWebhookId, setCopiedWebhookId] = useState(null); + const [governorPolicyDraft, setGovernorPolicyDraft] = useState< + AutoHeartbeatGovernorPolicy | undefined + >(undefined); + const [governorPolicyError, setGovernorPolicyError] = useState( + null, + ); + const [governorPolicySaveSuccess, setGovernorPolicySaveSuccess] = useState< + string | null + >(null); + const onboardingParam = searchParams.get("onboarding"); const searchParamsString = searchParams.toString(); const shouldAutoOpenOnboarding = @@ -419,6 +445,59 @@ export default function EditBoardPage() { }, ); + const governorPolicyQuery = useQuery({ + queryKey: boardId ? governorPolicyQueryKey(boardId) : [], + enabled: Boolean(isSignedIn && isAdmin && boardId), + retry: false, + queryFn: async () => { + if (!boardId) return null; + const resp = await customFetch<{ data: AutoHeartbeatGovernorPolicy }>( + `/api/v1/boards/${boardId}/auto-heartbeat-governor-policy`, + { method: "GET" }, + ); + return resp.data; + }, + }); + + useEffect(() => { + if (!governorPolicyQuery.data) return; + setGovernorPolicyDraft(governorPolicyQuery.data); + }, [governorPolicyQuery.data]); + + const saveGovernorPolicyMutation = useMutation({ + mutationFn: async (policy: Partial) => { + if (!boardId) throw new Error("Missing board id"); + const resp = await customFetch<{ data: AutoHeartbeatGovernorPolicy }>( + `/api/v1/boards/${boardId}/auto-heartbeat-governor-policy`, + { + method: "PATCH", + body: JSON.stringify(policy), + }, + ); + return resp.data; + }, + onSuccess: async (data) => { + if (!boardId) return; + setGovernorPolicyError(null); + setGovernorPolicySaveSuccess("Governor policy saved."); + setGovernorPolicyDraft(data); + await queryClient.invalidateQueries({ + queryKey: governorPolicyQueryKey(boardId), + }); + window.setTimeout(() => setGovernorPolicySaveSuccess(null), 2500); + }, + onError: (err: unknown) => { + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Unable to save governor policy."; + setGovernorPolicySaveSuccess(null); + setGovernorPolicyError(message); + }, + }); + const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch({ mutation: { onSuccess: (result) => { @@ -538,12 +617,16 @@ export default function EditBoardPage() { gatewaysQuery.isLoading || groupsQuery.isLoading || boardQuery.isLoading || + governorPolicyQuery.isLoading || updateBoardMutation.isPending; const errorMessage = error ?? gatewaysQuery.error?.message ?? groupsQuery.error?.message ?? boardQuery.error?.message ?? + (governorPolicyQuery.error instanceof Error + ? governorPolicyQuery.error.message + : null) ?? null; const webhookErrorMessage = webhookError ?? @@ -1087,6 +1170,203 @@ export default function EditBoardPage() { +
+
+

+ Auto heartbeat governor +

+

+ Controls how Mission Control automatically backs off agent + heartbeats when this board is idle. +

+
+ + {governorPolicySaveSuccess ? ( +

+ {governorPolicySaveSuccess} +

+ ) : null} + {governorPolicyError ? ( +

{governorPolicyError}

+ ) : null} + + {governorPolicyDraft ? ( +
+
+ + + + Enabled + + + If disabled, the governor will not manage agent heartbeats + for this board. + + +
+ +
+
+ + { + const next = Number.parseInt(event.target.value, 10); + setGovernorPolicyDraft({ + ...governorPolicyDraft, + run_interval_seconds: Number.isNaN(next) + ? 300 + : Math.max(30, next), + }); + }} + disabled={isLoading || saveGovernorPolicyMutation.isPending} + /> +

+ Hint for cadence; backend enforces 30s minimum. +

+
+ +
+ + +
+ +
+ + { + const ladder = event.target.value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + setGovernorPolicyDraft({ + ...governorPolicyDraft, + ladder, + }); + }} + placeholder="10m, 30m, 1h, 3h, 6h" + disabled={isLoading || saveGovernorPolicyMutation.isPending} + /> +

+ Comma-separated durations (e.g. 10m, 1h). Non-leads go + fully off after the last rung. +

+
+ +
+ + + setGovernorPolicyDraft({ + ...governorPolicyDraft, + lead_cap_every: event.target.value, + }) + } + placeholder="1h" + disabled={isLoading || saveGovernorPolicyMutation.isPending} + /> +

+ Leads never go fully off; they cap at this value. +

+
+
+ +
+ +
+
+ ) : ( +
+ Loading governor policy… +
+ )} +
+ {gateways.length === 0 ? (