mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Fix missing refresh token on desktop (#6838)
# Description of Changes Fix #6801, along with fixing policies on desktop, which would attempt to download policy outputs from the local backend instead of the server, where they actually live. I've changed the policies logic to maintain the same backend for the file retrieval as it used for the policy running, so when we support running policies locally, it should still work correctly.
This commit is contained in:
@@ -5886,6 +5886,17 @@ successMessage = "Your license has been successfully activated. You can now clos
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
deleteConfirmTitle = "Delete {{label}} policy?"
|
||||
|
||||
[policies.activity]
|
||||
enforced = "enforced"
|
||||
enforcing = "Enforcing..."
|
||||
failed = "Enforcement failed"
|
||||
outputsUnavailable = "Policy outputs are no longer available to download."
|
||||
partialOutputsUnavailable = "Some policy outputs are no longer available to download."
|
||||
retrying = "Busy, retrying..."
|
||||
runNotFound = "The enforcement run could no longer be found."
|
||||
step = "step {{current}}/{{total}}"
|
||||
timedOut = "Enforcement timed out before the run could finish."
|
||||
|
||||
[policies.catalog]
|
||||
compliance = "Compliance"
|
||||
ingestion = "Ingestion"
|
||||
|
||||
@@ -400,6 +400,7 @@ struct SupabaseUser {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SupabaseLoginResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
user: SupabaseUser,
|
||||
}
|
||||
|
||||
@@ -408,6 +409,7 @@ pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Login command - makes HTTP request from Rust to bypass CORS
|
||||
@@ -513,6 +515,7 @@ pub async fn login(
|
||||
token: login_response.access_token,
|
||||
username,
|
||||
email,
|
||||
refresh_token: login_response.refresh_token,
|
||||
})
|
||||
} else {
|
||||
// Spring Boot authentication flow
|
||||
@@ -615,6 +618,7 @@ pub async fn login(
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
refresh_token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface LoginResponse {
|
||||
token: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
refresh_token: string | null;
|
||||
}
|
||||
|
||||
interface OAuthCallbackResult {
|
||||
@@ -347,11 +348,18 @@ export class AuthService {
|
||||
saasServerUrl: STIRLING_SAAS_URL,
|
||||
});
|
||||
|
||||
const { token, username: returnedUsername, email } = response;
|
||||
const {
|
||||
token,
|
||||
username: returnedUsername,
|
||||
email,
|
||||
refresh_token: refreshToken,
|
||||
} = response;
|
||||
|
||||
// Save token to all storage locations
|
||||
// Save token to all storage locations. Supabase (SaaS) logins include a
|
||||
// refresh token so the short-lived access token can be renewed; self-hosted
|
||||
// logins return null here and refresh via the current access token instead.
|
||||
try {
|
||||
await this.saveTokenEverywhere(token);
|
||||
await this.saveTokenEverywhere(token, refreshToken);
|
||||
} catch (error) {
|
||||
console.error("[Desktop AuthService] Failed to save token:", error);
|
||||
throw new Error("Failed to save authentication token", {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
|
||||
import type { PolicyExecutionTarget } from "@app/services/policyPipeline";
|
||||
|
||||
/**
|
||||
* Desktop: a policy run's outputs live on the backend that executed it.
|
||||
*/
|
||||
export function getPolicyOutputBaseUrl(target: PolicyExecutionTarget): string {
|
||||
if (target === "saas") {
|
||||
return (STIRLING_SAAS_BACKEND_API_URL ?? "").replace(/\/$/, "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function rec(over: Partial<PolicyRunRecord>): PolicyRunRecord {
|
||||
fileId: "f1",
|
||||
fileName: "f.pdf",
|
||||
fileSize: 10,
|
||||
target: "saas",
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { PolicyRunStatus } from "@app/services/policyPipeline";
|
||||
import type {
|
||||
PolicyExecutionTarget,
|
||||
PolicyRunStatus,
|
||||
} from "@app/services/policyPipeline";
|
||||
|
||||
export interface PolicyRunRecord {
|
||||
runId: string;
|
||||
@@ -18,6 +21,7 @@ export interface PolicyRunRecord {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
target: PolicyExecutionTarget;
|
||||
status: PolicyRunStatus;
|
||||
/** Pipeline progress reported by the run-status endpoint: the 1-based step
|
||||
* currently running, and the total step count. Drive the "step X/Y" label
|
||||
@@ -72,6 +76,8 @@ function read(): RunState {
|
||||
importedFileIds: Array.isArray(r.importedFileIds)
|
||||
? r.importedFileIds
|
||||
: [],
|
||||
// Records predating per-run targets all executed on SaaS.
|
||||
target: r.target === "local" ? "local" : "saas",
|
||||
}))
|
||||
: [],
|
||||
dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [],
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@app/services/policyApi", () => ({
|
||||
getPolicyRun: vi.fn(),
|
||||
listPolicyRuns: mocks.listPolicyRuns,
|
||||
downloadPolicyOutput: mocks.downloadPolicyOutput,
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
@@ -75,6 +76,7 @@ function recordCompletedRun() {
|
||||
fileId: "file-1",
|
||||
fileName: "doc.pdf",
|
||||
fileSize: 1234,
|
||||
target: "saas",
|
||||
status: "COMPLETED",
|
||||
outputs: [{ fileId: "out-file-1", fileName: "doc.pdf" }],
|
||||
error: null,
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@app/services/policyApi", () => ({
|
||||
runStoredPolicy: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
downloadPolicyOutput: vi.fn(),
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() },
|
||||
@@ -80,6 +81,7 @@ describe("auto-run queue-rejection retry", () => {
|
||||
fileId: "file-1",
|
||||
fileName: "doc.pdf",
|
||||
fileSize: 1234,
|
||||
target: "saas",
|
||||
status: "RUNNING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
|
||||
@@ -20,11 +20,13 @@ import {
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import i18n from "@app/i18n";
|
||||
import {
|
||||
runStoredPolicy,
|
||||
getPolicyRun,
|
||||
listPolicyRuns,
|
||||
downloadPolicyOutput,
|
||||
resolvePolicyRunTarget,
|
||||
} from "@app/services/policyApi";
|
||||
import type {
|
||||
PolicyRunStatus,
|
||||
@@ -82,9 +84,9 @@ const QUEUE_RETRY_BASE_MS = 4000;
|
||||
* to an instance that hasn't seen it) then fail, rather than polling forever. */
|
||||
const MAX_NOT_FOUND = 3;
|
||||
|
||||
/** A 404 from the run-status endpoint, across the web (axios) and desktop
|
||||
* (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */
|
||||
function isRunNotFound(err: unknown): boolean {
|
||||
/** A 404 (run status gone, or output file gone), across the web (axios) and
|
||||
* desktop (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
const e = err as
|
||||
| { code?: string; status?: number; response?: { status?: number } }
|
||||
| null
|
||||
@@ -354,6 +356,9 @@ async function reconcileServerRuns(
|
||||
fileId: "",
|
||||
fileName: view.outputs[0]?.fileName ?? "",
|
||||
fileSize: 0,
|
||||
// Rediscovered from the SaaS run registry (listPolicyRuns), so its outputs
|
||||
// live on the cloud backend.
|
||||
target: "saas",
|
||||
status: view.status,
|
||||
outputs: view.outputs,
|
||||
error: view.error,
|
||||
@@ -403,9 +408,9 @@ async function importOutputs(
|
||||
const targetName = ctx.outputName
|
||||
? undefined // use the run's per-output (renamed) name below
|
||||
: run.fileName;
|
||||
const results = await Promise.allSettled(
|
||||
const settled = await Promise.allSettled(
|
||||
pending.map(async (out) => {
|
||||
const blob = await downloadPolicyOutput(out.fileId);
|
||||
const blob = await downloadPolicyOutput(out.fileId, run.target);
|
||||
return {
|
||||
fileId: out.fileId,
|
||||
file: new File([blob], targetName ?? out.fileName ?? run.fileName, {
|
||||
@@ -414,13 +419,33 @@ async function importOutputs(
|
||||
};
|
||||
}),
|
||||
);
|
||||
const fetched = results
|
||||
const fetched = settled
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{ fileId: string; file: File }> =>
|
||||
r.status === "fulfilled",
|
||||
)
|
||||
.map((r) => r.value);
|
||||
if (fetched.length === 0) return; // all failed — retry the lot on a later tick
|
||||
// A 404 means the backend no longer has that output (past its retention
|
||||
// window); retrying it can never succeed, so don't loop on it forever. Any
|
||||
// other rejection is transient and worth retrying on a later tick.
|
||||
const rejections = settled
|
||||
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
||||
.map((r) => r.reason);
|
||||
const allFailuresPermanent =
|
||||
rejections.length > 0 && rejections.every(isNotFoundError);
|
||||
|
||||
if (fetched.length === 0) {
|
||||
if (allFailuresPermanent) {
|
||||
failRun(
|
||||
run.runId,
|
||||
i18n.t(
|
||||
"policies.activity.outputsUnavailable",
|
||||
"Policy outputs are no longer available to download.",
|
||||
),
|
||||
);
|
||||
}
|
||||
return; // transient/mixed: retry the lot later; permanent: already failed.
|
||||
}
|
||||
|
||||
// Deliver, then mark exactly those imported. If delivery throws we don't mark
|
||||
// them, so they retry (without having been added).
|
||||
@@ -472,12 +497,26 @@ async function importOutputs(
|
||||
deliveredIds = added.map((f) => f.fileId as string);
|
||||
}
|
||||
const importedFileIds = [...done, ...fetched.map((f) => f.fileId)];
|
||||
const imported = run.outputs.every((out) =>
|
||||
importedFileIds.includes(out.fileId),
|
||||
);
|
||||
updateRun(run.runId, {
|
||||
importedFileIds,
|
||||
// Accumulate across partial-import retries rather than overwriting.
|
||||
outputFileIds: [...(run.outputFileIds ?? []), ...deliveredIds],
|
||||
imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)),
|
||||
imported,
|
||||
});
|
||||
// Some outputs landed but the rest are permanently gone (404): finalize so the
|
||||
// run stops re-fetching the missing ones on every tick.
|
||||
if (!imported && allFailuresPermanent) {
|
||||
failRun(
|
||||
run.runId,
|
||||
i18n.t(
|
||||
"policies.activity.partialOutputsUnavailable",
|
||||
"Some policy outputs are no longer available to download.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,6 +554,7 @@ export async function runPolicyOnFile(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const target = resolvePolicyRunTarget();
|
||||
const runId = await runStoredPolicy(backendId, [file]);
|
||||
// recordRunStart marks this (policy, file) dispatched as it records the run.
|
||||
recordRunStart({
|
||||
@@ -523,6 +563,7 @@ export async function runPolicyOnFile(
|
||||
fileId,
|
||||
fileName,
|
||||
fileSize: file.size,
|
||||
target,
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
@@ -562,9 +603,15 @@ export async function poll(
|
||||
// The server lost the run's (in-memory) state — a restart, or a poll that
|
||||
// hopped to an instance without it. Tolerate a brief blip, then fail so
|
||||
// the file stops enforcing forever; the user can retry.
|
||||
if (isRunNotFound(err)) {
|
||||
if (isNotFoundError(err)) {
|
||||
if (++notFoundStreak >= MAX_NOT_FOUND) {
|
||||
failRun(runId, "The enforcement run could no longer be found.");
|
||||
failRun(
|
||||
runId,
|
||||
i18n.t(
|
||||
"policies.activity.runNotFound",
|
||||
"The enforcement run could no longer be found.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -591,5 +638,11 @@ export async function poll(
|
||||
}
|
||||
// Budget exhausted without a terminal status — stop here and fail it, so the
|
||||
// file doesn't enforce forever and reloads don't re-poll it.
|
||||
failRun(runId, "Enforcement timed out — the run didn't finish in time.");
|
||||
failRun(
|
||||
runId,
|
||||
i18n.t(
|
||||
"policies.activity.timedOut",
|
||||
"Enforcement timed out before the run could finish.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ function run(overrides: Partial<PolicyRunRecord>): PolicyRunRecord {
|
||||
fileId: "in",
|
||||
fileName: "in.pdf",
|
||||
fileSize: 1,
|
||||
target: "saas",
|
||||
status: "COMPLETED",
|
||||
outputs: [],
|
||||
outputFileIds: ["out"],
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { getPolicyOutputBaseUrl } from "@app/services/policyOutputBaseUrl";
|
||||
import type {
|
||||
BackendPipelineDefinition,
|
||||
BackendPolicy,
|
||||
PolicyExecutionTarget,
|
||||
PolicyRunView,
|
||||
} from "@app/services/policyPipeline";
|
||||
|
||||
@@ -88,10 +90,25 @@ export async function runPolicyPipeline(
|
||||
return res.data.jobId;
|
||||
}
|
||||
|
||||
/** Download a run's output file by id (via the shared general-files endpoint). */
|
||||
export async function downloadPolicyOutput(fileId: string): Promise<Blob> {
|
||||
/**
|
||||
* Where a policy run executes, and thus the backend that holds its outputs.
|
||||
*/
|
||||
export function resolvePolicyRunTarget(): PolicyExecutionTarget {
|
||||
return "saas";
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a run's output file by id (via the shared general-files endpoint).
|
||||
* `target` is where the run executed: it selects the backend the file is fetched
|
||||
* from, so a SaaS run's output isn't looked for on the bundled local backend.
|
||||
*/
|
||||
export async function downloadPolicyOutput(
|
||||
fileId: string,
|
||||
target: PolicyExecutionTarget,
|
||||
): Promise<Blob> {
|
||||
const base = getPolicyOutputBaseUrl(target);
|
||||
const res = await apiClient.get<Blob>(
|
||||
`/api/v1/general/files/${encodeURIComponent(fileId)}`,
|
||||
`${base}/api/v1/general/files/${encodeURIComponent(fileId)}`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return res.data;
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
runStoredPolicy,
|
||||
getPolicyRun,
|
||||
downloadPolicyOutput,
|
||||
resolvePolicyRunTarget,
|
||||
} from "@app/services/policyApi";
|
||||
import type { PolicyExecutionTarget } from "@app/services/policyPipeline";
|
||||
import {
|
||||
recordRunStart,
|
||||
isDispatched,
|
||||
@@ -52,6 +54,7 @@ interface ExportPolicy {
|
||||
interface PolicyRunResult {
|
||||
file: File;
|
||||
runId: string;
|
||||
target: PolicyExecutionTarget;
|
||||
outputs: { fileId: string; fileName: string }[];
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ async function runToCompletion(
|
||||
backendId: string,
|
||||
file: File,
|
||||
): Promise<PolicyRunResult> {
|
||||
const target = resolvePolicyRunTarget();
|
||||
const runId = await runStoredPolicy(backendId, [file]);
|
||||
for (let i = 0; i < MAX_POLLS; i++) {
|
||||
await delay(POLL_MS);
|
||||
@@ -96,12 +100,12 @@ async function runToCompletion(
|
||||
if (view.status === "COMPLETED") {
|
||||
const out = view.outputs?.[0];
|
||||
if (!out) throw new Error("policy produced no output");
|
||||
const blob = await downloadPolicyOutput(out.fileId);
|
||||
const blob = await downloadPolicyOutput(out.fileId, target);
|
||||
// Keep the export's filename; only the bytes are the enforced result.
|
||||
const enforced = new File([blob], file.name, {
|
||||
type: blob.type || file.type || "application/pdf",
|
||||
});
|
||||
return { file: enforced, runId, outputs: view.outputs ?? [] };
|
||||
return { file: enforced, runId, target, outputs: view.outputs ?? [] };
|
||||
}
|
||||
if (view.status === "FAILED" || view.status === "CANCELLED") {
|
||||
throw new Error(view.error || `policy run ${view.status.toLowerCase()}`);
|
||||
@@ -219,6 +223,7 @@ export async function enforceExportPolicies(
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
target: versionRun!.target,
|
||||
status: "COMPLETED",
|
||||
outputs: versionRun.outputs,
|
||||
error: null,
|
||||
|
||||
@@ -14,6 +14,7 @@ function run(over: Partial<PolicyRunRecord>): PolicyRunRecord {
|
||||
fileId: "f1",
|
||||
fileName: "f.pdf",
|
||||
fileSize: 0,
|
||||
target: "saas",
|
||||
status: "COMPLETED",
|
||||
outputs: [],
|
||||
error: null,
|
||||
@@ -47,7 +48,7 @@ describe("runsToActivity", () => {
|
||||
expect(activity[0]).toMatchObject({
|
||||
doc: "fresh.pdf",
|
||||
status: "processing",
|
||||
action: "Enforcing…",
|
||||
action: "Enforcing...",
|
||||
});
|
||||
expect(activity[1]).toMatchObject({
|
||||
doc: "contract.pdf",
|
||||
@@ -66,9 +67,9 @@ describe("runsToActivity", () => {
|
||||
run({ runId: "a", status: "RUNNING", currentStep: 1, stepCount: 2 }),
|
||||
run({ runId: "b", status: "RUNNING" }),
|
||||
]);
|
||||
expect(withStep.action).toBe("Enforcing… · step 1/2");
|
||||
expect(withStep.action).toBe("Enforcing... · step 1/2");
|
||||
// Before the first status report (no step yet) it stays the plain label.
|
||||
expect(noStep.action).toBe("Enforcing…");
|
||||
expect(noStep.action).toBe("Enforcing...");
|
||||
});
|
||||
|
||||
it("shows a queue-rejected run awaiting retry as busy, not a failure", () => {
|
||||
@@ -81,7 +82,7 @@ describe("runsToActivity", () => {
|
||||
}),
|
||||
]);
|
||||
expect(item.status).toBe("processing");
|
||||
expect(item.action).toBe("Busy — retrying…");
|
||||
expect(item.action).toBe("Busy, retrying...");
|
||||
});
|
||||
|
||||
it("shows a queue rejection that has exhausted its retries as a failure", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* policy's actual enforcement history — not a cosmetic file listing.
|
||||
*/
|
||||
|
||||
import i18n from "@app/i18n";
|
||||
import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
|
||||
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
|
||||
|
||||
@@ -58,17 +59,20 @@ function activityStatus(run: PolicyRunRecord): PolicyActivityItem["status"] {
|
||||
function activityAction(run: PolicyRunRecord): string {
|
||||
switch (activityStatus(run)) {
|
||||
case "enforced":
|
||||
return `${formatBytes(run.fileSize)} • enforced`;
|
||||
return `${formatBytes(run.fileSize)} • ${i18n.t("policies.activity.enforced", "enforced")}`;
|
||||
case "flagged":
|
||||
return run.error ?? "Enforcement failed";
|
||||
return (
|
||||
run.error ?? i18n.t("policies.activity.failed", "Enforcement failed")
|
||||
);
|
||||
default: {
|
||||
if (run.retrying) return "Busy — retrying…";
|
||||
// Show pipeline progress while running, once the status endpoint reports
|
||||
// it — turns a static "Enforcing…" into visible movement on slow steps.
|
||||
if (run.retrying)
|
||||
return i18n.t("policies.activity.retrying", "Busy, retrying...");
|
||||
const enforcing = i18n.t("policies.activity.enforcing", "Enforcing...");
|
||||
// Show pipeline progress while running, once the status endpoint reports it
|
||||
const { currentStep, stepCount } = run;
|
||||
return currentStep && stepCount
|
||||
? `Enforcing… · step ${currentStep}/${stepCount}`
|
||||
: "Enforcing…";
|
||||
? `${enforcing} · ${i18n.t("policies.activity.step", "step {{current}}/{{total}}", { current: currentStep, total: stepCount })}`
|
||||
: enforcing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PolicyExecutionTarget } from "@app/services/policyPipeline";
|
||||
|
||||
/**
|
||||
* Base URL for downloading a policy run's output file, given where the run
|
||||
* executed.
|
||||
*
|
||||
* Web builds are served from their own backend, so a relative request resolves
|
||||
* to the right place regardless of where the run ran, hence "" for every
|
||||
* target.
|
||||
*/
|
||||
export function getPolicyOutputBaseUrl(_target: PolicyExecutionTarget): string {
|
||||
return "";
|
||||
}
|
||||
@@ -56,6 +56,12 @@ export interface BackendPolicy {
|
||||
output: BackendOutputSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a policy run executes, and therefore where its output files live and
|
||||
* are downloaded from.
|
||||
*/
|
||||
export type PolicyExecutionTarget = "local" | "saas";
|
||||
|
||||
/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
|
||||
export type PolicyRunStatus =
|
||||
| "PENDING"
|
||||
|
||||
Reference in New Issue
Block a user