mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Report editor-originated failures into the same queue (Review Flow PR 2) (#7296)
Review Flow PR 2 of 5. Editor tool failures now reach the same durable queue as failures from folders, buckets and webhooks. ## What's added **A report endpoint** — `POST /api/v1/file-run-events/reports`, open to any authenticated user. Takes four fields: `operation`, `errorCode`, `fileIds`, `detail`. No team, no actor, no filename: the first two come from the session, the third is never a field. Refused with 400 above 200 file ids, and nothing is written when refused. **Automatic reporting from every tool** — wired into `useToolOperation`, so no per-tool work is needed. Client-side refusals (an unsupported format that never reaches the server) are reported too. User cancellations are not. **Error codes parsed from Blob bodies as well as JSON** — a download-typed tool call fails with a Blob, so `errorCodeOf` handles both shapes. **Source attribution for unattended runs** — `sourceId` is threaded from `PolicyRunner` through `PolicyRun` to the recorded row and out to the wire, so a folder, bucket or webhook failure names what fed it. Previously it had none. **Deleting a file closes its failures** — `FileContext.removeFiles` notifies `POST /removed-files`, which transitions those incidents to `FILE_REMOVED`. Terminal, so they leave every reviewer's queue. The rows stay for audit. **The queue can be emptied** — reads now default to open statuses only; ask for a status explicitly to see closed rows. ## Behaviour changes - **Editor failures dedup per person.** `RecordFailure.scopeRef()` includes the actor for TOOL-origin rows, so two people hitting the same failure on the same file are two incidents rather than one. Processor rows are unaffected and their dedup key is byte-identical to before. - **`UNKNOWN` offers only Dismiss.** Acknowledge is no longer offered on it. - **Background reports no longer raise a toast.** Both calls pass `suppressErrorToast`, so a failed report is silent as intended; previously a core build showed the user a "Not Found" toast on every tool failure. ## What is stored File ids only, never names. The request type has no filename field, and a `fileNames` value handed to the client reporter is accepted and ignored. One caveat to review deliberately: the free-text `detail` is stored **verbatim**. `RecordFailure` truncates it at 2000 characters and nothing else; the redaction that used to strip name-shaped text was reverted in `024899f3f6` because it made an unclassified failure impossible to act on. A backend message that embeds a filename (LibreOffice conversion errors, IO errors) will therefore persist that text and show it to a team leader. ## How to test Needs a proprietary or SaaS build with login enabled. `task dev:all` gives you one. 1. **Report a failure from a tool.** Open a PDF, run **Remove Password** on it with a wrong password. Nothing visible changes for you: reporting is silent by design. 2. **See it recorded.** Go to `/processor/documents` and scroll to **Failures** (dev builds only). A row appears titled "Password-protected document", with `Hit by <your user>`. Press **Show raw JSON** to see exactly what was stored. 3. **Confirm no filename is stored as data.** In that JSON, `fileId` is an opaque uuid and there is no name field. Note the `detail` string may contain a filename if the backend put one in its message, per the caveat above. 4. **Confirm the request is capped.** In DevTools, POST to `/api/v1/file-run-events/reports` with 201 entries in `fileIds`. It returns 400 naming the limit, and no rows are added. 5. **Deleting a file clears its failure.** Back in the editor, delete the file you just failed on. Refresh the failures list: its row is gone from the default view. Filter by `FILE_REMOVED` to see it still exists. 6. **Two people, two incidents.** Have a colleague fail the same tool on their own copy of the same file. Two rows, not one occurrence count. ## Migration `source_id` is a new column and `FILE_REMOVED` a new status value. Both are already in the SaaS migration ([Stirling-PDF-SaaS #322](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/322)); self-hosted picks them up from `ddl-auto`.
This commit is contained in:
@@ -26,6 +26,7 @@ watchedFolders/
|
||||
# also matches this frontend source component dir; keep the source tracked.
|
||||
!frontend/editor/src/proprietary/components/watchedFolders/
|
||||
clientWebUI/
|
||||
policy-webhook-spool/
|
||||
# Scratch dir used by local fixture-regeneration runs (see
|
||||
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
|
||||
# Holds downloaded JARs and disposable workdirs. Never committed.
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A failure a user hit in the editor, reported by their own client. The editor calls tools directly
|
||||
* rather than through the policy engine, so nothing server-side sees these unless the client says
|
||||
* so.
|
||||
*
|
||||
* <p>Note what the client cannot supply: no team, no actor, no document name. The first two come
|
||||
* from the authenticated session, and the third is never stored.
|
||||
*
|
||||
* @param operation the tool that failed, e.g. {@code remove-password}
|
||||
* @param errorCode the code from the tool's Problem Details response, or null when there was none
|
||||
* @param fileIds opaque client-side ids of the documents involved; empty when none is attributable
|
||||
* @param detail the message the user saw
|
||||
*/
|
||||
public record EditorFailureReport(
|
||||
String operation, String errorCode, List<String> fileIds, String detail) {
|
||||
|
||||
/**
|
||||
* Cap on the files one report may name. Each one becomes a permanent incident and this endpoint
|
||||
* is open to any authenticated user, so without a bound a single call can flood a leader's
|
||||
* queue. 200 is several times the largest batch an editor session plausibly fails on, and the
|
||||
* most the review queue shows in one page, so a real report never meets it.
|
||||
*
|
||||
* <p>An oversized report is refused whole rather than trimmed: see {@link
|
||||
* FileRunEventController#report}.
|
||||
*/
|
||||
static final int MAX_FILE_IDS = 200;
|
||||
|
||||
public EditorFailureReport {
|
||||
fileIds = fileIds == null ? List.of() : List.copyOf(fileIds);
|
||||
}
|
||||
|
||||
boolean hasOperation() {
|
||||
return operation != null && !operation.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted before the blank ids are dropped, because this bounds the request rather than the
|
||||
* rows it would produce.
|
||||
*/
|
||||
boolean namesTooManyFiles() {
|
||||
return fileIds.size() > MAX_FILE_IDS;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -23,6 +23,10 @@ import lombok.Getter;
|
||||
* <p>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.
|
||||
*
|
||||
* <p>A kind offers an acknowledgement only where there is something to acknowledge <em>doing</em>.
|
||||
* With nothing to fix, "seen it" and "clear it" are the same decision, so the row offers only the
|
||||
* one that clears it.
|
||||
*/
|
||||
@Getter
|
||||
public enum FailureKind {
|
||||
@@ -43,7 +47,6 @@ public enum FailureKind {
|
||||
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.";
|
||||
|
||||
@@ -20,6 +20,7 @@ public record FileRunEvent(
|
||||
FailureOrigin origin,
|
||||
String policyId,
|
||||
String runId,
|
||||
String sourceId,
|
||||
String fileId,
|
||||
String detail,
|
||||
String dedupKey,
|
||||
@@ -46,6 +47,7 @@ public record FileRunEvent(
|
||||
entity.getOrigin(),
|
||||
entity.getPolicyId(),
|
||||
entity.getRunId(),
|
||||
entity.getSourceId(),
|
||||
entity.getFileId(),
|
||||
entity.getDetail(),
|
||||
entity.getDedupKey(),
|
||||
|
||||
+61
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -91,6 +92,50 @@ public class FileRunEventController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/reports")
|
||||
@Operation(
|
||||
summary = "Report a failure hit in the editor",
|
||||
description =
|
||||
"For failures the server never sees, because the editor calls tools directly."
|
||||
+ " Open to any authenticated user, unlike the read and triage endpoints:"
|
||||
+ " whoever's work failed can say so, and a leader reviews it. Rejected"
|
||||
+ " with 400 if it names more files than one report may carry.")
|
||||
public ResponseEntity<Void> report(@RequestBody EditorFailureReport report) {
|
||||
if (report == null || !report.hasOperation()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "operation is required to report a failure");
|
||||
}
|
||||
// Refused whole rather than trimmed, and refused before the first write, so an oversized
|
||||
// report leaves no rows at all. Trimming would hand a reviewer part of a set with nothing
|
||||
// saying the rest existed, which is what the cap inside the service used to do. The limit
|
||||
// is stated in the message because the editor reports in the background: a client author
|
||||
// reading a log is the only person who will ever see this.
|
||||
if (report.namesTooManyFiles()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"a report may name at most "
|
||||
+ EditorFailureReport.MAX_FILE_IDS
|
||||
+ " files, and this one named "
|
||||
+ report.fileIds().size());
|
||||
}
|
||||
service.report(report);
|
||||
// No body: the editor reports and moves on, and has nothing to do with the row.
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/removed-files")
|
||||
@Operation(
|
||||
summary = "Close the incidents about files deleted from the editor",
|
||||
description =
|
||||
"Deleting the document leaves nothing to act on, so its incidents drop out of"
|
||||
+ " the queue while the rows stay for audit. Open to any authenticated"
|
||||
+ " user, and applies only to their own editor rows.")
|
||||
public ResponseEntity<Void> filesRemoved(@RequestBody(required = false) RemovedFiles request) {
|
||||
service.forgetFiles(request == null ? List.of() : request.safeFileIds());
|
||||
// No body: the editor is telling the server, not asking it anything.
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/kinds")
|
||||
@Operation(
|
||||
summary = "List known failure kinds",
|
||||
@@ -136,6 +181,22 @@ public class FileRunEventController {
|
||||
/** Wrapped rather than a bare array so pagination can be added without breaking clients. */
|
||||
public record FileRunEventsResponse(List<FileRunEventView> events) {}
|
||||
|
||||
/**
|
||||
* Files gone from the caller's editor. Opaque ids only, as everywhere else on this API.
|
||||
*
|
||||
* <p>Deliberately uncapped where a report is capped, because this creates nothing: it closes
|
||||
* rows the caller already owns, so however long the list is, it can only ever touch incidents
|
||||
* that already exist. Refusing an oversized one would also be the harmful direction here, since
|
||||
* the editor says this once and never retries: those incidents would sit in the queue asking
|
||||
* for attention about files that no longer exist.
|
||||
*/
|
||||
public record RemovedFiles(List<String> fileIds) {
|
||||
|
||||
List<String> safeFileIds() {
|
||||
return fileIds == null ? List.of() : fileIds;
|
||||
}
|
||||
}
|
||||
|
||||
/** Inputs an action declared it needs. Empty for both actions that exist today. */
|
||||
public record ActionRequest(Map<String, String> inputs) {
|
||||
|
||||
|
||||
+4
@@ -84,6 +84,10 @@ public class FileRunEventEntity implements Serializable {
|
||||
@Column(name = "run_id")
|
||||
private String runId;
|
||||
|
||||
/** Which folder, bucket or webhook fed the run. Null when a user supplied the file. */
|
||||
@Column(name = "source_id")
|
||||
private String sourceId;
|
||||
|
||||
@Column(name = "file_id")
|
||||
private String fileId;
|
||||
|
||||
|
||||
+36
-9
@@ -17,19 +17,21 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity, String> {
|
||||
|
||||
/**
|
||||
* This team's events, newest first, scoped in the query rather than loaded and filtered. A
|
||||
* {@code null} teamId matches the rows with no team (login disabled), mirroring {@link
|
||||
* stirling.software.proprietary.policy.source.SourceRepository#findByTeam}, since a plain
|
||||
* {@code = null} would return nothing.
|
||||
* As {@link #findByTeamAndStatus} but for a set of statuses, e.g. the open ones. The kind
|
||||
* filter is in the query, before the limit: filtering an already-limited page could return
|
||||
* nothing while matching rows exist.
|
||||
*/
|
||||
@Query(
|
||||
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
|
||||
+ " e.teamId = :teamId) and (:kindId is null or e.kindId = :kindId)"
|
||||
+ " order by e.lastSeenAt desc")
|
||||
List<FileRunEventEntity> findByTeam(
|
||||
@Param("teamId") Long teamId, @Param("kindId") String kindId, Pageable pageable);
|
||||
+ " e.teamId = :teamId) and e.status in :statuses"
|
||||
+ " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc")
|
||||
List<FileRunEventEntity> findByTeamAndStatusIn(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("statuses") List<FileRunEventStatus> statuses,
|
||||
@Param("kindId") String kindId,
|
||||
Pageable pageable);
|
||||
|
||||
/** As {@link #findByTeam} but restricted to one status, for the review surface's filters. */
|
||||
/** As {@link #findByTeamAndStatusIn} but for exactly one status, for the surface's filters. */
|
||||
@Query(
|
||||
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
|
||||
+ " e.teamId = :teamId) and e.status = :status"
|
||||
@@ -85,6 +87,31 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
|
||||
@Param("now") Instant now,
|
||||
@Param("allowedFrom") Collection<FileRunEventStatus> allowedFrom);
|
||||
|
||||
/**
|
||||
* Close the incidents about documents their owner deleted from the editor: the queue is what
|
||||
* needs attention, and a document that no longer exists needs none.
|
||||
*
|
||||
* <p>Restricted to that owner's own editor rows. File ids are minted by the client, so scoping
|
||||
* on team alone would let one caller close a colleague's incidents by naming ids. Processor
|
||||
* rows are excluded outright: nothing was deleted from an editor there.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Transactional
|
||||
@Query(
|
||||
"update FileRunEventEntity e set e.status ="
|
||||
+ " stirling.software.proprietary.failure.FileRunEventStatus.FILE_REMOVED,"
|
||||
+ " e.statusActor = :actor, e.statusAt = :now where e.origin ="
|
||||
+ " stirling.software.proprietary.failure.FailureOrigin.TOOL and ((:teamId is"
|
||||
+ " null and e.teamId is null) or e.teamId = :teamId) and ((:actor is null and"
|
||||
+ " e.actor is null) or e.actor = :actor) and e.fileId in :fileIds and e.status in"
|
||||
+ " :allowedFrom")
|
||||
int markFilesRemoved(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("actor") String actor,
|
||||
@Param("fileIds") Collection<String> fileIds,
|
||||
@Param("now") Instant now,
|
||||
@Param("allowedFrom") Collection<FileRunEventStatus> allowedFrom);
|
||||
|
||||
/**
|
||||
* The most recent row for this exact failure, so the rollup can increment an existing incident
|
||||
* instead of opening a new one. Team-scoped, so the same failure in two teams stays two rows.
|
||||
|
||||
+61
@@ -31,6 +31,67 @@ public class FileRunEventService {
|
||||
private final UserServiceInterface userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Record a failure a user hit in the editor. One incident per named file, so each document
|
||||
* stays separately actionable; one unattributed incident when the report names none.
|
||||
*
|
||||
* <p>The team and actor come from the session rather than the report, and the kind is
|
||||
* classified from the reported code, falling back to {@link FailureKind#UNKNOWN} for a code no
|
||||
* kind claims.
|
||||
*/
|
||||
public List<FileRunEvent> report(EditorFailureReport report) {
|
||||
FailureKind kind = FailureKind.byErrorCode(report.errorCode()).orElse(FailureKind.UNKNOWN);
|
||||
Long teamId = scope().teamId();
|
||||
String actor = currentActor();
|
||||
String detail = detailFor(report);
|
||||
|
||||
List<String> fileIds =
|
||||
report.fileIds().stream().filter(id -> id != null && !id.isBlank()).toList();
|
||||
if (fileIds.isEmpty()) {
|
||||
return List.of(recordReported(kind, teamId, actor, null, detail));
|
||||
}
|
||||
// Every named file gets its row. An earlier cap here silently dropped the rest, which lost
|
||||
// failures a reviewer needed and was inconsistent with the processor path, where a sweep
|
||||
// records one row per failing file with no limit at all. How many files one report may name
|
||||
// is bounded at the boundary instead (see EditorFailureReport#MAX_FILE_IDS), where an
|
||||
// oversized report can be refused whole before anything is written.
|
||||
return fileIds.stream()
|
||||
.map(fileId -> recordReported(kind, teamId, actor, fileId, detail))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private FileRunEvent recordReported(
|
||||
FailureKind kind, Long teamId, String actor, String fileId, String detail) {
|
||||
return store.record(RecordFailure.forEditor(kind, teamId, actor, fileId, detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* The operation is the context a reviewer needs, since an editor failure has no policy or run.
|
||||
*/
|
||||
private String detailFor(EditorFailureReport report) {
|
||||
String message = report.detail() == null ? "" : report.detail();
|
||||
return message.isBlank() ? report.operation() : report.operation() + ": " + message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the incidents about documents the caller has deleted from their editor. The queue means
|
||||
* "needs attention", and a document that no longer exists needs none; the rows stay for audit.
|
||||
*
|
||||
* <p>Best-effort by nature: this only arrives if the browser that owns the file says so, and a
|
||||
* cleared cache or another device never will. Rows left open that way are retention's problem,
|
||||
* not this method's.
|
||||
*
|
||||
* @return how many incidents were closed
|
||||
*/
|
||||
public int forgetFiles(List<String> fileIds) {
|
||||
TeamScope scope = scope();
|
||||
if (!scope.permitted()) {
|
||||
return 0;
|
||||
}
|
||||
List<String> named = fileIds.stream().filter(id -> id != null && !id.isBlank()).toList();
|
||||
return store.markFilesRemoved(scope.teamId(), currentActor(), named);
|
||||
}
|
||||
|
||||
/** The calling user's events, newest first. Empty when their team cannot be resolved. */
|
||||
public List<FileRunEvent> list(FileRunEventStatus status, String kindId, int limit) {
|
||||
TeamScope scope = scope();
|
||||
|
||||
+8
-1
@@ -11,7 +11,14 @@ public enum FileRunEventStatus {
|
||||
NEW(false),
|
||||
ACKNOWLEDGED(false),
|
||||
DISMISSED(true),
|
||||
RESOLVED(true);
|
||||
RESOLVED(true),
|
||||
|
||||
/**
|
||||
* The document this incident was about was deleted from its owner's editor, so there is nothing
|
||||
* left to act on. Distinct from {@code DISMISSED}, which is a reviewer's decision, and from
|
||||
* {@code RESOLVED}, which reopens on recurrence: this one cannot recur, the file is gone.
|
||||
*/
|
||||
FILE_REMOVED(true);
|
||||
|
||||
/** The statuses a review queue shows by default: everything still needing a decision. */
|
||||
private static final List<FileRunEventStatus> OPEN =
|
||||
|
||||
+27
-4
@@ -98,6 +98,7 @@ public class FileRunEventStore {
|
||||
entity.setOrigin(command.origin());
|
||||
entity.setPolicyId(command.policyId());
|
||||
entity.setRunId(command.runId());
|
||||
entity.setSourceId(command.sourceId());
|
||||
entity.setFileId(command.fileId());
|
||||
entity.setDetail(command.detail());
|
||||
entity.setDedupKey(dedupKey);
|
||||
@@ -112,9 +113,14 @@ public class FileRunEventStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of incidents, newest first, optionally narrowed to one status and one kind. The kind
|
||||
* filter lives in the query, before the limit: filtering a already-limited page could return
|
||||
* nothing while matching rows exist.
|
||||
* A page of incidents, newest first, optionally narrowed to one status and one kind.
|
||||
*
|
||||
* <p>With no status asked for this is the <em>open</em> queue rather than every row ever
|
||||
* recorded: a dismissed failure has been dealt with, and leaving it in the default view means
|
||||
* the list can never be cleared. Ask for a status to see closed rows.
|
||||
*
|
||||
* <p>Both filters live in the query, before the limit: filtering an already-limited page could
|
||||
* return nothing while matching rows exist.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<FileRunEvent> list(
|
||||
@@ -122,7 +128,8 @@ public class FileRunEventStore {
|
||||
Pageable page = PageRequest.of(0, Math.max(1, limit));
|
||||
List<FileRunEventEntity> rows =
|
||||
status == null
|
||||
? repository.findByTeam(teamId, kindId, page)
|
||||
? repository.findByTeamAndStatusIn(
|
||||
teamId, FileRunEventStatus.open(), kindId, page)
|
||||
: repository.findByTeamAndStatus(teamId, status, kindId, page);
|
||||
return rows.stream().map(FileRunEvent::of).toList();
|
||||
}
|
||||
@@ -184,6 +191,22 @@ public class FileRunEventStore {
|
||||
.orElseThrow(() -> refusalFor(id, teamId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close this owner's open incidents about {@code fileIds}, because the documents are gone. The
|
||||
* rows stay for audit; they just leave the queue. Only open rows move, so a reviewer's dismiss
|
||||
* keeps its meaning and its actor.
|
||||
*
|
||||
* @return how many incidents were closed
|
||||
*/
|
||||
@Transactional
|
||||
public int markFilesRemoved(Long teamId, String actor, Collection<String> fileIds) {
|
||||
if (fileIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return repository.markFilesRemoved(
|
||||
teamId, actor, fileIds, Instant.now(), FileRunEventStatus.open());
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the guarded UPDATE refused, worked out only once it has. Missing and closed are told
|
||||
* apart after the fact rather than before, so the answer describes the row the UPDATE saw.
|
||||
|
||||
+2
@@ -22,6 +22,7 @@ public record FileRunEventView(
|
||||
String detail,
|
||||
String policyId,
|
||||
String runId,
|
||||
String sourceId,
|
||||
String fileId,
|
||||
String actor,
|
||||
int occurrences,
|
||||
@@ -48,6 +49,7 @@ public record FileRunEventView(
|
||||
event.detail(),
|
||||
event.policyId(),
|
||||
event.runId(),
|
||||
event.sourceId(),
|
||||
event.fileId(),
|
||||
event.actor(),
|
||||
event.occurrences(),
|
||||
|
||||
+21
-6
@@ -30,11 +30,12 @@ public class PolicyFailureRecorder {
|
||||
public void recordRunFailure(
|
||||
String runId,
|
||||
String policyId,
|
||||
String actor,
|
||||
String sourceId,
|
||||
String fileIdentity,
|
||||
String actor,
|
||||
String detail,
|
||||
Throwable cause) {
|
||||
record(classifier.classify(cause), runId, policyId, actor, fileIdentity, detail);
|
||||
record(classifier.classify(cause), runId, policyId, sourceId, fileIdentity, actor, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,21 +45,35 @@ public class PolicyFailureRecorder {
|
||||
* pick the wrong one.
|
||||
*/
|
||||
public void recordRunFailureAs(
|
||||
FailureKind kind, String runId, String policyId, String actor, String detail) {
|
||||
record(kind, runId, policyId, actor, null, detail);
|
||||
FailureKind kind,
|
||||
String runId,
|
||||
String policyId,
|
||||
String sourceId,
|
||||
String actor,
|
||||
String detail) {
|
||||
// No document reference: a run rejected at admission never got as far as one.
|
||||
record(kind, runId, policyId, sourceId, null, actor, detail);
|
||||
}
|
||||
|
||||
private void record(
|
||||
FailureKind kind,
|
||||
String runId,
|
||||
String policyId,
|
||||
String actor,
|
||||
String sourceId,
|
||||
String fileIdentity,
|
||||
String actor,
|
||||
String detail) {
|
||||
try {
|
||||
store.record(
|
||||
RecordFailure.forRun(
|
||||
kind, teamFor(policyId), actor, policyId, runId, fileIdentity, detail));
|
||||
kind,
|
||||
teamFor(policyId),
|
||||
actor,
|
||||
policyId,
|
||||
runId,
|
||||
sourceId,
|
||||
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);
|
||||
|
||||
+40
-10
@@ -38,17 +38,40 @@ public record RecordFailure(
|
||||
detail = truncate(detail);
|
||||
}
|
||||
|
||||
/** A processor-side failure with no file or source context, e.g. a run that failed outright. */
|
||||
/**
|
||||
* A processor-side run failure. {@code sourceId} says which folder, bucket or webhook fed the
|
||||
* run, and is the only attribution an unattended failure has: there is no user to name. {@code
|
||||
* fileId} is the source's opaque reference to the document, already hashed upstream.
|
||||
*/
|
||||
public static RecordFailure forRun(
|
||||
FailureKind kind,
|
||||
Long teamId,
|
||||
String actor,
|
||||
String policyId,
|
||||
String runId,
|
||||
String sourceId,
|
||||
String fileId,
|
||||
String detail) {
|
||||
return new RecordFailure(
|
||||
kind, FailureOrigin.POLICY, teamId, actor, policyId, runId, null, fileId, detail);
|
||||
kind,
|
||||
FailureOrigin.POLICY,
|
||||
teamId,
|
||||
actor,
|
||||
policyId,
|
||||
runId,
|
||||
sourceId,
|
||||
fileId,
|
||||
detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure a user hit in their own editor. There is no policy, run or source: the user is the
|
||||
* attribution, and {@code fileId} may be null when the report named no file.
|
||||
*/
|
||||
public static RecordFailure forEditor(
|
||||
FailureKind kind, Long teamId, String actor, String fileId, String detail) {
|
||||
return new RecordFailure(
|
||||
kind, FailureOrigin.TOOL, teamId, actor, null, null, null, fileId, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,14 +79,21 @@ public record RecordFailure(
|
||||
* 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 -> "";
|
||||
};
|
||||
String about =
|
||||
switch (kind.getScope()) {
|
||||
case FILE -> nullToEmpty(policyId) + "|" + fileOrRun();
|
||||
// An editor report has no run, so a RUN-scoped kind would otherwise put every
|
||||
// such failure in a team into one incident: fall back to the document.
|
||||
case RUN -> isBlank(runId) ? fileOrRun() : nullToEmpty(runId);
|
||||
case POLICY -> nullToEmpty(policyId);
|
||||
case SOURCE -> nullToEmpty(sourceId);
|
||||
// One server-wide condition is one incident regardless of which run hit it.
|
||||
case SERVER -> "";
|
||||
};
|
||||
// An editor failure belongs to the person who hit it, so two colleagues hitting the same
|
||||
// thing are two incidents. Folding them would credit one actor for both and offer the
|
||||
// wrong person the row. Unattended runs have no such owner and are unaffected.
|
||||
return origin == FailureOrigin.TOOL ? nullToEmpty(actor) + "|" + about : about;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+26
-14
@@ -130,25 +130,27 @@ public class PolicyEngine {
|
||||
// worker.
|
||||
String principal = currentActingPrincipal();
|
||||
return submitForPrincipal(
|
||||
principal, principal, policyId, definition, inputs, null, listener);
|
||||
principal, principal, policyId, definition, inputs, listener, null, null);
|
||||
}
|
||||
|
||||
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
|
||||
public PolicyRunHandle runPolicy(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
return runPolicy(policy, inputs, null, listener);
|
||||
return runPolicy(policy, inputs, listener, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, with the source's opaque
|
||||
* reference to the document being run. Carried so a failure can say which document it was
|
||||
* about, and so the same document failing again folds into one incident.
|
||||
* As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, recording which source
|
||||
* fed the run and its opaque reference to the document. The first says where an unattended
|
||||
* failure came from; the second says which document, and is what lets the same document failing
|
||||
* again fold into one incident. Both null for a user's upload.
|
||||
*/
|
||||
public PolicyRunHandle runPolicy(
|
||||
Policy policy,
|
||||
PolicyInputs inputs,
|
||||
String fileIdentity,
|
||||
PolicyProgressListener listener) {
|
||||
PolicyProgressListener listener,
|
||||
String sourceId,
|
||||
String fileIdentity) {
|
||||
// Bill the policy owner: trigger-fired runs have no security context, and the async worker
|
||||
// doesn't inherit the caller's, so the owner (stamped at policy creation) is the reliable
|
||||
// billing identity — and for org-wide policies the org/owner is meant to pay. But own the
|
||||
@@ -172,9 +174,12 @@ public class PolicyEngine {
|
||||
fileOwner,
|
||||
policy.id(),
|
||||
definition,
|
||||
// main's asset-resolved inputs, not the raw ones: stored certificates and watermark
|
||||
// images bind here, before the async hop, because worker threads have no principal.
|
||||
resolved,
|
||||
fileIdentity,
|
||||
listener);
|
||||
listener,
|
||||
sourceId,
|
||||
fileIdentity);
|
||||
}
|
||||
|
||||
private PolicyRunHandle submitForPrincipal(
|
||||
@@ -183,8 +188,9 @@ public class PolicyEngine {
|
||||
String policyId,
|
||||
PipelineDefinition definition,
|
||||
PolicyInputs inputs,
|
||||
String fileIdentity,
|
||||
PolicyProgressListener listener) {
|
||||
PolicyProgressListener listener,
|
||||
String sourceId,
|
||||
String fileIdentity) {
|
||||
// Scope the run id to the current user (this request thread) so the file-download
|
||||
// ownership check passes. No-op when security is off.
|
||||
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
|
||||
@@ -193,7 +199,7 @@ public class PolicyEngine {
|
||||
if (policyId != null) {
|
||||
taskManager.putMetadata(runId, "policyId", policyId);
|
||||
}
|
||||
PolicyRun run = new PolicyRun(runId, policyId, definition, fileIdentity);
|
||||
PolicyRun run = new PolicyRun(runId, policyId, definition, sourceId, fileIdentity);
|
||||
registry.register(run);
|
||||
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
||||
PolicyProgressListener tracking = trackingListener(runId, run, listener);
|
||||
@@ -359,7 +365,12 @@ public class PolicyEngine {
|
||||
// No exception to classify here: nothing was thrown by a tool, the run simply was not
|
||||
// admitted. Record it explicitly so a run lost to load pressure is still accounted for.
|
||||
failureRecorder.recordRunFailureAs(
|
||||
FailureKind.UNKNOWN, run.getRunId(), run.getPolicyId(), null, message);
|
||||
FailureKind.UNKNOWN,
|
||||
run.getRunId(),
|
||||
run.getPolicyId(),
|
||||
run.getSourceId(),
|
||||
null,
|
||||
message);
|
||||
completion.complete(run);
|
||||
}
|
||||
return null;
|
||||
@@ -373,8 +384,9 @@ public class PolicyEngine {
|
||||
failureRecorder.recordRunFailure(
|
||||
run.getRunId(),
|
||||
run.getPolicyId(),
|
||||
MDC.get(AUDIT_PRINCIPAL_MDC_KEY),
|
||||
run.getSourceId(),
|
||||
run.getFileIdentity(),
|
||||
MDC.get(AUDIT_PRINCIPAL_MDC_KEY),
|
||||
message,
|
||||
cause);
|
||||
}
|
||||
|
||||
+16
-4
@@ -79,7 +79,8 @@ public class PolicyRunner {
|
||||
// Generator pipeline: one run with no input. Still fall through to the cleanup
|
||||
// below so rows recorded for its folder outputs are pruned like anything else,
|
||||
// instead of accumulating until the policy is deleted.
|
||||
runIds.add(startRun(policy, PolicyInputs.of(List.of()), null, unused -> {}));
|
||||
// Generator pipeline: no input, so neither a source nor a document to attribute to.
|
||||
runIds.add(startRun(policy, null, null, PolicyInputs.of(List.of()), unused -> {}));
|
||||
}
|
||||
for (PipelineInput input : inputs) {
|
||||
String sourceId = input.sourceId();
|
||||
@@ -167,7 +168,13 @@ public class PolicyRunner {
|
||||
List<String> runIds = new ArrayList<>();
|
||||
long docsFed = 0;
|
||||
for (ResolvedInput unit : work) {
|
||||
runIds.add(startRun(policy, unit.inputs(), unit.fileIdentity(), unit.onComplete()));
|
||||
runIds.add(
|
||||
startRun(
|
||||
policy,
|
||||
sourceId,
|
||||
unit.fileIdentity(),
|
||||
unit.inputs(),
|
||||
unit.onComplete()));
|
||||
docsFed += unit.inputs().primary().size();
|
||||
}
|
||||
docCounter.record(sourceId, docsFed);
|
||||
@@ -175,10 +182,15 @@ public class PolicyRunner {
|
||||
}
|
||||
|
||||
private String startRun(
|
||||
Policy policy, PolicyInputs inputs, String fileIdentity, Consumer<Boolean> onComplete) {
|
||||
Policy policy,
|
||||
String sourceId,
|
||||
String fileIdentity,
|
||||
PolicyInputs inputs,
|
||||
Consumer<Boolean> onComplete) {
|
||||
log.info("Running policy {} ({})", policy.id(), policy.name());
|
||||
PolicyRunHandle handle =
|
||||
policyEngine.runPolicy(policy, inputs, fileIdentity, PolicyProgressListener.NOOP);
|
||||
policyEngine.runPolicy(
|
||||
policy, inputs, PolicyProgressListener.NOOP, sourceId, fileIdentity);
|
||||
handle.completion()
|
||||
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
|
||||
return handle.runId();
|
||||
|
||||
+18
-1
@@ -21,6 +21,12 @@ public class PolicyRun {
|
||||
/** ID of the stored policy that produced this run; null for ad-hoc pipelines. */
|
||||
private final String policyId;
|
||||
|
||||
/**
|
||||
* ID of the source the input came from (folder, S3, webhook), or null when a user supplied the
|
||||
* files. Recorded on a failure so a reviewer can see where an unattended file came from.
|
||||
*/
|
||||
private final String sourceId;
|
||||
|
||||
private final PipelineDefinition definition;
|
||||
|
||||
/**
|
||||
@@ -56,10 +62,21 @@ public class PolicyRun {
|
||||
private volatile List<ResultFile> outputs = List.of();
|
||||
private volatile Instant updatedAt = Instant.now();
|
||||
|
||||
/**
|
||||
* Both references are required rather than defaulted: a run with neither is a real case (a
|
||||
* user's upload, an ad-hoc pipeline), but it should be stated at the call site. Overloads that
|
||||
* omitted them would make losing the attribution the frictionless option, which is how both
|
||||
* fields went unpopulated in the first place.
|
||||
*/
|
||||
public PolicyRun(
|
||||
String runId, String policyId, PipelineDefinition definition, String fileIdentity) {
|
||||
String runId,
|
||||
String policyId,
|
||||
PipelineDefinition definition,
|
||||
String sourceId,
|
||||
String fileIdentity) {
|
||||
this.runId = runId;
|
||||
this.policyId = policyId;
|
||||
this.sourceId = sourceId;
|
||||
this.definition = definition;
|
||||
this.fileIdentity = fileIdentity;
|
||||
}
|
||||
|
||||
+11
-5
@@ -182,10 +182,10 @@ class FailureKindTest {
|
||||
class Unknown {
|
||||
|
||||
@Test
|
||||
void existsAndCanBeTriaged() {
|
||||
assertThat(FailureKind.UNKNOWN.getActions())
|
||||
.containsExactlyInAnyOrder(
|
||||
FailureActionId.ACKNOWLEDGE, FailureActionId.DISMISS);
|
||||
void offersOnlyTheActionThatClearsIt() {
|
||||
// Nothing here can be fixed, so "seen it" and "clear it" would be the same decision.
|
||||
// Offering both just asks the reviewer to press two buttons to reach one outcome.
|
||||
assertThat(FailureKind.UNKNOWN.getActions()).containsExactly(FailureActionId.DISMISS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -233,8 +233,14 @@ class FailureKindTest {
|
||||
|
||||
@Test
|
||||
void declaresOnlyWhatItLists() {
|
||||
assertThat(FailureKind.UNKNOWN.declares(FailureActionId.ACKNOWLEDGE)).isTrue();
|
||||
assertThat(FailureKind.UNKNOWN.declares(FailureActionId.DISMISS)).isTrue();
|
||||
assertThat(FailureKind.UNKNOWN.declares(FailureActionId.ACKNOWLEDGE)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKindWithSomethingToFixOffersTheFixAndAWayToSkipIt() {
|
||||
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getActions())
|
||||
.containsExactly(FailureActionId.ACKNOWLEDGE, FailureActionId.DISMISS);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+123
-14
@@ -74,6 +74,20 @@ class FileRunEventControllerTest {
|
||||
"the raw failure message"));
|
||||
}
|
||||
|
||||
private static List<String> fileIds(int count) {
|
||||
return java.util.stream.IntStream.range(0, count).mapToObj(i -> "f-" + i).toList();
|
||||
}
|
||||
|
||||
/** The status a refused call came back with. Fails the test if the call was allowed. */
|
||||
private HttpStatus statusOf(Runnable call) {
|
||||
try {
|
||||
call.run();
|
||||
} catch (ResponseStatusException e) {
|
||||
return HttpStatus.valueOf(e.getStatusCode().value());
|
||||
}
|
||||
throw new AssertionError("expected the call to be refused");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("listing")
|
||||
class Listing {
|
||||
@@ -117,6 +131,7 @@ class FileRunEventControllerTest {
|
||||
|
||||
@Test
|
||||
void showsAClosedRowsActionsDisabledWithAReasonRatherThanHidingThem() {
|
||||
// Only visible by asking for the closed status: the default queue drops it.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
controller.act(event.id(), "DISMISS", null);
|
||||
|
||||
@@ -134,15 +149,16 @@ class FileRunEventControllerTest {
|
||||
|
||||
@Test
|
||||
void filtersByStatusAndByKind() {
|
||||
FileRunEvent open = given(FailureKind.UNKNOWN, TEAM, "open");
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked");
|
||||
controller.act(open.id(), "ACKNOWLEDGE", null);
|
||||
FileRunEvent locked = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked");
|
||||
given(FailureKind.UNKNOWN, TEAM, "open");
|
||||
controller.act(locked.id(), "ACKNOWLEDGE", null);
|
||||
|
||||
assertThat(controller.list(FileRunEventStatus.ACKNOWLEDGED, null, null).events())
|
||||
.hasSize(1);
|
||||
assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events())
|
||||
.extracting(FileRunEventView::fileId)
|
||||
.containsExactly("locked");
|
||||
// Acknowledged is still open work, so it stays in the default queue.
|
||||
assertThat(controller.list(null, "NO_SUCH_KIND", null).events()).isEmpty();
|
||||
}
|
||||
|
||||
@@ -182,7 +198,7 @@ class FileRunEventControllerTest {
|
||||
|
||||
@Test
|
||||
void appliesADeclaredActionAndReturnsTheUpdatedRow() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
FileRunEventView updated = controller.act(event.id(), "ACKNOWLEDGE", null);
|
||||
|
||||
@@ -218,21 +234,12 @@ class FileRunEventControllerTest {
|
||||
@Test
|
||||
void anAlreadyClosedRowIsAConflict() {
|
||||
// The request was well formed and would have been valid a moment earlier.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
controller.act(event.id(), "DISMISS", null);
|
||||
|
||||
assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null)))
|
||||
.isEqualTo(HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
private HttpStatus statusOf(Runnable call) {
|
||||
try {
|
||||
call.run();
|
||||
} catch (ResponseStatusException e) {
|
||||
return HttpStatus.valueOf(e.getStatusCode().value());
|
||||
}
|
||||
throw new AssertionError("expected the call to be refused");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -387,6 +394,108 @@ class FileRunEventControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("reporting from the editor")
|
||||
class Reporting {
|
||||
|
||||
@Test
|
||||
void aMemberMayReportEvenThoughTheyMayNotRead() {
|
||||
// The asymmetry is the point: anyone whose work failed can say so, but only a leader
|
||||
// reviews the queue.
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
controller.report(
|
||||
new EditorFailureReport(
|
||||
"compress", "E004", List.of("f-1"), "boom")))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatThrownBy(() -> controller.list(null, null, null))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void answersWithNoContentSoTheEditorNeverWaitsOnABody() {
|
||||
EditorFailureReport report =
|
||||
new EditorFailureReport("compress", "E004", List.of("f-1"), "boom");
|
||||
|
||||
assertThat(controller.report(report).getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAReportWithNoOperation() {
|
||||
assertThat(
|
||||
statusOf(
|
||||
() ->
|
||||
controller.report(
|
||||
new EditorFailureReport(
|
||||
" ", "E004", List.of("f-1"), "boom"))))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAReportAtTheFileLimitAndRecordsEveryRow() {
|
||||
List<String> atLimit = fileIds(EditorFailureReport.MAX_FILE_IDS);
|
||||
|
||||
EditorFailureReport report =
|
||||
new EditorFailureReport("compress", "E004", atLimit, "boom");
|
||||
|
||||
assertThat(controller.report(report).getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10))
|
||||
.hasSize(EditorFailureReport.MAX_FILE_IDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAReportOverTheFileLimitAndRecordsNothing() {
|
||||
// One call used to be able to mint an unbounded number of permanent incidents, since
|
||||
// each named file gets its own row and TOOL dedup keys never fold across ids. Refused
|
||||
// rather than trimmed so nothing is lost silently, and refused before the first write
|
||||
// so a rejected report cannot leave a partial set behind either.
|
||||
List<String> overLimit = fileIds(EditorFailureReport.MAX_FILE_IDS + 1);
|
||||
|
||||
assertThat(
|
||||
statusOf(
|
||||
() ->
|
||||
controller.report(
|
||||
new EditorFailureReport(
|
||||
"compress",
|
||||
"E004",
|
||||
overLimit,
|
||||
"boom"))))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void saysWhatTheLimitIsSoAClientAuthorCanSeeWhatHappened() {
|
||||
// The editor reports in the background, so the message is the only place this surfaces.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.report(
|
||||
new EditorFailureReport(
|
||||
"compress",
|
||||
"E004",
|
||||
fileIds(EditorFailureReport.MAX_FILE_IDS + 1),
|
||||
"boom")))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining(String.valueOf(EditorFailureReport.MAX_FILE_IDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theReportHasNoTeamOrFileNameToSupply() {
|
||||
// Stated as a test because the absence of those fields is the property. Adding either
|
||||
// to
|
||||
// EditorFailureReport breaks this at compile time.
|
||||
List<String> components =
|
||||
java.util.Arrays.stream(EditorFailureReport.class.getRecordComponents())
|
||||
.map(java.lang.reflect.RecordComponent::getName)
|
||||
.toList();
|
||||
|
||||
assertThat(components).containsExactly("operation", "errorCode", "fileIds", "detail");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("action request body")
|
||||
class RequestBody {
|
||||
|
||||
+74
-5
@@ -152,9 +152,9 @@ class FileRunEventHttpIntegrationTest {
|
||||
|
||||
@Test
|
||||
void coercesQueryParametersAndFiltersOnThem() throws Exception {
|
||||
String open = seed(FailureKind.UNKNOWN, TEAM, "open", "a");
|
||||
seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked", "b");
|
||||
post("/api/v1/file-run-events/" + open + "/actions/ACKNOWLEDGE", "{\"inputs\":{}}");
|
||||
String locked = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked", "b");
|
||||
seed(FailureKind.UNKNOWN, TEAM, "open", "a");
|
||||
post("/api/v1/file-run-events/" + locked + "/actions/ACKNOWLEDGE", "{\"inputs\":{}}");
|
||||
|
||||
JsonNode acknowledged =
|
||||
mapper.readTree(get("/api/v1/file-run-events?status=ACKNOWLEDGED").body())
|
||||
@@ -189,6 +189,75 @@ class FileRunEventHttpIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("reporting from the editor")
|
||||
class Reporting {
|
||||
|
||||
@Test
|
||||
void bindsAReportBodyAndAnswersNoContent() throws Exception {
|
||||
HttpResponse<String> response =
|
||||
post(
|
||||
"/api/v1/file-run-events/reports",
|
||||
"{\"operation\":\"remove-password\",\"errorCode\":\"E004\","
|
||||
+ "\"fileIds\":[\"f-1\",\"f-2\"],\"detail\":\"locked\"}");
|
||||
|
||||
assertThat(response.statusCode()).isEqualTo(204);
|
||||
assertThat(response.body()).isEmpty();
|
||||
|
||||
JsonNode events = mapper.readTree(get("/api/v1/file-run-events").body()).get("events");
|
||||
assertThat(events).hasSize(2);
|
||||
assertThat(events.get(0).get("origin").asString()).isEqualTo("TOOL");
|
||||
assertThat(events.get(0).get("kindId").asString())
|
||||
.isEqualTo("INPUT_PASSWORD_PROTECTED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAReportWithNoCodeOrFiles() throws Exception {
|
||||
HttpResponse<String> response =
|
||||
post(
|
||||
"/api/v1/file-run-events/reports",
|
||||
"{\"operation\":\"compress\",\"detail\":\"network died\"}");
|
||||
|
||||
assertThat(response.statusCode()).isEqualTo(204);
|
||||
JsonNode events = mapper.readTree(get("/api/v1/file-run-events").body()).get("events");
|
||||
assertThat(events).hasSize(1);
|
||||
assertThat(events.get(0).get("kindId").asString()).isEqualTo("UNKNOWN");
|
||||
assertThat(events.get(0).get("fileId").isNull()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAReportWithNoOperation() throws Exception {
|
||||
assertThat(
|
||||
post(
|
||||
"/api/v1/file-run-events/reports",
|
||||
"{\"errorCode\":\"E004\",\"detail\":\"boom\"}")
|
||||
.statusCode())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnOversizedReportWithoutRecordingAnyOfIt() throws Exception {
|
||||
// Over the wire because that is where the flood would arrive: one request, an
|
||||
// arbitrarily long fileIds array, a permanent row per entry. The read-back is the point
|
||||
// of the test, since a partial write would be worse than either accepting or refusing.
|
||||
String ids =
|
||||
java.util.stream.IntStream.range(0, EditorFailureReport.MAX_FILE_IDS + 1)
|
||||
.mapToObj(i -> "\"f-" + i + "\"")
|
||||
.collect(java.util.stream.Collectors.joining(","));
|
||||
|
||||
HttpResponse<String> response =
|
||||
post(
|
||||
"/api/v1/file-run-events/reports",
|
||||
"{\"operation\":\"compress\",\"errorCode\":\"E004\",\"fileIds\":["
|
||||
+ ids
|
||||
+ "],\"detail\":\"boom\"}");
|
||||
|
||||
assertThat(response.statusCode()).isEqualTo(400);
|
||||
assertThat(mapper.readTree(get("/api/v1/file-run-events").body()).get("events"))
|
||||
.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("action dispatch")
|
||||
class Dispatch {
|
||||
@@ -197,7 +266,7 @@ class FileRunEventHttpIntegrationTest {
|
||||
void bindsTheRequestBodyAndReturnsTheUpdatedRow() throws Exception {
|
||||
// The regression guard: an object body, sent as real JSON over the wire, binding into
|
||||
// ActionRequest. A double-encoded string would fail here.
|
||||
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
|
||||
String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "boom");
|
||||
|
||||
HttpResponse<String> response =
|
||||
post(
|
||||
@@ -262,7 +331,7 @@ class FileRunEventHttpIntegrationTest {
|
||||
|
||||
@Test
|
||||
void mapsAnAlreadyClosedRowToConflict() throws Exception {
|
||||
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
|
||||
String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "boom");
|
||||
post("/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{}}");
|
||||
|
||||
assertThat(
|
||||
|
||||
+254
-6
@@ -76,7 +76,7 @@ class FileRunEventServiceTest {
|
||||
|
||||
@Test
|
||||
void movesANewEventToAcknowledgedAndStampsTheActor() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
FileRunEvent updated = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
|
||||
@@ -87,7 +87,7 @@ class FileRunEventServiceTest {
|
||||
|
||||
@Test
|
||||
void isANoOpWhenAlreadyAcknowledgedSoOwnershipIsNotStolen() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
FileRunEvent first = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
Instant originalAt = first.statusAt();
|
||||
|
||||
@@ -114,7 +114,7 @@ class FileRunEventServiceTest {
|
||||
|
||||
@Test
|
||||
void closesAnAcknowledgedEvent() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
|
||||
assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status())
|
||||
@@ -225,7 +225,7 @@ class FileRunEventServiceTest {
|
||||
|
||||
@Test
|
||||
void aClosedEventCannotBeActedOnAgain() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
service.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()))
|
||||
@@ -286,8 +286,7 @@ class FileRunEventServiceTest {
|
||||
|
||||
assertThat(service.availableActions(event))
|
||||
.extracting(FileRunEventService.AvailableAction::labelKey)
|
||||
.containsExactlyInAnyOrder(
|
||||
"portal.failures.action.acknowledge", "portal.failures.action.dismiss");
|
||||
.containsExactly("portal.failures.action.dismiss");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,4 +389,253 @@ class FileRunEventServiceTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("reporting a failure the user hit in the editor")
|
||||
class Reporting {
|
||||
|
||||
@Test
|
||||
void classifiesTheReportedCodeAndStampsItAsEditorOrigin() {
|
||||
service.report(
|
||||
new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom"));
|
||||
|
||||
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
|
||||
assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
|
||||
assertThat(event.origin()).isEqualTo(FailureOrigin.TOOL);
|
||||
assertThat(event.fileId()).isEqualTo("f-1");
|
||||
assertThat(event.detail()).contains("boom");
|
||||
}
|
||||
|
||||
@Test
|
||||
void takesTheTeamAndActorFromThePrincipalNotTheReport() {
|
||||
// The report carries no team or actor field at all; both come from the caller's
|
||||
// session.
|
||||
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
|
||||
|
||||
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
|
||||
assertThat(event.teamId()).isEqualTo(TEAM);
|
||||
assertThat(event.actor()).isEqualTo(ACTOR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsAnUnrecognisedCodeAsUnknownRatherThanDroppingIt() {
|
||||
service.report(new EditorFailureReport("ocr", "E999", List.of("f-1"), "no idea"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10).getFirst().kind())
|
||||
.isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsAnAbsentCodeAsUnknown() {
|
||||
service.report(new EditorFailureReport("ocr", null, List.of("f-1"), "network died"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10).getFirst().kind())
|
||||
.isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsOneIncidentPerFileSoEachDocumentStaysActionable() {
|
||||
service.report(
|
||||
new EditorFailureReport(
|
||||
"compress", "E004", List.of("f-1", "f-2", "f-3"), "boom"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.hasSize(3)
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactlyInAnyOrder("f-1", "f-2", "f-3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void foldsARepeatOfTheSameFileIntoTheExistingIncident() {
|
||||
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
|
||||
service.report(
|
||||
new EditorFailureReport("compress", "E004", List.of("f-1"), "boom again"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.singleElement()
|
||||
.extracting(FileRunEvent::occurrences)
|
||||
.isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsOneUnattributedIncidentWhenNoFileWasNamed() {
|
||||
service.report(new EditorFailureReport("compress", "E004", List.of(), "boom"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.singleElement()
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsEveryFileInALargeBatchRatherThanTrimmingIt() {
|
||||
// A cap here used to drop the overflow silently, so a reviewer saw 25 of 60 failures
|
||||
// with nothing indicating the rest existed. The processor path has never had one.
|
||||
List<String> many =
|
||||
java.util.stream.IntStream.range(0, 60).mapToObj(i -> "f-" + i).toList();
|
||||
|
||||
service.report(new EditorFailureReport("compress", "E004", many, "boom"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 200)).hasSize(60);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheOperationNameOutOfTheStoredDocumentReferences() {
|
||||
// The operation is context for the reviewer, not a document reference: it belongs in
|
||||
// detail, never in fileId.
|
||||
service.report(
|
||||
new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom"));
|
||||
|
||||
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
|
||||
assertThat(event.detail()).contains("remove-password");
|
||||
assertThat(event.fileId()).isEqualTo("f-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesTheReportedMessageVerbatimAlongsideTheOperation() {
|
||||
// The user's own error about their own file. The operation is prefixed because an
|
||||
// editor failure has no run to give a reviewer context.
|
||||
service.report(
|
||||
new EditorFailureReport(
|
||||
"compress", "E004", List.of("f-1"), "Failed on Q4 report.pdf"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10).getFirst().detail())
|
||||
.isEqualTo("compress: Failed on Q4 report.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theServiceHoldsNothingThatCouldReachADocument() {
|
||||
// Asserted structurally rather than with verifyNoInteractions on unwired mocks, which
|
||||
// is how the version of this test on the other branch passed without proving anything.
|
||||
List<Class<?>> forbidden =
|
||||
List.of(
|
||||
stirling.software.proprietary.policy.ledger.ProcessedLedger.class,
|
||||
stirling.software.common.service.FileStorage.class,
|
||||
stirling.software.proprietary.policy.output.PolicyOutputSink.class);
|
||||
|
||||
List<Class<?>> held =
|
||||
Arrays.stream(FileRunEventService.class.getDeclaredFields())
|
||||
.filter(field -> !field.isSynthetic())
|
||||
.map(Field::getType)
|
||||
.toList();
|
||||
|
||||
assertThat(held).isNotEmpty().doesNotContainAnyElementsOf(forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("editor incidents stay separate")
|
||||
class EditorIncidentIdentity {
|
||||
|
||||
private void reportedBy(String actor, String fileId) {
|
||||
when(userService.getCurrentUsername()).thenReturn(actor);
|
||||
service.report(new EditorFailureReport("compress", null, List.of(fileId), "boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoPeopleHittingTheSameUnclassifiedFailureAreTwoIncidents() {
|
||||
// UNKNOWN is RUN scoped and an editor report has no run, so without the fallback every
|
||||
// unclassified editor failure in a team collapsed into one row: one actor credited for
|
||||
// everyone's, and the wrong person offered the row.
|
||||
reportedBy("alice@example.com", "a-1");
|
||||
reportedBy("bob@example.com", "b-1");
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.extracting(FileRunEvent::actor)
|
||||
.containsExactlyInAnyOrder("alice@example.com", "bob@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePersonsTwoBrokenFilesAreTwoIncidents() {
|
||||
reportedBy("alice@example.com", "a-1");
|
||||
reportedBy("alice@example.com", "a-2");
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactlyInAnyOrder("a-1", "a-2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSamePersonHittingTheSameFileTwiceIsOneIncident() {
|
||||
reportedBy("alice@example.com", "a-1");
|
||||
reportedBy("alice@example.com", "a-1");
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.singleElement()
|
||||
.extracting(FileRunEvent::occurrences)
|
||||
.isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("files deleted from the editor")
|
||||
class RemovedFiles {
|
||||
|
||||
private void reported(String fileId) {
|
||||
service.report(new EditorFailureReport("compress", "E004", List.of(fileId), "boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeTheirIncidentsSoTheQueueStopsAskingAboutThem() {
|
||||
reported("f-1");
|
||||
|
||||
assertThat(service.forgetFiles(List.of("f-1"))).isEqualTo(1);
|
||||
assertThat(service.list(null, null, 10))
|
||||
.as("the open queue is what the reviewer works from")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRowSurvivesForAudit() {
|
||||
reported("f-1");
|
||||
service.forgetFiles(List.of("f-1"));
|
||||
|
||||
assertThat(service.list(FileRunEventStatus.FILE_REMOVED, null, 10))
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
event -> {
|
||||
assertThat(event.fileId()).isEqualTo("f-1");
|
||||
assertThat(event.detail()).contains("boom");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReviewersDismissKeepsItsMeaningAndItsActor() {
|
||||
reported("f-1");
|
||||
FileRunEvent event = service.list(null, null, 10).getFirst();
|
||||
service.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
assertThat(service.forgetFiles(List.of("f-1")))
|
||||
.as("only open rows move; a closed one has already been decided")
|
||||
.isZero();
|
||||
assertThat(service.list(FileRunEventStatus.DISMISSED, null, 10)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aColleaguesIncidentIsUntouched() {
|
||||
// File ids come from the client, so naming one must not close someone else's row.
|
||||
store.record(
|
||||
RecordFailure.forEditor(
|
||||
FailureKind.UNKNOWN, TEAM, "employee@example.com", "f-1", "theirs"));
|
||||
|
||||
assertThat(service.forgetFiles(List.of("f-1"))).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProcessorIncidentIsUntouchedEvenOnTheSameFileId() {
|
||||
// Nothing was deleted from an editor there, and the file may still be in the bucket.
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f-1");
|
||||
|
||||
assertThat(service.forgetFiles(List.of("f-1"))).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void namingNoFilesClosesNothing() {
|
||||
reported("f-1");
|
||||
|
||||
assertThat(service.forgetFiles(List.of())).isZero();
|
||||
assertThat(service.forgetFiles(java.util.Arrays.asList(null, " "))).isZero();
|
||||
assertThat(service.list(null, null, 10)).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@@ -198,12 +199,66 @@ class FileRunEventStoreDbTest {
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED,
|
||||
TEAM,
|
||||
null,
|
||||
null,
|
||||
"policy-1",
|
||||
runId,
|
||||
fileId,
|
||||
"locked");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("closing deleted files touches only that owner's own open editor rows")
|
||||
void markFilesRemovedIsScopedBySqlNotByTheCaller() {
|
||||
// The scoping is entirely in the JPQL, so the in-memory fake proves nothing about it:
|
||||
// it implements the same rules by hand and would agree with a wrong query.
|
||||
FileRunEvent mine =
|
||||
store.record(
|
||||
RecordFailure.forEditor(
|
||||
FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1", "boom"));
|
||||
FileRunEvent theirs =
|
||||
store.record(
|
||||
RecordFailure.forEditor(
|
||||
FailureKind.UNKNOWN, TEAM, "colleague@example.com", "f-1", "boom"));
|
||||
FileRunEvent otherTeam =
|
||||
store.record(
|
||||
RecordFailure.forEditor(
|
||||
FailureKind.UNKNOWN,
|
||||
OTHER_TEAM,
|
||||
"owner@example.com",
|
||||
"f-1",
|
||||
"boom"));
|
||||
FileRunEvent fromProcessor = store.record(failure(FailureKind.UNKNOWN, TEAM, "f-1"));
|
||||
|
||||
int closed = store.markFilesRemoved(TEAM, "owner@example.com", List.of("f-1"));
|
||||
|
||||
assertThat(closed).isEqualTo(1);
|
||||
assertThat(store.find(mine.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.FILE_REMOVED);
|
||||
assertThat(store.find(theirs.id(), TEAM).orElseThrow().status())
|
||||
.as("another person's incident about their own file")
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
assertThat(store.find(otherTeam.id(), OTHER_TEAM).orElseThrow().status())
|
||||
.as("another team entirely")
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
assertThat(store.find(fromProcessor.id(), TEAM).orElseThrow().status())
|
||||
.as("nothing was deleted from an editor here")
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a row already closed by a reviewer is left as they left it")
|
||||
void markFilesRemovedLeavesClosedRowsAlone() {
|
||||
FileRunEvent event =
|
||||
store.record(
|
||||
RecordFailure.forEditor(
|
||||
FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1", "boom"));
|
||||
store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer@example.com");
|
||||
|
||||
assertThat(store.markFilesRemoved(TEAM, "owner@example.com", List.of("f-1"))).isZero();
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().statusActor())
|
||||
.isEqualTo("reviewer@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the kind filter applies before the limit, not after")
|
||||
void kindFilterAppliesBeforeTheLimit() {
|
||||
|
||||
+19
@@ -298,6 +298,25 @@ class FileRunEventStoreTest {
|
||||
.containsExactly("unteamed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dismissingARowClearsItFromTheDefaultQueue() {
|
||||
// The reviewer's whole complaint: without this, dismissing changes the buttons and
|
||||
// leaves the row sitting there, so the list can only ever grow.
|
||||
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom"));
|
||||
store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer");
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10)).isEmpty();
|
||||
assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, 10)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAcknowledgedRowIsStillOpenWorkSoItStays() {
|
||||
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom"));
|
||||
store.applyStatus(event.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "reviewer");
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersByStatus() {
|
||||
FileRunEvent open = store.record(failure(FailureKind.UNKNOWN, TEAM, "open", "a"));
|
||||
|
||||
+41
-10
@@ -6,6 +6,7 @@ import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
|
||||
@@ -53,16 +54,6 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
|
||||
return kindId == null || kindId.equals(entity.getKindId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findByTeam(Long teamId, String kindId, Pageable pageable) {
|
||||
return page(
|
||||
newestFirst(
|
||||
rows.values().stream()
|
||||
.filter(e -> sameTeam(e, teamId) && sameKind(e, kindId))
|
||||
.toList()),
|
||||
pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findByTeamAndStatus(
|
||||
Long teamId, FileRunEventStatus status, String kindId, Pageable pageable) {
|
||||
@@ -124,6 +115,46 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int markFilesRemoved(
|
||||
Long teamId,
|
||||
String actor,
|
||||
Collection<String> fileIds,
|
||||
Instant now,
|
||||
Collection<FileRunEventStatus> allowedFrom) {
|
||||
int closed = 0;
|
||||
for (FileRunEventEntity entity : rows.values()) {
|
||||
if (entity.getOrigin() != FailureOrigin.TOOL
|
||||
|| !sameTeam(entity, teamId)
|
||||
|| !Objects.equals(entity.getActor(), actor)
|
||||
|| entity.getFileId() == null
|
||||
|| !fileIds.contains(entity.getFileId())
|
||||
|| !allowedFrom.contains(entity.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
entity.setStatus(FileRunEventStatus.FILE_REMOVED);
|
||||
entity.setStatusActor(actor);
|
||||
entity.setStatusAt(now);
|
||||
closed++;
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findByTeamAndStatusIn(
|
||||
Long teamId, List<FileRunEventStatus> statuses, String kindId, Pageable pageable) {
|
||||
return page(
|
||||
newestFirst(
|
||||
rows.values().stream()
|
||||
.filter(
|
||||
e ->
|
||||
sameTeam(e, teamId)
|
||||
&& statuses.contains(e.getStatus())
|
||||
&& sameKind(e, kindId))
|
||||
.toList()),
|
||||
pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findByTeamAndDedupKey(
|
||||
Long teamId, String dedupKey, Pageable pageable) {
|
||||
|
||||
+86
-14
@@ -89,8 +89,9 @@ class PolicyFailureRecorderTest {
|
||||
recorder.recordRunFailure(
|
||||
"run-1",
|
||||
"policy-1",
|
||||
"dana@example.com",
|
||||
null,
|
||||
null,
|
||||
"dana@example.com",
|
||||
"Policy run failed: locked",
|
||||
passwordFailure());
|
||||
|
||||
@@ -114,8 +115,9 @@ class PolicyFailureRecorderTest {
|
||||
recorder.recordRunFailure(
|
||||
"run-1",
|
||||
"policy-1",
|
||||
"dana@example.com",
|
||||
null,
|
||||
null,
|
||||
"dana@example.com",
|
||||
"Policy run failed: something we do not recognise",
|
||||
new RuntimeException("boom"));
|
||||
|
||||
@@ -133,6 +135,7 @@ class PolicyFailureRecorderTest {
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Policy run failed: java.lang.NullPointerException",
|
||||
new RuntimeException("npe"));
|
||||
|
||||
@@ -147,7 +150,7 @@ class PolicyFailureRecorderTest {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailureAs(
|
||||
FailureKind.UNKNOWN, "run-3", "policy-1", null, "could not be queued");
|
||||
FailureKind.UNKNOWN, "run-3", "policy-1", null, null, "could not be queued");
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
|
||||
}
|
||||
@@ -161,9 +164,21 @@ class PolicyFailureRecorderTest {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure());
|
||||
"run-1",
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
"dana@example.com",
|
||||
"locked",
|
||||
passwordFailure());
|
||||
recorder.recordRunFailure(
|
||||
"run-2", "policy-1", "dana@example.com", null, "locked", passwordFailure());
|
||||
"run-2",
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
"dana@example.com",
|
||||
"locked",
|
||||
passwordFailure());
|
||||
|
||||
List<FileRunEvent> events = store.list(TEAM, null, null, 10);
|
||||
assertThat(events).hasSize(2);
|
||||
@@ -173,14 +188,62 @@ class PolicyFailureRecorderTest {
|
||||
.containsExactlyInAnyOrder("run-1", "run-2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesTheSourceWhenNoUserWasInvolved() {
|
||||
// An unattended file has no actor, so the source is the only attribution a reviewer
|
||||
// gets: which bucket, folder or webhook fed the run.
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1",
|
||||
"policy-1",
|
||||
"src-s3-invoices",
|
||||
null,
|
||||
null,
|
||||
"locked",
|
||||
passwordFailure());
|
||||
|
||||
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
|
||||
assertThat(event.sourceId()).isEqualTo("src-s3-invoices");
|
||||
assertThat(event.actor()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTwoSourcesApartEvenWhenTheyFailIdentically() {
|
||||
// Same kind, same policy, no user on either: without the source they would be one row.
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailureAs(
|
||||
FailureKind.UNKNOWN, "run-1", "policy-1", "src-a", null, "unreachable");
|
||||
recorder.recordRunFailureAs(
|
||||
FailureKind.UNKNOWN, "run-2", "policy-1", "src-b", null, "unreachable");
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.hasSize(2)
|
||||
.extracting(FileRunEvent::sourceId)
|
||||
.containsExactlyInAnyOrder("src-a", "src-b");
|
||||
}
|
||||
|
||||
@Test
|
||||
void thatSameRunFailingTwiceStaysOneIncident() {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure());
|
||||
"run-1",
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
"dana@example.com",
|
||||
"locked",
|
||||
passwordFailure());
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure());
|
||||
"run-1",
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
"dana@example.com",
|
||||
"locked",
|
||||
passwordFailure());
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.singleElement()
|
||||
@@ -198,7 +261,7 @@ class PolicyFailureRecorderTest {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "boom", new RuntimeException());
|
||||
"run-1", "policy-1", null, null, null, "boom", new RuntimeException());
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
|
||||
}
|
||||
@@ -207,7 +270,8 @@ class PolicyFailureRecorderTest {
|
||||
void leavesAnAdHocRunUnteamedRatherThanGuessing() {
|
||||
// No stored policy means no team to attribute it to. Recorded unteamed rather than
|
||||
// attributed to whichever team happened to be nearby.
|
||||
recorder.recordRunFailure("run-1", null, null, null, "boom", new RuntimeException());
|
||||
recorder.recordRunFailure(
|
||||
"run-1", null, null, null, null, "boom", new RuntimeException());
|
||||
|
||||
assertThat(store.list(null, null, null, 10)).hasSize(1);
|
||||
assertThat(store.list(TEAM, null, null, 10)).isEmpty();
|
||||
@@ -224,6 +288,7 @@ class PolicyFailureRecorderTest {
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"boom",
|
||||
new RuntimeException()))
|
||||
.doesNotThrowAnyException();
|
||||
@@ -258,6 +323,7 @@ class PolicyFailureRecorderTest {
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Policy run failed: locked",
|
||||
passwordFailure()))
|
||||
.doesNotThrowAnyException();
|
||||
@@ -270,7 +336,13 @@ class PolicyFailureRecorderTest {
|
||||
assertThatCode(
|
||||
() ->
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "no cause", null))
|
||||
"run-1",
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"no cause",
|
||||
null))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(store.list(TEAM, null, null, 10).getFirst().kind())
|
||||
.isEqualTo(FailureKind.UNKNOWN);
|
||||
@@ -286,9 +358,9 @@ class PolicyFailureRecorderTest {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "boom", new IOException("x"));
|
||||
"run-1", "policy-1", null, null, null, "boom", new IOException("x"));
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "boom", new IOException("x"));
|
||||
"run-1", "policy-1", null, null, null, "boom", new IOException("x"));
|
||||
|
||||
List<FileRunEvent> events = store.list(TEAM, null, null, 10);
|
||||
assertThat(events).hasSize(1);
|
||||
@@ -301,9 +373,9 @@ class PolicyFailureRecorderTest {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "boom", new IOException("x"));
|
||||
"run-1", "policy-1", null, null, null, "boom", new IOException("x"));
|
||||
recorder.recordRunFailure(
|
||||
"run-2", "policy-1", null, null, "boom", new IOException("x"));
|
||||
"run-2", "policy-1", null, null, null, "boom", new IOException("x"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10)).hasSize(2);
|
||||
}
|
||||
|
||||
+8
-1
@@ -24,7 +24,14 @@ class RecordFailurePrivacyTest {
|
||||
|
||||
private static RecordFailure withDetail(String detail) {
|
||||
return RecordFailure.forRun(
|
||||
FailureKind.UNKNOWN, 1L, "dana@example.com", "policy-1", "run-1", null, detail);
|
||||
FailureKind.UNKNOWN,
|
||||
1L,
|
||||
"dana@example.com",
|
||||
"policy-1",
|
||||
"run-1",
|
||||
null,
|
||||
null,
|
||||
detail);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+7
-5
@@ -216,7 +216,7 @@ class PolicyControllerTest {
|
||||
}
|
||||
|
||||
private static PolicyRunHandle handle(String runId) {
|
||||
PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null);
|
||||
PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null);
|
||||
return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run));
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ class PolicyControllerTest {
|
||||
@Test
|
||||
@DisplayName("returns the run view when present")
|
||||
void found() {
|
||||
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null);
|
||||
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null);
|
||||
when(runRegistry.get("run-3")).thenReturn(run);
|
||||
|
||||
ResponseEntity<PolicyRunView> response = controller.status("run-3");
|
||||
@@ -346,9 +346,11 @@ class PolicyControllerTest {
|
||||
@Test
|
||||
@DisplayName("excludes ad-hoc runs and runs owned by others")
|
||||
void filtersRuns() {
|
||||
PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null);
|
||||
PolicyRun ownedStored = new PolicyRun("owned", "policy-A", definitionWithStep(), null);
|
||||
PolicyRun otherStored = new PolicyRun("other", "policy-B", definitionWithStep(), null);
|
||||
PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null);
|
||||
PolicyRun ownedStored =
|
||||
new PolicyRun("owned", "policy-A", definitionWithStep(), null, null);
|
||||
PolicyRun otherStored =
|
||||
new PolicyRun("other", "policy-B", definitionWithStep(), null, null);
|
||||
when(runRegistry.all()).thenReturn(List.of(adHoc, ownedStored, otherStored));
|
||||
|
||||
// ownedByCurrentUser: strip then re-apply scope reproduces the key only for the owned
|
||||
|
||||
+36
-2
@@ -225,7 +225,41 @@ class PolicyEngineTest {
|
||||
// A failed run is recorded durably, so an admin can see it after the in-memory run expires.
|
||||
verify(failureRecorder)
|
||||
.recordRunFailure(
|
||||
eq(runId), any(), any(), any(), anyString(), any(Throwable.class));
|
||||
eq(runId), any(), any(), any(), any(), anyString(), any(Throwable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsWhichSourceFedAFailedRun() throws Exception {
|
||||
// The source is threaded onto the run so an unattended failure is attributable: there is no
|
||||
// user to name for a file that arrived from a bucket.
|
||||
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
|
||||
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
PolicyRunHandle handle =
|
||||
engine.runPolicy(
|
||||
new Policy(
|
||||
"p1",
|
||||
"rotate",
|
||||
"owner",
|
||||
true,
|
||||
List.of(),
|
||||
List.of(new PipelineStep(ROTATE, Map.of())),
|
||||
OutputSpec.inline()),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP,
|
||||
"src-s3-invoices",
|
||||
"file-hash-1");
|
||||
handle.completion().get(10, TimeUnit.SECONDS);
|
||||
|
||||
verify(failureRecorder)
|
||||
.recordRunFailure(
|
||||
anyString(),
|
||||
any(),
|
||||
eq("src-s3-invoices"),
|
||||
eq("file-hash-1"),
|
||||
any(),
|
||||
anyString(),
|
||||
any(Throwable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -238,7 +272,7 @@ class PolicyEngineTest {
|
||||
doThrow(new RuntimeException("event store unavailable"))
|
||||
.when(failureRecorder)
|
||||
.recordRunFailure(
|
||||
anyString(), any(), any(), any(), anyString(), any(Throwable.class));
|
||||
anyString(), any(), any(), any(), any(), anyString(), any(Throwable.class));
|
||||
|
||||
PolicyRunHandle handle =
|
||||
engine.submit(
|
||||
|
||||
+5
-1
@@ -97,7 +97,11 @@ class PolicyRunRegistryTest {
|
||||
private PolicyRun register(String runId) {
|
||||
PolicyRun run =
|
||||
new PolicyRun(
|
||||
runId, null, new PipelineDefinition(runId, List.of(), List.of()), null);
|
||||
runId,
|
||||
null,
|
||||
new PipelineDefinition(runId, List.of(), List.of()),
|
||||
null,
|
||||
null);
|
||||
registry.register(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
+11
-10
@@ -78,13 +78,13 @@ class PolicyRunnerTest {
|
||||
@Test
|
||||
void runsOnceWithNoFilesWhenThePolicyHasNoSources() {
|
||||
Policy policy = policy(List.of());
|
||||
when(policyEngine.runPolicy(eq(policy), any(), any(), any()))
|
||||
when(policyEngine.runPolicy(eq(policy), any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.run(policy);
|
||||
|
||||
ArgumentCaptor<PolicyInputs> inputs = ArgumentCaptor.forClass(PolicyInputs.class);
|
||||
verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any(), any());
|
||||
verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any(), any(), any());
|
||||
assertTrue(inputs.getValue().primary().isEmpty());
|
||||
// Ledger hygiene still runs: rows recorded for a generator policy's folder outputs
|
||||
// are pruned by its own sweeps rather than accumulating until the policy is deleted.
|
||||
@@ -137,12 +137,12 @@ class PolicyRunnerTest {
|
||||
List.of(
|
||||
ResolvedInput.of(PolicyInputs.of(List.of())),
|
||||
ResolvedInput.of(PolicyInputs.of(List.of()))));
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.run(policy);
|
||||
|
||||
verify(policyEngine, times(2)).runPolicy(eq(policy), any(), any(), any());
|
||||
verify(policyEngine, times(2)).runPolicy(eq(policy), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -154,7 +154,7 @@ class PolicyRunnerTest {
|
||||
when(folderSource.supports(spec)).thenReturn(true);
|
||||
when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit));
|
||||
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", completion));
|
||||
|
||||
runner.run(policy);
|
||||
@@ -175,7 +175,7 @@ class PolicyRunnerTest {
|
||||
when(folderSource.supports(spec)).thenReturn(true);
|
||||
when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit));
|
||||
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", completion));
|
||||
|
||||
runner.run(policy);
|
||||
@@ -224,12 +224,12 @@ class PolicyRunnerTest {
|
||||
when(folderSource.supports(spec)).thenReturn(true);
|
||||
when(folderSource.resolve(eq(spec), any()))
|
||||
.thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of()))));
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.run(policy, SweepKind.LIGHT);
|
||||
|
||||
verify(policyEngine).runPolicy(eq(policy), any(), any(), any());
|
||||
verify(policyEngine).runPolicy(eq(policy), any(), any(), any(), any());
|
||||
verify(processedLedger, never()).markSeen(any(), any());
|
||||
verify(processedLedger, never()).deleteUnseen(any(), anyLong());
|
||||
}
|
||||
@@ -244,12 +244,13 @@ class PolicyRunnerTest {
|
||||
when(folderSource.resolve(eq(broken), any())).thenThrow(new IOException("mount gone"));
|
||||
when(folderSource.resolve(eq(healthy), any()))
|
||||
.thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of()))));
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.run(policy);
|
||||
|
||||
verify(policyEngine).runPolicy(eq(policy), any(), any(), any()); // healthy source still ran
|
||||
verify(policyEngine)
|
||||
.runPolicy(eq(policy), any(), any(), any(), any()); // healthy source still ran
|
||||
verify(processedLedger, never()).deleteUnseen(any(), anyLong()); // history preserved
|
||||
}
|
||||
|
||||
|
||||
@@ -7332,9 +7332,11 @@ retry = "Try again"
|
||||
title = "Something went wrong on this page"
|
||||
|
||||
[portal.failures]
|
||||
fromSource = "From source {{source}}"
|
||||
occurrences = "{{count}} occurrences"
|
||||
reportedBy = "Hit by {{actor}}"
|
||||
runReference = "Run {{runId}}"
|
||||
subtitle = "Failures recorded from your policy runs, with the actions you can take."
|
||||
subtitle = "Failures recorded from your policy runs and your team's editors, with the actions you can take."
|
||||
title = "Failures"
|
||||
|
||||
[portal.failures.action]
|
||||
@@ -7359,6 +7361,11 @@ title = "Password-protected document"
|
||||
description = "This run failed for a reason Stirling does not yet recognise. The raw message is shown below."
|
||||
title = "Unrecognised failure"
|
||||
|
||||
[portal.failures.origin]
|
||||
pipeline = "Pipeline"
|
||||
policy = "Policy"
|
||||
tool = "Tool run"
|
||||
|
||||
[portal.failures.stage]
|
||||
blocked = "Blocked"
|
||||
input = "Input"
|
||||
|
||||
@@ -67,6 +67,7 @@ import { alert } from "@app/components/toast";
|
||||
import { buildRemovePasswordFormData } from "@app/hooks/tools/removePassword/buildRemovePasswordFormData";
|
||||
import type { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { reportFilesRemoved } from "@app/services/failureReporting";
|
||||
import { processResponse } from "@app/utils/toolResponseProcessor";
|
||||
import { ToolOperation } from "@app/types/file";
|
||||
import { handlePasswordError } from "@app/utils/toolErrorHandler";
|
||||
@@ -610,6 +611,10 @@ function FileContextInner({
|
||||
// Remove from memory and cleanup resources
|
||||
lifecycleManager.removeFiles(fileIds, stateRef);
|
||||
|
||||
// Any failure recorded against these stops needing attention: the document is gone.
|
||||
// Fire-and-forget, so a server that cannot be told never blocks the delete.
|
||||
void reportFilesRemoved(fileIds);
|
||||
|
||||
// Remove from IndexedDB if enabled
|
||||
if (indexedDB && enablePersistence && deleteFromStorage !== false) {
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import { FILE_EVENTS } from "@app/services/errorUtils";
|
||||
import { reportToolFailure } from "@app/services/failureReporting";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { getFilenameWithoutExtension } from "@app/utils/fileUtils";
|
||||
import {
|
||||
@@ -603,6 +604,14 @@ export const useToolOperation = <TParams>(
|
||||
void _e;
|
||||
}
|
||||
|
||||
// Report it so a leader sees the failure too, then carry on with the user's
|
||||
// own error handling. Fire-and-forget: the reporter swallows its own errors.
|
||||
void reportToolFailure({
|
||||
operation: config.operationType,
|
||||
error,
|
||||
fileIds: validFiles.map((file) => file.fileId),
|
||||
});
|
||||
|
||||
const errorMessage =
|
||||
config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||
actions.setError(errorMessage);
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests for the editor's failure reporter. Two properties matter: it never sends a
|
||||
* document name, and it never lets its own failure reach the tool the user was
|
||||
* running.
|
||||
*/
|
||||
|
||||
const post = vi.fn();
|
||||
|
||||
/**
|
||||
* Indirection so one test can replace the transport with a plain throwing closure.
|
||||
* A vi.fn that throws has its error re-reported by vitest even once the code under
|
||||
* test has caught it, which would fail the very test asserting it was caught.
|
||||
*/
|
||||
let transport: (...args: unknown[]) => unknown = (...args) => post(...args);
|
||||
|
||||
vi.mock("@app/services/apiClient", () => ({
|
||||
default: { post: (...args: unknown[]) => transport(...args) },
|
||||
}));
|
||||
|
||||
const { reportToolFailure, reportFilesRemoved, errorCodeOf } =
|
||||
await import("@app/services/failureReporting");
|
||||
|
||||
/** An axios-shaped rejection carrying a Problem Details body. */
|
||||
function problemDetail(errorCode: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
response: {
|
||||
status: 400,
|
||||
data: { type: "/errors/pdf-password", errorCode, ...extra },
|
||||
},
|
||||
message: "Request failed with status code 400",
|
||||
};
|
||||
}
|
||||
|
||||
describe("errorCodeOf", () => {
|
||||
it("reads the code out of a Problem Details body", async () => {
|
||||
await expect(errorCodeOf(problemDetail("E004"))).resolves.toBe("E004");
|
||||
});
|
||||
|
||||
it("reads the code out of a blob body, which is how a download-typed call fails", async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
text: () => Promise.resolve(JSON.stringify({ errorCode: "E001" })),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(errorCodeOf(error)).resolves.toBe("E001");
|
||||
});
|
||||
|
||||
it("returns null when the body carries no code", async () => {
|
||||
await expect(
|
||||
errorCodeOf({ response: { data: { title: "nope" } } }),
|
||||
).resolves.toBeNull();
|
||||
await expect(errorCodeOf({ message: "network error" })).resolves.toBeNull();
|
||||
await expect(errorCodeOf(undefined)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null rather than throwing on an unparseable blob", async () => {
|
||||
const error = {
|
||||
response: { data: { text: () => Promise.resolve("<html>502</html>") } },
|
||||
};
|
||||
|
||||
await expect(errorCodeOf(error)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportToolFailure", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset().mockResolvedValue({ status: 204 });
|
||||
transport = (...args) => post(...args);
|
||||
});
|
||||
|
||||
it("posts the operation, code and file ids", async () => {
|
||||
await reportToolFailure({
|
||||
operation: "remove-password",
|
||||
error: problemDetail("E004"),
|
||||
fileIds: ["f-1", "f-2"],
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
const [path, body] = post.mock.calls[0] as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(path).toBe("/api/v1/file-run-events/reports");
|
||||
expect(body).toMatchObject({
|
||||
operation: "remove-password",
|
||||
errorCode: "E004",
|
||||
fileIds: ["f-1", "f-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores names a caller hands it, and identifies files by id", async () => {
|
||||
// fileNames is accepted and dropped on purpose, so a call site holding names cannot pass
|
||||
// them somewhere they would be stored as a document reference.
|
||||
await reportToolFailure({
|
||||
operation: "compress",
|
||||
error: { response: { status: 500, data: {} }, message: "boom" },
|
||||
fileIds: ["f-1"],
|
||||
fileNames: ["Q4 report.pdf"],
|
||||
});
|
||||
|
||||
const body = post.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(body.fileIds).toEqual(["f-1"]);
|
||||
expect(JSON.stringify(body)).not.toContain("Q4 report.pdf");
|
||||
});
|
||||
|
||||
it("sends the message the user saw, unaltered", async () => {
|
||||
// Their own error about their own file: trimming it only makes the row harder to act on.
|
||||
await reportToolFailure({
|
||||
operation: "compress",
|
||||
error: {
|
||||
response: { status: 500, data: {} },
|
||||
message: "Failed on Q4 report.pdf",
|
||||
},
|
||||
fileIds: ["f-1"],
|
||||
});
|
||||
|
||||
const body = post.mock.calls[0]?.[1] as { detail: string };
|
||||
expect(body.detail).toBe("Failed on Q4 report.pdf");
|
||||
});
|
||||
|
||||
it("sends no team, because the server derives it", async () => {
|
||||
await reportToolFailure({
|
||||
operation: "compress",
|
||||
error: problemDetail("E004"),
|
||||
fileIds: ["f-1"],
|
||||
});
|
||||
|
||||
expect(JSON.stringify(post.mock.calls[0]?.[1])).not.toMatch(/team/i);
|
||||
});
|
||||
|
||||
it("swallows its own failure so the tool's own error handling is unaffected", async () => {
|
||||
transport = () => {
|
||||
throw new Error("404 - no such route on a core build");
|
||||
};
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await reportToolFailure({
|
||||
operation: "compress",
|
||||
error: problemDetail("E004"),
|
||||
fileIds: ["f-1"],
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for its own failure not to be shown, since the tool's error is already on screen", async () => {
|
||||
await reportToolFailure({
|
||||
operation: "compress",
|
||||
error: problemDetail("E004"),
|
||||
fileIds: ["f-1"],
|
||||
});
|
||||
|
||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ suppressErrorToast: true });
|
||||
});
|
||||
|
||||
it("logs a rejected report instead of losing it, and still does not throw", async () => {
|
||||
// A 400 means this client built a bad report, e.g. one naming more files than a report may
|
||||
// carry. Nothing else would ever surface it: the call is fire-and-forget and its toast is
|
||||
// suppressed, so the console line is the only sign a client author gets.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
transport = () => {
|
||||
throw Object.assign(new Error("Request failed with status code 400"), {
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
detail:
|
||||
"a report may name at most 200 files, and this one named 5000",
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await reportToolFailure({
|
||||
operation: "compress",
|
||||
error: problemDetail("E004"),
|
||||
fileIds: ["f-1"],
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
|
||||
expect(threw).toBe(false);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain("at most 200 files");
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("stays quiet when the route is simply absent, as on a build without failure tracking", async () => {
|
||||
// Otherwise every tool failure on such a build would log, which is noise rather than a
|
||||
// diagnostic.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
transport = () => {
|
||||
throw Object.assign(new Error("Request failed with status code 404"), {
|
||||
response: { status: 404, data: {} },
|
||||
});
|
||||
};
|
||||
|
||||
await reportToolFailure({
|
||||
operation: "compress",
|
||||
error: problemDetail("E004"),
|
||||
fileIds: ["f-1"],
|
||||
});
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("reports a client-side refusal, which is a failure a leader can act on", async () => {
|
||||
// The same class of problem as the processor rejecting a file type, which is
|
||||
// already recorded. Unclassified, so the server files it as UNKNOWN.
|
||||
await reportToolFailure({
|
||||
operation: "convert",
|
||||
error: new Error("Unsupported conversion format"),
|
||||
fileIds: ["f-1"],
|
||||
});
|
||||
|
||||
const body = post.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(body).toMatchObject({
|
||||
operation: "convert",
|
||||
errorCode: null,
|
||||
detail: "Unsupported conversion format",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a network failure, which got no reply but did leave the browser", async () => {
|
||||
await reportToolFailure({
|
||||
operation: "compress",
|
||||
error: { request: {}, message: "Network Error" },
|
||||
fileIds: ["f-1"],
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an axios cancellation", { code: "ERR_CANCELED", message: "canceled" }],
|
||||
[
|
||||
"the rethrown wrapper useToolApiCalls builds",
|
||||
new Error("Operation was cancelled", {
|
||||
cause: { code: "ERR_CANCELED" },
|
||||
}),
|
||||
],
|
||||
])("ignores %s, because the user chose to stop", async (_label, error) => {
|
||||
await reportToolFailure({ operation: "compress", error, fileIds: ["f-1"] });
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells the server when files are deleted, so their failures leave the queue", async () => {
|
||||
await reportFilesRemoved(["f-1", "f-2"]);
|
||||
|
||||
const [path, body] = post.mock.calls[0] as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(path).toBe("/api/v1/file-run-events/removed-files");
|
||||
expect(body).toEqual({ fileIds: ["f-1", "f-2"] });
|
||||
});
|
||||
|
||||
it("says nothing when no real file ids were deleted", async () => {
|
||||
await reportFilesRemoved([]);
|
||||
await reportFilesRemoved(["", " "]);
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows a failed deletion notice, since the file is gone locally either way", async () => {
|
||||
transport = () => {
|
||||
throw new Error("404 - no such route on a core build");
|
||||
};
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await reportFilesRemoved(["f-1"]);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing without an operation to attribute the failure to", async () => {
|
||||
await reportToolFailure({
|
||||
operation: "",
|
||||
error: { response: { status: 500 } },
|
||||
fileIds: ["f-1"],
|
||||
});
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends every file id, since trimming would lose failures silently", async () => {
|
||||
const many = Array.from({ length: 60 }, (_, i) => `f-${i}`);
|
||||
|
||||
await reportToolFailure({
|
||||
operation: "compress",
|
||||
error: problemDetail("E004"),
|
||||
fileIds: many,
|
||||
});
|
||||
|
||||
const body = post.mock.calls[0]?.[1] as { fileIds: string[] };
|
||||
expect(body.fileIds).toEqual(many);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
/**
|
||||
* Reports a tool failure the user hit in the editor, so it lands in the same queue
|
||||
* as one from a folder or bucket. The editor calls tools directly, so nothing
|
||||
* server-side knows about these unless the client says so.
|
||||
*
|
||||
* Best-effort throughout: a build without the failure registry has no such route,
|
||||
* and a report failing must never disturb the tool's own error handling.
|
||||
*/
|
||||
|
||||
const REPORT_PATH = "/api/v1/file-run-events/reports";
|
||||
const REMOVED_FILES_PATH = "/api/v1/file-run-events/removed-files";
|
||||
|
||||
interface ToolFailureReport {
|
||||
/** The tool that failed, e.g. `remove-password`. */
|
||||
operation: string;
|
||||
/** Whatever the tool call rejected with. */
|
||||
error: unknown;
|
||||
/** Opaque file ids from FileContext. Names are deliberately not accepted. */
|
||||
fileIds?: string[];
|
||||
/**
|
||||
* Accepted and ignored, so a call site that has names on hand cannot pass them
|
||||
* somewhere they would be stored. Present to make that explicit rather than to
|
||||
* be used.
|
||||
*/
|
||||
fileNames?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `errorCode` from a tool's Problem Details response, or null when there is
|
||||
* none. A download-typed call fails with a Blob body, so that shape is parsed too.
|
||||
*/
|
||||
export async function errorCodeOf(error: unknown): Promise<string | null> {
|
||||
const data = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
if (!data) return null;
|
||||
|
||||
const body = await asJson(data);
|
||||
const code = (body as { errorCode?: unknown })?.errorCode;
|
||||
return typeof code === "string" && code.trim() !== "" ? code : null;
|
||||
}
|
||||
|
||||
async function asJson(data: unknown): Promise<unknown> {
|
||||
if (typeof data === "object" && data !== null && !isBlobLike(data)) {
|
||||
return data;
|
||||
}
|
||||
try {
|
||||
const text = isBlobLike(data)
|
||||
? await data.text()
|
||||
: typeof data === "string"
|
||||
? data
|
||||
: "";
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isBlobLike(value: unknown): value is { text: () => Promise<string> } {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as { text?: unknown }).text === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export async function reportToolFailure({
|
||||
operation,
|
||||
error,
|
||||
fileIds = [],
|
||||
}: ToolFailureReport): Promise<void> {
|
||||
if (!operation || operation.trim() === "") return;
|
||||
if (wasCancelled(error)) return;
|
||||
|
||||
try {
|
||||
await apiClient.post(
|
||||
REPORT_PATH,
|
||||
{
|
||||
operation,
|
||||
errorCode: await errorCodeOf(error),
|
||||
fileIds,
|
||||
detail: messageOf(error),
|
||||
},
|
||||
// The reporter's own failure must not reach the user: they already have the
|
||||
// tool's error on screen, and a second toast about the report would be noise
|
||||
// about something they never asked for.
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
} catch (reportError) {
|
||||
// Still never rethrown: a core build has no such route, and a member's report can
|
||||
// also be refused. But a report the server rejected as invalid is logged rather
|
||||
// than lost, because nothing else would ever surface it.
|
||||
warnIfRejected(operation, reportError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A report the server refused as malformed, which means this client built a bad one: worth a
|
||||
* line in the console for whoever wrote it, since the call is fire-and-forget and its toast is
|
||||
* suppressed. An absent route (404, a core build) or a session not allowed to report are
|
||||
* expected, and stay quiet so an ordinary build does not log on every tool failure.
|
||||
*/
|
||||
function warnIfRejected(operation: string, error: unknown): void {
|
||||
const response = (error as { response?: { status?: number; data?: unknown } })
|
||||
?.response;
|
||||
if (response?.status !== 400) return;
|
||||
|
||||
console.warn(
|
||||
`Failure report for "${operation}" was rejected by the server: ${reasonOf(response.data)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whatever the server said, out of a Problem Details body. */
|
||||
function reasonOf(data: unknown): string {
|
||||
const body = data as { detail?: unknown; message?: unknown };
|
||||
const stated =
|
||||
typeof body?.detail === "string"
|
||||
? body.detail
|
||||
: typeof body?.message === "string"
|
||||
? body.message
|
||||
: "";
|
||||
return stated.trim() === "" ? "no reason given" : stated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the server a user deleted these files, so any failure recorded against them stops asking
|
||||
* for attention. The rows stay for audit; they just leave the queue.
|
||||
*
|
||||
* <p>Best-effort like the reporter: a build without the failure registry has no such route, and
|
||||
* deleting a file must not fail because the server could not be told.
|
||||
*/
|
||||
export async function reportFilesRemoved(fileIds: string[]): Promise<void> {
|
||||
const named = fileIds.filter(
|
||||
(id) => typeof id === "string" && id.trim() !== "",
|
||||
);
|
||||
if (named.length === 0) return;
|
||||
|
||||
try {
|
||||
// Toast suppressed for the same reason as a report: the user deleted a file and is not
|
||||
// waiting to hear whether the server was told.
|
||||
await apiClient.post(
|
||||
REMOVED_FILES_PATH,
|
||||
{ fileIds: named },
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
} catch {
|
||||
// The file is gone locally either way. A row left open is retention's problem.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The message the user saw, sent as-is. It is their own error about their own file, so hiding
|
||||
* parts of it would only make the row harder to act on.
|
||||
*/
|
||||
function messageOf(error: unknown): string {
|
||||
const candidate = error as { message?: unknown };
|
||||
return typeof candidate?.message === "string" ? candidate.message : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* A user cancelling is the one failure worth dropping: nothing went wrong and there
|
||||
* is nothing for a reviewer to do. `useToolApiCalls` rethrows an axios cancellation
|
||||
* as a plain Error with the original as its cause, so both shapes are checked.
|
||||
*
|
||||
* <p>Everything else is reported, client-side refusals included: an unsupported input
|
||||
* format is the same class of problem as the processor rejecting a file type, which
|
||||
* is already recorded.
|
||||
*/
|
||||
function wasCancelled(error: unknown): boolean {
|
||||
const candidate = error as {
|
||||
code?: unknown;
|
||||
name?: unknown;
|
||||
message?: unknown;
|
||||
cause?: { code?: unknown; name?: unknown };
|
||||
};
|
||||
return (
|
||||
candidate?.code === "ERR_CANCELED" ||
|
||||
candidate?.cause?.code === "ERR_CANCELED" ||
|
||||
candidate?.name === "CanceledError" ||
|
||||
candidate?.cause?.name === "CanceledError" ||
|
||||
candidate?.message === "Operation was cancelled"
|
||||
);
|
||||
}
|
||||
@@ -569,8 +569,14 @@ describe("Convert Tool Integration Tests", () => {
|
||||
await result.current.executeOperation(parameters, [testFile]);
|
||||
});
|
||||
|
||||
// Verify integration: utils validation prevents API call, hook shows error
|
||||
expect(mockedApiClient.post).not.toHaveBeenCalled();
|
||||
// Verify integration: utils validation prevents the conversion call, hook shows
|
||||
// error. Failure reporting posts separately and is not a conversion request.
|
||||
const conversionCalls = vi
|
||||
.mocked(mockedApiClient.post)
|
||||
.mock.calls.filter(
|
||||
([url]) => !String(url).includes("/file-run-events/"),
|
||||
);
|
||||
expect(conversionCalls).toHaveLength(0);
|
||||
expect(result.current.errorMessage).toContain(
|
||||
"Unsupported conversion format",
|
||||
);
|
||||
|
||||
@@ -35,7 +35,9 @@ export type FileRunEventStatus =
|
||||
| "NEW"
|
||||
| "ACKNOWLEDGED"
|
||||
| "DISMISSED"
|
||||
| "RESOLVED";
|
||||
| "RESOLVED"
|
||||
/** Its document was deleted from the owner's editor, so there is nothing left to act on. */
|
||||
| "FILE_REMOVED";
|
||||
|
||||
/**
|
||||
* One button as offered for one row. `id` is a plain string rather than a union
|
||||
@@ -64,6 +66,8 @@ export interface FileRunEvent {
|
||||
detail: string | null;
|
||||
policyId: string | null;
|
||||
runId: string | null;
|
||||
/** Which folder, bucket or webhook fed the run. Null when a user supplied the file. */
|
||||
sourceId: string | null;
|
||||
/**
|
||||
* Opaque reference, never a name. Only the owner's own client can resolve it to
|
||||
* something readable, from its local file store.
|
||||
|
||||
@@ -70,6 +70,7 @@ function event(actions: FailureActionOffer[]): FileRunEvent {
|
||||
detail: "boom",
|
||||
policyId: "p1",
|
||||
runId: "r1",
|
||||
sourceId: null,
|
||||
fileId: "f-1",
|
||||
actor: "someone@example.com",
|
||||
occurrences: 1,
|
||||
|
||||
@@ -21,7 +21,14 @@ vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
// Faithful to i18next: a known key resolves, an unknown key falls back to
|
||||
// defaultValue. That is what exercises the server-key-then-generic chain.
|
||||
t: (key: string, options?: { defaultValue?: string } | string) => {
|
||||
// i18next's real signature: t(key, options) or t(key, defaultValue, options).
|
||||
t: (
|
||||
key: string,
|
||||
second?: { defaultValue?: string } | string,
|
||||
third?: Record<string, unknown>,
|
||||
) => {
|
||||
const options = typeof second === "string" ? third : second;
|
||||
const fallback = typeof second === "string" ? second : undefined;
|
||||
const known: Record<string, string> = {
|
||||
"portal.failures.kind.inputPasswordProtected.title":
|
||||
"Password-protected document",
|
||||
@@ -30,11 +37,20 @@ vi.mock("react-i18next", () => ({
|
||||
"portal.failures.occurrences": "occurrences",
|
||||
"portal.failures.runReference": "Run r1",
|
||||
"portal.failures.stage.input": "Input",
|
||||
"portal.failures.origin.tool": "Tool run",
|
||||
"portal.failures.origin.policy": "Policy",
|
||||
};
|
||||
if (key === "portal.failures.fromSource") {
|
||||
return `From source ${(options as { source?: string })?.source ?? ""}`;
|
||||
}
|
||||
if (key === "portal.failures.reportedBy") {
|
||||
return `Hit by ${(options as { actor?: string })?.actor ?? ""}`;
|
||||
}
|
||||
if (known[key]) return known[key];
|
||||
if (typeof options === "string") return options;
|
||||
if (options?.defaultValue) return options.defaultValue;
|
||||
return key;
|
||||
if ((options as { defaultValue?: string })?.defaultValue) {
|
||||
return (options as { defaultValue: string }).defaultValue;
|
||||
}
|
||||
return fallback ?? key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
@@ -61,6 +77,7 @@ function event(overrides: Partial<FileRunEvent> = {}): FileRunEvent {
|
||||
detail: "The PDF Document is passworded",
|
||||
policyId: "p1",
|
||||
runId: "r1",
|
||||
sourceId: null,
|
||||
fileId: "f-1",
|
||||
actor: "dana@example.com",
|
||||
occurrences: 1,
|
||||
@@ -102,6 +119,29 @@ describe("FileRunEventList", () => {
|
||||
expect(screen.getByText("The PDF Document is passworded")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the person whose editor hit it, and marks it a tool run", async () => {
|
||||
// The point of reporting editor failures: a reviewer needs the person, since a
|
||||
// run reference means nothing for a failure that never had a run.
|
||||
fetchFileRunEvents.mockResolvedValue([
|
||||
event({ origin: "TOOL", actor: "dana@example.com", runId: null }),
|
||||
]);
|
||||
|
||||
render(<FileRunEventList />);
|
||||
|
||||
expect(await screen.findByText("Tool run")).toBeTruthy();
|
||||
expect(screen.getByText("Hit by dana@example.com")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the source when no user was involved, since that is the only attribution", async () => {
|
||||
fetchFileRunEvents.mockResolvedValue([
|
||||
event({ origin: "POLICY", actor: null, sourceId: "src-s3-invoices" }),
|
||||
]);
|
||||
|
||||
render(<FileRunEventList />);
|
||||
|
||||
expect(await screen.findByText("From source src-s3-invoices")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the occurrence count only once a failure has repeated", async () => {
|
||||
fetchFileRunEvents.mockResolvedValue([event({ occurrences: 1 })]);
|
||||
const { unmount } = render(<FileRunEventList />);
|
||||
|
||||
@@ -29,6 +29,7 @@ export function FileRunEventList() {
|
||||
const { apply, refresh } = useFileRunEventActions();
|
||||
const [busy, setBusy] = useState<{ id: string; action: string } | null>(null);
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
// A build without the proprietary module has no such route, and a caller who is
|
||||
// not a team leader gets a 403. Both mean there is nothing to show.
|
||||
@@ -43,6 +44,26 @@ export function FileRunEventList() {
|
||||
}
|
||||
};
|
||||
|
||||
// Empties the queue so a test run starts from nothing. Sequential rather than
|
||||
// concurrent: dismissing is cheap, and one request at a time keeps the failure
|
||||
// obvious if the endpoint refuses one of them.
|
||||
const dismissAll = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
for (const event of events ?? []) {
|
||||
const dismiss = event.actions.find(
|
||||
(action) => action.id === "DISMISS" && action.enabled,
|
||||
);
|
||||
if (dismiss) {
|
||||
await apply(event.id, "DISMISS");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setClearing(false);
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
||||
// Dev-only inspector for hand-checking classification against real uploads.
|
||||
// Vite folds `import.meta.env.DEV` to false, so builds drop this entirely.
|
||||
const debugPanel = !import.meta.env.DEV ? null : (
|
||||
@@ -50,6 +71,14 @@ export function FileRunEventList() {
|
||||
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
|
||||
Refresh failures
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={clearing || (events?.length ?? 0) === 0}
|
||||
onClick={() => void dismissAll()}
|
||||
>
|
||||
{clearing ? "Dismissing..." : `Dismiss all (${events?.length ?? 0})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -158,8 +187,32 @@ function FailureBody({
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span className="portal-failures__origin">
|
||||
{t(
|
||||
`portal.failures.origin.${event.origin.toLowerCase()}`,
|
||||
event.origin,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Who or what it came from. An unattended file has no user, so the source
|
||||
is the only attribution there is. */}
|
||||
{event.actor ? (
|
||||
<div className="portal-failures__actor">
|
||||
{t("portal.failures.reportedBy", "Hit by {{actor}}", {
|
||||
actor: event.actor,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
event.sourceId && (
|
||||
<div className="portal-failures__actor">
|
||||
{t("portal.failures.fromSource", "From source {{source}}", {
|
||||
source: event.sourceId,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* A reference, not a name. The record deliberately holds no document
|
||||
identity, so a reviewer sees which run failed, never which file. */}
|
||||
{event.runId && (
|
||||
|
||||
@@ -65,6 +65,19 @@
|
||||
|
||||
/* The raw failure message. Monospace because for an unclassified failure this is
|
||||
a stack-trace-ish diagnostic, not prose. */
|
||||
.portal-failures__origin {
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-text-subtle);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
padding: 0 0.35rem;
|
||||
}
|
||||
|
||||
.portal-failures__actor {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.portal-failures__detail {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.76rem;
|
||||
|
||||
@@ -46,6 +46,7 @@ export const FILE_RUN_EVENTS: FileRunEvent[] = [
|
||||
detail: "The PDF Document is passworded and the password was not provided",
|
||||
policyId: "policy-contract-redaction",
|
||||
runId: "run-8841",
|
||||
sourceId: null,
|
||||
fileId: "f-8841a",
|
||||
actor: "dana@example.com",
|
||||
occurrences: 1,
|
||||
@@ -74,15 +75,70 @@ export const FILE_RUN_EVENTS: FileRunEvent[] = [
|
||||
"Policy run failed: Tool returned HTTP 500 INTERNAL_SERVER_ERROR for /api/v1/misc/ocr-pdf",
|
||||
policyId: "policy-invoice-ocr",
|
||||
runId: "run-8839",
|
||||
sourceId: "src-s3-invoices",
|
||||
fileId: "f-8839b",
|
||||
actor: "sam@example.com",
|
||||
// Unattended: arrived from a bucket, so there is no user to name.
|
||||
actor: null,
|
||||
occurrences: 12,
|
||||
status: "ACKNOWLEDGED",
|
||||
statusActor: "ops@example.com",
|
||||
actions: [acknowledgeOffer(), dismissOffer()],
|
||||
status: "NEW",
|
||||
statusActor: null,
|
||||
// Nothing to fix, so the only decision is whether to clear it.
|
||||
actions: [dismissOffer()],
|
||||
createdAt: NOW - 6 * HOUR,
|
||||
lastSeenAt: NOW - 2 * HOUR,
|
||||
},
|
||||
{
|
||||
id: "fre-editor-1",
|
||||
kindId: "UNKNOWN",
|
||||
stage: "INTERNAL",
|
||||
severity: "ERROR",
|
||||
scope: "FILE",
|
||||
origin: "TOOL",
|
||||
remedy: "PERMANENT",
|
||||
titleKey: "portal.failures.kind.unknown.title",
|
||||
descriptionKey: "portal.failures.kind.unknown.description",
|
||||
defaultTitle: "Unrecognised failure",
|
||||
// Reported by the user's own client, so there is no run to reference.
|
||||
detail: "compress: Request failed with status code 500",
|
||||
policyId: null,
|
||||
runId: null,
|
||||
sourceId: null,
|
||||
fileId: "f-editor-77",
|
||||
actor: "priya@example.com",
|
||||
occurrences: 1,
|
||||
status: "NEW",
|
||||
statusActor: null,
|
||||
actions: [dismissOffer()],
|
||||
createdAt: NOW - 3 * HOUR,
|
||||
lastSeenAt: NOW - 3 * HOUR,
|
||||
},
|
||||
{
|
||||
id: "fre-editor-2",
|
||||
kindId: "INPUT_PASSWORD_PROTECTED",
|
||||
stage: "INPUT",
|
||||
severity: "ERROR",
|
||||
scope: "FILE",
|
||||
origin: "TOOL",
|
||||
remedy: "NEEDS_USER_INPUT",
|
||||
titleKey: "portal.failures.kind.inputPasswordProtected.title",
|
||||
descriptionKey: "portal.failures.kind.inputPasswordProtected.description",
|
||||
defaultTitle: "Password-protected document",
|
||||
detail: "remove-password: The PDF Document is passworded",
|
||||
policyId: null,
|
||||
runId: null,
|
||||
sourceId: null,
|
||||
fileId: "f-editor-91",
|
||||
// A colleague's own upload: the reviewer sees it, but unlocking is not theirs to do.
|
||||
actor: "sam@example.com",
|
||||
occurrences: 1,
|
||||
status: "NEW",
|
||||
statusActor: null,
|
||||
// A colleague's own upload. Nothing here acts on the document, so triage is just
|
||||
// acknowledging or clearing the row.
|
||||
actions: [dismissOffer(true, "portal.failures.action.dismissSkipFile")],
|
||||
createdAt: NOW - 4 * HOUR,
|
||||
lastSeenAt: NOW - 4 * HOUR,
|
||||
},
|
||||
{
|
||||
id: "fre-3",
|
||||
kindId: "INPUT_PASSWORD_PROTECTED",
|
||||
@@ -97,6 +153,7 @@ export const FILE_RUN_EVENTS: FileRunEvent[] = [
|
||||
detail: "The PDF Document is passworded and the password was not provided",
|
||||
policyId: "policy-contract-redaction",
|
||||
runId: "run-8790",
|
||||
sourceId: null,
|
||||
fileId: "f-8790c",
|
||||
actor: "dana@example.com",
|
||||
// Closed rows keep their actions, disabled with a reason, so the reviewer
|
||||
|
||||
@@ -26,9 +26,13 @@ export const fileRunEventsHandlers = [
|
||||
const status = url.searchParams.get("status");
|
||||
const kindId = url.searchParams.get("kindId");
|
||||
|
||||
// Mirrors the server: no status asked for means the open queue, so a dismissed
|
||||
// row leaves the list instead of sitting there with its buttons greyed out.
|
||||
const filtered = events.filter(
|
||||
(event) =>
|
||||
(!status || event.status === status) &&
|
||||
(status
|
||||
? event.status === status
|
||||
: event.status !== "DISMISSED" && event.status !== "RESOLVED") &&
|
||||
(!kindId || event.kindId === kindId),
|
||||
);
|
||||
return HttpResponse.json({
|
||||
|
||||
Reference in New Issue
Block a user