mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
fe(payg): show the usage-limit modal when the limit is hit (direct + policy) (#6626)
This commit is contained in:
@@ -31,6 +31,8 @@ export interface PolicyRunRecord {
|
||||
* which marks the policy's OUTPUT — not the input it ran on. */
|
||||
outputFileIds?: string[];
|
||||
error: string | null;
|
||||
/** Stable backend failure code (e.g. an entitlement sentinel) when FAILED; null otherwise. */
|
||||
errorCode?: string | null;
|
||||
/** Epoch ms when the run was dispatched. */
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* in the run store), so re-renders and remounts don't re-fire.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
useAllFiles,
|
||||
useFileManagement,
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
getPolicyRun,
|
||||
downloadPolicyOutput,
|
||||
} from "@app/services/policyApi";
|
||||
import type { PolicyRunStatus } from "@app/services/policyPipeline";
|
||||
import type {
|
||||
PolicyRunStatus,
|
||||
PolicyRunView,
|
||||
PolicyLimitReachedDetail,
|
||||
} from "@app/services/policyPipeline";
|
||||
import { POLICY_LIMIT_REACHED_EVENT } from "@app/services/policyPipeline";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
||||
@@ -69,6 +74,30 @@ export function usePolicyAutoRun(): void {
|
||||
const importing = useRef<Set<string>>(new Set());
|
||||
const dispatching = useRef<Set<string>>(new Set());
|
||||
|
||||
// A policy's tool calls run server-side, so a usage-limit 402 never reaches the apiClient
|
||||
// interceptor (and thus never pops the modal that direct calls get). The backend surfaces the
|
||||
// limit sentinel on the run's errorCode; when a run we polled finishes blocked, broadcast a
|
||||
// window event. A saas-layer listener (which can read the wallet + open the modal — this
|
||||
// proprietary hook can't import the saas modal API) decides free-limit vs spend-cap. Dedupe per
|
||||
// run so a folder-watch burst opens the modal once, not once per file.
|
||||
const firedLimitModal = useRef<Set<string>>(new Set());
|
||||
|
||||
const onRunFinished = useCallback((view: PolicyRunView) => {
|
||||
const code = view.errorCode;
|
||||
if (code !== "PAYG_LIMIT_REACHED" && code !== "FEATURE_DEGRADED") return;
|
||||
if (firedLimitModal.current.has(view.runId)) return;
|
||||
firedLimitModal.current.add(view.runId);
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<PolicyLimitReachedDetail>(POLICY_LIMIT_REACHED_EVENT, {
|
||||
detail: { subscribed: view.errorSubscribed ?? null },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// non-browser env (tests / SSR) — no-op.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Dispatch: for each active policy × each session file not yet run, fire a run.
|
||||
useEffect(() => {
|
||||
if (!POLICIES_ENABLED) return;
|
||||
@@ -116,9 +145,11 @@ export function usePolicyAutoRun(): void {
|
||||
for (const run of runs) {
|
||||
if (isTerminal(run.status) || polling.current.has(run.runId)) continue;
|
||||
polling.current.add(run.runId);
|
||||
void poll(run.runId).finally(() => polling.current.delete(run.runId));
|
||||
void poll(run.runId, onRunFinished).finally(() =>
|
||||
polling.current.delete(run.runId),
|
||||
);
|
||||
}
|
||||
}, [runs]);
|
||||
}, [runs, onRunFinished]);
|
||||
|
||||
// Import each completed run's outputs into the workspace (each output once),
|
||||
// so the enforced file appears in the app rather than only on the backend.
|
||||
@@ -305,8 +336,16 @@ export async function runPolicyOnFile(
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll a run's status until it reaches a terminal state (or the cap). */
|
||||
async function poll(runId: string): Promise<void> {
|
||||
/**
|
||||
* Poll a run's status until it reaches a terminal state (or the cap). Calls {@code onTerminal} once
|
||||
* with the final view when it terminates — the caller uses that to pop the usage-limit modal when a
|
||||
* run was blocked. Only runs polled this session fire it (terminal runs aren't re-polled), so a
|
||||
* persisted failed run never re-triggers a modal on reload.
|
||||
*/
|
||||
async function poll(
|
||||
runId: string,
|
||||
onTerminal?: (view: PolicyRunView) => void,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < MAX_POLLS; i++) {
|
||||
await delay(POLL_MS);
|
||||
let view;
|
||||
@@ -319,7 +358,11 @@ async function poll(runId: string): Promise<void> {
|
||||
status: view.status,
|
||||
outputs: view.outputs,
|
||||
error: view.error,
|
||||
errorCode: view.errorCode ?? null,
|
||||
});
|
||||
if (isTerminal(view.status)) return;
|
||||
if (isTerminal(view.status)) {
|
||||
onTerminal?.(view);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,24 @@ export interface BackendPolicy {
|
||||
output: BackendOutputSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Window event fired when a policy run is blocked by a usage limit (its tool call got a 402
|
||||
* entitlement sentinel). Dispatched from the proprietary auto-run poll; a saas-layer listener
|
||||
* reads the wallet and opens the matching usage-limit modal. Kept here (proprietary) so both
|
||||
* layers share one name — proprietary can't import the saas modal API directly.
|
||||
*/
|
||||
export const POLICY_LIMIT_REACHED_EVENT = "payg:policyLimitReached";
|
||||
|
||||
/** Detail carried on {@link POLICY_LIMIT_REACHED_EVENT}. */
|
||||
export interface PolicyLimitReachedDetail {
|
||||
/**
|
||||
* Whether the blocked team was subscribed (over its spending cap) vs un-subscribed (free
|
||||
* allowance spent), from the blocking 402. The listener uses it to choose the spend-cap vs
|
||||
* free-limit modal. Null when unknown → treat as free-limit.
|
||||
*/
|
||||
subscribed: boolean | null;
|
||||
}
|
||||
|
||||
/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
|
||||
export type PolicyRunStatus =
|
||||
| "PENDING"
|
||||
@@ -77,6 +95,17 @@ export interface PolicyRunView {
|
||||
currentStep: number;
|
||||
stepCount: number;
|
||||
error: string | null;
|
||||
/**
|
||||
* Stable failure code from the backend (e.g. an entitlement sentinel
|
||||
* {@code PAYG_LIMIT_REACHED} / {@code FEATURE_DEGRADED} propagated from a
|
||||
* downstream tool's 402). Null/absent for ordinary failures.
|
||||
*/
|
||||
errorCode?: string | null;
|
||||
/**
|
||||
* For an entitlement-limit failure, the {@code subscribed} flag from the blocking 402 — picks the
|
||||
* spend-cap (true) vs free-limit (false) modal. Null/absent otherwise.
|
||||
*/
|
||||
errorSubscribed?: boolean | null;
|
||||
outputs: BackendResultFile[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user