- {t("accountLink.panel.instancesSub")}
+ {t(
+ "accountLink.panel.instancesSub",
+ "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access.",
+ )}
{instancesState.loading ? (
@@ -93,12 +101,21 @@ export function AccountLinkPanel() {
) : instancesState.error ? (
{instancesState.error instanceof HttpError &&
instancesState.error.status === 403
- ? t("accountLink.panel.loadError.forbidden")
- : t("accountLink.panel.loadError.generic")}
+ ? t(
+ "accountLink.panel.loadError.forbidden",
+ "Only the team owner can view the org's linked instances.",
+ )
+ : t(
+ "accountLink.panel.loadError.generic",
+ "Couldn't load the team's linked instances. Try again in a moment.",
+ )}
) : (
+
{revokeError}
)}
diff --git a/frontend/portal/src/components/account-link/LinkAccountCard.tsx b/frontend/portal/src/components/account-link/LinkAccountCard.tsx
index 7231fbbe28..f47accf391 100644
--- a/frontend/portal/src/components/account-link/LinkAccountCard.tsx
+++ b/frontend/portal/src/components/account-link/LinkAccountCard.tsx
@@ -24,30 +24,44 @@ export function LinkAccountCard({ link }: Props) {
- {t("billing.spendLimit.guardrailLabel")}{" "}
- {t("billing.spendLimit.guardrailBody")}
+
+ {t("billing.spendLimit.guardrailLabel", "Your guardrail:")}
+ {" "}
+ {t(
+ "billing.spendLimit.guardrailBody",
+ "a hard ceiling — you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost.",
+ )}
- {t("billing.spendLimit.displaySub")}
+ {t(
+ "billing.spendLimit.displaySub",
+ "You're only billed for what you process automatically — never past the ceiling.",
+ )}
+ );
+}
+
+const meta: Meta = {
+ title: "Brand/Logos",
+ parameters: { layout: "padded" },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Every brand mark + wordmark, per variant, on the background each is built for. */
+export const Logos: Story = {
+ render: () => (
+
+ {SETS.map((set) => (
+
+
+ {set.variant}
+
+
+
+
+ ))}
+
+ ),
+};
diff --git a/frontend/editor/public/classic-logo/Firstpage.png b/frontend/shared/assets/brand/classic-logo/Firstpage.png
similarity index 100%
rename from frontend/editor/public/classic-logo/Firstpage.png
rename to frontend/shared/assets/brand/classic-logo/Firstpage.png
diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoBlackText.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg
similarity index 100%
rename from frontend/editor/public/classic-logo/StirlingPDFLogoBlackText.svg
rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg
diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoGreyText.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg
similarity index 100%
rename from frontend/editor/public/classic-logo/StirlingPDFLogoGreyText.svg
rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg
diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoNoTextDark.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg
similarity index 100%
rename from frontend/editor/public/classic-logo/StirlingPDFLogoNoTextDark.svg
rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg
diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoNoTextLight.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg
similarity index 100%
rename from frontend/editor/public/classic-logo/StirlingPDFLogoNoTextLight.svg
rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg
diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoWhiteText.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg
similarity index 100%
rename from frontend/editor/public/classic-logo/StirlingPDFLogoWhiteText.svg
rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg
diff --git a/frontend/editor/public/classic-logo/favicon.ico b/frontend/shared/assets/brand/classic-logo/favicon.ico
similarity index 100%
rename from frontend/editor/public/classic-logo/favicon.ico
rename to frontend/shared/assets/brand/classic-logo/favicon.ico
diff --git a/frontend/editor/public/classic-logo/logo-tooltip.svg b/frontend/shared/assets/brand/classic-logo/logo-tooltip.svg
similarity index 100%
rename from frontend/editor/public/classic-logo/logo-tooltip.svg
rename to frontend/shared/assets/brand/classic-logo/logo-tooltip.svg
diff --git a/frontend/editor/public/classic-logo/logo192.png b/frontend/shared/assets/brand/classic-logo/logo192.png
similarity index 100%
rename from frontend/editor/public/classic-logo/logo192.png
rename to frontend/shared/assets/brand/classic-logo/logo192.png
diff --git a/frontend/editor/public/classic-logo/logo512.png b/frontend/shared/assets/brand/classic-logo/logo512.png
similarity index 100%
rename from frontend/editor/public/classic-logo/logo512.png
rename to frontend/shared/assets/brand/classic-logo/logo512.png
diff --git a/frontend/editor/public/modern-logo/Firstpage.png b/frontend/shared/assets/brand/modern-logo/Firstpage.png
similarity index 100%
rename from frontend/editor/public/modern-logo/Firstpage.png
rename to frontend/shared/assets/brand/modern-logo/Firstpage.png
diff --git a/frontend/editor/public/modern-logo/LoginDarkModeHeader.svg b/frontend/shared/assets/brand/modern-logo/LoginDarkModeHeader.svg
similarity index 100%
rename from frontend/editor/public/modern-logo/LoginDarkModeHeader.svg
rename to frontend/shared/assets/brand/modern-logo/LoginDarkModeHeader.svg
diff --git a/frontend/shared/assets/login/LoginLightModeHeader.svg b/frontend/shared/assets/brand/modern-logo/LoginLightModeHeader.svg
similarity index 100%
rename from frontend/shared/assets/login/LoginLightModeHeader.svg
rename to frontend/shared/assets/brand/modern-logo/LoginLightModeHeader.svg
diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoBlackText.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg
similarity index 100%
rename from frontend/editor/public/modern-logo/StirlingPDFLogoBlackText.svg
rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg
diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoGreyText.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg
similarity index 100%
rename from frontend/editor/public/modern-logo/StirlingPDFLogoGreyText.svg
rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg
diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoNoTextDark.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg
similarity index 100%
rename from frontend/editor/public/modern-logo/StirlingPDFLogoNoTextDark.svg
rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg
diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoNoTextLight.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg
similarity index 100%
rename from frontend/editor/public/modern-logo/StirlingPDFLogoNoTextLight.svg
rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg
diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoWhiteText.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg
similarity index 100%
rename from frontend/editor/public/modern-logo/StirlingPDFLogoWhiteText.svg
rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg
diff --git a/frontend/editor/public/modern-logo/favicon.ico b/frontend/shared/assets/brand/modern-logo/favicon.ico
similarity index 100%
rename from frontend/editor/public/modern-logo/favicon.ico
rename to frontend/shared/assets/brand/modern-logo/favicon.ico
diff --git a/frontend/editor/public/modern-logo/logo-tooltip.svg b/frontend/shared/assets/brand/modern-logo/logo-tooltip.svg
similarity index 100%
rename from frontend/editor/public/modern-logo/logo-tooltip.svg
rename to frontend/shared/assets/brand/modern-logo/logo-tooltip.svg
diff --git a/frontend/editor/public/modern-logo/logo192.png b/frontend/shared/assets/brand/modern-logo/logo192.png
similarity index 100%
rename from frontend/editor/public/modern-logo/logo192.png
rename to frontend/shared/assets/brand/modern-logo/logo192.png
diff --git a/frontend/editor/public/modern-logo/logo512.png b/frontend/shared/assets/brand/modern-logo/logo512.png
similarity index 100%
rename from frontend/editor/public/modern-logo/logo512.png
rename to frontend/shared/assets/brand/modern-logo/logo512.png
diff --git a/frontend/shared/assets/stirling-mark-dark.svg b/frontend/shared/assets/stirling-mark-dark.svg
deleted file mode 100644
index a6f82dd6f6..0000000000
--- a/frontend/shared/assets/stirling-mark-dark.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/frontend/shared/assets/stirling-mark-light.svg b/frontend/shared/assets/stirling-mark-light.svg
deleted file mode 100644
index 62a5b38387..0000000000
--- a/frontend/shared/assets/stirling-mark-light.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/frontend/shared/assets/stirling-pdf-logo-dark.svg b/frontend/shared/assets/stirling-pdf-logo-dark.svg
deleted file mode 100644
index ade6937870..0000000000
--- a/frontend/shared/assets/stirling-pdf-logo-dark.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/frontend/shared/assets/stirling-pdf-logo-light.svg b/frontend/shared/assets/stirling-pdf-logo-light.svg
deleted file mode 100644
index a4a1a1f87e..0000000000
--- a/frontend/shared/assets/stirling-pdf-logo-light.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
From e44da5c410fc0d5a8a645be4045082bdddc670fc Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Tue, 30 Jun 2026 15:07:12 +0100
Subject: [PATCH 3/9] 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.
---
.../public/locales/en-US/translation.toml | 11 +++
.../editor/src-tauri/src/commands/auth.rs | 4 +
.../src/desktop/services/authService.ts | 14 +++-
.../desktop/services/policyOutputBaseUrl.ts | 12 +++
.../policies/policyRunStore.test.ts | 1 +
.../components/policies/policyRunStore.ts | 8 +-
.../policies/usePolicyAutoRun.import.test.tsx | 2 +
.../policies/usePolicyAutoRun.retry.test.tsx | 2 +
.../components/policies/usePolicyAutoRun.ts | 75 ++++++++++++++++---
.../hooks/usePolicyFileBadges.test.ts | 1 +
.../src/proprietary/services/policyApi.ts | 23 +++++-
.../src/proprietary/services/policyExport.ts | 9 ++-
.../services/policyLiveData.test.ts | 9 ++-
.../proprietary/services/policyLiveData.ts | 18 +++--
.../services/policyOutputBaseUrl.ts | 13 ++++
.../proprietary/services/policyPipeline.ts | 6 ++
16 files changed, 177 insertions(+), 31 deletions(-)
create mode 100644 frontend/editor/src/desktop/services/policyOutputBaseUrl.ts
create mode 100644 frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 41a2f220e5..670c315589 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -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"
diff --git a/frontend/editor/src-tauri/src/commands/auth.rs b/frontend/editor/src-tauri/src/commands/auth.rs
index 28d2ddd376..b906bbbf7d 100644
--- a/frontend/editor/src-tauri/src/commands/auth.rs
+++ b/frontend/editor/src-tauri/src/commands/auth.rs
@@ -400,6 +400,7 @@ struct SupabaseUser {
#[derive(Debug, Deserialize)]
struct SupabaseLoginResponse {
access_token: String,
+ refresh_token: Option,
user: SupabaseUser,
}
@@ -408,6 +409,7 @@ pub struct LoginResponse {
pub token: String,
pub username: String,
pub email: Option,
+ pub refresh_token: Option,
}
/// 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,
})
}
}
diff --git a/frontend/editor/src/desktop/services/authService.ts b/frontend/editor/src/desktop/services/authService.ts
index 6f5d3214f3..63181ef1dd 100644
--- a/frontend/editor/src/desktop/services/authService.ts
+++ b/frontend/editor/src/desktop/services/authService.ts
@@ -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", {
diff --git a/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts b/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts
new file mode 100644
index 0000000000..82b452aa0c
--- /dev/null
+++ b/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts
@@ -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 "";
+}
diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts
index 922e64650f..2034866b87 100644
--- a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts
+++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts
@@ -18,6 +18,7 @@ function rec(over: Partial): PolicyRunRecord {
fileId: "f1",
fileName: "f.pdf",
fileSize: 10,
+ target: "saas",
status: "PENDING",
outputs: [],
error: null,
diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts
index 295c45d737..45a8066bf0 100644
--- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts
+++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts
@@ -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 : [],
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx
index 36763a3e37..6090557c70 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx
@@ -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,
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx
index a0891a28f5..7acc1ae2bb 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx
@@ -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,
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
index 8440d53b5b..19f88c9304 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
@@ -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.",
+ ),
+ );
}
diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts
index 30c5a83952..db69bb77af 100644
--- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts
+++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts
@@ -15,6 +15,7 @@ function run(overrides: Partial): PolicyRunRecord {
fileId: "in",
fileName: "in.pdf",
fileSize: 1,
+ target: "saas",
status: "COMPLETED",
outputs: [],
outputFileIds: ["out"],
diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts
index 1936cf0894..b3f1fd999f 100644
--- a/frontend/editor/src/proprietary/services/policyApi.ts
+++ b/frontend/editor/src/proprietary/services/policyApi.ts
@@ -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 {
+/**
+ * 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 {
+ const base = getPolicyOutputBaseUrl(target);
const res = await apiClient.get(
- `/api/v1/general/files/${encodeURIComponent(fileId)}`,
+ `${base}/api/v1/general/files/${encodeURIComponent(fileId)}`,
{ responseType: "blob" },
);
return res.data;
diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts
index f4dce74873..92b14712dd 100644
--- a/frontend/editor/src/proprietary/services/policyExport.ts
+++ b/frontend/editor/src/proprietary/services/policyExport.ts
@@ -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 {
+ 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,
diff --git a/frontend/editor/src/proprietary/services/policyLiveData.test.ts b/frontend/editor/src/proprietary/services/policyLiveData.test.ts
index 38b96c845e..c3016f071b 100644
--- a/frontend/editor/src/proprietary/services/policyLiveData.test.ts
+++ b/frontend/editor/src/proprietary/services/policyLiveData.test.ts
@@ -14,6 +14,7 @@ function run(over: Partial): 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", () => {
diff --git a/frontend/editor/src/proprietary/services/policyLiveData.ts b/frontend/editor/src/proprietary/services/policyLiveData.ts
index 67d53f9c5e..56401f4d19 100644
--- a/frontend/editor/src/proprietary/services/policyLiveData.ts
+++ b/frontend/editor/src/proprietary/services/policyLiveData.ts
@@ -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;
}
}
}
diff --git a/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts b/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts
new file mode 100644
index 0000000000..c5a127c0d5
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts
@@ -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 "";
+}
diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts
index 21e304e4fb..b27bd09e4d 100644
--- a/frontend/editor/src/proprietary/services/policyPipeline.ts
+++ b/frontend/editor/src/proprietary/services/policyPipeline.ts
@@ -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"
From 276eb8f2a74e4f5d37389debff387aae97353ec4 Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Tue, 30 Jun 2026 17:11:48 +0100
Subject: [PATCH 4/9] Add pipelines page to portal (#6818)
# Description of Changes
Connect pipelines page to the backend. Note that this is really half an
implementation because the portal doesn't have access to the tools list
and their settings, but I can't fix that without re-architecture work,
which I'll do in another PR, then come back to finish this off in a new
PR.
---
.../policy/controller/PolicyController.java | 52 ++
.../policy/engine/PolicyRunner.java | 26 +-
.../overview/PoliciesOverviewResponse.java | 6 +
.../policy/overview/PolicyKpi.java | 4 +
.../overview/PolicyOverviewService.java | 102 +++
.../policy/overview/PolicyView.java | 24 +
.../policy/trigger/FolderWatchTrigger.java | 11 +
.../policy/trigger/PolicyTrigger.java | 20 +
.../policy/trigger/TriggerInfo.java | 18 +
.../controller/PolicyControllerTest.java | 79 ++
.../overview/PolicyOverviewServiceTest.java | 186 +++++
.../public/locales/en-US/translation.toml | 191 ++---
frontend/portal/src/api/pipelines.ts | 189 ++++-
.../DeployedPipelinesTable.stories.tsx | 32 -
.../pipelines/DeployedPipelinesTable.tsx | 106 ---
.../src/components/pipelines/KpiStrip.tsx | 39 +
.../pipelines/PipelineCard.stories.tsx | 30 -
.../src/components/pipelines/PipelineCard.tsx | 132 ---
.../pipelines/PipelineComposer.stories.tsx | 25 -
.../components/pipelines/PipelineComposer.tsx | 744 ++++++++++-------
.../pipelines/PipelineDetail.stories.tsx | 31 -
.../components/pipelines/PipelineDetail.tsx | 183 -----
.../pipelines/PipelineDetailCard.tsx | 213 +++++
.../PipelineListSkeleton.stories.tsx | 19 -
.../pipelines/PipelineListSkeleton.tsx | 19 -
.../components/pipelines/PipelinesTable.tsx | 122 +++
.../pipelines/PromotedPipelines.stories.tsx | 26 -
.../pipelines/PromotedPipelines.tsx | 139 ----
.../portal/src/components/pipelines/format.ts | 26 -
.../pipelines/pipelineOperations.ts | 50 ++
.../src/components/pipelines/stageAccent.ts | 35 -
.../src/components/pipelines/storyFixtures.ts | 102 ---
.../portal/src/mocks/handlers/pipelines.ts | 214 ++++-
frontend/portal/src/mocks/pipelines.ts | 415 ----------
frontend/portal/src/views/Pipelines.css | 764 ++++++------------
frontend/portal/src/views/Pipelines.test.tsx | 245 ++++++
frontend/portal/src/views/Pipelines.tsx | 261 +++---
37 files changed, 2444 insertions(+), 2436 deletions(-)
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java
delete mode 100644 frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx
delete mode 100644 frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx
create mode 100644 frontend/portal/src/components/pipelines/KpiStrip.tsx
delete mode 100644 frontend/portal/src/components/pipelines/PipelineCard.stories.tsx
delete mode 100644 frontend/portal/src/components/pipelines/PipelineCard.tsx
delete mode 100644 frontend/portal/src/components/pipelines/PipelineComposer.stories.tsx
delete mode 100644 frontend/portal/src/components/pipelines/PipelineDetail.stories.tsx
delete mode 100644 frontend/portal/src/components/pipelines/PipelineDetail.tsx
create mode 100644 frontend/portal/src/components/pipelines/PipelineDetailCard.tsx
delete mode 100644 frontend/portal/src/components/pipelines/PipelineListSkeleton.stories.tsx
delete mode 100644 frontend/portal/src/components/pipelines/PipelineListSkeleton.tsx
create mode 100644 frontend/portal/src/components/pipelines/PipelinesTable.tsx
delete mode 100644 frontend/portal/src/components/pipelines/PromotedPipelines.stories.tsx
delete mode 100644 frontend/portal/src/components/pipelines/PromotedPipelines.tsx
delete mode 100644 frontend/portal/src/components/pipelines/format.ts
create mode 100644 frontend/portal/src/components/pipelines/pipelineOperations.ts
delete mode 100644 frontend/portal/src/components/pipelines/stageAccent.ts
delete mode 100644 frontend/portal/src/components/pipelines/storyFixtures.ts
delete mode 100644 frontend/portal/src/mocks/pipelines.ts
create mode 100644 frontend/portal/src/views/Pipelines.test.tsx
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
index 8a06582b58..64e6aa0523 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -52,11 +53,15 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.PolicyRunView;
+import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse;
+import stirling.software.proprietary.policy.overview.PolicyOverviewService;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
+import stirling.software.proprietary.policy.trigger.PolicyTrigger;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
+import stirling.software.proprietary.policy.trigger.TriggerInfo;
/**
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
@@ -80,6 +85,8 @@ public class PolicyController {
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
private final PolicyTriggerManager policyTriggerManager;
+ private final PolicyOverviewService policyOverviewService;
+ private final List policyTriggers;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final JobOwnershipService jobOwnershipService;
@@ -287,6 +294,31 @@ public class PolicyController {
return policyAccessGuard.visibleFrom(policyStore);
}
+ @GetMapping("/overview")
+ @Operation(
+ summary = "Pipelines overview",
+ description =
+ "Returns the KPI strip plus one row per policy the caller's team owns, each with"
+ + " its referenced sources resolved to names, its pipeline steps, and a"
+ + " trigger/output summary. Backs the portal's all-pipelines surface.")
+ public PoliciesOverviewResponse overview() {
+ return policyOverviewService.overview();
+ }
+
+ @GetMapping("/triggers")
+ @Operation(
+ summary = "List available triggers",
+ description =
+ "Lists each trigger kind with whether it needs a source and which source types"
+ + " it supports, so the UI can offer triggers and pair them with the"
+ + " right sources.")
+ public List triggers() {
+ return policyTriggers.stream()
+ .map(TriggerInfo::of)
+ .sorted(Comparator.comparing(TriggerInfo::type))
+ .toList();
+ }
+
@GetMapping("/{policyId}")
@Operation(summary = "Get a policy by id")
public ResponseEntity getPolicy(@PathVariable String policyId) {
@@ -337,6 +369,26 @@ public class PolicyController {
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
+ @PostMapping("/{policyId}/trigger")
+ @Operation(
+ summary = "Run a stored policy against its sources",
+ description =
+ "Pulls the policy's configured sources and runs the pipeline now, regardless of"
+ + " the enabled flag (which only gates automatic triggering). Returns"
+ + " the ids of the runs started; poll the run-status endpoint for each."
+ + " Empty when the sources yielded no work to do.")
+ public ResponseEntity> trigger(@PathVariable String policyId) {
+ Policy policy =
+ policyStore
+ .get(policyId)
+ .filter(policyAccessGuard::canAccess)
+ .orElseThrow(
+ () ->
+ new ResponseStatusException(
+ HttpStatus.NOT_FOUND, "No policy: " + policyId));
+ return ResponseEntity.accepted().body(policyRunner.run(policy));
+ }
+
private static void requireRunnable(PipelineDefinition definition) {
if (definition.steps().isEmpty()) {
throw new ResponseStatusException(
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java
index 27f43c74da..819b6cae02 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java
@@ -1,6 +1,7 @@
package stirling.software.proprietary.policy.engine;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -41,14 +42,15 @@ public class PolicyRunner {
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
* one failure does not affect the others. No sources means one run with no input (generator
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
- * rest.
+ * rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
+ * manual trigger can report back which runs to follow.
*/
- public void run(Policy policy) {
+ public List run(Policy policy) {
List sourceIds = policy.sourceIds();
if (sourceIds.isEmpty()) {
- startRun(policy, PolicyInputs.of(List.of()), unused -> {});
- return;
+ return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
}
+ List runIds = new ArrayList<>();
for (String sourceId : sourceIds) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null) {
@@ -63,8 +65,9 @@ public class PolicyRunner {
policy.id());
continue;
}
- pullAndRun(policy, source.toInputSpec());
+ runIds.addAll(pullAndRun(policy, source.toInputSpec()));
}
+ return runIds;
}
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
@@ -79,14 +82,14 @@ public class PolicyRunner {
return policyEngine.submit(definition, inputs, listener);
}
- private void pullAndRun(Policy policy, InputSpec spec) {
+ private List pullAndRun(Policy policy, InputSpec spec) {
InputSource source = sourceFor(spec);
if (source == null) {
log.warn(
"No input source for type '{}' (policy {}); skipping",
spec.type(),
policy.id());
- return;
+ return List.of();
}
List work;
try {
@@ -97,19 +100,22 @@ public class PolicyRunner {
spec.type(),
policy.id(),
e.getMessage());
- return;
+ return List.of();
}
+ List runIds = new ArrayList<>();
for (ResolvedInput unit : work) {
- startRun(policy, unit.inputs(), unit.onComplete());
+ runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
}
+ return runIds;
}
- private void startRun(Policy policy, PolicyInputs inputs, Consumer onComplete) {
+ private String startRun(Policy policy, PolicyInputs inputs, Consumer onComplete) {
log.info("Running policy {} ({})", policy.id(), policy.name());
PolicyRunHandle handle =
policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP);
handle.completion()
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
+ return handle.runId();
}
private static boolean succeeded(PolicyRun run, Throwable throwable) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java
new file mode 100644
index 0000000000..9f94391f8b
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java
@@ -0,0 +1,6 @@
+package stirling.software.proprietary.policy.overview;
+
+import java.util.List;
+
+/** The Pipelines overview payload: a KPI strip plus one row per policy. */
+public record PoliciesOverviewResponse(List kpis, List pipelines) {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java
new file mode 100644
index 0000000000..a266cbe883
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java
@@ -0,0 +1,4 @@
+package stirling.software.proprietary.policy.overview;
+
+/** One headline figure in the Pipelines overview strip. */
+public record PolicyKpi(long value, String description) {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
new file mode 100644
index 0000000000..5ba7856606
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
@@ -0,0 +1,102 @@
+package stirling.software.proprietary.policy.overview;
+
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+
+import stirling.software.proprietary.policy.config.PolicyAccessGuard;
+import stirling.software.proprietary.policy.model.OutputSpec;
+import stirling.software.proprietary.policy.model.PipelineStep;
+import stirling.software.proprietary.policy.model.Policy;
+import stirling.software.proprietary.policy.model.TriggerConfig;
+import stirling.software.proprietary.policy.source.Source;
+import stirling.software.proprietary.policy.source.SourceAccessGuard;
+import stirling.software.proprietary.policy.source.SourceStore;
+import stirling.software.proprietary.policy.store.PolicyStore;
+
+/**
+ * Builds the Pipelines overview: every policy the caller's team owns, each annotated with its
+ * referenced sources (resolved to display names), its pipeline steps, and a trigger/output summary.
+ * Source names are resolved from the team's sources in memory rather than persisted on the policy,
+ * so the view always reflects the live source set. This is the "all pipelines" admin surface; the
+ * user-facing Policies page builds only a friendly subset of the same backend policies.
+ */
+@Service
+@RequiredArgsConstructor
+@ConditionalOnBooleanProperty(name = "policies.enabled")
+public class PolicyOverviewService {
+
+ private final PolicyStore policyStore;
+ private final SourceStore sourceStore;
+ private final PolicyAccessGuard policyAccessGuard;
+ private final SourceAccessGuard sourceAccessGuard;
+
+ public PoliciesOverviewResponse overview() {
+ List policies = policyAccessGuard.visibleFrom(policyStore);
+ Map sourceNames = sourceNames();
+
+ List views =
+ policies.stream()
+ .map(policy -> toView(policy, sourceNames))
+ .sorted(
+ Comparator.comparing(
+ PolicyView::name, String.CASE_INSENSITIVE_ORDER))
+ .toList();
+
+ return new PoliciesOverviewResponse(buildKpis(policies), views);
+ }
+
+ /** Display names for every source the caller's team can see, keyed by source id. */
+ private Map sourceNames() {
+ Map names = new HashMap<>();
+ for (Source source : sourceAccessGuard.visibleFrom(sourceStore)) {
+ names.put(source.id(), source.name());
+ }
+ return names;
+ }
+
+ private static PolicyView toView(Policy policy, Map sourceNames) {
+ List sources =
+ policy.sourceIds().stream()
+ // An unresolved id (source deleted, or not visible) falls back to the id so
+ // the row still renders rather than dropping the reference silently.
+ .map(id -> new PolicyView.SourceRef(id, sourceNames.getOrDefault(id, id)))
+ .toList();
+ List steps = policy.steps().stream().map(PipelineStep::operation).toList();
+ return new PolicyView(
+ policy.id(),
+ policy.name(),
+ policy.enabled(),
+ policy.enabled() ? "active" : "paused",
+ triggerSummary(policy.trigger()),
+ sources,
+ steps,
+ outputSummary(policy.output()),
+ policy.owner());
+ }
+
+ /** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */
+ private static String triggerSummary(TriggerConfig trigger) {
+ return trigger == null ? "manual" : trigger.type();
+ }
+
+ private static String outputSummary(OutputSpec output) {
+ return output == null ? "inline" : output.type();
+ }
+
+ private static List buildKpis(List policies) {
+ long total = policies.size();
+ long active = policies.stream().filter(Policy::enabled).count();
+ long paused = total - active;
+ return List.of(
+ new PolicyKpi(total, "pipelines"),
+ new PolicyKpi(active, "running automatically"),
+ new PolicyKpi(paused, "paused"));
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java
new file mode 100644
index 0000000000..509379a66a
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java
@@ -0,0 +1,24 @@
+package stirling.software.proprietary.policy.overview;
+
+import java.util.List;
+
+/**
+ * One row in the Pipelines overview: a stored policy shown for the admin portal, with its
+ * referenced sources resolved to names and its pipeline summarised. The portal's "all pipelines"
+ * surface lists every backend policy (the user-facing Policies page builds only a friendly subset
+ * of these).
+ */
+public record PolicyView(
+ String id,
+ String name,
+ boolean enabled,
+ String status,
+ String trigger,
+ List sources,
+ List steps,
+ String output,
+ String owner) {
+
+ /** A source a policy pulls documents from, resolved to its display name. */
+ public record SourceRef(String id, String name) {}
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java
index 35c530fad0..79ba3e52e1 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java
@@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
+import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -74,6 +75,16 @@ public class FolderWatchTrigger implements PolicyTrigger {
return TYPE;
}
+ @Override
+ public boolean requiresSource() {
+ return true;
+ }
+
+ @Override
+ public Set supportedSourceTypes() {
+ return Set.of(FolderAccessGuard.FOLDER_TYPE);
+ }
+
@Override
public void validate(Policy policy) {
if (watchDirsOf(policy).isEmpty()) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java
index a97a9ac880..ade5357162 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java
@@ -1,5 +1,7 @@
package stirling.software.proprietary.policy.trigger;
+import java.util.Set;
+
import stirling.software.proprietary.policy.model.Policy;
/**
@@ -11,6 +13,24 @@ public interface PolicyTrigger {
/** Matches {@code TriggerConfig.type()}. */
String type();
+ /**
+ * Whether this trigger needs at least one compatible input source to function. A schedule fires
+ * on the clock regardless of sources, so it is false; folder-watch derives the directories it
+ * watches from the policy's sources, so it is true. Drives whether the UI offers the trigger.
+ */
+ default boolean requiresSource() {
+ return false;
+ }
+
+ /**
+ * The source {@code type()}s this trigger is compatible with (e.g. {@code "folder"}). Empty
+ * means source-agnostic (no constraint). Lets the UI offer a trigger only when a compatible
+ * source is selected, without hard-coding the relationship.
+ */
+ default Set supportedSourceTypes() {
+ return Set.of();
+ }
+
/**
* Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole
* {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that.
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java
new file mode 100644
index 0000000000..f8ad6e278b
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java
@@ -0,0 +1,18 @@
+package stirling.software.proprietary.policy.trigger;
+
+import java.util.List;
+
+/**
+ * Describes an available trigger for the admin UI: its {@code type} (matching {@code
+ * TriggerConfig.type()}), whether it needs a compatible source, and which source types it works
+ * with. Lets the UI list supported triggers and pair them with sources without hard-coding the set.
+ */
+public record TriggerInfo(String type, boolean requiresSource, List supportedSourceTypes) {
+
+ public static TriggerInfo of(PolicyTrigger trigger) {
+ return new TriggerInfo(
+ trigger.type(),
+ trigger.requiresSource(),
+ List.copyOf(trigger.supportedSourceTypes()));
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java
index 98ed101246..8a9a085653 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java
@@ -57,12 +57,23 @@ class PolicyControllerTest {
@Mock private PolicyAccessGuard policyAccessGuard;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private PolicyTriggerManager policyTriggerManager;
+
+ @Mock
+ private stirling.software.proprietary.policy.overview.PolicyOverviewService
+ policyOverviewService;
+
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
private ApplicationProperties applicationProperties;
private PolicyController controller;
+ private final java.util.List
+ policyTriggers =
+ java.util.List.of(
+ trigger("schedule", false, java.util.Set.of()),
+ trigger("folder-watch", true, java.util.Set.of("folder")));
+
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
@@ -77,11 +88,33 @@ class PolicyControllerTest {
policyAccessGuard,
policyManagementAuthority,
policyTriggerManager,
+ policyOverviewService,
+ policyTriggers,
applicationProperties,
tempFileManager,
jobOwnershipService);
}
+ private static stirling.software.proprietary.policy.trigger.PolicyTrigger trigger(
+ String type, boolean requiresSource, java.util.Set sourceTypes) {
+ return new stirling.software.proprietary.policy.trigger.PolicyTrigger() {
+ @Override
+ public String type() {
+ return type;
+ }
+
+ @Override
+ public boolean requiresSource() {
+ return requiresSource;
+ }
+
+ @Override
+ public java.util.Set supportedSourceTypes() {
+ return sourceTypes;
+ }
+ };
+ }
+
private static PipelineDefinition definitionWithStep() {
return new PipelineDefinition(
"pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null);
@@ -431,4 +464,50 @@ class PolicyControllerTest {
.isEqualTo(HttpStatus.NOT_FOUND));
}
}
+
+ @Nested
+ @DisplayName("triggers / trigger")
+ class Triggers {
+
+ @Test
+ @DisplayName("lists triggers sorted, with source compatibility")
+ void listsTriggers() {
+ List infos =
+ controller.triggers();
+
+ assertThat(infos).extracting(t -> t.type()).containsExactly("folder-watch", "schedule");
+ stirling.software.proprietary.policy.trigger.TriggerInfo folderWatch = infos.get(0);
+ assertThat(folderWatch.requiresSource()).isTrue();
+ assertThat(folderWatch.supportedSourceTypes()).containsExactly("folder");
+ assertThat(infos.get(1).requiresSource()).isFalse();
+ assertThat(infos.get(1).supportedSourceTypes()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("trigger runs an accessible policy against its sources and returns run ids")
+ void triggersRun() {
+ Policy p = policy("a", 1L);
+ when(policyStore.get("a")).thenReturn(Optional.of(p));
+ when(policyAccessGuard.canAccess(p)).thenReturn(true);
+ when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b"));
+
+ ResponseEntity> response = controller.trigger("a");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
+ assertThat(response.getBody()).containsExactly("run-a", "run-b");
+ }
+
+ @Test
+ @DisplayName("trigger is 404 when the policy is inaccessible")
+ void triggerNotFound() {
+ when(policyStore.get("z")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> controller.trigger("z"))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ e ->
+ assertThat(((ResponseStatusException) e).getStatusCode())
+ .isEqualTo(HttpStatus.NOT_FOUND));
+ }
+ }
}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java
new file mode 100644
index 0000000000..221cbc4482
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java
@@ -0,0 +1,186 @@
+package stirling.software.proprietary.policy.overview;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import stirling.software.common.model.ApplicationProperties;
+import stirling.software.common.service.UserServiceInterface;
+import stirling.software.proprietary.policy.config.PolicyAccessGuard;
+import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
+import stirling.software.proprietary.policy.model.OutputSpec;
+import stirling.software.proprietary.policy.model.PipelineStep;
+import stirling.software.proprietary.policy.model.Policy;
+import stirling.software.proprietary.policy.model.TriggerConfig;
+import stirling.software.proprietary.policy.source.InProcessSourceStore;
+import stirling.software.proprietary.policy.source.Source;
+import stirling.software.proprietary.policy.source.SourceAccessGuard;
+import stirling.software.proprietary.policy.source.SourceStore;
+import stirling.software.proprietary.policy.store.InProcessPolicyStore;
+import stirling.software.proprietary.policy.store.PolicyStore;
+
+/**
+ * Tests for {@link PolicyOverviewService}: every policy appears once with its sources resolved to
+ * names, its steps and trigger/output summarised, and the KPI strip counting active vs paused.
+ * Login is disabled so the team guards pass everything through.
+ */
+class PolicyOverviewServiceTest {
+
+ private final SourceStore sourceStore = new InProcessSourceStore();
+ private final PolicyStore policyStore = new InProcessPolicyStore();
+ private PolicyOverviewService service;
+
+ @BeforeEach
+ void setUp() {
+ ApplicationProperties properties = new ApplicationProperties();
+ properties.getSecurity().setEnableLogin(false);
+ UserServiceInterface userService = mock(UserServiceInterface.class);
+ PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
+ SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
+ PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
+ service = new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
+ }
+
+ @Test
+ void eachPolicyAppearsWithResolvedSourcesStepsAndSummary() {
+ Source claims = source("Claims intake", "/claims");
+ policyStore.save(
+ new Policy(
+ null,
+ "Redaction",
+ "owner",
+ true,
+ new TriggerConfig("schedule", Map.of()),
+ List.of(claims.id()),
+ List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
+ OutputSpec.inline()));
+ policyStore.save(
+ new Policy(
+ null,
+ "Archive (paused)",
+ "owner",
+ false,
+ null,
+ List.of(),
+ List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
+ OutputSpec.inline()));
+
+ PoliciesOverviewResponse response = service.overview();
+
+ assertEquals(2, response.pipelines().size());
+ // Sorted by name, case-insensitive, so "Archive" leads "Redaction".
+ PolicyView archive = response.pipelines().get(0);
+ assertEquals("Archive (paused)", archive.name());
+ assertEquals("paused", archive.status());
+ assertEquals("manual", archive.trigger());
+
+ PolicyView redaction = find(response, "Redaction");
+ assertEquals("active", redaction.status());
+ assertEquals("schedule", redaction.trigger());
+ assertEquals("inline", redaction.output());
+ assertEquals(List.of("/api/v1/security/auto-redact"), redaction.steps());
+ assertEquals(1, redaction.sources().size());
+ assertEquals(claims.id(), redaction.sources().get(0).id());
+ assertEquals("Claims intake", redaction.sources().get(0).name());
+
+ // KPI strip: total, active, paused.
+ assertEquals(List.of(2L, 1L, 1L), response.kpis().stream().map(PolicyKpi::value).toList());
+ }
+
+ @Test
+ void anUnresolvedSourceFallsBackToItsId() {
+ policyStore.save(
+ new Policy(
+ null,
+ "Orphan",
+ "owner",
+ true,
+ null,
+ List.of("src-missing"),
+ List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
+ OutputSpec.inline()));
+
+ PolicyView view = find(service.overview(), "Orphan");
+ assertEquals(1, view.sources().size());
+ assertEquals("src-missing", view.sources().get(0).id());
+ assertEquals("src-missing", view.sources().get(0).name());
+ }
+
+ @Test
+ void overviewLoadsOnlyTheCallersTeam() {
+ ApplicationProperties properties = new ApplicationProperties();
+ properties.getSecurity().setEnableLogin(true);
+ UserServiceInterface userService = mock(UserServiceInterface.class);
+ PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
+ when(authority.currentUserTeamId()).thenReturn(1L);
+ SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
+ PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
+ PolicyOverviewService scoped =
+ new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
+
+ Source ours = teamSource("Ours", "/ours", 1L);
+ teamPolicy("Our policy", 1L, ours.id());
+ teamPolicy("Their policy", 2L, ours.id());
+
+ PoliciesOverviewResponse response = scoped.overview();
+
+ assertEquals(1, response.pipelines().size());
+ PolicyView view = response.pipelines().get(0);
+ assertEquals("Our policy", view.name());
+ assertEquals("Ours", view.sources().get(0).name());
+ assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
+ }
+
+ @Test
+ void emptyStoreReportsZeroKpis() {
+ PoliciesOverviewResponse response = service.overview();
+ assertTrue(response.pipelines().isEmpty());
+ assertEquals(List.of(0L, 0L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
+ }
+
+ private Source source(String name, String directory) {
+ return sourceStore.save(
+ new Source(
+ null, name, "folder", Map.of("directory", directory), true, "owner", null));
+ }
+
+ private Source teamSource(String name, String directory, Long teamId) {
+ return sourceStore.save(
+ new Source(
+ null,
+ name,
+ "folder",
+ Map.of("directory", directory),
+ true,
+ "owner",
+ teamId));
+ }
+
+ private void teamPolicy(String name, Long teamId, String... sourceIds) {
+ policyStore.save(
+ new Policy(
+ null,
+ name,
+ "owner",
+ true,
+ null,
+ List.of(sourceIds),
+ List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
+ OutputSpec.inline(),
+ teamId));
+ }
+
+ private static PolicyView find(PoliciesOverviewResponse response, String name) {
+ return response.pipelines().stream()
+ .filter(view -> view.name().equals(name))
+ .findFirst()
+ .orElseThrow();
+ }
+}
diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml
index c0b3ccae91..4e90ab71f1 100644
--- a/frontend/portal/public/locales/en-US/translation.toml
+++ b/frontend/portal/public/locales/en-US/translation.toml
@@ -206,141 +206,96 @@ description = "The component catalogue could not be loaded. Try again shortly."
[pipelines]
title = "Pipelines"
-subtitle = "Document workflows composed from typed operations — deployed, versioned, and continuously validated against a golden set."
+subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources."
+
+[pipelines.actions]
newPipeline = "New pipeline"
+[pipelines.kpi]
+total = "Pipelines"
+active = "Active"
+paused = "Paused"
+
[pipelines.status]
-healthy = "Healthy"
-degraded = "Degraded"
+active = "Active"
+paused = "Paused"
-[pipelines.fleet]
-healthy_one = "{{count}} healthy"
-healthy_other = "{{count}} healthy"
-degraded_one = "{{count}} degraded"
-degraded_other = "{{count}} degraded"
-deployed_one = "{{count}} deployed"
-deployed_other = "{{count}} deployed"
+[pipelines.trigger]
+manual = "Manual"
+schedule = "Scheduled"
+folder-watch = "Folder watch"
-[pipelines.evals]
-title = "Shadow + comparative evals active"
-body_one = "{{count}} pipeline running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}"
-body_other = "{{count}} pipelines running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}"
+[pipelines.output]
+inline = "Return files"
+folder = "Write to folder"
[pipelines.empty]
title = "No pipelines yet"
-description = "Compose your first document workflow from the typed operation library — pick a source, chain the ops, and route the output."
-action = "Build your first pipeline"
-
-[pipelines.reliability]
-heading = "Golden-set reliability"
-description = "Pass rate against each pipeline's golden set, judged against its own bound. Anything below bound shows amber or red."
-
-[pipelines.promoted]
-heading = "Promoted from the Editor"
-description = "Watch-folder flows built in the Editor and promoted into the portal. Promote one to a policy to apply its rules fleet-wide."
-policyCreated = "Policy created"
-promoteToPolicy = "Promote to policy"
-
-[pipelines.promoted.table]
-sourceDocType = "Source doc type"
-watchFolder = "Watch folder"
-status = "Status"
-
-[pipelines.promoted.status]
-deployed = "Deployed"
-staged = "Staged"
-review = "Needs review"
-
-[pipelines.table.header]
-name = "Pipeline"
-health = "Health"
-goldenSet = "Golden set"
-docs24h = "Docs / 24h"
-version = "Version"
+description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes."
+action = "Create a pipeline"
[pipelines.table]
-boundTooltip = "Bound: {{bound}}"
+name = "Pipeline"
+status = "Status"
+steps = "Steps"
+sources = "Sources"
-[pipelines.metrics]
-docs24h = "Docs / 24h"
-throughput = "Throughput"
-errorRate = "Error rate"
-p95Latency = "P95 latency"
-uptime = "Uptime"
+[pipelines.detail]
+subtitle = "{{trigger}} · {{status}}"
+closeAriaLabel = "Close detail"
+steps = "Operations"
+noSteps = "No operations configured."
+sources = "Sources"
+noSources = "No sources. Files are supplied directly to each run."
+output = "Output"
+run = "Run now"
+edit = "Edit"
+pause = "Pause"
+resume = "Resume"
+delete = "Delete pipeline"
-[pipelines.card]
-stageTooltip_one = "{{label}}: {{count}} op"
-stageTooltip_other = "{{label}}: {{count}} ops"
-golden = "Golden {{passing}}/{{total}}"
-drift_one = "{{count}} drift"
-drift_other = "{{count}} drifts"
+[pipelines.run]
+empty = "Nothing to run: the sources had no documents to process."
+failed = "Run failed: {{error}}"
+running = "Run started; still in progress."
+completed_one = "Run completed."
+completed_other = "All {{count}} runs completed."
+
+[pipelines.delete]
+title = "Delete pipeline?"
+body = "Delete \"{{name}}\"? This can't be undone."
+cancel = "Cancel"
+confirm = "Delete"
[pipelines.composer]
title = "New pipeline"
-subtitle = "Pick a source, compose the operation chain, then route the output."
+editTitle = "Edit pipeline"
+subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes."
cancel = "Cancel"
-back = "Back"
-deploy = "Deploy pipeline"
-continue = "Continue"
-quickAddBundles = "Quick-add bundles"
-chainEmpty = "Add operations from the library below."
-operationChain_one = "Operation chain ({{count}})"
-operationChain_other = "Operation chain ({{count}})"
-destination = "Destination"
-alerts = "Alerts"
+create = "Create pipeline"
+save = "Save changes"
+name = "Name"
+namePlaceholder = "e.g. Redaction sweep"
+sources = "Sources"
+sourcesLoading = "Loading sources..."
+noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly."
+operations_one = "Operation ({{count}})"
+operations_other = "Operations ({{count}})"
+chainEmpty = "Add operations from the palette below."
+moveUp = "Move up"
+moveDown = "Move down"
+removeStep = "Remove operation"
+trigger = "Trigger"
+triggerManual = "Manual only"
+scheduleEvery = "Run every"
+output = "Output"
+directory = "Output folder"
+directoryHelp = "Absolute path on the server. Must be within the configured allowed folders."
-[pipelines.composer.steps]
-source = "Source"
-operations = "Operations"
-routing = "Routing"
-
-[pipelines.composer.anySource]
-label = "Any source"
-desc = "Accept documents from every connected channel"
-
-[pipelines.composer.opKind]
-ingest = "Ingest"
-validate = "Validate"
-modify = "Modify"
-secure = "Secure"
-store = "Route / Store"
-alert = "Alerts"
-
-[pipelines.composer.alert.email]
-title = "Email on failure"
-desc = "Notify the on-call list when error rate trips its bound"
-
-[pipelines.composer.alert.webhook]
-title = "Webhook on completion"
-desc = "POST a run summary to a URL you control"
-
-[pipelines.composer.alert.review]
-title = "Route low-confidence to review"
-desc = "Send docs under the confidence bound to a human queue"
-
-[pipelines.detail]
-subtitle = "{{version}} · {{source}} → {{destination}}"
-
-[pipelines.detail.stages]
-heading = "Pipeline stages"
-description = "Every document flows through five stages between {{source}} and {{destination}}."
-noOps = "No ops"
-
-[pipelines.detail.golden]
-heading = "Golden-set validation"
-passing = "{{passing}} of {{total}} passing"
-lastRun = "last run {{lastRun}}"
-barLabel = "Golden set {{passing}} of {{total}} passing"
-
-[pipelines.detail.drift]
-heading = "Schema drift"
-confidence = "{{delta}} conf"
-docs_one = "{{count}} docs"
-docs_other = "{{count}} docs"
-
-[pipelines.detail.drift.empty]
-title = "No drift detected"
-description = "Every document in the last 24h matched its inferred schema."
+[pipelines.composer.unit]
+minutes = "minutes"
+hours = "hours"
+days = "days"
[sources]
title = "Sources"
diff --git a/frontend/portal/src/api/pipelines.ts b/frontend/portal/src/api/pipelines.ts
index e7bb6222b6..20f26fccc6 100644
--- a/frontend/portal/src/api/pipelines.ts
+++ b/frontend/portal/src/api/pipelines.ts
@@ -1,39 +1,170 @@
import { apiClient } from "@portal/api/http";
-import type { PipelinesResponse } from "@portal/mocks/pipelines";
-import type { Tier } from "@portal/contexts/TierContext";
-export type {
- EvalsNote,
- GoldenSet,
- Pipeline,
- PipelineMetrics,
- PipelinesResponse,
- PipelineStatus,
- PromotedPipeline,
- PromotedStatus,
- SchemaDrift,
- StageKey,
- StageSummary,
-} from "@portal/mocks/pipelines";
+/**
+ * Pipelines service layer: the backend contract.
+ *
+ * A "pipeline" in the portal IS a backend policy (PolicyController, Policy.java):
+ * an ordered chain of tool steps with input sources, a trigger, and an output
+ * destination. This surface lists EVERY backend policy (the user-facing Policies
+ * page builds only a friendly subset of the same records). Like Sources, it calls
+ * the REAL Stirling API base `/api/v1/policies`, so dropping MSW points these exact
+ * calls at the live backend.
+ */
-/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */
-export async function fetchPipelines(tier: Tier): Promise {
- return apiClient.local.json(
- `/v1/pipelines?tier=${encodeURIComponent(tier)}`,
- );
+/** One tool invocation in a pipeline. `operation` is a Stirling endpoint path. */
+export interface PipelineStep {
+ operation: string;
+ parameters: Record;
+ fileParameters?: Record;
+}
+
+/** When a policy fires automatically. `type` keys a trigger bean (e.g. "schedule"). */
+export interface TriggerConfig {
+ type: string;
+ options: Record;
+}
+
+/** Where a run's outputs are delivered. `type` keys an output sink (e.g. "inline"). */
+export interface OutputSpec {
+ type: string;
+ options: Record;
}
/**
- * Promote a watch-folder-derived pipeline into a governed org policy, so its
- * rules apply fleet-wide instead of just to the originating flow.
- *
- * TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — should create the
- * policy from the pipeline's stages and return the new policy id. The mock
- * handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
+ * The stored policy record: the create/update body (`id` blank on create) and what
+ * the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId`
+ * are stamped server-side. A `null` trigger means manual-only.
*/
-export async function promoteToPolicy(id: string): Promise<{ ok: true }> {
- return apiClient.local.json<{ ok: true }>(
- `/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
+export interface Policy {
+ id?: string;
+ name: string;
+ owner?: string | null;
+ enabled: boolean;
+ trigger: TriggerConfig | null;
+ sourceIds: string[];
+ steps: PipelineStep[];
+ output: OutputSpec;
+ teamId?: number | null;
+}
+
+/** Overview row status: enabled (fires automatically) or paused. */
+export type PipelineStatus = "active" | "paused";
+
+/** A source a pipeline pulls documents from, resolved to its display name. */
+export interface PipelineSourceRef {
+ id: string;
+ name: string;
+}
+
+/** One row in the Pipelines overview. Mirrors the backend `PolicyView`. */
+export interface PipelineView {
+ id: string;
+ name: string;
+ enabled: boolean;
+ status: PipelineStatus;
+ /** Trigger summary: "manual" or the trigger type (e.g. "schedule"). */
+ trigger: string;
+ sources: PipelineSourceRef[];
+ /** Operation endpoint paths, in run order. */
+ steps: string[];
+ /** Output sink type (e.g. "inline", "folder"). */
+ output: string;
+ owner: string;
+}
+
+export interface PipelineKpi {
+ value: number;
+ description: string;
+}
+
+export interface PipelinesOverviewResponse {
+ kpis: PipelineKpi[];
+ pipelines: PipelineView[];
+}
+
+/** A trigger kind and the source types it works with. Mirrors the backend `TriggerInfo`. */
+export interface TriggerInfo {
+ /** Matches `TriggerConfig.type` (e.g. "schedule", "folder-watch"). */
+ type: string;
+ /** Whether the trigger needs at least one compatible source to function. */
+ requiresSource: boolean;
+ /** Source types it supports; empty means source-agnostic (no constraint). */
+ supportedSourceTypes: string[];
+}
+
+export type PolicyRunStatus =
+ | "PENDING"
+ | "RUNNING"
+ | "WAITING_FOR_INPUT"
+ | "COMPLETED"
+ | "FAILED"
+ | "CANCELLED";
+
+/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */
+export interface PolicyRunView {
+ runId: string;
+ policyId: string | null;
+ status: PolicyRunStatus;
+ currentStep: number;
+ stepCount: number;
+ /** Human-readable failure message; set when status is FAILED. */
+ error: string | null;
+ errorCode: string | null;
+ createdAt: number;
+}
+
+/** GET /api/v1/policies/overview: KPI strip + one row per policy for the admin. */
+export async function fetchPipelines(): Promise {
+ return apiClient.local.json(
+ "/api/v1/policies/overview",
+ );
+}
+
+/** GET /api/v1/policies/{id}: the raw policy record (steps, sources, trigger), for editing. */
+export async function fetchPipeline(id: string): Promise {
+ return apiClient.local.json(
+ `/api/v1/policies/${encodeURIComponent(id)}`,
+ );
+}
+
+/** POST /api/v1/policies: create (blank id) or update (matched id) a policy. */
+export async function savePipeline(policy: Policy): Promise {
+ return apiClient.local.json("/api/v1/policies", {
+ method: "POST",
+ body: policy,
+ });
+}
+
+/** DELETE /api/v1/policies/{id}: remove a policy. */
+export async function deletePipeline(id: string): Promise {
+ await apiClient.local.json(
+ `/api/v1/policies/${encodeURIComponent(id)}`,
+ {
+ method: "DELETE",
+ },
+ );
+}
+
+/** GET /api/v1/policies/triggers: available triggers + their source compatibility. */
+export async function fetchTriggers(): Promise {
+ return apiClient.local.json("/api/v1/policies/triggers");
+}
+
+/**
+ * POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured
+ * sources, regardless of the enabled flag. Returns the ids of the runs started
+ * (empty when the sources yielded no work); poll {@link fetchRun} for each.
+ */
+export async function triggerPipeline(id: string): Promise {
+ return apiClient.local.json(
+ `/api/v1/policies/${encodeURIComponent(id)}/trigger`,
{ method: "POST" },
);
}
+
+/** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */
+export async function fetchRun(runId: string): Promise {
+ return apiClient.local.json(
+ `/api/v1/policies/run/${encodeURIComponent(runId)}`,
+ );
+}
diff --git a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx
deleted file mode 100644
index 3dc731a47a..0000000000
--- a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable";
-import {
- DEGRADED_PIPELINE,
- HEALTHY_PIPELINE,
-} from "@portal/components/pipelines/storyFixtures";
-import "@portal/views/Pipelines.css";
-
-const meta: Meta = {
- title: "Portal/Pipelines/DeployedPipelinesTable",
- component: DeployedPipelinesTable,
- parameters: { layout: "padded" },
- args: { onRowClick: () => {} },
- decorators: [
- (S) => (
-
-
-
- ),
- ],
-};
-export default meta;
-type Story = StoryObj;
-
-/** A healthy pipeline at bound and one degraded below its golden-set bound. */
-export const Default: Story = {
- args: { pipelines: [HEALTHY_PIPELINE, DEGRADED_PIPELINE] },
-};
-
-export const Empty: Story = {
- args: { pipelines: [] },
-};
diff --git a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx
deleted file mode 100644
index 03d2e6f015..0000000000
--- a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { useMemo } from "react";
-import { useTranslation } from "react-i18next";
-import { StatusBadge, Table, type TableColumn } from "@shared/components";
-import type { Pipeline } from "@portal/api/pipelines";
-import { compact, goldenTone, pct } from "@portal/components/pipelines/format";
-
-interface DeployedPipelinesTableProps {
- pipelines: Pipeline[];
- onRowClick: (p: Pipeline) => void;
-}
-
-/**
- * Dense roster of the deployed fleet that puts golden-set reliability up front.
- * The card list below it carries the full per-pipeline story; this table is the
- * scannable "is anything below its bound?" view across the whole fleet.
- */
-export function DeployedPipelinesTable({
- pipelines,
- onRowClick,
-}: DeployedPipelinesTableProps) {
- const { t } = useTranslation();
- const columns = useMemo[]>(
- () => [
- {
- key: "name",
- header: t("pipelines.table.header.name"),
- render: (p) => (
-