Sourced from ossf/scorecard-action's releases.
v2.4.4
What's Changed
This update bumps the Scorecard version to the v5.5.0 release. For a complete list of changes, please refer to the Scorecard v5.4.0 release notes and the Scorecard v5.5.0 release notes.
- log POST failures instead of failing entire action by
@spencerschrockin ossf/scorecard-action#1625Full Changelog: https://github.com/ossf/scorecard-action/compare/v2.4.3...v2.4.4
2d11466
Bump action tag for v2.4.4 release (#1688)1bd3285
:seedling: Bump the docker-images group across 1 directory with 2
updates (#1...913edce
:seedling: Bump github.com/containerd/containerd from 1.7.32 to 1.7.33
(#1671)0957b8f
:seedling: Bump golang.org/x/net from 0.56.0 to 0.57.0 (#1680)f0061eb
:seedling: Bump google.golang.org/grpc from 1.81.1 to 1.82.1 (#1687)20ee732
:seedling: Bump github.com/sigstore/cosign/v2 from 2.6.3 to 2.6.4 (#1685)9f295ef
:seedling: Bump the github-actions group with 6 updates (#1686)69bf556
:seedling: Bump github.com/sigstore/sigstore-go from 1.1.4 to 1.2.0 (#1681)94e8b96
:seedling: Bump github.com/sigstore/rekor from 1.5.0 to 1.5.2 (#1673)c7a1b37
:seedling: Bump github.com/sigstore/fulcio from 1.8.5 to 1.8.6 (#1675)Sourced from docker/login-action's releases.
v4.6.0
- Harden buildx scoped config path handling by
@crazy-maxin docker/login-action#1059- Bump
@aws-sdk/client-ecrand@aws-sdk/client-ecr-publicto 3.1095.0 in docker/login-action#1051- Bump js-yaml from 5.2.1 to 5.2.2 in docker/login-action#1057
- Bump postcss from 8.5.10 to 8.5.22 in docker/login-action#1056
Full Changelog: https://github.com/docker/login-action/compare/v4.5.2...v4.6.0
v4.5.2
- Surface Docker Hub OIDC error responses by
@crazy-maxin docker/login-action#1058Full Changelog: https://github.com/docker/login-action/compare/v4.5.1...v4.5.2
dbcb813
Merge pull request #1051
from docker/dependabot/npm_and_yarn/aws-sdk-dependen...5bcb015
[dependabot skip] chore: update generated contentb30b2f2
build(deps): bump the aws-sdk-dependencies group across 1 directory with
2 up...9087f1e
Merge pull request #1057
from docker/dependabot/npm_and_yarn/js-yaml-5.2.20009830
[dependabot skip] chore: update generated content2325523
build(deps): bump js-yaml from 5.2.1 to 5.2.24ec1d4a
Merge pull request #1056
from docker/dependabot/npm_and_yarn/postcss-8.5.225fc99ba
Merge pull request #1053
from docker/dependabot/github_actions/aws-actions/co...e512bd5
Merge pull request #1052
from docker/dependabot/github_actions/codeql-actions...a146c91
Merge pull request #1059
from crazy-max/harden-buildx-scope-pathsSourced from com.drewnoakes:metadata-extractor's releases.
2.21.0
What's Changed
- Replace custom number formatting in IccDescriptor with String.format by
@drewnoakeswith@Copilotin drewnoakes/metadata-extractor#721- Fix OOM DoS vector in BmpHeaderDescriptor.formatHex via unbounded digits parameter by
@drewnoakeswith@Copilotin drewnoakes/metadata-extractor#722- Normalize sign to numerator in Rational.toString() by
@drewnoakeswith@Copilotin drewnoakes/metadata-extractor#724- Extract IPTC metadata from PNG text chunks by
@drewnoakeswith@Copilotin drewnoakes/metadata-extractor#723- Tolerate duplicate PNG chunks that are not allowed to appear multiple times by
@drewnoakeswith@Copilotin drewnoakes/metadata-extractor#725- Null-check and length-guard getPowerUpTimeDescription by
@drewnoakeswith@Copilotin drewnoakes/metadata-extractor#727- Move DisposalMethod description logic to GifControlDescriptor by
@drewnoakeswith@Copilotin drewnoakes/metadata-extractor#729- Port .NET RiffReader.processChunks error checking to Java by
@drewnoakeswith@Copilotin drewnoakes/metadata-extractor#728- Fix NPE in ItemLocationBox when offset_size/length_size is 0 (valid AVIF) by
@hanskrin drewnoakes/metadata-extractor#732- Harden ICO parsing against oversized image counts by
@drewnoakeswith@Copilotin drewnoakes/metadata-extractor#734- Support LONG8 and SLONG8 TIFF data formats by
@dschmidtin drewnoakes/metadata-extractor#739- Detect QuickTime files that lack a leading ftyp box by
@dschmidtin drewnoakes/metadata-extractor#736- Add basic MP4 udta metadata (Title, Comment, Subtitle, Rating, Category, Mood) by
@drewnoakesin drewnoakes/metadata-extractor#740New Contributors
@drewnoakeswith@Copilotmade their first contribution in drewnoakes/metadata-extractor#721@hanskrmade their first contribution in drewnoakes/metadata-extractor#732@dschmidtmade their first contribution in drewnoakes/metadata-extractor#739Full Changelog: https://github.com/drewnoakes/metadata-extractor/compare/2.20.0...2.20.1
6f03794
Merge pull request #740
from drewnoakes/dev/drnoakes/mp4-metadata-pr-reviewd01cc41
Detect QuickTime files that lack a leading ftyp box (#736)1b45d70
Support LONG8 and SLONG8 TIFF data formats353e27c
Address Copilot review: align entry parsing and fix tag name casing31632d2
Clean up salvaged MP4 udta metadata code845cdba
Add more UDTA metadata to MP4 parser (Title, Comment, Subtitle, Rating,
Categ...5647ffd
Harden ICO parsing against oversized image counts (#734)99383f6
Merge pull request #732
from hanskr/fix-iloc-zero-offset-size-npe35af34a
Fix NPE in ItemLocationBox when offset_size/length_size is 0520e07f
Merge pull request #728
from drewnoakes/copilot/enhance-error-checking-riffre...Sourced from actions/setup-java's releases.
v5.7.0
What's Changed
- Fix npm audit failures on releases/v5 by
@brunoborgesin actions/setup-java#1154- Backport #1151: Fix missing wrapper cache distributions by
@brunoborgesin actions/setup-java#1153- Deprecate legacy Adopt distributions in v5 by
@brunoborgesin actions/setup-java#1186Full Changelog: https://github.com/actions/setup-java/compare/v5.6.0...v5.7.0
v5.6.0
What's Changed
- Backport to v5: Add Maven compiler problem matcher for javac diagnostics by
@brunoborgesin actions/setup-java#1087- feat: expose cache-primary-key output (#597) [v5 backport] by
@brunoborgesin actions/setup-java#1089- dist: Cover Tencent Kona JDK 25 (#1108) [v5 backport] by
@brunoborgesin actions/setup-java#1110- Backport #1111: Preserve Maven toolchains across repeated setup-java runs (#1099) by
@brunoborgesin actions/setup-java#1113- Backport #1097/#1098 to v5: cache Maven and Gradle wrapper distributions separately by
@brunoborgesin actions/setup-java#1122Full Changelog: https://github.com/actions/setup-java/compare/v5...v5.6.0
v5.5.0
What's Changed
- chore: enforce pre-PR validation (aggregate scripts, git hooks, PR checklist) by
@brunoborgesin actions/setup-java#1061- Bump github/codeql-action from 3 to 4 by
@dependabot[bot] in actions/setup-java#1069- Bump actions/checkout from 6 to 7 by
@dependabot[bot] in actions/setup-java#1068- Bump actions/setup-python from 5 to 6 by
@dependabot[bot] in actions/setup-java#1067- Bump
@typescript-eslint/parserfrom 8.61.1 to 8.62.0 by@dependabot[bot] in actions/setup-java#1062- feat: Add verify-signature plumbing and Temurin+Microsoft verification support by
@johnoliverin actions/setup-java#1060- Updated jetbrains test: https.request() now catches errors. This fixes leaking tests as well by
@jmjaffe37in actions/setup-java#1070- Fix arm64 e2e workflow tests mislabeled as x64 by
@brunoborgeswith@Copilotin actions/setup-java#1073- feat: suppress Maven transfer progress via MAVEN_ARGS by default (add show-download-progress input) by
@brunoborgesin actions/setup-java#1053- feat: Disable interactiveMode in generated Maven settings.xml by
@brunoborgeswith@Copilotin actions/setup-java#1052- Bump prettier from 3.6.2 to 3.9.1 by
@dependabot[bot] in actions/setup-java#1066- chore(deps-dev): bump eslint-plugin-jest from 29.0.1 to 29.15.4 by
@dependabot[bot] in actions/setup-java#1074- fix: Maven Toolchains grows unexpectedly by
@Okeanosin actions/setup-java#534- dist: Support Tencent Kona JDK by
@johnshajiangin actions/setup-java#672- feat: Add set-default option by
@gsmetin actions/setup-java#1017- docs: document problem matcher (and how to disable it), Maven Wrapper caching, and generated interactiveMode by
@brunoborgesin actions/setup-java#1075- feat: Add distribution detection support to .sdkmanrc file by
@lukaszgygin actions/setup-java#975New Contributors
@jmjaffe37made their first contribution in actions/setup-java#1070@gsmetmade their first contribution in actions/setup-java#1017@lukaszgygmade their first contribution in actions/setup-java#975Full Changelog: https://github.com/actions/setup-java/compare/v5...v5.5.0
v5.4.0
What's Changed
- Bump
@typescript-eslint/parserfrom 8.48.0 to 8.61.1 by@dependabot[bot] in actions/setup-java#1021
... (truncated)
b6effb0
Deprecate legacy Adopt distributions in v5 (#1186)e498d2a
Backport #1151:
Fix missing wrapper cache distributions (#1153)6a3384d
Fix npm audit failures on releases/v5 (#1154)03ad4de
Backport #1097/#1098:
cache Maven and Gradle wrapper distributions separately...d229d2e
Backport #1111:
Preserve Maven toolchains across repeated setup-java runs (#1...bbf0f69
dist: Cover Tencent Kona JDK 25 (#1110)513edc4
feat: expose cache-primary-key output (#597)
[v5 backport] (#1089)62df799
Add Maven compiler problem matcher for javac diagnostics (#1087)176156a
chore: bump version to 5.6.0 for v5 release linebf7b8de
build: rebuild dist for backported changes (#1079,
#1083,
#1084)Sourced from docker/setup-qemu-action's releases.
v4.2.0
- Preserve names in esbuild bundle by
@crazy-maxin docker/setup-qemu-action#311- Bump
@actions/corefrom 3.0.0 to 3.0.1 in docker/setup-qemu-action#295- Bump
@docker/actions-toolkitfrom 0.91.0 to 0.92.0 in docker/setup-qemu-action#315- Bump
@sigstore/corefrom 3.1.0 to 3.2.1 in docker/setup-qemu-action#312- Bump js-yaml from 4.1.1 to 4.2.0 in docker/setup-qemu-action#310
- Bump tmp from 0.2.6 to 0.2.7 in docker/setup-qemu-action#304
- Bump undici from 6.26.0 to 6.27.0 in docker/setup-qemu-action#308
- Bump vite from 7.3.2 to 7.3.6 in docker/setup-qemu-action#307
Full Changelog: https://github.com/docker/setup-qemu-action/compare/v4.1.0...v4.2.0
v4.1.0
- Add
resetinput to uninstall current emulators by@crazy-maxin docker/setup-qemu-action#21- Bump
@docker/actions-toolkitfrom 0.77.0 to 0.91.0 in docker/setup-qemu-action#250 docker/setup-qemu-action#247- Bump brace-expansion from 1.1.12 to 1.1.15 in docker/setup-qemu-action#265
- Bump fast-xml-builder from 1.0.0 to 1.2.0 in docker/setup-qemu-action#286
- Bump fast-xml-parser from 5.4.2 to 5.8.0 in docker/setup-qemu-action#255
- Bump flatted from 3.3.3 to 3.4.2 in docker/setup-qemu-action#257
- Bump glob from 10.3.15 to 10.5.0 in docker/setup-qemu-action#254
- Bump handlebars from 4.7.8 to 4.7.9 in docker/setup-qemu-action#262
- Bump lodash from 4.17.23 to 4.18.1 in docker/setup-qemu-action#273
- Bump postcss from 8.5.6 to 8.5.10 in docker/setup-qemu-action#285
- Bump tar from 6.2.1 to 7.5.15 in docker/setup-qemu-action#287
- Bump tmp from 0.2.5 to 0.2.6 in docker/setup-qemu-action#291
- Bump undici from 6.23.0 to 6.26.0 in docker/setup-qemu-action#251
- Bump vite from 7.3.1 to 7.3.2 in docker/setup-qemu-action#271
Full Changelog: https://github.com/docker/setup-qemu-action/compare/v4.0.0...v4.1.0
96fe6ef
Merge pull request #315
from docker/dependabot/npm_and_yarn/docker/actions-to...31f08d3
[dependabot skip] chore: update generated content4e7017a
build(deps): bump @docker/actions-toolkit from 0.91.0 to
0.92.00eca235
Merge pull request #314
from crazy-max/fix-yarn-preapprove-actions-toolkitea66a41
chore: allow actions-toolkit to bypass yarn age gate451542b
Merge pull request #308
from docker/dependabot/npm_and_yarn/undici-6.27.0532ae00
[dependabot skip] chore: update generated contentb6f5af6
build(deps): bump undici from 6.26.0 to 6.27.0cf96b86
Merge pull request #304
from docker/dependabot/npm_and_yarn/tmp-0.2.7f0ba643
[dependabot skip] chore: update generated content
+
{code}
step editor
} ++ {t("portal.pipelines.graph.dragHint")} +
+ )} + + {nodes.map((node) => { + const style = { + left: `${node.x}px`, + top: `${node.y}px`, + width: `${NODE_WIDTH}px`, + minHeight: `${NODE_HEIGHT}px`, + }; + if (node.kind === "placeholder") { + return ( +- {t("portal.pipelines.composer.sourcesLoading")} -
- ) : ( - <> -- {t("portal.pipelines.builder.noSources")} -
- )} - > - )} -- {t("portal.pipelines.composer.chainEmpty")} -
- )} - -{t("portal.pipelines.builder.unsavedBody")}
+ +Re-acknowledging is a no-op that keeps the original actor and timestamp, so the first person
+ * to pick it up stays credited.
+ */
+@Component
+@RequiredArgsConstructor
+public class AcknowledgeAction implements FailureAction {
+
+ private final FileRunEventStore store;
+
+ @Override
+ public FailureActionId id() {
+ return FailureActionId.ACKNOWLEDGE;
+ }
+
+ @Override
+ public FileRunEvent execute(FileRunEvent event, Map Recurrences of this exact failure fold onto the dismissed row, which is what makes "stop
+ * showing me this" hold for a source that re-lists the same failing file each sweep.
+ */
+@Component
+@RequiredArgsConstructor
+public class DismissAction implements FailureAction {
+
+ private final FileRunEventStore store;
+
+ @Override
+ public FailureActionId id() {
+ return FailureActionId.DISMISS;
+ }
+
+ @Override
+ public FileRunEvent execute(FileRunEvent event, Map Keeping behaviour out of the registry keeps that pure data, so a new kind ships by declaring
+ * an action id that already has a handler.
+ */
+public interface FailureAction {
+
+ FailureActionId id();
+
+ /**
+ * Apply the action and return the updated event. {@code inputs} carries whatever the action
+ * declared it needs, which is nothing for the two that exist today. Implementations leave the
+ * document, ledger, run and output destinations alone unless that is the action's purpose.
+ */
+ FileRunEvent execute(FileRunEvent event, Map Unreachable today: both kinds declare both actions, so no request can trip this guard
+ * until a kind ships with a restricted action set. Declared now because the guard must
+ * exist before that kind does, not after.
+ */
+ ACTION_NOT_DECLARED,
+
+ /** The event is already closed, so no further transition is possible. */
+ ALREADY_CLOSED
+ }
+
+ private final Reason reason;
+
+ public FailureActionException(Reason reason, String message) {
+ this(reason, message, null);
+ }
+
+ /** For a refusal that follows from a lower-level failure, so its stack is not dropped. */
+ public FailureActionException(Reason reason, String message, Throwable cause) {
+ super(message, cause);
+ this.reason = reason;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java
new file mode 100644
index 0000000000..dbfcff7fb4
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java
@@ -0,0 +1,11 @@
+package stirling.software.proprietary.failure;
+
+/**
+ * The actions a {@link FailureKind} may declare. Both are incident dispositions: they change how
+ * the event is shown and touch nothing else, which is what makes them valid for every kind
+ * including {@link FailureKind#UNKNOWN}, and why there is no {@code APPROVE} yet.
+ */
+public enum FailureActionId {
+ ACKNOWLEDGE,
+ DISMISS
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionRegistry.java
new file mode 100644
index 0000000000..3928e91845
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionRegistry.java
@@ -0,0 +1,68 @@
+package stirling.software.proprietary.failure;
+
+import java.util.Arrays;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.stereotype.Service;
+
+import jakarta.annotation.PostConstruct;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Resolves a {@link FailureActionId} to the bean that implements it. The startup check is the
+ * point: because kinds declare action ids as data, one could name an action nobody implements,
+ * which would otherwise show up as a button that 400s rather than as a failed boot.
+ */
+@Slf4j
+@Service
+public class FailureActionRegistry {
+
+ private final Map Anything unrecognised becomes {@link FailureKind#UNKNOWN}, so every failed run still gets a
+ * record.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class FailureClassifier {
+
+ /** Set by {@code GlobalExceptionHandler#createProblemDetailResponse}. */
+ private static final String ERROR_CODE_PROPERTY = "errorCode";
+
+ /**
+ * Depth bound on the cause chain. The JDK forbids self-causation but not a longer cycle (A
+ * caused by B caused by A), which an unbounded walk would spin on. Real chains are a handful
+ * deep.
+ */
+ private static final int MAX_CAUSE_DEPTH = 16;
+
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Refuse to start on an ambiguous registry: two kinds claiming one code would make {@link
+ * #classify} depend on declaration order. Checked here because this is what resolves codes to
+ * kinds, and at boot so the message names the codes rather than arriving as a class-init error.
+ */
+ @PostConstruct
+ void verifyNoErrorCodeIsClaimedTwice() {
+ List Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a
+ * new kind ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every
+ * failed run a record, and kinds get promoted out of it as production shows what occurs.
+ */
+@Getter
+public enum FailureKind {
+ INPUT_PASSWORD_PROTECTED(
+ FailureStage.INPUT,
+ FailureSeverity.ERROR,
+ FailureRemedy.NEEDS_USER_INPUT,
+ FailureScope.FILE,
+ errorCodes("E004"),
+ fallback("This document is password-protected, so the pipeline could not read it."),
+ offer(ACKNOWLEDGE),
+ offer(DISMISS, "dismissSkipFile")),
+
+ UNKNOWN(
+ FailureStage.INTERNAL,
+ FailureSeverity.ERROR,
+ FailureRemedy.PERMANENT,
+ FailureScope.RUN,
+ noErrorCodes(),
+ fallback("This run failed for a reason Stirling does not yet recognise."),
+ offer(ACKNOWLEDGE),
+ offer(DISMISS));
+
+ private static final String KEY_PREFIX = "portal.failures.kind.";
+ private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
+
+ /**
+ * Every claimed {@code ErrorCode}, to the kind claiming it. Indexed once rather than scanned
+ * per lookup, so a duplicate cannot be resolved by declaration order without anyone noticing. A
+ * duplicate is refused at boot; see {@link #duplicateErrorCodes()}.
+ */
+ private static final Map Reported for a caller to act on rather than thrown from class init, where it would arrive
+ * as an {@code ExceptionInInitializerError} blamed on whatever touched the enum first, then as
+ * {@code NoClassDefFoundError} everywhere after. {@link FailureClassifier} refuses to start.
+ */
+ static List All five declared up front so a later kind needs no enum change, which would strand the value
+ * already snapshotted on existing rows.
+ */
+public enum FailureStage {
+ INPUT,
+ INTERNAL,
+ OUTPUT,
+ BLOCKED,
+ NEVER_RAN
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEvent.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEvent.java
new file mode 100644
index 0000000000..cb6b3a452f
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEvent.java
@@ -0,0 +1,59 @@
+package stirling.software.proprietary.failure;
+
+import java.time.Instant;
+
+/**
+ * Read model for one recorded incident: the entity's fields with the registry kind already
+ * resolved.
+ *
+ * The facets are the row's own snapshots and can differ from {@code kind}'s current facets on an
+ * old row, which is the point of snapshotting, so read them here rather than from {@code kind}.
+ */
+public record FileRunEvent(
+ String id,
+ Long teamId,
+ String actor,
+ FailureKind kind,
+ FailureStage stage,
+ FailureSeverity severity,
+ FailureScope scope,
+ FailureOrigin origin,
+ String policyId,
+ String runId,
+ String fileId,
+ String detail,
+ String dedupKey,
+ int occurrences,
+ FileRunEventStatus status,
+ String statusActor,
+ Instant statusAt,
+ Instant createdAt,
+ Instant lastSeenAt) {
+
+ /**
+ * Project an entity. An unrecognised {@code kindId} (written by a newer build, or since
+ * removed) falls back to {@link FailureKind#UNKNOWN}, so the row stays readable and actionable.
+ */
+ public static FileRunEvent of(FileRunEventEntity entity) {
+ return new FileRunEvent(
+ entity.getId(),
+ entity.getTeamId(),
+ entity.getActor(),
+ FailureKind.byId(entity.getKindId()).orElse(FailureKind.UNKNOWN),
+ entity.getStage(),
+ entity.getSeverity(),
+ entity.getScope(),
+ entity.getOrigin(),
+ entity.getPolicyId(),
+ entity.getRunId(),
+ entity.getFileId(),
+ entity.getDetail(),
+ entity.getDedupKey(),
+ entity.getOccurrences(),
+ entity.getStatus(),
+ entity.getStatusActor(),
+ entity.getStatusAt(),
+ entity.getCreatedAt(),
+ entity.getLastSeenAt());
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java
new file mode 100644
index 0000000000..49aab9aff6
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java
@@ -0,0 +1,146 @@
+package stirling.software.proprietary.failure;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.ApplicationProperties;
+import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
+
+/**
+ * Read and triage recorded failures for the caller's team. Note the absence of a team parameter:
+ * the team comes from the authenticated principal, never the request.
+ *
+ * Reviewing failures is a leader-level capability, gated the same way policy editing is: see
+ * {@link #requireFailureReviewAllowed()}.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/file-run-events")
+@Hidden
+@RequiredArgsConstructor
+@Tag(name = "File run events", description = "Recorded policy and pipeline failures")
+public class FileRunEventController {
+
+ /** Cap on one page, so a client cannot ask for the whole table. */
+ private static final int MAX_LIMIT = 200;
+
+ private static final int DEFAULT_LIMIT = 50;
+
+ private final FileRunEventService service;
+ private final PolicyManagementAuthority policyManagementAuthority;
+ private final ApplicationProperties applicationProperties;
+
+ @GetMapping
+ @Operation(
+ summary = "List recorded failures",
+ description =
+ "Failures recorded for the caller's team, newest first. Each row carries its"
+ + " available actions already resolved.")
+ public FileRunEventsResponse list(
+ // Spring's converter 400s on a value outside the enum, so no hand-rolled parse.
+ @RequestParam(required = false) FileRunEventStatus status,
+ @RequestParam(required = false) String kindId,
+ @RequestParam(required = false) Integer limit) {
+ requireFailureReviewAllowed();
+ int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
+
+ List Login disabled means a single-user deployment with no roles to tell apart, the same
+ * carve-out the policy endpoints make. Team scoping is separate, and lives in the service.
+ */
+ private void requireFailureReviewAllowed() {
+ if (!applicationProperties.getSecurity().isEnableLogin()) {
+ return;
+ }
+ if (!policyManagementAuthority.canEditPolicies()) {
+ throw new ResponseStatusException(
+ HttpStatus.FORBIDDEN,
+ "Recorded failures may only be reviewed by a team leader");
+ }
+ }
+
+ /**
+ * A closed row is a conflict rather than a bad request: the request was well-formed and would
+ * have been valid a moment earlier.
+ */
+ private static HttpStatus statusFor(FailureActionException.Reason reason) {
+ return switch (reason) {
+ case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
+ case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED -> HttpStatus.BAD_REQUEST;
+ case ALREADY_CLOSED -> HttpStatus.CONFLICT;
+ };
+ }
+
+ /** Wrapped rather than a bare array so pagination can be added without breaking clients. */
+ public record FileRunEventsResponse(List Holds no document name or content: {@code fileId} is opaque, and {@code detail} is stripped of
+ * names by {@link RecordFailure}.
+ */
+@Entity
+@Table(
+ name = "file_run_events",
+ indexes = {
+ // the review surface: this team's rows, newest first, optionally filtered by status
+ @Index(name = "idx_file_run_events_team", columnList = "team_id, status, last_seen_at")
+ },
+ // One row per incident, enforced by the database rather than only by the store's read-then-
+ // insert: two runs failing identically at the same moment would otherwise both find no row
+ // and both insert, leaving a twin that reappears after the first is dismissed. Doubles as
+ // the
+ // rollup's lookup index. Note SQL treats NULLs as distinct, so this does not constrain the
+ // unteamed rows a login-disabled deployment writes.
+ uniqueConstraints =
+ @UniqueConstraint(
+ name = "uk_file_run_events_dedup",
+ columnNames = {"team_id", "dedup_key"}))
+@NoArgsConstructor
+@Getter
+@Setter
+public class FileRunEventEntity implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @Column(name = "id")
+ private String id;
+
+ @Column(name = "team_id")
+ private Long teamId;
+
+ /** The user whose work produced the failure. Null when login is disabled. */
+ @Column(name = "actor")
+ private String actor;
+
+ @Column(name = "kind_id", nullable = false)
+ private String kindId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "stage", nullable = false)
+ private FailureStage stage;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "severity", nullable = false)
+ private FailureSeverity severity;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "scope", nullable = false)
+ private FailureScope scope;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "origin", nullable = false)
+ private FailureOrigin origin;
+
+ @Column(name = "policy_id")
+ private String policyId;
+
+ @Column(name = "run_id")
+ private String runId;
+
+ @Column(name = "file_id")
+ private String fileId;
+
+ @Column(name = "detail", columnDefinition = "text")
+ private String detail;
+
+ @Column(name = "dedup_key", length = 64, nullable = false)
+ private String dedupKey;
+
+ /** How many times this same failure has been seen. Starts at 1; the rollup increments it. */
+ @Column(name = "occurrences", nullable = false)
+ private int occurrences;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false)
+ private FileRunEventStatus status;
+
+ /** Who last changed {@link #status}. Null while the row is untouched. */
+ @Column(name = "status_actor")
+ private String statusActor;
+
+ @Column(name = "status_at")
+ private Instant statusAt;
+
+ @Column(name = "created_at", nullable = false)
+ private Instant createdAt;
+
+ @Column(name = "last_seen_at", nullable = false)
+ private Instant lastSeenAt;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java
new file mode 100644
index 0000000000..6da6a9b004
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java
@@ -0,0 +1,104 @@
+package stirling.software.proprietary.failure;
+
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+@Repository
+public interface FileRunEventRepository extends JpaRepository Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the
+ * team always comes from the authenticated principal, and scoping applies only when login is
+ * enabled so single-user deployments keep working. When the team cannot be resolved the caller
+ * reads nothing; see {@link #scope()}.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class FileRunEventService {
+
+ private final FileRunEventStore store;
+ private final FailureActionRegistry actionRegistry;
+ private final PolicyManagementAuthority policyManagementAuthority;
+ private final UserServiceInterface userService;
+ private final ApplicationProperties applicationProperties;
+
+ /** The calling user's events, newest first. Empty when their team cannot be resolved. */
+ public List {@link #record} folds a repeat into the existing incident rather than inserting again, keyed
+ * on {@code (teamId, dedupKey)}. That matters for a stateless {@code snapshot} source, which
+ * re-lists every file on each sweep: the same broken file is one incident, not one per sweep.
+ * Distinct files keep distinct rows, so a reviewer can still act on any one of them.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class FileRunEventStore {
+
+ /** Only ever need the newest match; the index is ordered so this is a single-row read. */
+ private static final Pageable NEWEST = PageRequest.of(0, 1);
+
+ private final FileRunEventRepository repository;
+
+ /**
+ * Record a failure, folding it into an existing incident when one matches.
+ *
+ * Deliberately not {@code @Transactional}: each repository call runs in its own transaction,
+ * so losing the insert race below leaves no rolled-back transaction to recover from.
+ */
+ public FileRunEvent record(RecordFailure command) {
+ String dedupKey = command.dedupKey();
+ Instant now = Instant.now();
+
+ Optional Both steps are single guarded UPDATE statements against the row's current values, never a
+ * save of the entity we read: that would merge a possibly stale snapshot over concurrent
+ * writes, losing counts and reverting a dismiss that landed in between.
+ *
+ * Empty when the row went away between the caller's read and the fold, so the caller inserts
+ * instead. Reporting that as an error would lose the incident for a log line.
+ */
+ private Optional The UPDATE runs first and nothing is read beforehand: a pre-read only to classify the
+ * refusal would be one more thing to race with, and would report a row deleted in between as
+ * closed rather than missing.
+ */
+ @Transactional
+ public Optional The team comes from the originating policy rather than the calling thread, which carries only
+ * an audit principal. An ad-hoc run has no stored policy, so its rows land unteamed.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PolicyFailureRecorder {
+
+ private final FailureClassifier classifier;
+ private final FileRunEventStore store;
+ private final PolicyStore policyStore;
+
+ /**
+ * Record a failed run, classifying {@code cause}. {@code detail} is the message the run itself
+ * reported, kept verbatim so an {@link FailureKind#UNKNOWN} row is still diagnosable.
+ */
+ public void recordRunFailure(
+ String runId,
+ String policyId,
+ String actor,
+ String fileIdentity,
+ String detail,
+ Throwable cause) {
+ record(classifier.classify(cause), runId, policyId, actor, fileIdentity, detail);
+ }
+
+ /**
+ * Record a failure whose kind is already decided, for paths with no exception to classify (a
+ * run rejected at admission). Named distinctly rather than overloading {@link
+ * #recordRunFailure}, whose argument list is otherwise near-identical, so a null cause cannot
+ * pick the wrong one.
+ */
+ public void recordRunFailureAs(
+ FailureKind kind, String runId, String policyId, String actor, String detail) {
+ record(kind, runId, policyId, actor, null, detail);
+ }
+
+ private void record(
+ FailureKind kind,
+ String runId,
+ String policyId,
+ String actor,
+ String fileIdentity,
+ String detail) {
+ try {
+ store.record(
+ RecordFailure.forRun(
+ kind, teamFor(policyId), actor, policyId, runId, fileIdentity, detail));
+ } catch (RuntimeException e) {
+ // Deliberately swallowed: see the class comment.
+ log.warn("Could not record failure event for run {} (kind {})", runId, kind.getId(), e);
+ }
+ }
+
+ private Long teamFor(String policyId) {
+ if (policyId == null || policyId.isBlank()) {
+ return null;
+ }
+ try {
+ return policyStore.get(policyId).map(policy -> policy.teamId()).orElse(null);
+ } catch (RuntimeException e) {
+ log.debug("Could not resolve team for policy {}: {}", policyId, e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/RecordFailure.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/RecordFailure.java
new file mode 100644
index 0000000000..1f45d52e17
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/RecordFailure.java
@@ -0,0 +1,117 @@
+package stirling.software.proprietary.failure;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+/**
+ * Everything needed to record one failure. Every reference field is nullable, because a failure can
+ * happen before a file or policy is known and with no user at all (a trigger-fired run on a
+ * login-disabled deployment). Only {@code kind} and {@code origin} are required.
+ */
+public record RecordFailure(
+ FailureKind kind,
+ FailureOrigin origin,
+ Long teamId,
+ String actor,
+ String policyId,
+ String runId,
+ String sourceId,
+ String fileId,
+ String detail) {
+
+ /** Upper bound on a stored message, so one enormous stack trace cannot fill the column. */
+ private static final int MAX_DETAIL_LENGTH = 2_000;
+
+ public RecordFailure {
+ if (kind == null) {
+ throw new IllegalArgumentException("kind is required");
+ }
+ if (origin == null) {
+ throw new IllegalArgumentException("origin is required");
+ }
+ // Capped here rather than at each call site, since this record is the only way a row is
+ // written, and an unclassified failure carries a raw message of unbounded length. Stored
+ // verbatim otherwise: it is the user's own error about their own file, and hiding parts of
+ // it makes the row harder to act on without making it meaningfully safer.
+ detail = truncate(detail);
+ }
+
+ /** A processor-side failure with no file or source context, e.g. a run that failed outright. */
+ public static RecordFailure forRun(
+ FailureKind kind,
+ Long teamId,
+ String actor,
+ String policyId,
+ String runId,
+ String fileId,
+ String detail) {
+ return new RecordFailure(
+ kind, FailureOrigin.POLICY, teamId, actor, policyId, runId, null, fileId, detail);
+ }
+
+ /**
+ * What this failure is about, per the row's scope. Two failures sharing a kind and a scope
+ * reference are the same incident; see {@link #dedupKey()}.
+ */
+ public String scopeRef() {
+ return switch (kind.getScope()) {
+ case FILE -> nullToEmpty(policyId) + "|" + fileOrRun();
+ case RUN -> nullToEmpty(runId);
+ case POLICY -> nullToEmpty(policyId);
+ case SOURCE -> nullToEmpty(sourceId);
+ // One server-wide condition is one incident regardless of which run tripped over it.
+ case SERVER -> "";
+ };
+ }
+
+ /**
+ * The file this failure is about, or the run when the producer could not name one. Without the
+ * fallback, every file failing the same way under one policy shares a scope reference, and the
+ * second onwards folds into the first as though one document had failed repeatedly.
+ *
+ * Prefixed so a file id and a run id cannot collide.
+ */
+ private String fileOrRun() {
+ return isBlank(fileId) ? "run:" + nullToEmpty(runId) : "file:" + fileId;
+ }
+
+ /**
+ * Decides whether this failure repeats an existing incident: SHA-256 of {@code
+ * kindId|scope|scopeRef}. Hashed so a long scope reference (an S3 key) fits a fixed-width
+ * index, and deterministic so two nodes converge on one incident. Every input is an enum name
+ * or an id.
+ */
+ public String dedupKey() {
+ String raw = kind.getId() + "|" + kind.getScope().name() + "|" + scopeRef();
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(raw.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is mandated by the JDK; unreachable outside a broken runtime.
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+
+ private static String truncate(String detail) {
+ if (detail == null || detail.length() <= MAX_DETAIL_LENGTH) {
+ return detail;
+ }
+ // Leave room for the ellipsis so the cap is the cap, and step back once more rather than
+ // cutting between the halves of a surrogate pair, which would store invalid UTF-16.
+ int end = MAX_DETAIL_LENGTH - 1;
+ if (Character.isHighSurrogate(detail.charAt(end - 1))) {
+ end--;
+ }
+ return detail.substring(0, end) + "…";
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+
+ private static String nullToEmpty(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java
index 1f31d153a6..33ac70d510 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java
@@ -32,6 +32,8 @@ import stirling.software.common.service.ResourceMonitor;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.ExecutorFactory;
import stirling.software.common.util.JobContext;
+import stirling.software.proprietary.failure.FailureKind;
+import stirling.software.proprietary.failure.PolicyFailureRecorder;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
@@ -72,6 +74,8 @@ public class PolicyEngine {
private final PolicyExecutor stepExecutor;
private final TaskManager taskManager;
private final PolicyRunRegistry registry;
+ // Durable record of why a run failed. Best-effort by contract: see PolicyFailureRecorder.
+ private final PolicyFailureRecorder failureRecorder;
private final FileStorage fileStorage;
private final JobOwnershipService jobOwnershipService;
private final List The other tests here call controller methods directly, which skips response serialisation,
+ * body binding, query coercion and status mapping. A double-encoded request body already slipped
+ * through that gap once, so these use a real {@link HttpClient} against a real socket.
+ */
+@SpringBootTest(
+ classes = FileRunEventHttpIntegrationTest.TestApp.class,
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class FileRunEventHttpIntegrationTest {
+
+ private static final Long TEAM = 42L;
+ private static final String ACTOR = "reviewer@example.com";
+
+ @LocalServerPort private int port;
+
+ private final HttpClient http = HttpClient.newHttpClient();
+ private final JsonMapper mapper = JsonMapper.builder().build();
+
+ /** Shared so a test can seed a row and then read it back over the wire. */
+ private static InMemoryFileRunEventRepository repository;
+
+ @BeforeEach
+ void resetRows() {
+ repository.rows.clear();
+ }
+
+ private String seed(FailureKind kind, Long teamId, String fileId, String detail) {
+ FileRunEventStore store = new FileRunEventStore(repository);
+ return store.record(
+ new RecordFailure(
+ kind,
+ FailureOrigin.POLICY,
+ teamId,
+ "author@example.com",
+ "policy-1",
+ "run-1",
+ null,
+ fileId,
+ detail))
+ .id();
+ }
+
+ private HttpResponse {@code NOT_SUPPORTED} suspends {@code @DataJpaTest}'s per-test transaction, so every store
+ * call commits in its own transaction as at runtime. That is what makes the stale-snapshot test
+ * meaningful: inside one shared persistence context, both reads would hand back the same managed
+ * instance and the bug could not show.
+ */
+@DataJpaTest
+@Transactional(propagation = Propagation.NOT_SUPPORTED)
+class FileRunEventStoreDbTest {
+
+ private static final Long TEAM = 7L;
+ private static final Long OTHER_TEAM = 9L;
+
+ @Autowired private FileRunEventRepository repository;
+
+ private FileRunEventStore store;
+
+ @BeforeEach
+ void setUp() {
+ store = new FileRunEventStore(repository);
+ }
+
+ @AfterEach
+ void wipe() {
+ repository.deleteAllInBatch();
+ }
+
+ private RecordFailure failure(FailureKind kind, Long teamId, String fileId) {
+ return new RecordFailure(
+ kind,
+ FailureOrigin.POLICY,
+ teamId,
+ "author@example.com",
+ "policy-1",
+ "run-1",
+ null,
+ fileId,
+ "detail");
+ }
+
+ @Test
+ @DisplayName("team isolation is enforced by the query, including the unteamed rows")
+ void teamIsolationIsEnforcedBySql() {
+ store.record(failure(FailureKind.UNKNOWN, TEAM, "ours"));
+ store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs"));
+ store.record(failure(FailureKind.UNKNOWN, null, "unteamed"));
+
+ assertThat(store.list(TEAM, null, null, 10))
+ .extracting(FileRunEvent::fileId)
+ .containsExactly("ours");
+ // A plain `e.teamId = :teamId` would return nothing here: SQL equality against NULL is
+ // never true, which is what the explicit null branch in the JPQL exists for.
+ assertThat(store.list(null, null, null, 10))
+ .extracting(FileRunEvent::fileId)
+ .containsExactly("unteamed");
+ }
+
+ @Test
+ @DisplayName("a fold lands on the row's current state, not the caller's snapshot")
+ void foldTargetsTheCurrentRowNotACallersSnapshot() {
+ // The lost-update shape: record reads the row, a reviewer dismisses it, then the fold
+ // applies. A save() of the stale snapshot would merge status = NEW back over the dismiss.
+ FileRunEvent event = store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
+ store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer@example.com");
+
+ FileRunEvent folded =
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
+
+ assertThat(folded.status()).isEqualTo(FileRunEventStatus.DISMISSED);
+ assertThat(folded.statusActor()).isEqualTo("reviewer@example.com");
+ assertThat(folded.occurrences()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("a dismiss landing mid-fold is not reverted by the folder's stale snapshot")
+ void aDismissLandingMidFoldIsNotReverted() {
+ // The reviewer's repro, made deterministic: the fold's read happens, then a dismiss lands,
+ // then the fold applies. The old save()-based absorb merged the pre-dismiss snapshot back
+ // over the row, reverting status to NEW and erasing the reviewer's action.
+ FileRunEvent event = store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
+ AtomicBoolean interleaved = new AtomicBoolean();
+ FileRunEventRepository interleaving =
+ (FileRunEventRepository)
+ Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[] {FileRunEventRepository.class},
+ (proxy, method, args) -> {
+ Object result;
+ try {
+ result = method.invoke(repository, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ if ("findByTeamAndDedupKey".equals(method.getName())
+ && interleaved.compareAndSet(false, true)) {
+ store.applyStatus(
+ event.id(),
+ TEAM,
+ FileRunEventStatus.DISMISSED,
+ "reviewer@example.com");
+ }
+ return result;
+ });
+
+ FileRunEvent folded =
+ new FileRunEventStore(interleaving)
+ .record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
+
+ assertThat(folded.status()).isEqualTo(FileRunEventStatus.DISMISSED);
+ assertThat(folded.statusActor()).isEqualTo("reviewer@example.com");
+ assertThat(folded.occurrences()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("a row deleted between the dedup read and the fold is re-inserted, not lost")
+ void aVanishedRowIsReinsertedRatherThanLost() {
+ // Retention or a manual purge can delete the row a fold was about to land on. The fold
+ // then matches nothing, and treating that as an error would drop the incident entirely:
+ // the recorder swallows what record() throws, leaving the failure as only a log line.
+ FileRunEvent first = store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
+ AtomicBoolean deleted = new AtomicBoolean();
+ FileRunEventRepository vanishing =
+ (FileRunEventRepository)
+ Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[] {FileRunEventRepository.class},
+ (proxy, method, args) -> {
+ if ("fold".equals(method.getName())
+ && deleted.compareAndSet(false, true)) {
+ repository.deleteById(first.id());
+ }
+ try {
+ return method.invoke(repository, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ });
+
+ FileRunEvent replacement =
+ new FileRunEventStore(vanishing).record(failure(FailureKind.UNKNOWN, TEAM, "f"));
+
+ assertThat(replacement.id()).isNotEqualTo(first.id());
+ assertThat(replacement.occurrences()).isEqualTo(1);
+ assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("the same broken file folds across runs, and distinct files do not")
+ void repeatsFoldOnTheDocumentNotTheRun() {
+ // The point of the rollup. Each sweep starts a new run, so keying on the run id alone
+ // meant a file that failed every sweep opened a new incident every sweep, and several
+ // files failing in one run collapsed into one. Both are wrong; the document is the key.
+ RecordFailure firstSweep = locked("run-1", "file-hash-a");
+ RecordFailure secondSweep = locked("run-2", "file-hash-a");
+ RecordFailure otherFile = locked("run-1", "file-hash-b");
+
+ store.record(firstSweep);
+ store.record(otherFile);
+ FileRunEvent folded = store.record(secondSweep);
+
+ assertThat(folded.occurrences()).isEqualTo(2);
+ assertThat(store.list(TEAM, null, null, 10))
+ .as("one incident per document, however many runs it failed in")
+ .extracting(FileRunEvent::fileId)
+ .containsExactlyInAnyOrder("file-hash-a", "file-hash-b");
+ }
+
+ private RecordFailure locked(String runId, String fileId) {
+ return RecordFailure.forRun(
+ FailureKind.INPUT_PASSWORD_PROTECTED,
+ TEAM,
+ null,
+ "policy-1",
+ runId,
+ fileId,
+ "locked");
+ }
+
+ @Test
+ @DisplayName("the kind filter applies before the limit, not after")
+ void kindFilterAppliesBeforeTheLimit() {
+ store.record(failure(FailureKind.UNKNOWN, TEAM, "old-unknown"));
+ for (int i = 0; i < 3; i++) {
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i));
+ }
+
+ assertThat(store.list(TEAM, null, "UNKNOWN", 1))
+ .extracting(FileRunEvent::fileId)
+ .containsExactly("old-unknown");
+ }
+
+ @Test
+ @DisplayName("two racing closes resolve to one winner")
+ void twoClosesResolveToOneWinner() {
+ FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
+ store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "first@example.com");
+
+ // The second close passed the service's terminal() pre-check in its own request; the
+ // guarded UPDATE is what refuses it here.
+ assertThatThrownBy(
+ () ->
+ store.applyStatus(
+ event.id(),
+ TEAM,
+ FileRunEventStatus.DISMISSED,
+ "second@example.com"))
+ .isInstanceOf(FailureActionException.class)
+ .extracting(e -> ((FailureActionException) e).getReason())
+ .isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
+
+ assertThat(store.find(event.id(), TEAM).orElseThrow().statusActor())
+ .isEqualTo("first@example.com");
+ }
+
+ @Test
+ @DisplayName("losing a delete race reports the row missing, not closed")
+ void aDeletedRowIsReportedMissingRatherThanClosed() {
+ // Both refusals come from the same guarded UPDATE returning zero rows, so the store has to
+ // tell them apart afterwards. Getting it wrong answers 400 for a row that is simply gone,
+ // where every other "no such event" answers 404.
+ FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
+ repository.deleteById(event.id());
+
+ assertThatThrownBy(
+ () ->
+ store.applyStatus(
+ event.id(),
+ TEAM,
+ FileRunEventStatus.DISMISSED,
+ "reviewer@example.com"))
+ .isInstanceOf(FailureActionException.class)
+ .extracting(e -> ((FailureActionException) e).getReason())
+ .isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("a reopened incident only flips from RESOLVED, never from DISMISSED")
+ void reopenFlipsOnlyResolved() {
+ FileRunEvent resolved = store.record(failure(FailureKind.UNKNOWN, TEAM, "resolved-file"));
+ store.applyStatus(resolved.id(), TEAM, FileRunEventStatus.RESOLVED, "system");
+
+ FileRunEvent reopened = store.record(failure(FailureKind.UNKNOWN, TEAM, "resolved-file"));
+
+ assertThat(reopened.status()).isEqualTo(FileRunEventStatus.NEW);
+ assertThat(reopened.statusActor()).isNull();
+ }
+
+ @Test
+ @DisplayName("the unique constraint exists in the DDL, not only in the entity annotation")
+ void duplicateIncidentInsertTripsTheUniqueConstraint() {
+ store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
+ String dedupKey = failure(FailureKind.UNKNOWN, TEAM, "f").dedupKey();
+
+ FileRunEventEntity duplicate = new FileRunEventEntity();
+ duplicate.setId(UUID.randomUUID().toString());
+ duplicate.setTeamId(TEAM);
+ duplicate.setKindId(FailureKind.UNKNOWN.getId());
+ duplicate.setStage(FailureStage.INTERNAL);
+ duplicate.setSeverity(FailureSeverity.ERROR);
+ duplicate.setScope(FailureScope.RUN);
+ duplicate.setOrigin(FailureOrigin.POLICY);
+ duplicate.setDedupKey(dedupKey);
+ duplicate.setOccurrences(1);
+ duplicate.setStatus(FileRunEventStatus.NEW);
+ duplicate.setCreatedAt(Instant.now());
+ duplicate.setLastSeenAt(Instant.now());
+
+ assertThatThrownBy(() -> repository.saveAndFlush(duplicate))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+
+ @SpringBootConfiguration
+ @AutoConfigurationPackage
+ static class TestApp {}
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java
new file mode 100644
index 0000000000..2adbc7d2b2
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java
@@ -0,0 +1,351 @@
+package stirling.software.proprietary.failure;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link FileRunEventStore}, driven through a hand-written in-memory repository so the
+ * rollup rules are tested directly and fast. The rollup is the load-bearing part: without it,
+ * Dismiss means nothing for a source that re-lists the same failing file on every sweep.
+ */
+class FileRunEventStoreTest {
+
+ private static final Long TEAM = 7L;
+ private static final Long OTHER_TEAM = 9L;
+
+ private InMemoryFileRunEventRepository repository;
+ private FileRunEventStore store;
+
+ @BeforeEach
+ void setUp() {
+ repository = new InMemoryFileRunEventRepository();
+ store = new FileRunEventStore(repository);
+ }
+
+ private RecordFailure failure(FailureKind kind, Long teamId, String fileId, String detail) {
+ return new RecordFailure(
+ kind,
+ FailureOrigin.POLICY,
+ teamId,
+ "ethan@example.com",
+ "policy-1",
+ "run-1",
+ null,
+ fileId,
+ detail);
+ }
+
+ @Nested
+ @DisplayName("recording a new failure")
+ class Insert {
+
+ @Test
+ void persistsEveryFieldAndSnapshotsTheRegistryFacets() {
+ FileRunEvent event =
+ store.record(
+ failure(
+ FailureKind.INPUT_PASSWORD_PROTECTED,
+ TEAM,
+ "file-1",
+ "locked"));
+
+ assertThat(event.id()).isNotBlank();
+ assertThat(event.teamId()).isEqualTo(TEAM);
+ assertThat(event.actor()).isEqualTo("ethan@example.com");
+ assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
+ assertThat(event.policyId()).isEqualTo("policy-1");
+ assertThat(event.runId()).isEqualTo("run-1");
+ assertThat(event.fileId()).isEqualTo("file-1");
+ assertThat(event.origin()).isEqualTo(FailureOrigin.POLICY);
+ assertThat(event.status()).isEqualTo(FileRunEventStatus.NEW);
+ assertThat(event.occurrences()).isEqualTo(1);
+ assertThat(event.createdAt()).isNotNull();
+ assertThat(event.lastSeenAt()).isNotNull();
+
+ // Snapshotted, not derived: re-classifying a kind later must not rewrite this row.
+ assertThat(event.stage()).isEqualTo(FailureStage.INPUT);
+ assertThat(event.severity()).isEqualTo(FailureSeverity.ERROR);
+ assertThat(event.scope()).isEqualTo(FailureScope.FILE);
+ }
+
+ @Test
+ void keepsTheRawDetailVerbatimForAnUnknownKind() {
+ // For UNKNOWN this string is the only diagnostic there is, so it has to reach the row
+ // unaltered. Redaction itself has its own tests.
+ String raw = "Policy run failed: java.lang.IllegalStateException: pool exhausted";
+ FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, null, raw));
+
+ assertThat(event.detail()).isEqualTo(raw);
+ }
+ }
+
+ @Nested
+ @DisplayName("the rollup")
+ class Rollup {
+
+ @Test
+ void foldsARepeatIntoTheOpenIncidentInsteadOfInsertingAgain() {
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "first"));
+ FileRunEvent second =
+ store.record(
+ failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "again"));
+
+ assertThat(second.occurrences()).isEqualTo(2);
+ assertThat(repository.rows).hasSize(1);
+ }
+
+ @Test
+ void refreshesTheDetailSoTheNewestDiagnosticIsTheOneOnShow() {
+ store.record(failure(FailureKind.UNKNOWN, TEAM, "file-1", "old message"));
+ FileRunEvent second =
+ store.record(failure(FailureKind.UNKNOWN, TEAM, "file-1", "new message"));
+
+ assertThat(second.detail()).isEqualTo("new message");
+ }
+
+ @Test
+ void treatsADifferentFileAsItsOwnIncident() {
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "a"));
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-2", "b"));
+
+ assertThat(repository.rows).hasSize(2);
+ }
+
+ @Test
+ void treatsADifferentKindAsItsOwnIncident() {
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "a"));
+ store.record(failure(FailureKind.UNKNOWN, TEAM, "file-1", "b"));
+
+ assertThat(repository.rows).hasSize(2);
+ }
+
+ @Test
+ void neverFoldsAcrossTeams() {
+ // Two tenants hitting the same problem are two incidents, each owned by its own team.
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "a"));
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, OTHER_TEAM, "file-1", "b"));
+
+ assertThat(repository.rows).hasSize(2);
+ }
+
+ @Test
+ void absorbsARepeatOntoADismissedRowWithoutReopeningIt() {
+ // A snapshot-mode source re-lists every file each sweep, so without absorption a
+ // dismissed failure would reappear as a new row every time.
+ FileRunEvent first =
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a"));
+ store.applyStatus(
+ first.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer@example.com");
+
+ FileRunEvent repeat =
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "b"));
+
+ assertThat(repository.rows).hasSize(1);
+ assertThat(repeat.status()).isEqualTo(FileRunEventStatus.DISMISSED);
+ assertThat(repeat.occurrences()).isEqualTo(2);
+ assertThat(repeat.statusActor()).isEqualTo("reviewer@example.com");
+ }
+
+ @Test
+ void reopensAResolvedRowBecauseTheProblemHasComeBack() {
+ // The counterweight to absorption, so a recurrence still surfaces.
+ FileRunEvent first =
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a"));
+ store.applyStatus(first.id(), TEAM, FileRunEventStatus.RESOLVED, "system");
+
+ FileRunEvent repeat =
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "b"));
+
+ assertThat(repository.rows).hasSize(1);
+ assertThat(repeat.status()).isEqualTo(FileRunEventStatus.NEW);
+ assertThat(repeat.occurrences()).isEqualTo(2);
+ // The previous closer no longer owns a reopened incident.
+ assertThat(repeat.statusActor()).isNull();
+ assertThat(repeat.statusAt()).isNull();
+ }
+
+ @Test
+ void keepsAnAcknowledgedRowAcknowledgedOnRepeat() {
+ FileRunEvent first =
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a"));
+ store.applyStatus(first.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "owner");
+
+ FileRunEvent repeat =
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "b"));
+
+ assertThat(repeat.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
+ assertThat(repeat.statusActor()).isEqualTo("owner");
+ }
+
+ @Test
+ void aSweepWhereEveryFileFailsProducesARowPerFile() {
+ // Each resolved input gets its own run, so a 400-file sweep is 400 incidents rather
+ // than one with a counter of 400. Grouping for display is a read-time concern.
+ for (int i = 0; i < 50; i++) {
+ store.record(
+ new RecordFailure(
+ FailureKind.UNKNOWN,
+ FailureOrigin.POLICY,
+ TEAM,
+ "ethan@example.com",
+ "policy-1",
+ "run-" + i,
+ null,
+ "file-" + i,
+ "OCR unavailable"));
+ }
+
+ assertThat(repository.rows).hasSize(50);
+ assertThat(repository.rows.values()).allMatch(row -> row.getOccurrences() == 1);
+ }
+
+ @Test
+ void treatsEachFileAsItsOwnIncidentEvenWhenNoFileIdWasCaptured() {
+ // The producer path cannot always name a file, and a FILE-scoped kind then falls back
+ // to
+ // the run. Without that, every locked file under one policy would fold into one row.
+ store.record(withoutFileId("run-a"));
+ store.record(withoutFileId("run-b"));
+
+ assertThat(repository.rows).hasSize(2);
+ assertThat(repository.rows.values()).allMatch(row -> row.getOccurrences() == 1);
+ }
+
+ @Test
+ void aRetryOfTheSameRunStillFoldsWhenNoFileIdWasCaptured() {
+ // The flip side: the fallback must not turn a genuine repeat into a second row.
+ store.record(withoutFileId("run-a"));
+ store.record(withoutFileId("run-a"));
+
+ assertThat(repository.rows).hasSize(1);
+ assertThat(repository.rows.values().iterator().next().getOccurrences()).isEqualTo(2);
+ }
+
+ @Test
+ void foldsIntoTheWinnersRowWhenAConcurrentWriterInsertedFirst() {
+ // Two runs failing identically at once: both read, find nothing, and try to insert. The
+ // loser's insert trips the unique constraint and has to fold rather than fail.
+ RecordFailure command = failure(FailureKind.UNKNOWN, TEAM, "file-1", "OCR unavailable");
+ FileRunEventEntity winner = rowFor(command);
+ repository.beforeNextSave = () -> repository.rows.put(winner.getId(), winner);
+
+ FileRunEvent result = store.record(command);
+
+ assertThat(repository.rows).hasSize(1);
+ assertThat(result.id()).isEqualTo(winner.getId());
+ assertThat(result.occurrences()).isEqualTo(2);
+ }
+
+ /** A row as the winning writer would have inserted it, for the race above. */
+ private FileRunEventEntity rowFor(RecordFailure command) {
+ FileRunEventEntity entity = new FileRunEventEntity();
+ entity.setId("winner");
+ entity.setTeamId(command.teamId());
+ entity.setKindId(command.kind().getId());
+ entity.setStage(command.kind().getStage());
+ entity.setSeverity(command.kind().getSeverity());
+ entity.setScope(command.kind().getScope());
+ entity.setOrigin(command.origin());
+ entity.setDedupKey(command.dedupKey());
+ entity.setOccurrences(1);
+ entity.setStatus(FileRunEventStatus.NEW);
+ entity.setCreatedAt(java.time.Instant.now());
+ entity.setLastSeenAt(java.time.Instant.now());
+ return entity;
+ }
+
+ private RecordFailure withoutFileId(String runId) {
+ return new RecordFailure(
+ FailureKind.INPUT_PASSWORD_PROTECTED,
+ FailureOrigin.POLICY,
+ TEAM,
+ "ethan@example.com",
+ "policy-1",
+ runId,
+ null,
+ null,
+ "The PDF Document is passworded");
+ }
+ }
+
+ @Nested
+ @DisplayName("reading")
+ class Reading {
+
+ @Test
+ void neverReturnsAnotherTeamsRows() {
+ store.record(failure(FailureKind.UNKNOWN, TEAM, "mine", "a"));
+ store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs", "b"));
+
+ assertThat(store.list(TEAM, null, null, 50))
+ .extracting(FileRunEvent::fileId)
+ .containsExactly("mine");
+ }
+
+ @Test
+ void treatsANullTeamAsTheUnteamedRows() {
+ // Login-disabled deployments have no team; a plain `= null` comparison would return
+ // nothing, which is why the query special-cases it.
+ store.record(failure(FailureKind.UNKNOWN, null, "unteamed", "a"));
+ store.record(failure(FailureKind.UNKNOWN, TEAM, "teamed", "b"));
+
+ assertThat(store.list(null, null, null, 50))
+ .extracting(FileRunEvent::fileId)
+ .containsExactly("unteamed");
+ }
+
+ @Test
+ void filtersByStatus() {
+ FileRunEvent open = store.record(failure(FailureKind.UNKNOWN, TEAM, "open", "a"));
+ store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "closed", "b"));
+ store.applyStatus(open.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "me");
+
+ assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, 50))
+ .extracting(FileRunEvent::fileId)
+ .containsExactly("open");
+ }
+
+ @Test
+ void findScopesByTeamSoAnIdFromAnotherTeamIsInvisible() {
+ FileRunEvent theirs = store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "x", "a"));
+
+ assertThat(store.find(theirs.id(), TEAM)).isEmpty();
+ assertThat(store.find(theirs.id(), OTHER_TEAM)).isPresent();
+ }
+ }
+
+ @Nested
+ @DisplayName("dedup keys")
+ class Keys {
+
+ @Test
+ void areDeterministicForTheSameFailure() {
+ RecordFailure command = failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a");
+
+ assertThat(command.dedupKey()).isEqualTo(command.dedupKey());
+ }
+
+ @Test
+ void differByKindAndByScopeReference() {
+ RecordFailure a = failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "x");
+ RecordFailure b = failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f2", "x");
+ RecordFailure c = failure(FailureKind.UNKNOWN, TEAM, "f1", "x");
+
+ assertThat(a.dedupKey()).isNotEqualTo(b.dedupKey());
+ assertThat(a.dedupKey()).isNotEqualTo(c.dedupKey());
+ }
+
+ @Test
+ void areFixedWidthSoAnyLengthOfScopeReferenceFitsTheIndex() {
+ String longKey = "s3://bucket/" + "nested/".repeat(500) + "file.pdf";
+ RecordFailure command =
+ failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, longKey, "x");
+
+ assertThat(command.dedupKey()).hasSize(64);
+ }
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java
new file mode 100644
index 0000000000..ed8b6f29cd
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java
@@ -0,0 +1,316 @@
+package stirling.software.proprietary.failure;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+
+import org.springframework.data.domain.Pageable;
+
+/**
+ * Minimal stand-in for the JPA repository: enough to exercise the store's branching without a
+ * database. Ordering mirrors the real queries (newest first) since the rollup relies on it, and
+ * {@link #save} enforces the {@code (team_id, dedup_key)} unique constraint the real table
+ * declares.
+ */
+class InMemoryFileRunEventRepository implements FileRunEventRepository {
+
+ final Map S save(S entity) {
+ if (beforeNextSave != null) {
+ Runnable hook = beforeNextSave;
+ beforeNextSave = null;
+ hook.run();
+ }
+ boolean isInsert = !rows.containsKey(entity.getId());
+ if (isInsert && clashesOnDedupKey(entity)) {
+ throw new org.springframework.dao.DataIntegrityViolationException(
+ "uk_file_run_events_dedup violated for " + entity.getDedupKey());
+ }
+ rows.put(entity.getId(), entity);
+ return entity;
+ }
+
+ /** Mirrors the unique constraint. SQL treats NULL teams as distinct, and so does this. */
+ private boolean clashesOnDedupKey(FileRunEventEntity candidate) {
+ if (candidate.getTeamId() == null) {
+ return false;
+ }
+ return rows.values().stream()
+ .anyMatch(
+ row ->
+ candidate.getTeamId().equals(row.getTeamId())
+ && candidate.getDedupKey().equals(row.getDedupKey()));
+ }
+
+ // ── unused JpaRepository surface ────────────────────────────────────────
+ @Override
+ public void flush() {}
+
+ @Override
+ public S saveAndFlush(S entity) {
+ return save(entity);
+ }
+
+ @Override
+ public List saveAllAndFlush(Iterable entities) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void deleteAllInBatch(Iterable List findAll(
+ org.springframework.data.domain.Example example) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findAll(
+ org.springframework.data.domain.Example example,
+ org.springframework.data.domain.Sort sort) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List saveAll(Iterable entities) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List