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:
EthanHealy01
2026-08-14 13:24:41 +00:00
committed by GitHub
parent 6f2b829f72
commit 2483e9f37a
42 changed files with 1814 additions and 136 deletions
@@ -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({