diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionSlot.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionSlot.java new file mode 100644 index 0000000000..a604a239d0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionSlot.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.failure; + +/** + * Where a kind would like one of its actions to sit: the thing that fixes it, a supporting action, + * or one folded away in a menu. + * + *

Intent, not layout. The client does the final promotion, because only it knows whether the + * document is still in its own file store, and a resolution it cannot run is worth less than a + * secondary action it can. Declaration order in {@link FailureKind} breaks a tie within a slot. + */ +public enum FailureActionSlot { + + /** The action that resolves the failure. At most one per kind is worth declaring here. */ + RESOLUTION, + + /** Offered alongside the resolution, for a caller the resolution is not aimed at. */ + SECONDARY, + + /** Available but folded away: correct, rarely the next thing anyone wants to press. */ + OVERFLOW +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java index 19432d7bd3..39d5212fc0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java @@ -5,6 +5,8 @@ import static stirling.software.proprietary.failure.FailureActionId.RETRY; import static stirling.software.proprietary.failure.FailureActionId.VIEW_FILE; import static stirling.software.proprietary.failure.FailureActionId.VIEW_IN_PROCESSOR; +import static stirling.software.proprietary.failure.FailureActionSlot.OVERFLOW; +import static stirling.software.proprietary.failure.FailureActionSlot.SECONDARY; import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES; import static stirling.software.proprietary.failure.FailureAudience.OWNER; import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER; @@ -31,9 +33,9 @@ * 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. * - *

Each offer also says who it is for, because the same incident is read by the person who hit it - * and by whoever reviews after them: only the owner holds the document, only a reviewer wants the - * run. + *

Each offer also says who it is for and where the kind wants it, because the same incident is + * read by the person who hit it and by whoever reviews after them: only the owner can supply a + * password, only a reviewer wants the run. */ @Getter public enum FailureKind { @@ -46,11 +48,11 @@ public enum FailureKind { fallback("This document is password-protected, so the pipeline could not read it."), // The password is the fix and only the owner has it, so everyone else is // offered the run and a way to close the row. - offer(DECRYPT_AND_RETRY, OWNER), - offer(RETRY, OWNER), - offer(VIEW_FILE, OWNER), - offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER), - offer(DISMISS, ANYONE_WHO_SEES)), + resolution(DECRYPT_AND_RETRY, OWNER), + global(RETRY, OWNER, OVERFLOW), + global(VIEW_FILE, OWNER, OVERFLOW), + global(VIEW_IN_PROCESSOR, TEAM_REVIEWER, SECONDARY), + global(DISMISS, ANYONE_WHO_SEES, OVERFLOW)), UNKNOWN( FailureStage.INTERNAL, @@ -59,12 +61,12 @@ public enum FailureKind { FailureScope.RUN, noErrorCodes(), fallback("This run failed for a reason Stirling does not yet recognise."), - // Nothing here is known to be fixable, but a plain retry is still worth offering: an - // unrecognised failure is often a one-off. - offer(RETRY, OWNER), - offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER), - offer(VIEW_FILE, OWNER), - offer(DISMISS, ANYONE_WHO_SEES)); + // Nothing here is known to be fixable, so there is no resolution to declare. A plain + // retry is still worth offering: an unrecognised failure is often a one-off. + global(RETRY, OWNER, SECONDARY), + global(VIEW_IN_PROCESSOR, TEAM_REVIEWER, SECONDARY), + global(VIEW_FILE, OWNER, OVERFLOW), + global(DISMISS, ANYONE_WHO_SEES, OVERFLOW)); private static final String KEY_PREFIX = "portal.failures.kind."; private static final String ACTION_KEY_PREFIX = "portal.failures.action."; @@ -112,30 +114,52 @@ public enum FailureKind { } /** - * One action this kind offers: who it is for, and the key to label it by. One ordered list - * rather than ids plus parallel maps of audiences and label overrides, which could disagree - * with each other. + * One action this kind offers: who it is for, where it wants to sit, and the key to label it + * by. One ordered list rather than ids plus parallel maps of audiences, slots and label + * overrides, which could disagree with each other. * * @param labelKeySuffix key under {@code portal.failures.action.}, or null for the generic * label */ - private record Offer(FailureActionId id, FailureAudience audience, String labelKeySuffix) {} + private record Offer( + FailureActionId id, + FailureAudience audience, + FailureActionSlot slot, + String labelKeySuffix) {} /** - * An action this kind offers, for whoever can actually take it, labelled by the shared wording. - * Declaration order is display order. + * The action that fixes this kind, for whoever can actually apply it. In the resolution slot by + * definition: a kind needing two of these would be two kinds. */ - private static Offer offer(FailureActionId id, FailureAudience audience) { - return new Offer(id, audience, null); + private static Offer resolution(FailureActionId id, FailureAudience audience) { + return new Offer(id, audience, FailureActionSlot.RESOLUTION, null); + } + + /** As {@link #resolution(FailureActionId, FailureAudience)}, with this kind's own wording. */ + private static Offer resolution( + FailureActionId id, FailureAudience audience, String labelKeySuffix) { + return new Offer(id, audience, FailureActionSlot.RESOLUTION, labelKeySuffix); } /** - * As {@link #offer(FailureActionId, FailureAudience)}, but labelled by this kind's own wording - * where the shared one reads badly. + * An action that is not this kind's fix: the same offer any kind can make, placed where this + * kind wants it and labelled by the shared wording. */ - private static Offer offer( - FailureActionId id, FailureAudience audience, String labelKeySuffix) { - return new Offer(id, audience, labelKeySuffix); + private static Offer global( + FailureActionId id, FailureAudience audience, FailureActionSlot slot) { + return new Offer(id, audience, slot, null); + } + + /** + * As {@link #global(FailureActionId, FailureAudience, FailureActionSlot)}, but labelled by this + * kind's own wording where the shared one reads badly. + */ + private static Offer global( + FailureActionId id, + FailureAudience audience, + FailureActionSlot slot, + String labelKeySuffix) { + return new Offer(id, audience, slot, labelKeySuffix); } /** @@ -175,20 +199,27 @@ public List getActions() { } /** - * What this kind offers, in declaration order, each with its label resolved. What a review - * surface reads, so it never has to ask two separate questions about one offer. + * What this kind offers, in declaration order, each with its label and placement resolved. What + * a review surface reads, so it never has to ask three separate questions about one offer. */ public List getOfferedActions() { return offers.stream() .map( offer -> new OfferedAction( - offer.id(), labelKeyFor(offer.id()), offer.audience())) + offer.id(), + labelKeyFor(offer.id()), + offer.audience(), + offer.slot())) .toList(); } - /** One action as a kind declares it: what to call it and who it is for. */ - public record OfferedAction(FailureActionId id, String labelKey, FailureAudience audience) {} + /** One action as a kind declares it: what to call it, who it is for, where it wants to sit. */ + public record OfferedAction( + FailureActionId id, + String labelKey, + FailureAudience audience, + FailureActionSlot slot) {} /** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */ public boolean declares(FailureActionId action) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java index bd02a6ede1..43470f387f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java @@ -252,7 +252,8 @@ private static AvailableAction availability( boolean unattended, boolean documentless) { String reason = disabledReasonFor(offer.audience(), closed, unattended, documentless); - return new AvailableAction(offer.id(), offer.labelKey(), reason == null, reason); + return new AvailableAction( + offer.id(), offer.labelKey(), offer.slot(), reason == null, reason); } /** @@ -363,7 +364,14 @@ private boolean enforced() { return applicationProperties.getSecurity().isEnableLogin(); } - /** One action as offered to one caller about one event, with its availability resolved. */ + /** + * One action as offered to one caller about one event, with its availability resolved. {@code + * slot} is the kind's placement intent, carried through for the client to make the final call. + */ public record AvailableAction( - FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {} + FailureActionId id, + String labelKey, + FailureActionSlot slot, + boolean enabled, + String disabledReasonKey) {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java index af68233e25..aa7621d83f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java @@ -63,13 +63,15 @@ public static FileRunEventView of( /** * One button, as offered to this caller about this row. {@code defaultLabel} and {@code * execution} are here for the reason {@code defaultTitle} is on the row: a client can then - * render, and route, an action it was never built with. Declaration order is display order. + * render, and route, an action it was never built with. {@code slot} is placement intent; see + * {@link FailureActionSlot}. */ public record ActionView( String id, String labelKey, String defaultLabel, FailureActionId.Execution execution, + FailureActionSlot slot, boolean enabled, String disabledReasonKey) { @@ -80,6 +82,7 @@ public static ActionView of(FileRunEventService.AvailableAction action) { action.labelKey(), action.id().getDefaultLabel(), action.id().getExecution(), + action.slot(), action.enabled(), action.disabledReasonKey()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/CheckConstrainedEnumsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/CheckConstrainedEnumsTest.java index 15fd66cb1e..7ed759f8af 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/CheckConstrainedEnumsTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/CheckConstrainedEnumsTest.java @@ -47,6 +47,7 @@ void nothingAddedToTheModelReachedTheTable() throws Exception { assertThat(persisted) .doesNotContain( FailureAudience.class, + FailureActionSlot.class, FailureActionId.class, FailureActionId.Execution.class, Ownership.class); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java index c87bc90d23..ab167bff20 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java @@ -1,6 +1,9 @@ package stirling.software.proprietary.failure; import static org.assertj.core.api.Assertions.assertThat; +import static stirling.software.proprietary.failure.FailureActionSlot.OVERFLOW; +import static stirling.software.proprietary.failure.FailureActionSlot.RESOLUTION; +import static stirling.software.proprietary.failure.FailureActionSlot.SECONDARY; import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES; import static stirling.software.proprietary.failure.FailureAudience.OWNER; import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER; @@ -37,9 +40,12 @@ class FailureKindTest { * declaration that pairs the right action with the wrong audience cannot pass. */ private static FailureKind.OfferedAction offered( - FailureActionId id, FailureAudience audience, String labelKeySuffix) { + FailureActionId id, + FailureAudience audience, + FailureActionSlot slot, + String labelKeySuffix) { return new FailureKind.OfferedAction( - id, "portal.failures.action." + labelKeySuffix, audience); + id, "portal.failures.action." + labelKeySuffix, audience, slot); } @Nested @@ -103,13 +109,14 @@ void everyDeclaredActionResolvesToALabelKey(FailureKind kind) { @ParameterizedTest @EnumSource(FailureKind.class) - void everyOfferSaysWhoItIsFor(FailureKind kind) { - // Read per row to decide what a caller is shown, so a missing one would be a button - // offered to whoever the null case happened to let through. + void everyOfferSaysWhoItIsForAndWhereItGoes(FailureKind kind) { + // Both are read per row to decide what a caller is shown, so a missing one would be a + // button placed by whatever the null case happened to do. for (FailureKind.OfferedAction offer : kind.getOfferedActions()) { assertThat(offer.audience()) .as("%s offers %s", kind.getId(), offer.id()) .isNotNull(); + assertThat(offer.slot()).as("%s offers %s", kind.getId(), offer.id()).isNotNull(); } } @@ -121,6 +128,17 @@ void offersEachActionAtMostOnce(FailureKind kind) { assertThat(kind.getActions()).doesNotHaveDuplicates(); } + @ParameterizedTest + @EnumSource(FailureKind.class) + void declaresAtMostOneResolution(FailureKind kind) { + // Two things that both claim to fix it is a sign of two kinds wearing one id. + assertThat( + kind.getOfferedActions().stream() + .filter(offer -> offer.slot() == FailureActionSlot.RESOLUTION) + .toList()) + .hasSizeLessThanOrEqualTo(1); + } + @Test void noTwoKindsClaimTheSameErrorCode() { // Computed independently of duplicateErrorCodes(), then checked against it: the boot @@ -220,13 +238,14 @@ void offersARetryToItsOwnerAndTheRunToWhoeverReviews() { // worth offering the person who hit it: an unrecognised failure is often a one-off. assertThat(FailureKind.UNKNOWN.getOfferedActions()) .containsExactly( - offered(FailureActionId.RETRY, OWNER, "retry"), + offered(FailureActionId.RETRY, OWNER, SECONDARY, "retry"), offered( FailureActionId.VIEW_IN_PROCESSOR, TEAM_REVIEWER, + SECONDARY, "viewInProcessor"), - offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"), - offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss")); + offered(FailureActionId.VIEW_FILE, OWNER, OVERFLOW, "viewFile"), + offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, OVERFLOW, "dismiss")); } @Test @@ -284,14 +303,19 @@ void aKindWithSomethingToFixOffersTheFixToItsOwnerAndTheRunToItsReviewer() { // so a reviewer is offered the run and a way to close the row instead. assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getOfferedActions()) .containsExactly( - offered(FailureActionId.DECRYPT_AND_RETRY, OWNER, "decryptAndRetry"), - offered(FailureActionId.RETRY, OWNER, "retry"), - offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"), + offered( + FailureActionId.DECRYPT_AND_RETRY, + OWNER, + RESOLUTION, + "decryptAndRetry"), + offered(FailureActionId.RETRY, OWNER, OVERFLOW, "retry"), + offered(FailureActionId.VIEW_FILE, OWNER, OVERFLOW, "viewFile"), offered( FailureActionId.VIEW_IN_PROCESSOR, TEAM_REVIEWER, + SECONDARY, "viewInProcessor"), - offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss")); + offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, OVERFLOW, "dismiss")); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java index 9acce8cd24..c01b81e2eb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java @@ -153,6 +153,7 @@ void carriesEnoughForAClientToRenderAndRouteAnActionItDoesNotKnow() { action -> { assertThat(action.defaultLabel()).isNotBlank(); assertThat(action.execution()).isNotNull(); + assertThat(action.slot()).isNotNull(); }) .filteredOn(action -> "VIEW_IN_PROCESSOR".equals(action.id())) .singleElement() @@ -160,6 +161,7 @@ void carriesEnoughForAClientToRenderAndRouteAnActionItDoesNotKnow() { action -> { assertThat(action.execution()) .isEqualTo(FailureActionId.Execution.CLIENT); + assertThat(action.slot()).isEqualTo(FailureActionSlot.SECONDARY); assertThat(action.defaultLabel()).isEqualTo("View in processor"); }); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java index 8df0eed0e1..4d5b24c300 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java @@ -130,6 +130,7 @@ void serialisesAnEventWithItsFacetsCopyKeysAndResolvedActions() throws Exception assertThat(actions.get(0).get("defaultLabel").asString()) .isEqualTo("View in processor"); assertThat(actions.get(0).get("execution").asString()).isEqualTo("CLIENT"); + assertThat(actions.get(0).get("slot").asString()).isEqualTo("SECONDARY"); assertThat(actions.get(0).get("enabled").asBoolean()).isTrue(); assertThat(actions.get(0).get("disabledReasonKey").isNull()).isTrue(); assertThat(actions.get(1).get("id").asString()).isEqualTo("DISMISS"); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java index 70a1d80a23..c75cec003a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java @@ -585,6 +585,17 @@ void closedRowOffersThemDisabledWithAReasonRatherThanHidingThem() { .equals(action.disabledReasonKey())); } + @Test + void carriesTheKindsPlacementIntentForEachOffer() { + FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + + assertThat(service.availableActions(mine)) + .filteredOn(action -> action.id() == FailureActionId.DECRYPT_AND_RETRY) + .singleElement() + .extracting(FileRunEventService.AvailableAction::slot) + .isEqualTo(FailureActionSlot.RESOLUTION); + } + @Test void carriesTheLabelKeyForEachOffer() { FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/NotificationActionDispatchTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/NotificationActionDispatchTest.java index c9e65ecd0b..ae811e191a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/NotificationActionDispatchTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/NotificationActionDispatchTest.java @@ -311,6 +311,7 @@ void carriesWhatAClientNeedsToRenderAnActionItDoesNotKnow() { assertThat(action.labelKey()).startsWith("portal.failures.action."); assertThat(action.defaultLabel()).isNotBlank(); assertThat(action.execution()).isNotNull(); + assertThat(action.slot()).isNotNull(); }); } } diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 967f9d0b16..d7a862dfb3 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5051,13 +5051,19 @@ title = "PDF Multi Tool" search = "Search" [notifications] +adoptFailed = "The document was unlocked but could not be opened here. Try the tool directly." empty = "Nothing to report." handoffUnavailable = "This browser will not let the processor pass the document to the editor. Open it from the editor instead." noDocumentLinked = "This failure is not linked to a specific document, so it cannot be opened or retried here." notOnThisDevice = "This document is not on this device, so it cannot be opened or retried here." occurrences = "{{count}} times" open = "Notifications" +rerunRejected = "The policy could not be run again just now. Try again in a moment." +rerunUndelivered = "The policy re-run started, but its result cannot be delivered here, so this failure stays open." +retryUnavailable = "This document can no longer be retried from this browser." title = "Notifications" +unlockedNotRerun = "The document was unlocked and opened here, but the policy could not be run on it again." +unlockedRerunUndelivered = "The document was unlocked and the policy re-run started, but its result cannot be delivered here, so this failure stays open." unread = "Unread" [notifications.action] @@ -5070,6 +5076,11 @@ copy = "Copy error" less = "Show less" more = "Show full message" +[notifications.password] +cancel = "Cancel" +label = "Document password" +working = "Unlocking..." + [oauth.error] message = "Authentication was not successful. You can close this window and try again." title = "Authentication Failed" diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.css b/frontend/editor/src/core/components/notifications/NotificationBell.css index 9352f6bef8..0d6a1b5855 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.css +++ b/frontend/editor/src/core/components/notifications/NotificationBell.css @@ -162,6 +162,14 @@ color: var(--c-text-subtle); } +.notification-bell__password { + grid-column: 2; + display: flex; + align-items: center; + gap: var(--sp-1, 0.25rem); + margin-top: var(--sp-2, 0.5rem); +} + .notification-bell__message { grid-column: 2; margin-top: var(--sp-1, 0.25rem); diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx index 9dd8b5528c..d16244a68e 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx +++ b/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx @@ -19,7 +19,8 @@ const render = (ui: Parameters[0]) => /** * The bell renders whatever the server sends, and does with each row's actions only what the registry * for this build says it can. Two things are its own and worth pinning: which notifications the user has - * already looked at, and how a row behaves around an action (message on failure, re-read on success). + * already looked at, and how a row behaves around an action (password first, message on failure, re-read + * on success). */ const fetchNotifications = vi.fn(); @@ -34,11 +35,13 @@ vi.mock("@app/services/notifications", () => ({ // availability is a fact of the test rather than of the environment. const h = vi.hoisted(() => ({ hasLocalFile: true, + retryPayload: { operation: "removePassword" } as unknown, specs: {} as Record< string, { available: (context: unknown) => boolean; run: (context: unknown, password?: string) => unknown; + needsPassword?: boolean; closesPanel?: boolean; } >, @@ -46,6 +49,7 @@ const h = vi.hoisted(() => ({ vi.mock("@app/services/notificationRetry", () => ({ hasLocalFile: () => Promise.resolve(h.hasLocalFile), + loadRetryPayload: () => Promise.resolve(h.retryPayload), })); // Stands in for the layer that owns the destinations. Core's own registry is empty, so without @@ -130,6 +134,7 @@ describe("NotificationBell", () => { fetchNotifications.mockReset().mockResolvedValue([]); runNotificationAction.mockReset().mockResolvedValue(true); h.hasLocalFile = true; + h.retryPayload = { operation: "removePassword" }; h.specs = {}; }); @@ -427,32 +432,80 @@ describe("NotificationBell", () => { expect(document.querySelector(".notification-bell__actions")).toBeNull(); }); - it("shows a failed action in the row instead of leaving the user guessing", async () => { + it("asks for the password in the row before it retries", async () => { + const run = vi.fn().mockResolvedValue({ ok: true }); h.specs = { - VIEW_FILE: { + DECRYPT_AND_RETRY: { available: () => true, - run: () => Promise.resolve({ ok: false, message: "Could not open" }), + run, + needsPassword: true, + closesPanel: true, }, }; fetchNotifications.mockResolvedValue([ notification("a", "Password-protected document", { - actions: [offer("VIEW_FILE", "RESOLUTION")], + actions: [offer("DECRYPT_AND_RETRY", "RESOLUTION")], }), ]); render(); await openPanel(); + // First click reveals the field rather than running anything. fireEvent.click( screen.getByRole("button", { - name: "VIEW_FILE: Password-protected document", + name: "DECRYPT_AND_RETRY: Password-protected document", }), ); + expect(run).not.toHaveBeenCalled(); + + const field = screen.getByLabelText( + "Document password: Password-protected document", + ); + fireEvent.change(field, { target: { value: "hunter2" } }); + fireEvent.submit(field.closest("form") as HTMLFormElement); + + await waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + expect(run.mock.calls[0][1]).toBe("hunter2"); + // The server resolved the incident, so the list is re-read rather than patched here, and the + // panel gets out of the way of the document it just produced. + await waitFor(() => expect(fetchNotifications).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(screen.queryByText("Password-protected document")).toBeNull(), + ); + }); + + it("shows a failed unlock in the row instead of leaving the user guessing", async () => { + h.specs = { + DECRYPT_AND_RETRY: { + available: () => true, + run: () => Promise.resolve({ ok: false, message: "Wrong password" }), + needsPassword: true, + }, + }; + fetchNotifications.mockResolvedValue([ + notification("a", "Password-protected document", { + actions: [offer("DECRYPT_AND_RETRY", "RESOLUTION")], + }), + ]); + render(); + await openPanel(); + + fireEvent.click( + screen.getByRole("button", { + name: "DECRYPT_AND_RETRY: Password-protected document", + }), + ); + const field = screen.getByLabelText( + "Document password: Password-protected document", + ); + fireEvent.change(field, { target: { value: "nope" } }); + fireEvent.submit(field.closest("form") as HTMLFormElement); expect(await screen.findByRole("alert")).toHaveProperty( "textContent", - "Could not open", + "Wrong password", ); - // Still on screen, so the row remains actionable. + // Still on screen, so the user can try another password. expect(screen.getByText("Password-protected document")).toBeTruthy(); }); diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.tsx index d6f6ad6936..f31cdda46a 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.tsx +++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; +import { Button, Input } from "@app/ui"; import { BellIcon } from "@app/components/notifications/BellIcon"; import { isResolvableHere, @@ -12,6 +12,7 @@ import { type ClientActionRegistry, type NotificationActionContext, } from "@app/components/notifications/notificationActions"; +import { promoteActions } from "@app/components/notifications/notificationActionSlots"; import { runNotificationAction, type AppNotification, @@ -191,8 +192,8 @@ interface NotificationItemProps { } /** - * One row. Its own component because the message its last attempt came back with and whether that - * message is expanded are per-row state the panel cannot hold. + * One row. Its own component because the password it is collecting, the message its last attempt came + * back with and whether that message is expanded are all per-row state the panel cannot hold. */ function NotificationItem({ notification, @@ -203,6 +204,10 @@ function NotificationItem({ onChanged, }: NotificationItemProps) { const { t } = useTranslation(); + // Held only while the field is open, and dropped as soon as the row is done with it. Never stashed, + // never logged. + const [password, setPassword] = useState(""); + const [askingFor, setAskingFor] = useState(null); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(null); const [expanded, setExpanded] = useState(false); @@ -212,28 +217,19 @@ function NotificationItem({ const context: NotificationActionContext = { notification, hasLocalFile: documentState.hasLocalFile, + retryPayload: documentState.retryPayload, }; - // What this device can actually do, in the order the kind declared. An id this build has never - // heard of is skipped rather than rendered unwired: the server ships new kinds, and new actions, - // ahead of the clients that understand them. - const usable = notification.actions.filter((offer) => { - if (!offer.enabled) return false; - const spec = registry[offer.id]; - if (offer.execution === "SERVER") return true; - return spec ? spec.available(context) : false; - }); - - // Why the row is thin, when the server withheld something and said so. Only from an action this - // build would otherwise have rendered, so a reason about an action it cannot perform anyway is not - // presented as the row's explanation. - const withheldReasonKey = - notification.actions.find( - (offer) => - !offer.enabled && - offer.disabledReasonKey !== null && - (offer.execution === "SERVER" || registry[offer.id] !== undefined), - )?.disabledReasonKey ?? null; + const { primary, secondary, overflow, withheldReasonKey } = promoteActions( + notification.actions, + (offer) => { + const spec = registry[offer.id]; + // An id this build has never heard of: skipped, not rendered unwired. The server ships new kinds + // and their actions ahead of the clients that understand them. + if (!spec) return false; + return spec.available(context); + }, + ); const labelOf = (offer: NotificationActionOffer) => t(offer.labelKey, offer.defaultLabel); @@ -260,9 +256,18 @@ function NotificationItem({ const spec = registry[offer.id]; if (!spec) return; + // First click on a password action opens the field, the second one runs it. An empty field means + // the user clicked the button again rather than filling it in, so there is nothing to send yet. + if (spec.needsPassword && (askingFor !== offer.id || password === "")) { + setAskingFor(offer.id); + return; + } setBusy(offer.id); - const outcome = await spec.run(context); + const outcome = await spec.run( + context, + spec.needsPassword ? password : undefined, + ); setBusy(null); if (outcome && !outcome.ok) { setMessage( @@ -275,6 +280,10 @@ function NotificationItem({ return; } + setPassword(""); + setAskingFor(null); + // A password action resolves the incident server-side, so the row is expected to drop out. + if (spec.needsPassword) onChanged(); if (spec.closesPanel) onDismissPanel(); }; @@ -288,6 +297,10 @@ function NotificationItem({ } }; + const asking = askingFor + ? (notification.actions.find((offer) => offer.id === askingFor) ?? null) + : null; + const note = noteFor(notification, documentState, withheldReasonKey, t); return ( @@ -357,15 +370,30 @@ function NotificationItem({ {/* Actions were taken away from this row, so say why rather than leaving a bare row. */} {note && {note}} - {/* Every action the row has, in the kind's declared order, the first leading. Three is the most - any kind offers once the unusable ones are dropped, so hiding the tail behind a menu would - cost more than it saves. */} - {usable.length > 0 && ( + {/* Every action the row has, in the server's order. Three is the most any kind offers once the + unusable ones are dropped, so hiding the tail behind a menu would cost more than it saves. */} + {primary && ( - {usable.map((offer, index) => ( + void run(primary)} + /> + {secondary && ( + void run(secondary)} + /> + )} + {overflow.map((offer) => ( )} + {asking && ( +

{ + event.preventDefault(); + void run(asking); + }} + > + setPassword(event.target.value)} + /> + + +
+ )} + {message && ( {message} @@ -386,7 +459,7 @@ function NotificationItem({ interface ActionButtonProps { /** Solid for the row's answer, outlined for its runner-up, ghost for the rest. */ - variant: "primary" | "secondary"; + variant: "primary" | "secondary" | "tertiary"; rowTitle: string; label: string; busy: boolean; diff --git a/frontend/editor/src/core/components/notifications/notificationActionSlots.test.ts b/frontend/editor/src/core/components/notifications/notificationActionSlots.test.ts new file mode 100644 index 0000000000..2f4ad61cd6 --- /dev/null +++ b/frontend/editor/src/core/components/notifications/notificationActionSlots.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "vitest"; +import { promoteActions } from "@app/components/notifications/notificationActionSlots"; +import type { + NotificationActionOffer, + NotificationActionSlot, +} from "@app/services/notifications"; + +/** + * The one rule that decides how loud a notification is allowed to be. Pinned against the shapes the + * server actually sends for the two failure kinds that exist, because the promotions are only + * correct in combination: what is left over depends on what won the buttons. + */ + +/** + * The offers as `FailureKind` declares them. An unrecognised failure has no known fix, so its retry + * is only ever a supporting action; a password failure has one, and its plain retry drops in behind + * the unlock. + */ +const UNKNOWN_OFFERS: Record = { + RETRY: offer("RETRY", "CLIENT", "SECONDARY"), + VIEW_IN_PROCESSOR: offer("VIEW_IN_PROCESSOR", "CLIENT", "SECONDARY"), + VIEW_FILE: offer("VIEW_FILE", "CLIENT", "OVERFLOW"), + DISMISS: offer("DISMISS", "SERVER", "OVERFLOW"), +}; + +const PASSWORD_OFFERS: Record = { + DECRYPT_AND_RETRY: offer("DECRYPT_AND_RETRY", "CLIENT", "RESOLUTION"), + RETRY: offer("RETRY", "CLIENT", "OVERFLOW"), + VIEW_FILE: offer("VIEW_FILE", "CLIENT", "OVERFLOW"), + VIEW_IN_PROCESSOR: offer("VIEW_IN_PROCESSOR", "CLIENT", "SECONDARY"), + DISMISS: offer("DISMISS", "SERVER", "OVERFLOW"), +}; + +/** The reasons the server sends with an action it would refuse. */ +const NO_DOCUMENT = "portal.failures.disabled.noDocument"; +const UNATTENDED = "portal.failures.disabled.unattended"; +const CLOSED = "portal.failures.disabled.closed"; + +function offer( + id: string, + execution: "SERVER" | "CLIENT", + slot: NotificationActionSlot, + overrides: Partial = {}, +): NotificationActionOffer { + return { + id, + labelKey: `portal.failures.action.${id.toLowerCase()}`, + defaultLabel: id, + execution, + slot, + enabled: true, + disabledReasonKey: null, + ...overrides, + }; +} + +function from( + declared: Record, + ids: string[], +): NotificationActionOffer[] { + return ids.map((id) => { + const found = declared[id]; + if (!found) throw new Error(`That kind offers no ${id}`); + return found; + }); +} + +/** What the server sends for an unrecognised failure, for a reader offered these actions. */ +const unknown = (...ids: string[]) => from(UNKNOWN_OFFERS, ids); + +/** The same for a password-protected one. */ +const password = (...ids: string[]) => from(PASSWORD_OFFERS, ids); + +/** The same offers, with the named ones marked as the server would refuse them, and why. */ +function refusing( + offers: NotificationActionOffer[], + reasonKey: string, + ...ids: string[] +): NotificationActionOffer[] { + return offers.map((action) => + ids.includes(action.id) + ? { ...action, enabled: false, disabledReasonKey: reasonKey } + : action, + ); +} + +/** Everything this client can do, as the registry would answer with the file on this device. */ +const RUNNABLE = new Set([ + "RETRY", + "DECRYPT_AND_RETRY", + "VIEW_FILE", + "VIEW_IN_PROCESSOR", +]); + +/** The predicate the bell supplies: a known id, on a device that can act on it. */ +const canRun = (action: NotificationActionOffer) => RUNNABLE.has(action.id); + +function promoted(list: NotificationActionOffer[]) { + const { primary, secondary, overflow, withheldReasonKey } = promoteActions( + list, + canRun, + ); + return { + primary: primary?.id ?? null, + secondary: secondary?.id ?? null, + overflow: overflow.map((action) => action.id), + withheldReasonKey, + }; +} + +describe("promoteActions", () => { + it("gives the owner the retry, and keeps the rest quiet behind it", () => { + // No portal access, so the server never offered the processor link. + expect(promoted(unknown("RETRY", "VIEW_FILE", "DISMISS"))).toEqual({ + primary: "RETRY", + secondary: null, + overflow: ["VIEW_FILE", "DISMISS"], + withheldReasonKey: null, + }); + }); + + it("leads an attended policy failure with the queue, and states what was refused", () => { + // The document is not the reader's to open, so a greyed unlock would be false hope: the row loses + // the buttons and keeps the explanation. + expect( + promoted( + refusing( + unknown("RETRY", "VIEW_IN_PROCESSOR", "VIEW_FILE", "DISMISS"), + NO_DOCUMENT, + "RETRY", + "VIEW_FILE", + ), + ), + ).toEqual({ + primary: "VIEW_IN_PROCESSOR", + secondary: null, + overflow: ["DISMISS"], + withheldReasonKey: NO_DOCUMENT, + }); + }); + + it("leads an unattended failure with the queue, and says retrying is not available", () => { + // Nobody holds the document, so retrying is coming rather than missing. One reason for the row, + // taken from the best thing it lost. + expect( + promoted( + refusing( + unknown("RETRY", "VIEW_IN_PROCESSOR", "VIEW_FILE", "DISMISS"), + UNATTENDED, + "RETRY", + "VIEW_FILE", + ), + ), + ).toEqual({ + primary: "VIEW_IN_PROCESSOR", + secondary: null, + overflow: ["DISMISS"], + withheldReasonKey: UNATTENDED, + }); + }); + + it("explains nothing on a colleague's failure, having taken nothing away", () => { + // Not their document, so nothing that needs the bytes was offered at all. There is no loss to + // account for, and a note would only puzzle the reader. + expect(promoted(unknown("VIEW_IN_PROCESSOR", "DISMISS"))).toEqual({ + primary: "VIEW_IN_PROCESSOR", + secondary: null, + overflow: ["DISMISS"], + withheldReasonKey: null, + }); + }); + + it("leads a password failure with the unlock, not the plain retry", () => { + // Running it again unchanged is a second answer to the same problem, so it drops behind. + expect( + promoted(password("DECRYPT_AND_RETRY", "RETRY", "VIEW_FILE", "DISMISS")), + ).toEqual({ + primary: "DECRYPT_AND_RETRY", + secondary: null, + overflow: ["RETRY", "VIEW_FILE", "DISMISS"], + withheldReasonKey: null, + }); + }); + + it("gives a reviewer their own password failure the unlock plus the queue", () => { + expect( + promoted( + password( + "DECRYPT_AND_RETRY", + "RETRY", + "VIEW_FILE", + "VIEW_IN_PROCESSOR", + "DISMISS", + ), + ), + ).toEqual({ + primary: "DECRYPT_AND_RETRY", + secondary: "VIEW_IN_PROCESSOR", + overflow: ["RETRY", "VIEW_FILE", "DISMISS"], + withheldReasonKey: null, + }); + }); + + it("leaves a closed row no buttons at all, only its reason", () => { + // Already resolved elsewhere: every offer is refused, so the row is its message plus one line + // saying why there is nothing left to do. + expect( + promoted( + refusing( + unknown("RETRY", "VIEW_FILE", "DISMISS"), + CLOSED, + "RETRY", + "VIEW_FILE", + "DISMISS", + ), + ), + ).toEqual({ + primary: null, + secondary: null, + overflow: [], + withheldReasonKey: CLOSED, + }); + }); + + it("promotes past a resolution the shell cannot deliver", () => { + // The owner reading their own password failure from the processor: that shell has no FileContext, + // so the unlock has nowhere to put its output and reports itself unavailable. What is left is + // coherent on its own - the queue becomes the row's button. + const inProcessor = (action: NotificationActionOffer) => + action.id !== "DECRYPT_AND_RETRY" && canRun(action); + + const { primary, secondary, overflow } = promoteActions( + password( + "DECRYPT_AND_RETRY", + "RETRY", + "VIEW_FILE", + "VIEW_IN_PROCESSOR", + "DISMISS", + ), + inProcessor, + ); + + expect(primary?.id).toBe("VIEW_IN_PROCESSOR"); + expect(secondary).toBeNull(); + expect(overflow.map((action) => action.id)).toEqual([ + "RETRY", + "VIEW_FILE", + "DISMISS", + ]); + }); + + it("drops a client action this device cannot perform, without inventing a reason", () => { + // The document is gone from this browser: the retry disappears rather than failing on click, and + // what was behind it moves up. The server withheld nothing, so the row has no server reason and + // the bell falls back to what this device knows. + const { primary, overflow, withheldReasonKey } = promoteActions( + unknown("RETRY", "VIEW_FILE", "DISMISS"), + () => false, + ); + + expect(primary?.id).toBe("DISMISS"); + expect(overflow).toEqual([]); + expect(withheldReasonKey).toBeNull(); + }); + + it("skips an action id it has never heard of without touching the rest", () => { + // The server ships a kind with a new action before this build knows what it means. + const list = [ + offer("QUARANTINE", "CLIENT", "RESOLUTION"), + ...unknown("RETRY", "DISMISS"), + ]; + + expect(promoted(list)).toEqual({ + primary: "RETRY", + secondary: null, + overflow: ["DISMISS"], + withheldReasonKey: null, + }); + }); + + it("keeps a server action even when the client recognises nothing", () => { + // Dismissing needs no client: the server acts on its own record. + const { primary } = promoteActions(unknown("DISMISS"), () => false); + + expect(primary?.id).toBe("DISMISS"); + }); + + it("has nothing to promote when nothing survives", () => { + expect(promoteActions([], () => true)).toEqual({ + primary: null, + secondary: null, + overflow: [], + withheldReasonKey: null, + }); + }); + + it("takes the reason from the best action lost, not the first declared", () => { + // Two refusals, one row: the reader gets the one attached to the action they would have reached + // for first. + const list = [ + offer("VIEW_FILE", "CLIENT", "OVERFLOW", { + enabled: false, + disabledReasonKey: CLOSED, + }), + offer("DECRYPT_AND_RETRY", "CLIENT", "RESOLUTION", { + enabled: false, + disabledReasonKey: NO_DOCUMENT, + }), + ...password("VIEW_IN_PROCESSOR"), + ]; + + expect(promoted(list).withheldReasonKey).toBe(NO_DOCUMENT); + }); +}); diff --git a/frontend/editor/src/core/components/notifications/notificationActionSlots.ts b/frontend/editor/src/core/components/notifications/notificationActionSlots.ts new file mode 100644 index 0000000000..e79929fdbf --- /dev/null +++ b/frontend/editor/src/core/components/notifications/notificationActionSlots.ts @@ -0,0 +1,80 @@ +import type { + NotificationActionOffer, + NotificationActionSlot, +} from "@app/services/notifications"; + +/** + * Where each of a row's actions ends up on screen. The server says what an action does and how much of + * the row it has earned; this turns that into an order and a prominence. A pure function because it is + * the one piece of the bell that decides prominence, and every failure kind goes through it. + */ + +const SLOT_RANK: Record = { + RESOLUTION: 0, + SECONDARY: 1, + OVERFLOW: 2, +}; + +export interface PromotedActions { + /** The row's own button. Null when nothing survived the filter. */ + primary: NotificationActionOffer | null; + /** A second button, only ever an action the server marked SECONDARY. */ + secondary: NotificationActionOffer | null; + /** Everything else, in the server's order, for the row to render quietly after those two. */ + overflow: NotificationActionOffer[]; + /** + * The reason the server gave for the best action it withheld, for the row to state once. Null when + * it withheld nothing, or gave no reason. + */ + withheldReasonKey: string | null; +} + +/** + * Promote a row's offers into one primary button, at most one secondary button, and the quiet rest. + * + * `canRenderClientAction` is asked about CLIENT actions only: whether this build knows the id, and + * whether this device can currently perform it. A SERVER action needs no such check. + * + * A dropped action leaves no hole, and a disabled one is dropped too: a button that can never work is + * false hope. Its reason comes back instead, for the row to say in words. + */ +export function promoteActions( + offers: readonly NotificationActionOffer[], + canRenderClientAction: (offer: NotificationActionOffer) => boolean, +): PromotedActions { + const ranked = offers + .map((offer, declaredAt) => ({ offer, declaredAt })) + // Slot first, then declaration order, so two actions in one slot keep the server's ranking. + .sort( + (a, b) => + SLOT_RANK[a.offer.slot] - SLOT_RANK[b.offer.slot] || + a.declaredAt - b.declaredAt, + ) + .map(({ offer }) => offer); + + // The best one withheld, so a row explains itself once rather than once per lost action. + const withheldReasonKey = + ranked.find((offer) => !offer.enabled && offer.disabledReasonKey) + ?.disabledReasonKey ?? null; + + const renderable = ranked.filter( + (offer) => + offer.enabled && + (offer.execution === "SERVER" || canRenderClientAction(offer)), + ); + + const [primary, next, ...rest] = renderable; + if (!primary) + return { primary: null, secondary: null, overflow: [], withheldReasonKey }; + + // Only if the server ranked it SECONDARY: a second RESOLUTION would read as two answers to the same + // problem, and an OVERFLOW one was ranked below the row's own buttons by the server itself. + const secondary = next?.slot === "SECONDARY" ? next : null; + + return { + primary, + secondary, + overflow: secondary ? rest : next ? [next, ...rest] : rest, + withheldReasonKey, + }; +} diff --git a/frontend/editor/src/core/components/notifications/notificationActions.ts b/frontend/editor/src/core/components/notifications/notificationActions.ts index 3d19be0e26..0a3497a081 100644 --- a/frontend/editor/src/core/components/notifications/notificationActions.ts +++ b/frontend/editor/src/core/components/notifications/notificationActions.ts @@ -1,4 +1,5 @@ import type { AppNotification } from "@app/services/notifications"; +import type { RetryPayload } from "@app/services/notificationRetry"; /** * What this client can do about a notification, keyed by the action id the server offered. Keyed by @@ -11,6 +12,8 @@ export interface NotificationActionContext { notification: AppNotification; /** Whether the document is still in this browser, which is what most actions hinge on. */ hasLocalFile: boolean; + /** What the failed operation was, when this browser stashed it. */ + retryPayload: RetryPayload | null; } /** @@ -26,10 +29,13 @@ export interface ClientActionOutcome { export interface ClientActionSpec { /** Whether this device can perform it right now. Asked per row, never during a request. */ available(context: NotificationActionContext): boolean; - /** May answer synchronously. */ + /** `password` is only ever passed for a spec that asked for one. May answer synchronously. */ run( context: NotificationActionContext, + password?: string, ): ClientActionOutcome | void | Promise; + /** Collect a password in the row before running. Never stored, never logged. */ + needsPassword?: boolean; /** Whether the panel should get out of the way, because the destination is behind it. */ closesPanel?: boolean; } diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index 60ae392ca0..395d4967be 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -21,7 +21,11 @@ import { StirlingFileStub, } from "@app/types/fileContext"; import { FILE_EVENTS } from "@app/services/errorUtils"; -import { reportToolFailure } from "@app/services/failureReporting"; +import { + reportToolFailure, + wasCancelled, +} from "@app/services/failureReporting"; +import { stashRetryPayload } from "@app/services/notificationRetry"; import { refreshNotificationsNow } from "@app/hooks/useNotifications"; import { zipFileService } from "@app/services/zipFileService"; import { getFilenameWithoutExtension } from "@app/utils/fileUtils"; @@ -615,6 +619,20 @@ export const useToolOperation = ( fileIds: validFiles.map((file) => file.fileId), }).then(refreshNotificationsNow); + // Keep what a retry would need, since the report itself carries no + // operation and answers 204. Gated on the reporter's own cancellation + // test so the two cannot disagree about what counts as a failure, and on + // there being an endpoint: a custom processor has nothing to re-submit to. + if (!wasCancelled(error) && runtimeEndpoint) { + void stashRetryPayload({ + operation: config.operationType, + endpoint: runtimeEndpoint, + params: params as Record, + fileIds: validFiles.map((file) => file.fileId), + recordedAt: Date.now(), + }); + } + const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); actions.setError(errorMessage); diff --git a/frontend/editor/src/core/hooks/useNotifications.test.ts b/frontend/editor/src/core/hooks/useNotifications.test.ts index 28d437ffa5..3c6dda45b8 100644 --- a/frontend/editor/src/core/hooks/useNotifications.test.ts +++ b/frontend/editor/src/core/hooks/useNotifications.test.ts @@ -18,9 +18,11 @@ vi.mock("@app/services/notifications", () => ({ // The document lookups read IndexedDB, which jsdom has none of. Counted here so that "resolved once // per list, not once per row" is observable. const hasLocalFile = vi.fn((_fileId: string) => Promise.resolve(true)); +const loadRetryPayload = vi.fn((_fileId: string) => Promise.resolve(null)); vi.mock("@app/services/notificationRetry", () => ({ hasLocalFile: (fileId: string) => hasLocalFile(fileId), + loadRetryPayload: (fileId: string) => loadRetryPayload(fileId), })); const { useNotifications } = await import("@app/hooks/useNotifications"); @@ -56,6 +58,7 @@ describe("useNotifications", () => { window.localStorage.clear(); fetchNotifications.mockReset().mockResolvedValue([]); hasLocalFile.mockClear(); + loadRetryPayload.mockClear(); }); it("reads the list once however many bells are mounted", async () => { @@ -82,6 +85,7 @@ describe("useNotifications", () => { await waitFor(() => expect(result.current.notifications).toHaveLength(3)); expect(hasLocalFile).toHaveBeenCalledTimes(2); + expect(loadRetryPayload).toHaveBeenCalledTimes(2); }); it("looks up an attended run's document but never an unattended run's", async () => { diff --git a/frontend/editor/src/core/hooks/useNotifications.ts b/frontend/editor/src/core/hooks/useNotifications.ts index 7df8546757..c0e7e975f6 100644 --- a/frontend/editor/src/core/hooks/useNotifications.ts +++ b/frontend/editor/src/core/hooks/useNotifications.ts @@ -3,7 +3,11 @@ import { fetchNotifications, type AppNotification, } from "@app/services/notifications"; -import { hasLocalFile } from "@app/services/notificationRetry"; +import { + hasLocalFile, + loadRetryPayload, + type RetryPayload, +} from "@app/services/notificationRetry"; /** * The caller's notifications, refreshed on a timer because they arrive from background work rather @@ -50,10 +54,12 @@ function writeLastSeenId(id: string): void { */ export interface NotificationDocumentState { hasLocalFile: boolean; + retryPayload: RetryPayload | null; } const NO_DOCUMENT: NotificationDocumentState = { hasLocalFile: false, + retryPayload: null, }; /** @@ -132,6 +138,7 @@ async function read(forCycle: number): Promise { fileId, { hasLocalFile: await hasLocalFile(fileId), + retryPayload: await loadRetryPayload(fileId), }, ] as const, ), diff --git a/frontend/editor/src/core/services/notificationRetry.test.ts b/frontend/editor/src/core/services/notificationRetry.test.ts index b76ecd6d91..f14dec8ad7 100644 --- a/frontend/editor/src/core/services/notificationRetry.test.ts +++ b/frontend/editor/src/core/services/notificationRetry.test.ts @@ -1,23 +1,194 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "fake-indexeddb/auto"; +import { indexedDBManager } from "@app/services/indexedDBManager"; /** - * Tests for the one thing the bell asks about a failed document here: whether it is - * still in this browser, which is what decides if it can be opened. + * Tests for the notification bell's retry stash. Three properties matter: a + * retry offered by the bell can still find what it needs after a reload, the + * store cannot grow without bound, and a password never lands in it. */ const getStirlingFileStub = vi.fn(); +const getStirlingFiles = vi.fn(); +const post = vi.fn(); vi.mock("@app/services/fileStorage", () => ({ fileStorage: { getStirlingFileStub: (...args: unknown[]) => getStirlingFileStub(...args), + getStirlingFiles: (...args: unknown[]) => getStirlingFiles(...args), }, })); -const { hasLocalFile } = await import("@app/services/notificationRetry"); +vi.mock("@app/services/apiClient", () => ({ + default: { post: (...args: unknown[]) => post(...args) }, +})); + +const { + stashRetryPayload, + loadRetryPayload, + hasLocalFile, + retryWithPassword, + unlockLocalDocument, +} = await import("@app/services/notificationRetry"); + +/** Duplicated from the service, which keeps its storage details private. */ +const DB_NAME = "stirling-pdf-retry"; +const STORE_NAME = "retryPayloads"; -beforeEach(() => { +function payload(overrides: Partial> = {}) { + return { + operation: "remove-password", + endpoint: "/api/v1/security/remove-password", + params: {}, + fileIds: ["f-1"], + recordedAt: 1_000, + ...overrides, + } as Parameters[0]; +} + +/** Reads records straight out of IndexedDB, bypassing the service's own mapping. */ +async function storedRecords(): Promise[]> { + const db = await indexedDBManager.openDatabase({ + name: DB_NAME, + version: 1, + stores: [{ name: STORE_NAME, keyPath: "fileId" }], + }); + return new Promise((resolve, reject) => { + const request = db + .transaction([STORE_NAME], "readonly") + .objectStore(STORE_NAME) + .getAll(); + request.onsuccess = () => + resolve((request.result ?? []) as Record[]); + request.onerror = () => reject(request.error); + }); +} + +beforeEach(async () => { getStirlingFileStub.mockReset().mockResolvedValue(null); + getStirlingFiles.mockReset().mockResolvedValue([]); + post.mockReset().mockResolvedValue({ status: 200, data: new Blob() }); + await indexedDBManager.deleteDatabase(DB_NAME); +}); + +describe("the retry stash", () => { + it("gives back what was stashed, keyed on the file the failure was filed against", async () => { + await stashRetryPayload( + payload({ params: { onlyPages: "1-3" }, fileIds: ["f-1", "f-2"] }), + ); + + await expect(loadRetryPayload("f-1")).resolves.toEqual({ + operation: "remove-password", + endpoint: "/api/v1/security/remove-password", + params: { onlyPages: "1-3" }, + fileIds: ["f-1", "f-2"], + recordedAt: 1_000, + }); + // Every file in the run gets a record, so the bell can retry from any of them. + await expect(loadRetryPayload("f-2")).resolves.toMatchObject({ + operation: "remove-password", + }); + }); + + it("has nothing for a file it never saw, or for no file at all", async () => { + await stashRetryPayload(payload()); + + await expect(loadRetryPayload("f-other")).resolves.toBeNull(); + await expect(loadRetryPayload(null)).resolves.toBeNull(); + await expect(loadRetryPayload(" ")).resolves.toBeNull(); + }); + + it("keeps the most recent operation that failed on a file, matching the server's one-incident-per-file dedup", async () => { + await stashRetryPayload( + payload({ operation: "compress", endpoint: "/api/v1/misc/compress-pdf" }), + ); + await stashRetryPayload( + payload({ operation: "rotate", endpoint: "/api/v1/general/rotate-pdf" }), + ); + + await expect(loadRetryPayload("f-1")).resolves.toMatchObject({ + operation: "rotate", + endpoint: "/api/v1/general/rotate-pdf", + }); + expect(await storedRecords()).toHaveLength(1); + }); + + it("evicts the oldest once it is full, so it cannot grow for the lifetime of the origin", async () => { + // One past the cap: the first failure stashed is the one that goes. + for (let i = 0; i < 26; i += 1) { + await stashRetryPayload(payload({ fileIds: [`f-${i}`], recordedAt: i })); + } + + expect(await storedRecords()).toHaveLength(25); + await expect(loadRetryPayload("f-0")).resolves.toBeNull(); + await expect(loadRetryPayload("f-25")).resolves.toMatchObject({ + operation: "remove-password", + }); + }); + + it("stores no password, whichever field the tool submitted it in", async () => { + await stashRetryPayload( + payload({ + params: { + password: "hunter2", + newOwnerPassword: "hunter2", + passphrase: "hunter2", + apiToken: "hunter2", + nested: { ownerPassword: "hunter2", keep: "yes" }, + keepThese: ["a", "b"], + }, + }), + ); + + const stored = await storedRecords(); + expect(JSON.stringify(stored)).not.toContain("hunter2"); + // No password-shaped field survives either. Scoped to params, since the tool + // this failure came from is itself called remove-password. + expect(JSON.stringify(stored.map((record) => record.params))).not.toMatch( + /pass(word|phrase)|token/i, + ); + // The rest of the parameters survive: without them a retry re-runs a + // different operation than the one that failed. + expect((await loadRetryPayload("f-1"))?.params).toEqual({ + nested: { keep: "yes" }, + keepThese: ["a", "b"], + }); + }); + + it("stops descending into a pathologically deep object without exhausting the stack", async () => { + // 5000 levels: enough to overflow an unbounded walk, and nothing a tool would ever submit. + let deep: Record = { bottom: "reached" }; + for (let i = 0; i < 5000; i++) deep = { down: deep }; + + await expect( + stashRetryPayload(payload({ params: { deep } })), + ).resolves.toBeUndefined(); + expect(await loadRetryPayload("f-1")).not.toBeNull(); + }); + + it("drops a secret sitting just past the depth limit rather than passing the subtree through", async () => { + // Deliberately only a little past the limit, so the truncated subtree is small enough to store. + // A far deeper object would fail to store for unrelated reasons and pass this vacuously. + let past: Record = { password: "hunter2" }; + for (let i = 0; i < 25; i++) past = { down: past }; + + await stashRetryPayload(payload({ params: { past } })); + + // The point where the walk gives up is the one place it must not hand back a subtree it never + // examined: returning the value there would persist every secret below the limit. + expect(JSON.stringify(await storedRecords())).not.toContain("hunter2"); + }); + + it("survives a cycle in the parameters", async () => { + // A depth bound is what saves this: a cycle has no leaves to reach. + const cyclic: Record = { keep: "yes" }; + cyclic.self = cyclic; + + await expect( + stashRetryPayload(payload({ params: { cyclic } })), + ).resolves.toBeUndefined(); + expect(await loadRetryPayload("f-1")).not.toBeNull(); + }); }); describe("hasLocalFile", () => { @@ -34,3 +205,129 @@ describe("hasLocalFile", () => { await expect(hasLocalFile("f-1")).resolves.toBe(true); }); }); + +describe("retryWithPassword", () => { + it("reports the file is gone instead of throwing, which is an expected outcome here", async () => { + getStirlingFiles.mockResolvedValue([]); + + const result = await retryWithPassword(payload(), "hunter2"); + + expect(result.ok).toBe(false); + expect(result.message).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); + }); + + it("re-submits the stashed operation with the password added", async () => { + getStirlingFiles.mockResolvedValue([ + new File(["%PDF-1.7"], "doc.pdf", { type: "application/pdf" }), + ]); + + const result = await retryWithPassword( + payload({ params: { onlyPages: "1-3" } }), + "hunter2", + ); + + expect(result.ok).toBe(true); + const [path, formData] = post.mock.calls[0] as [string, FormData]; + expect(path).toBe("/api/v1/security/remove-password"); + expect(formData.get("password")).toBe("hunter2"); + expect(formData.get("onlyPages")).toBe("1-3"); + expect(formData.get("fileInput")).toBeInstanceOf(File); + // The password was used for the one call and nothing else. + expect(JSON.stringify(await storedRecords())).not.toContain("hunter2"); + }); + + it("hands the output back, since a retry the user cannot see the result of is no retry", async () => { + getStirlingFiles.mockResolvedValue([new File(["%PDF-1.7"], "doc.pdf")]); + const unlocked = new Blob(["unlocked"]); + post.mockResolvedValue({ + data: unlocked, + headers: { + "content-disposition": 'attachment; filename="doc_unlocked.pdf"', + }, + }); + + const result = await retryWithPassword(payload(), "hunter2"); + + expect(result.ok).toBe(true); + expect(result.files).toHaveLength(1); + expect(result.files?.[0].filename).toBe("doc_unlocked.pdf"); + // The response body itself, so the caller adopts the bytes the server sent. + expect(result.files?.[0].blob).toBe(unlocked); + }); + + it("names the output after its input when the server sent no filename", async () => { + getStirlingFiles.mockResolvedValue([new File(["%PDF-1.7"], "doc.pdf")]); + post.mockResolvedValue({ data: new Blob(["unlocked"]), headers: {} }); + + const result = await retryWithPassword(payload(), "hunter2"); + + expect(result.files?.[0].filename).toBe("doc.pdf"); + }); + + it("returns the server's own message when the retry fails again", async () => { + getStirlingFiles.mockResolvedValue([new File(["%PDF-1.7"], "doc.pdf")]); + post.mockRejectedValue({ + response: { data: "The password is incorrect." }, + message: "Request failed with status code 400", + }); + + const result = await retryWithPassword(payload(), "hunter2"); + + expect(result.ok).toBe(false); + expect(result.message).toBe("The password is incorrect."); + expect(result.message).not.toContain("hunter2"); + }); +}); + +/** + * The unlock for a failure with no stash behind it - an attended policy run, whose notification + * names the document and needs nothing else. Same request, fixed endpoint, no payload. + */ +describe("unlockLocalDocument", () => { + it("removes the password from the document this browser holds, and stores nothing", async () => { + getStirlingFiles.mockResolvedValue([ + new File(["%PDF-1.7"], "locked.pdf", { type: "application/pdf" }), + ]); + post.mockResolvedValue({ + data: new Blob(["unlocked"]), + headers: { + "content-disposition": 'attachment; filename="locked_unlocked.pdf"', + }, + }); + + const result = await unlockLocalDocument("f-1", "hunter2"); + + const [path, formData] = post.mock.calls[0] as [string, FormData]; + expect(path).toBe("/api/v1/security/remove-password"); + expect(formData.get("password")).toBe("hunter2"); + expect(formData.get("fileInput")).toBeInstanceOf(File); + expect(result.files?.[0].filename).toBe("locked_unlocked.pdf"); + // The password was used for the one call and nothing else: no stash is written here at all. + expect(await storedRecords()).toHaveLength(0); + }); + + it("reports the document is gone instead of posting a password nowhere", async () => { + getStirlingFiles.mockResolvedValue([]); + + const result = await unlockLocalDocument("f-1", "hunter2"); + + expect(result.ok).toBe(false); + expect(result.message).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); + }); + + it("returns the server's own message when the password is wrong", async () => { + getStirlingFiles.mockResolvedValue([new File(["%PDF-1.7"], "locked.pdf")]); + post.mockRejectedValue({ + response: { data: "The password is incorrect." }, + message: "Request failed with status code 400", + }); + + const result = await unlockLocalDocument("f-1", "wrong"); + + expect(result.ok).toBe(false); + expect(result.message).toBe("The password is incorrect."); + expect(result.message).not.toContain("wrong"); + }); +}); diff --git a/frontend/editor/src/core/services/notificationRetry.ts b/frontend/editor/src/core/services/notificationRetry.ts index 2ba39b526b..0e1ca43289 100644 --- a/frontend/editor/src/core/services/notificationRetry.ts +++ b/frontend/editor/src/core/services/notificationRetry.ts @@ -1,9 +1,111 @@ +import apiClient from "@app/services/apiClient"; import { fileStorage } from "@app/services/fileStorage"; +import { + indexedDBManager, + type DatabaseConfig, +} from "@app/services/indexedDBManager"; import type { FileId } from "@app/types/file"; +import type { ToolEndpoint } from "@app/types/toolApiTypes"; /** - * Whether the document a failure was filed against is still in this browser. It decides whether the - * bell can offer to open it: the id is this workspace's own, so no other device can answer yes. + * What the notification bell needs to offer "Retry" or "Decrypt and retry" on a + * failure the editor reported. The server keeps none of it: the report drops the + * operation and answers 204, so this lives here, keyed on the opaque `fileId` it was + * filed against. Last-write-wins per fileId, matching the server's actor|kind|file + * dedup: one file failing two operations is one incident with one retry button. + */ +export interface RetryPayload { + /** tool/endpoint identifier, e.g. "remove-password" */ + operation: string; + /** the API path that failed, so a retry needs no tool registry lookup */ + endpoint: string; + /** the tool parameters as submitted */ + params: Record; + fileIds: string[]; + recordedAt: number; +} + +/** + * Its own database rather than a store on `stirling-pdf-files`: that schema has + * shipped at v9, and adding a store there means a version bump plus an upgrade path + * on every install for a hint that is safe to lose. Still opened through + * `indexedDBManager`, so this is not a second way to reach IndexedDB. + */ +const RETRY_DB_CONFIG: DatabaseConfig = { + name: "stirling-pdf-retry", + version: 1, + stores: [{ name: "retryPayloads", keyPath: "fileId" }], +}; + +const STORE_NAME = "retryPayloads"; + +/** Capped, oldest evicted first, so the stash cannot grow for the origin's lifetime. */ +const MAX_RETAINED_PAYLOADS = 25; + +/** One record per file involved, so a retry can be found from any of them. */ +interface StoredRetryRecord extends RetryPayload { + fileId: string; +} + +/** + * Secret-looking field names. A tool's parameters can carry one (remove-password submits + * `password`), so they are stripped on the way in rather than trusted to be absent. + */ +const SECRET_FIELD = /pass(word|phrase)|secret|token|credential/i; + +/** + * Stash the retry payload for a failure that was just reported. Never rejects, like + * `reportToolFailure`: a browser that refuses IndexedDB should cost the user the retry + * button, not a second error on top of the failure they already have. + */ +export async function stashRetryPayload(payload: RetryPayload): Promise { + try { + const fileIds = payload.fileIds.filter(isUsableId); + if (!payload.operation.trim() || fileIds.length === 0) return; + + const record = { + ...payload, + fileIds, + // Persisting a password would defeat the point of asking for it again. + params: withoutSecrets(payload.params), + }; + + await writeRecords(fileIds.map((fileId) => ({ ...record, fileId }))); + } catch { + // Nothing to recover: the bell simply offers no retry for this failure. + } +} + +/** The most recent operation that failed on this file, or null when nothing is stashed. */ +export async function loadRetryPayload( + fileId: string | null, +): Promise { + if (!isUsableId(fileId)) return null; + + let record: StoredRetryRecord | undefined; + try { + record = await readRecord(fileId); + } catch { + return null; + } + if (!record) return null; + + // A record written by an older shape of this service is unusable rather than + // half-usable: a retry with no endpoint has nowhere to go. + if (!record.operation || !record.endpoint) return null; + + return { + operation: record.operation, + endpoint: record.endpoint, + params: record.params ?? {}, + fileIds: record.fileIds ?? [fileId], + recordedAt: record.recordedAt, + }; +} + +/** + * Whether the document is still in this browser, which decides whether a retry can run at + * all. Null once the user deletes the file, and on every other device they own. */ export async function hasLocalFile(fileId: string | null): Promise { if (!isUsableId(fileId)) return false; @@ -16,6 +118,253 @@ export async function hasLocalFile(fileId: string | null): Promise { } } +/** A file the retry produced, handed back for the caller to adopt. */ +export interface RetryOutputFile { + blob: Blob; + filename: string; +} + +/** What a password-carrying call comes back with. `files` only ever on success. */ +export interface PasswordRetryOutcome { + ok: boolean; + message?: string; + files?: RetryOutputFile[]; +} + +/** Checked against the generated endpoints, so a renamed route fails the build here. */ +const UNLOCK_ENDPOINT = + "/api/v1/security/remove-password" satisfies ToolEndpoint; + +/** + * Unlock a document this browser is holding, for a failure with no stashed operation such + * as an attended policy run: a password-protected input is fixed the same way whatever was + * reading it. Same contract as {@link retryWithPassword} otherwise. + */ +export async function unlockLocalDocument( + fileId: string, + password: string, +): Promise { + return postWithPassword(UNLOCK_ENDPOINT, {}, [fileId], password); +} + +/** + * Re-run the stashed operation with the password the user just typed, and hand back + * what it produced. The password is appended to a single request and then out of + * scope: never stashed, never logged, never in the message returned here. + * + * `files` is returned rather than adopted because every file operation goes through + * FileContext, which a service cannot reach. + */ +export async function retryWithPassword( + payload: RetryPayload, + password: string, +): Promise { + if (!payload.endpoint) { + return { ok: false, message: "This operation cannot be retried." }; + } + + return postWithPassword( + payload.endpoint, + payload.params, + payload.fileIds, + password, + ); +} + +/** Shared by both callers above, so a password reaches the network from one place only. */ +async function postWithPassword( + endpoint: string, + params: Record, + requestedFileIds: string[], + password: string, +): Promise { + const fileIds = requestedFileIds.filter(isUsableId); + let files: File[] = []; + try { + files = await fileStorage.getStirlingFiles(fileIds as FileId[]); + } catch { + files = []; + } + + // getStirlingFiles drops what it cannot find, so a short result means an input is + // gone. Resolved rather than thrown: the caller shows this next to the notification. + if (files.length === 0 || files.length !== fileIds.length) { + return { + ok: false, + message: + "This file is no longer stored in this browser, so it cannot be retried here.", + }; + } + + try { + const formData = toFormData(params, files); + formData.append("password", password); + const response = await apiClient.post(endpoint, formData, { + responseType: "blob", + }); + return { + ok: true, + files: [ + { + blob: response.data, + filename: filenameOf(response.headers, files[0].name), + }, + ], + }; + } catch (error) { + return { ok: false, message: messageOf(error) }; + } +} + +/** + * The name the server gave the output, falling back to the input's: a caller adopting an + * unnamed blob would put a file called "blob" in the user's workbench. + */ +function filenameOf(headers: unknown, fallback: string): string { + const disposition = (headers as Record | undefined)?.[ + "content-disposition" + ]; + if (typeof disposition !== "string") return fallback; + + // filename* (RFC 5987, percent-encoded) wins over plain filename, which is how a + // server sends a non-ASCII name. + const encoded = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(disposition)?.[1]; + const plain = /filename="?([^";]+)"?/i.exec(disposition)?.[1]; + const name = encoded ?? plain; + if (!name) return fallback; + + try { + return decodeURIComponent(name.trim().replace(/^"|"$/g, "")) || fallback; + } catch { + // A malformed escape is not worth failing an otherwise successful retry over. + return name.trim().replace(/^"|"$/g, "") || fallback; + } +} + function isUsableId(fileId: string | null | undefined): fileId is string { return typeof fileId === "string" && fileId.trim() !== ""; } + +/** + * The tool's parameters as form fields, alongside the documents under `fileInput`. + * `objectToFormData` is not reused: it is typed to the generated request union and throws on + * anything non-primitive, whereas a stashed payload is an opaque record read out of storage. + */ +function toFormData(params: Record, files: File[]): FormData { + const formData = new FormData(); + + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const item of value) formData.append(key, asField(item)); + } else { + formData.append(key, asField(value)); + } + } + + for (const file of files) formData.append("fileInput", file); + + return formData; +} + +function asField(value: unknown): string { + return typeof value === "object" ? JSON.stringify(value) : `${value}`; +} + +/** + * How deep the walk below goes before it stops descending. Tool parameters are shallow, so this is + * far above anything real; it exists so a pathological or cyclic object cannot exhaust the stack. + */ +const MAX_PARAM_DEPTH = 20; + +/** Stands in for a subtree too deep to walk. Never the value itself: see below. */ +const TOO_DEEP = "[nested too deeply to store]"; + +/** + * Every secret-looking field dropped, at any depth: a tool can nest its parameters, and a + * password one level down is still a password. + * + * Past {@link MAX_PARAM_DEPTH} the subtree is replaced rather than returned. Returning it would + * mean anything below the limit is persisted unexamined, so the one place this function must not + * fail open is exactly the place it stops looking. + */ +function withoutSecrets( + value: Record, +): Record; +function withoutSecrets(value: unknown): unknown; +function withoutSecrets(value: unknown): unknown { + return prunedBelow(value, 0); +} + +/** + * The walk itself. Separate from {@link withoutSecrets} because the depth is bookkeeping between + * one level and the next, and no caller should be able to start part-way down. + */ +function prunedBelow(value: unknown, depth: number): unknown { + if (depth >= MAX_PARAM_DEPTH) return TOO_DEEP; + if (Array.isArray(value)) + return value.map((item) => prunedBelow(item, depth + 1)); + if (value === null || typeof value !== "object") return value; + + const kept: Record = {}; + for (const [key, nested] of Object.entries(value)) { + if (SECRET_FIELD.test(key)) continue; + kept[key] = prunedBelow(nested, depth + 1); + } + return kept; +} + +/** What the user saw. Never carries the password: it is not interpolated here. */ +function messageOf(error: unknown): string { + const response = (error as { response?: { data?: unknown } })?.response?.data; + if (typeof response === "string" && response.trim() !== "") return response; + + const message = (error as { message?: unknown })?.message; + return typeof message === "string" && message.trim() !== "" + ? message + : "Retrying the operation failed."; +} + +async function writeRecords(records: StoredRetryRecord[]): Promise { + const db = await indexedDBManager.openDatabase(RETRY_DB_CONFIG); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error("Retry stash transaction aborted")); + + // put, not add: last write wins per fileId, matching the server's dedup. + for (const record of records) store.put(record); + + // Evict in the same transaction as the writes, so two concurrent stashes cannot + // both decide the store is under the cap. + const all = store.getAll(); + all.onsuccess = () => { + const stored = (all.result ?? []) as StoredRetryRecord[]; + const excess = stored.length - MAX_RETAINED_PAYLOADS; + if (excess <= 0) return; + stored + .sort((a, b) => a.recordedAt - b.recordedAt) + .slice(0, excess) + .forEach((record) => store.delete(record.fileId)); + }; + all.onerror = () => reject(all.error); + }); +} + +async function readRecord( + fileId: string, +): Promise { + const db = await indexedDBManager.openDatabase(RETRY_DB_CONFIG); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], "readonly"); + const request = transaction.objectStore(STORE_NAME).get(fileId); + request.onsuccess = () => + resolve(request.result as StoredRetryRecord | undefined); + request.onerror = () => reject(request.error); + }); +} diff --git a/frontend/editor/src/core/services/notifications.ts b/frontend/editor/src/core/services/notifications.ts index b7ea321bbd..c09de6b6ff 100644 --- a/frontend/editor/src/core/services/notifications.ts +++ b/frontend/editor/src/core/services/notifications.ts @@ -25,6 +25,12 @@ export type NotificationOwnership = "MINE" | "THEIRS" | "UNOWNED"; /** Who performs the action: the server on its own record, or this client on its own device. */ export type NotificationActionExecution = "SERVER" | "CLIENT"; +/** + * How much of the row an action has earned. The server ranks by what the action does, not by where it + * ends up on screen; {@link promoteActions} turns a slot into a button or a menu entry. + */ +export type NotificationActionSlot = "RESOLUTION" | "SECONDARY" | "OVERFLOW"; + /** * One action as offered for one notification. `id` is a plain string rather than a union because the * server may know actions this build does not, and the client skips the ones it cannot perform. @@ -35,6 +41,7 @@ export interface NotificationActionOffer { /** English fallback, for a build with no copy for `labelKey`. */ defaultLabel: string; execution: NotificationActionExecution; + slot: NotificationActionSlot; /** False means the server will refuse it. The bell renders no button and states the reason * instead; the portal's queue still shows it disabled. */ enabled: boolean; diff --git a/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx b/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx index aa45fb14ba..29f8177193 100644 --- a/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx +++ b/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx @@ -2,10 +2,7 @@ import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderHook } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import type { - AppNotification, - NotificationActionOffer, -} from "@app/services/notifications"; +import type { AppNotification } from "@app/services/notifications"; import type { NotificationActionContext } from "@core/components/notifications/notificationActions"; /** @@ -14,6 +11,29 @@ import type { NotificationActionContext } from "@core/components/notifications/n * are the interesting cases: selecting the document directly, or handing it over. */ +const retryWithPassword = vi.fn(); +const unlockLocalDocument = vi.fn(); +vi.mock("@app/services/notificationRetry", () => ({ + retryWithPassword: (...args: unknown[]) => retryWithPassword(...args), + unlockLocalDocument: (...args: unknown[]) => unlockLocalDocument(...args), +})); + +const rerunPolicy = vi.fn(); +const rerunPolicyOnDocument = vi.fn(); +vi.mock("@app/services/notificationPolicyRetry", () => ({ + rerunPolicy: (...args: unknown[]) => rerunPolicy(...args), + rerunPolicyOnDocument: (...args: unknown[]) => rerunPolicyOnDocument(...args), +})); + +const reportNotificationResolved = vi.fn(); +vi.mock("@app/services/notifications", async () => ({ + ...(await vi.importActual( + "@app/services/notifications", + )), + reportNotificationResolved: (...args: unknown[]) => + reportNotificationResolved(...args), +})); + const navigate = vi.fn(); vi.mock("react-router-dom", async () => ({ ...(await vi.importActual( @@ -34,6 +54,7 @@ const { useNotificationActions } = await import("@app/components/notifications/notificationActions"); const setSelectedFiles = vi.fn(); +const addFiles = vi.fn(); function notification( overrides: Partial = {}, @@ -60,24 +81,38 @@ function notification( }; } -/** An action the server offered, enabled: what it does with it is the client's decision. */ -function offer(id: string): NotificationActionOffer { +function context( + overrides: Partial = {}, +): NotificationActionContext { return { - id, - labelKey: `portal.failures.action.${id.toLowerCase()}`, - defaultLabel: id, - execution: "CLIENT", - enabled: true, - disabledReasonKey: null, + notification: notification(), + hasLocalFile: true, + retryPayload: { + operation: "removePassword", + endpoint: "/api/v1/security/remove-password", + params: {}, + fileIds: ["f-1"], + recordedAt: 0, + }, + ...overrides, }; } -function context( +/** + * A failure of an attended policy run: the editor started it on a document it was holding, so the + * row names the policy and that document, and no stash was ever written for it. + */ +function policyContext( overrides: Partial = {}, ): NotificationActionContext { return { - notification: notification(), + notification: notification({ + origin: "POLICY", + policyId: "pol-1", + sourceId: null, + }), hasLocalFile: true, + retryPayload: null, ...overrides, }; } @@ -87,7 +122,7 @@ const inEditor = ({ children }: { children: ReactNode }) => ( @@ -105,43 +140,83 @@ function registry(wrapper = inEditor) { return renderHook(() => useNotificationActions(), { wrapper }).result.current; } +/** A file's own bytes. Via FileReader because this environment's Blob has no `text`. */ +function bytesOf(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + beforeEach(() => { navigate.mockReset(); setSelectedFiles.mockReset(); + // The workspace's own id for the adopted document, which is what a policy re-run's output belongs + // to - not the reference the failure was filed against. + addFiles.mockReset().mockResolvedValue([{ fileId: "f-unlocked" }]); + reportNotificationResolved.mockReset().mockResolvedValue(true); + retryWithPassword.mockReset().mockResolvedValue({ ok: true, files: [] }); + // The unlock succeeds by default and produces a document, since almost every case below is about + // what happens to it afterwards. + unlockLocalDocument.mockReset().mockResolvedValue({ + ok: true, + files: [ + { + blob: new Blob(["pdf"], { type: "application/pdf" }), + filename: "invoice.pdf", + }, + ], + }); + // Tracked by default: the run is in the store, so something is polling it and its output will + // arrive. An untracked run is a separate case below, since it changes what the row may claim. + rerunPolicy.mockReset().mockResolvedValue({ ok: true, tracked: true }); + rerunPolicyOnDocument.mockReset().mockResolvedValue({ + ok: true, + tracked: true, + }); window.sessionStorage.clear(); window.history.pushState({}, "", "/"); }); describe("useNotificationActions", () => { - it("offers to open the document only while it is still in this browser", () => { - const actions = registry(); + it("opens the failed tool with the document selected", () => { + registry().RETRY?.run(context()); - expect(actions.VIEW_FILE?.available(context())).toBe(true); - expect(actions.VIEW_FILE?.available(context({ hasLocalFile: false }))).toBe( - false, - ); + expect(setSelectedFiles).toHaveBeenCalledWith(["f-1"]); + // The tool the stashed operation names, so the user sees the settings before it runs again. + expect(window.location.pathname).toBe("/remove-password"); }); - it("leaves View file as the only usable offer when the server offers retries this build cannot run", () => { - // The server offers the retries for a password-protected failure, but this build wires no client - // action for them, so they drop out rather than rendering dead and View file is all that is left. - const actions = registry(); - const usable = [ - offer("RETRY"), - offer("DECRYPT_AND_RETRY"), - offer("VIEW_FILE"), - ].filter( - (candidate) => actions[candidate.id]?.available(context()) ?? false, + it("opens the editor itself when the stashed operation names no tool this build has", () => { + registry().RETRY?.run( + context({ + retryPayload: { + operation: "quarantine", + endpoint: "/api/v1/quarantine", + params: {}, + fileIds: ["f-1"], + recordedAt: 0, + }, + }), ); - expect(usable.map((candidate) => candidate.id)).toEqual(["VIEW_FILE"]); + expect(window.location.pathname).toBe("/"); }); - it("opens the document with it selected when an editor is above", () => { - registry().VIEW_FILE?.run(context()); + it("offers no retry once the document has left this browser", () => { + const actions = registry(); - expect(setSelectedFiles).toHaveBeenCalledWith(["f-1"]); - expect(window.location.pathname).toBe("/"); + expect(actions.RETRY?.available(context({ hasLocalFile: false }))).toBe( + false, + ); + expect(actions.RETRY?.available(context({ retryPayload: null }))).toBe( + false, + ); + expect(actions.VIEW_FILE?.available(context({ hasLocalFile: false }))).toBe( + false, + ); }); it("hands the document over when there is no editor above it", () => { @@ -169,6 +244,119 @@ describe("useNotificationActions", () => { ).toBeNull(); }); + it("unlocks with the password it was given and reports what came back", async () => { + retryWithPassword.mockResolvedValue({ ok: false, message: "Wrong" }); + + const outcome = await registry().DECRYPT_AND_RETRY?.run( + context(), + "hunter2", + ); + + expect(retryWithPassword).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: "/api/v1/security/remove-password" }), + "hunter2", + ); + expect(outcome).toEqual({ ok: false, message: "Wrong" }); + }); + + it("takes the unlocked document into the workbench through FileContext", async () => { + // The whole point of the password: the user must end up holding the unlocked file. + retryWithPassword.mockResolvedValue({ + ok: true, + files: [ + { + blob: new Blob(["pdf"], { type: "application/pdf" }), + filename: "invoice.pdf", + }, + ], + }); + + const outcome = await registry().DECRYPT_AND_RETRY?.run( + context(), + "hunter2", + ); + + expect(outcome).toEqual({ ok: true }); + const [files, options] = addFiles.mock.calls[0]; + expect((files as File[]).map((file) => file.name)).toEqual(["invoice.pdf"]); + // Selected as well as added, so it is the document on screen when the panel closes; and marked + // in-app so `usePolicyAutoRun` does not enforce the upload chain on it by itself. + expect(options).toEqual({ selectFiles: true, derivedFromTool: true }); + // And the incident is closed, with the prefixed id: nothing else tells the server the client + // fixed it, so the bell would otherwise keep reporting a failure the user has dealt with. + expect(reportNotificationResolved).toHaveBeenCalledWith("failure:evt-1"); + }); + + it("closes the incident only once the document is safely in", async () => { + // Reported first, then a failed adoption, would leave the row closed with nothing to show. + retryWithPassword.mockResolvedValue({ + ok: true, + files: [{ blob: new Blob(["pdf"]), filename: "invoice.pdf" }], + }); + addFiles.mockRejectedValue(new Error("quota")); + + await registry().DECRYPT_AND_RETRY?.run(context(), "hunter2"); + + expect(reportNotificationResolved).not.toHaveBeenCalled(); + }); + + it("keeps the unlock a success when the server will not record it", async () => { + // A reviewer dismissed the row first, or it was never this caller's. The document is already in + // the workbench, so a refused resolve must not present as a failed unlock. + retryWithPassword.mockResolvedValue({ + ok: true, + files: [{ blob: new Blob(["pdf"]), filename: "invoice.pdf" }], + }); + reportNotificationResolved.mockResolvedValue(false); + + expect( + await registry().DECRYPT_AND_RETRY?.run(context(), "hunter2"), + ).toEqual({ ok: true }); + }); + + it("reports a failure when the unlocked document cannot be taken in", async () => { + // Unlocked but dropped is the one outcome that leaves the user with nothing, so it is never + // reported as success. + retryWithPassword.mockResolvedValue({ + ok: true, + files: [{ blob: new Blob(["pdf"]), filename: "invoice.pdf" }], + }); + addFiles.mockRejectedValue(new Error("quota")); + + const outcome = await registry().DECRYPT_AND_RETRY?.run( + context(), + "hunter2", + ); + + expect(outcome).toEqual({ + ok: false, + message: + "The document was unlocked but could not be opened here. Try the tool directly.", + }); + }); + + it("offers no unlock where there is nowhere to put the result", async () => { + // The processor shell has no FileContext, so unlocking would produce a document with nowhere to + // go. The row promotes its next offer instead. + const actions = registry(inProcessor); + + expect(actions.DECRYPT_AND_RETRY?.available(context())).toBe(false); + expect(actions.VIEW_IN_PROCESSOR?.available(context())).toBe(true); + // And it refuses rather than posting a password whose output would be discarded. + expect(await actions.DECRYPT_AND_RETRY?.run(context(), "hunter2")).toEqual({ + ok: false, + message: "This document can no longer be retried from this browser.", + }); + expect(retryWithPassword).not.toHaveBeenCalled(); + }); + + it("offers the unlock where the editor can take the result", () => { + expect(registry().DECRYPT_AND_RETRY?.available(context())).toBe(true); + expect( + registry().DECRYPT_AND_RETRY?.available(context({ hasLocalFile: false })), + ).toBe(false); + }); + it("says it cannot hand the document over rather than navigating to nothing", async () => { // Storage refused, so nothing would be selected on arrival: the row reports it and stays put. // On the prototype: jsdom's storage object is a proxy, so an own-property spy does not take. @@ -190,6 +378,19 @@ describe("useNotificationActions", () => { setItem.mockRestore(); }); + it("says so rather than posting nothing when the stash has gone", async () => { + const outcome = await registry().DECRYPT_AND_RETRY?.run( + context({ retryPayload: null }), + "hunter2", + ); + + expect(retryWithPassword).not.toHaveBeenCalled(); + expect(outcome).toEqual({ + ok: false, + message: "This document can no longer be retried from this browser.", + }); + }); + it("links to the recorded failures section of the processor", () => { registry().VIEW_IN_PROCESSOR?.run(context()); @@ -201,8 +402,303 @@ describe("useNotificationActions", () => { // to gate here. expect( registry(inProcessor).VIEW_IN_PROCESSOR?.available( - context({ hasLocalFile: false }), + context({ hasLocalFile: false, retryPayload: null }), ), ).toBe(true); }); }); + +/** + * The other retry shape. An attended policy run stashes nothing, so everything these actions need + * comes off the row itself: which policy failed, and which document this browser was holding. + */ +describe("retrying an attended policy run", () => { + it("runs the policy again on the document it already holds", async () => { + const outcome = await registry().RETRY?.run(policyContext()); + + expect(rerunPolicy).toHaveBeenCalledWith({ + policyId: "pol-1", + fileId: "f-1", + }); + expect(outcome).toEqual({ ok: true }); + // Nothing was stashed for this row, so nothing may be read from one either. + expect(retryWithPassword).not.toHaveBeenCalled(); + }); + + it("re-runs the policy rather than reopening a tool, even where a stash happens to exist", async () => { + // The same document can have failed a tool run earlier, leaving a stash keyed on it. The row + // is about the policy, so that is what runs again. + await registry().RETRY?.run( + policyContext({ + retryPayload: { + operation: "removePassword", + endpoint: "/api/v1/security/remove-password", + params: {}, + fileIds: ["f-1"], + recordedAt: 0, + }, + }), + ); + + expect(rerunPolicy).toHaveBeenCalled(); + expect(window.location.pathname).toBe("/"); + }); + + it("says the server refused rather than looking like it worked", async () => { + rerunPolicy.mockResolvedValue({ + ok: false, + reason: "rejected", + message: "That policy is no longer enabled.", + }); + + expect(await registry().RETRY?.run(policyContext())).toEqual({ + ok: false, + message: "That policy is no longer enabled.", + }); + }); + + it("has its own wording when the server refuses without any", async () => { + rerunPolicy.mockResolvedValue({ + ok: false, + reason: "rejected", + message: null, + }); + + expect(await registry().RETRY?.run(policyContext())).toEqual({ + ok: false, + message: + "The policy could not be run again just now. Try again in a moment.", + }); + }); + + it("reports the document is gone rather than blaming the policy", async () => { + rerunPolicy.mockResolvedValue({ ok: false, reason: "missingFile" }); + + expect(await registry().RETRY?.run(policyContext())).toEqual({ + ok: false, + message: + "This document is not on this device, so it cannot be opened or retried here.", + }); + }); + + it("is offered for an attended row whose document is here, and for nothing else", () => { + const actions = registry(); + + expect(actions.RETRY?.available(policyContext())).toBe(true); + expect(actions.DECRYPT_AND_RETRY?.available(policyContext())).toBe(true); + + // Unattended: the fileId is a source's hash of a path that was never on any device, so there + // is nothing here to re-submit. The server disables the owner's actions on these anyway. + const unattended = policyContext({ + notification: notification({ + origin: "POLICY", + policyId: "pol-1", + sourceId: "src-1", + }), + }); + expect(actions.RETRY?.available(unattended)).toBe(false); + expect(actions.DECRYPT_AND_RETRY?.available(unattended)).toBe(false); + + // No policy named, and no stash either: nothing describes what would run again. + expect( + actions.RETRY?.available( + policyContext({ + notification: notification({ origin: "POLICY", policyId: null }), + }), + ), + ).toBe(false); + + // Document gone from this browser. + expect( + actions.RETRY?.available(policyContext({ hasLocalFile: false })), + ).toBe(false); + }); + + it("is offered nowhere without an editor to collect the result", () => { + // The processor shell mounts the bell outside the app's providers, so a run fired from there + // would have no workspace to land its output in. + const actions = registry(inProcessor); + + expect(actions.RETRY?.available(policyContext())).toBe(false); + expect(actions.DECRYPT_AND_RETRY?.available(policyContext())).toBe(false); + }); + + it("refuses rather than firing a run the processor shell could not collect", async () => { + expect(await registry(inProcessor).RETRY?.run(policyContext())).toEqual({ + ok: false, + message: "This document can no longer be retried from this browser.", + }); + expect(rerunPolicy).not.toHaveBeenCalled(); + }); + + it("unlocks, takes the document in, runs the policy again, then closes the incident", async () => { + const order: string[] = []; + addFiles.mockImplementation(async () => { + order.push("adopt"); + return [{ fileId: "f-unlocked" }]; + }); + rerunPolicyOnDocument.mockImplementation(async () => { + order.push("rerun"); + return { ok: true, tracked: true }; + }); + reportNotificationResolved.mockImplementation(async () => { + order.push("resolve"); + return true; + }); + + const outcome = await registry().DECRYPT_AND_RETRY?.run( + policyContext(), + "hunter2", + ); + + expect(outcome).toEqual({ ok: true }); + // The unlock is the remove-password call on the document the row names, not a stashed endpoint. + expect(unlockLocalDocument).toHaveBeenCalledWith("f-1", "hunter2"); + // Added and selected, so the unlocked document is what is on screen once the panel closes. The + // encrypted original is left alone: the user never asked to lose it. + const [files, options] = addFiles.mock.calls[0]; + expect((files as File[]).map((file) => file.name)).toEqual(["invoice.pdf"]); + // derivedFromTool is what stops the adoption starting a SECOND run of this same policy: the + // dispatch effect in usePolicyAutoRun treats a plain upload as work to enforce. A policy run is + // a billed automation run, so a double dispatch double-charges and can open a second incident. + expect(options).toEqual({ selectFiles: true, derivedFromTool: true }); + // Re-submitted under the ORIGINAL reference, so a second failure folds onto this same incident + // instead of opening a new one about the same document - while the run's output is attributed to + // the ADOPTED document, which is the one now in front of the user. + expect(rerunPolicyOnDocument).toHaveBeenCalledWith( + { policyId: "pol-1", fileId: "f-1" }, + expect.any(File), + "f-unlocked", + ); + // And with the prefixed notification id, never a raw failure id. + expect(reportNotificationResolved).toHaveBeenCalledWith("failure:evt-1"); + expect(order).toEqual(["adopt", "rerun", "resolve"]); + }); + + it("starts exactly one run for one click", async () => { + await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2"); + + // One submission, from here. The other possible source is the adoption, which is silenced by + // derivedFromTool above; see the gate's own test in usePolicyAutoRun.chain.test.tsx. + expect(rerunPolicyOnDocument).toHaveBeenCalledTimes(1); + expect(rerunPolicy).not.toHaveBeenCalled(); + expect(addFiles.mock.calls[0][1]).toMatchObject({ derivedFromTool: true }); + }); + + it("still runs when the adoption reports no workspace id, rather than guessing one", async () => { + // Nothing to attribute the output to, so the run goes untracked rather than being filed against + // the encrypted original, which would version the wrong document. + addFiles.mockResolvedValue([]); + rerunPolicyOnDocument.mockResolvedValue({ ok: true, tracked: false }); + + await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2"); + + expect(rerunPolicyOnDocument).toHaveBeenCalledWith( + { policyId: "pol-1", fileId: "f-1" }, + expect.any(File), + null, + ); + }); + + it("leaves the row open when the re-run cannot deliver, and says why", async () => { + // The run went, but untracked: nothing polls it, so the processed document never reaches the + // workbench. The unlocked INPUT is in, which is not what the user was after, so this may not + // present as success. Closing the row here would retire a failure that produced nothing and + // still billed a run. + rerunPolicyOnDocument.mockResolvedValue({ ok: true, tracked: false }); + + expect( + await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2"), + ).toEqual({ + ok: false, + message: + "The document was unlocked and the policy re-run started, but its result cannot be delivered here, so this failure stays open.", + }); + // Adopted regardless: the password bought them the unlocked document either way. + expect(addFiles).toHaveBeenCalled(); + expect(reportNotificationResolved).not.toHaveBeenCalled(); + }); + + it("says an untracked plain re-run cannot be delivered either", async () => { + // Same hole without a password in it: the local cache could not place the policy, so the run is + // unpolled and its output is not coming. The reader is told rather than shown a silent success. + rerunPolicy.mockResolvedValue({ ok: true, tracked: false }); + + expect(await registry().RETRY?.run(policyContext())).toEqual({ + ok: false, + message: + "The policy re-run started, but its result cannot be delivered here, so this failure stays open.", + }); + expect(reportNotificationResolved).not.toHaveBeenCalled(); + }); + + it("shows a wrong password for what it is, and touches nothing else", async () => { + unlockLocalDocument.mockResolvedValue({ + ok: false, + message: "The password is incorrect.", + }); + + expect( + await registry().DECRYPT_AND_RETRY?.run(policyContext(), "wrong"), + ).toEqual({ ok: false, message: "The password is incorrect." }); + expect(addFiles).not.toHaveBeenCalled(); + expect(rerunPolicyOnDocument).not.toHaveBeenCalled(); + // The row is still a failure, so nothing may report it fixed. + expect(reportNotificationResolved).not.toHaveBeenCalled(); + }); + + it("neither re-runs nor closes the incident when the document cannot be taken in", async () => { + addFiles.mockRejectedValue(new Error("quota")); + + expect( + await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2"), + ).toEqual({ + ok: false, + message: + "The document was unlocked but could not be opened here. Try the tool directly.", + }); + expect(rerunPolicyOnDocument).not.toHaveBeenCalled(); + expect(reportNotificationResolved).not.toHaveBeenCalled(); + }); + + it("says the unlock worked but the re-run did not, and leaves the row open", async () => { + rerunPolicyOnDocument.mockResolvedValue({ + ok: false, + reason: "rejected", + message: "Queue full.", + }); + + expect( + await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2"), + ).toEqual({ + ok: false, + message: + "The document was unlocked and opened here, but the policy could not be run on it again.", + }); + // Adopted anyway: the password bought them the unlocked document, and that is theirs to keep. + expect(addFiles).toHaveBeenCalled(); + // But nothing is fixed server-side, so the incident stays open. + expect(reportNotificationResolved).not.toHaveBeenCalled(); + }); + + it("never hands the password to anything but the unlock", async () => { + await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2"); + + // Everything downstream of the unlock: the adoption, the re-run, the resolve. The password is + // an argument to one call and goes out of scope after it - it is in no payload, no stash and no + // id, so nothing here can persist it. + const downstream = [ + ...addFiles.mock.calls, + ...rerunPolicyOnDocument.mock.calls, + ...reportNotificationResolved.mock.calls, + ]; + expect(JSON.stringify(downstream)).not.toContain("hunter2"); + // Not in the file that goes back to the policy either: those are the server's unlocked bytes. + const [, document] = rerunPolicyOnDocument.mock.calls[0] as [ + unknown, + File, + unknown, + ]; + expect(await bytesOf(document)).not.toContain("hunter2"); + }); +}); diff --git a/frontend/editor/src/proprietary/components/notifications/notificationActions.ts b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts index 40bb497c4f..af0e9b0d4a 100644 --- a/frontend/editor/src/proprietary/components/notifications/notificationActions.ts +++ b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts @@ -3,10 +3,26 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { withBasePath } from "@app/constants/app"; import { FileActionsContext } from "@app/contexts/file/contexts"; +import { getToolUrlPath } from "@app/data/toolsTaxonomy"; import { PORTAL_BASENAME, PORTAL_FAILURES_ANCHOR, } from "@app/routes/portalBasename"; +import { + retryWithPassword, + unlockLocalDocument, + type RetryOutputFile, + type RetryPayload, +} from "@app/services/notificationRetry"; +import { + rerunPolicy, + rerunPolicyOnDocument, + type PolicyRerunOutcome, + type PolicyRetryTarget, +} from "@app/services/notificationPolicyRetry"; +import { reportNotificationResolved } from "@app/services/notifications"; +import { isValidToolId } from "@app/types/toolId"; +import type { FileContextActions } from "@app/types/fileContext"; import type { FileId } from "@app/types/file"; import { type ClientActionOutcome, @@ -23,8 +39,9 @@ export { }; /** - * What this build can do about a failure notification: open the document it is about, or go to the - * recorded failures in the processor. + * What this build can do about a failure notification: unlock the document and take the result, run the + * failing work again, open the tool that failed, or go to the incident in the processor. "Run it again" + * means two different things, so see {@link RetryTarget}. * * THE SHELL PROBLEM. The portal mounts as a sibling of the route that renders `AppProviders` (see * `proprietary/App.tsx`), so in the processor shell there is no FileContext, ToolWorkflowContext or @@ -78,6 +95,82 @@ function goToEditor(path: string): void { window.dispatchEvent(new PopStateEvent("popstate")); } +/** + * The tool whose run failed, when the stashed operation names one this build still has. The stashed + * `params` stay in the stash: a tool's parameters live in component state inside `useBaseParameters`, + * which has no seam for initial values, and this is the place that would hand them over once it does. + */ +function toolPathOf(payload: RetryPayload): string { + return isValidToolId(payload.operation) + ? getToolUrlPath(payload.operation) + : "/"; +} + +/** + * What a retry would re-run. A union because the two are genuinely different: a tool retry is an + * endpoint plus parameters, which exist only in the client that submitted them, and a policy retry is a + * stored policy plus a document, which the server named on the notification itself. + */ +type RetryTarget = + | { readonly kind: "tool"; readonly payload: RetryPayload } + /** The policy and the document, exactly as the re-run takes them. */ + | { readonly kind: "policy"; readonly policy: PolicyRetryTarget }; + +/** + * Which of the two a notification describes, or null when nothing here can re-run it. + * + * The policy shape wins where it applies, being the more specific claim: the row says which policy + * failed on which document, whereas a stash only says which operation this browser last saw fail on it. + * + * The attended check repeats `isResolvableHere` in `useNotifications` so this function holds on its own + * arguments rather than by arrangement with the caller. + */ +function retryTargetOf(context: NotificationActionContext): RetryTarget | null { + const { notification, hasLocalFile, retryPayload } = context; + if (!hasLocalFile) return null; + + const attended = (notification.sourceId ?? null) === null; + if (attended && notification.policyId && notification.fileId) { + return { + kind: "policy", + policy: { policyId: notification.policyId, fileId: notification.fileId }, + }; + } + + return retryPayload ? { kind: "tool", payload: retryPayload } : null; +} + +/** The documents a password-carrying call produced, as files the workbench can take. */ +function asFiles(outputs: RetryOutputFile[]): File[] { + return outputs.map( + (output) => + new File([output.blob], output.filename, { + type: output.blob.type || "application/pdf", + }), + ); +} + +/** + * Take what the retry produced into the workbench, so the unlocked document is what the user is looking + * at once the panel closes. Added and selected rather than replacing the encrypted original: the unlock + * is a new document, and deleting their input is not this button's business. + * + * `derivedFromTool` is load-bearing. A plain upload is what `usePolicyAutoRun`'s dispatch effect watches + * for, so without it this adoption would fire the whole upload policy chain by itself: BILLED automation + * runs nobody asked for, on a document that is only here because a retry produced it. It is also simply + * true, since the document came out of the remove-password tool. + */ +async function adopt( + actions: FileContextActions, + files: File[], +): Promise { + const adopted = await actions.addFiles(files, { + selectFiles: true, + derivedFromTool: true, + }); + return adopted.map((file) => file.fileId); +} + export function useNotificationActions(): ClientActionRegistry { const { t } = useTranslation(); const navigate = useNavigate(); @@ -116,6 +209,168 @@ export function useNotificationActions(): ClientActionRegistry { goToEditor(path); }; + const unavailable = (): ClientActionOutcome => ({ + ok: false, + message: t( + "notifications.retryUnavailable", + "This document can no longer be retried from this browser.", + ), + }); + + /** + * What a policy re-run amounted to, in the reader's terms. A rejection after the unlock reads + * differently from one before it, so the reader is not left thinking their password was wrong. + * + * An untracked run is reported as a failure on purpose. It did go, but nothing here will collect + * what it produces, and the processed document was the point of the retry: presenting that as + * success would close the row on a result that is never arriving. + */ + const rerunOutcome = ( + outcome: PolicyRerunOutcome, + adopted: boolean, + ): ClientActionOutcome => { + if (outcome.ok && outcome.tracked) return { ok: true }; + if (outcome.ok) { + return { + ok: false, + message: adopted + ? t( + "notifications.unlockedRerunUndelivered", + "The document was unlocked and the policy re-run started, but its result cannot be delivered here, so this failure stays open.", + ) + : t( + "notifications.rerunUndelivered", + "The policy re-run started, but its result cannot be delivered here, so this failure stays open.", + ), + }; + } + if (outcome.reason === "missingFile") { + return { + ok: false, + message: t( + "notifications.notOnThisDevice", + "This document is not on this device, so it cannot be opened or retried here.", + ), + }; + } + if (adopted) { + return { + ok: false, + message: t( + "notifications.unlockedNotRerun", + "The document was unlocked and opened here, but the policy could not be run on it again.", + ), + }; + } + return { + ok: false, + message: + outcome.message ?? + t( + "notifications.rerunRejected", + "The policy could not be run again just now. Try again in a moment.", + ), + }; + }; + + /** + * Whether this device can re-run what the row describes. A policy re-run also needs the editor's + * providers above the bell, not to submit the run but because a run fired from the processor shell + * has no mounted workspace to collect its output. + */ + const canRetry = (context: NotificationActionContext): boolean => { + const target = retryTargetOf(context); + if (!target) return false; + return target.kind === "tool" || fileContext !== undefined; + }; + + const retry: ClientActionSpec = { + available: canRetry, + closesPanel: true, + run: async (context): Promise => { + const target = retryTargetOf(context); + if (!target) return unavailable(); + + // A tool opens with the document selected rather than re-running from here: it failed once, so + // the user gets to see the settings first. A stored policy has none to show, so it simply goes. + if (target.kind === "tool") { + return openDocument( + context.notification.fileId, + toolPathOf(target.payload), + ); + } + if (!fileContext) return unavailable(); + return rerunOutcome(await rerunPolicy(target.policy), false); + }, + }; + + const decryptAndRetry: ClientActionSpec = { + // Only where there is somewhere to put the result: in the processor shell an unlocked document + // would have nowhere to go, so the row promotes its next offer instead. + available: (context) => fileContext !== undefined && canRetry(context), + needsPassword: true, + // On success the adopted document is the destination, and it is behind the panel. + closesPanel: true, + run: async (context, password): Promise => { + const target = retryTargetOf(context); + if (!target || !password || !fileContext) return unavailable(); + + // The stash for a tool, because only it knows what failed and with which parameters; the unlock + // endpoint for a policy, since a locked input is fixed the same way whatever was reading it. + const outcome = + target.kind === "tool" + ? await retryWithPassword(target.payload, password) + : await unlockLocalDocument(target.policy.fileId, password); + // A wrong password lands here, carrying the server's own words, which the row shows. + if (!outcome.ok) return outcome; + + // It unlocked, so the user must end up holding it. A failed adoption fails the whole action: + // claiming success and dropping the result leaves them nothing for the password they typed. + const unlocked = asFiles(outcome.files ?? []); + let adopted: FileId[] = []; + try { + adopted = await adopt(fileContext.actions, unlocked); + } catch { + return { + ok: false, + message: t( + "notifications.adoptFailed", + "The document was unlocked but could not be opened here. Try the tool directly.", + ), + }; + } + + // Back through the run that choked on the locked document, under the ORIGINAL reference, so a + // second failure folds onto this same incident. The adopted id goes too, since that is the + // document the output belongs to now. After the adoption, so a refused re-run still leaves the + // user holding what their password bought them. + if (target.kind === "policy") { + const document = unlocked[0]; + const rerun: PolicyRerunOutcome = document + ? await rerunPolicyOnDocument( + target.policy, + document, + adopted[0] ?? null, + ) + : { ok: false, reason: "missingFile" }; + // Anything short of a tracked run stops here, untracked included. The unlocked document + // being in the workbench is not the result the user asked for: they wanted what the policy + // makes of it, and that output has nowhere to land. Closing the row on the input alone + // would retire a failure that is still costing them a billed run and still producing + // nothing. One mapper decides, so the message and the resolve cannot disagree. + const result = rerunOutcome(rerun, true); + if (!result.ok) return result; + } + + // Nothing else tells the server the retry worked, so without this the bell keeps reporting a + // failure the user has fixed. Reached only once the whole retry has landed: the document is in, + // and the re-run is being polled by something that will deliver it. Its result is ignored, + // since a refused resolve is not a failed unlock. + await reportNotificationResolved(context.notification.id); + return { ok: true }; + }, + }; + const viewFile: ClientActionSpec = { available: (context) => context.hasLocalFile, closesPanel: true, @@ -133,6 +388,8 @@ export function useNotificationActions(): ClientActionRegistry { }; return { + RETRY: retry, + DECRYPT_AND_RETRY: decryptAndRetry, VIEW_FILE: viewFile, VIEW_IN_PROCESSOR: viewInProcessor, }; diff --git a/frontend/editor/src/proprietary/services/notificationPolicyRetry.test.ts b/frontend/editor/src/proprietary/services/notificationPolicyRetry.test.ts new file mode 100644 index 0000000000..5cfca7201a --- /dev/null +++ b/frontend/editor/src/proprietary/services/notificationPolicyRetry.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Re-running the policy a notification says failed. The whole point is that nothing was stashed for + * it: the policy and the document both come off the row, and the bytes come out of storage under the + * same reference the failure was filed against. + */ + +const getStirlingFile = vi.fn(); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: (...args: unknown[]) => getStirlingFile(...args), + }, +})); + +const runStoredPolicy = vi.fn(); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: (...args: unknown[]) => runStoredPolicy(...args), + resolvePolicyRunTarget: () => "saas", +})); + +/** The local policy cache, which is how a backend policy id becomes a category without any hook. */ +const policies = vi.hoisted(() => ({ + value: { security: { backendId: "pol-1" } } as Record< + string, + { backendId?: string } + >, +})); +vi.mock("@app/services/policyStorage", () => ({ + loadPolicies: () => policies.value, +})); + +// The REAL run store, because the point of registering is that the auto-run controller finds the run +// there and polls it. A mock would assert the call and prove nothing about the record. +const { getRun, isDispatched, resetPolicyRuns } = + await import("@app/components/policies/policyRunStore"); +const { rerunPolicy, rerunPolicyOnDocument } = + await import("@app/services/notificationPolicyRetry"); + +const target = { policyId: "pol-1", fileId: "f-1" }; + +beforeEach(() => { + getStirlingFile.mockReset().mockResolvedValue(null); + runStoredPolicy.mockReset().mockResolvedValue("run-1"); + policies.value = { security: { backendId: "pol-1" } }; + localStorage.clear(); + resetPolicyRuns(); +}); + +describe("rerunPolicy", () => { + it("submits the stored document under the reference the failure named", async () => { + const document = new File(["%PDF-1.7"], "invoice.pdf", { + type: "application/pdf", + }); + getStirlingFile.mockResolvedValue(document); + + await expect(rerunPolicy(target)).resolves.toEqual({ + ok: true, + tracked: true, + }); + // The original reference, not a new one: the server folds a repeat failure onto the same + // incident rather than opening a second row about the same document. + expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [document], "f-1"); + }); + + it("records the run so the editor polls it and delivers its output", async () => { + // Without this the retry is invisible: nothing polls the run, no output reaches the workspace, + // and the row sits open until the run fails again. usePolicyAutoRun drives all of that off the + // store, so being in the store IS the progress. + const document = new File(["%PDF-1.7"], "invoice.pdf"); + getStirlingFile.mockResolvedValue(document); + + await rerunPolicy(target); + + expect(getRun("run-1")).toMatchObject({ + runId: "run-1", + // The category the backend policy belongs to, which is what the import step needs to honour + // the policy's output mode and what the chain continues from. + categoryId: "security", + // The document that failed is still the document in the workspace, so the output belongs to it. + fileId: "f-1", + fileName: "invoice.pdf", + status: "PENDING", + target: "saas", + }); + // Marked dispatched as any other run is, so the pair is not treated as never having run. + expect(isDispatched("security", "f-1")).toBe(true); + }); + + it("still runs a policy the local cache cannot place, and says the run is untracked", async () => { + // A policy deleted since, or a cache this browser never built. The run is left to go, since the + // server-side effect is real and the submission has already happened, but a run with no category + // cannot be imported or chained, so nothing will ever deliver its output here. The caller is told + // as much rather than being handed a bare success it would close the failure on. + policies.value = {}; + getStirlingFile.mockResolvedValue(new File(["%PDF-1.7"], "invoice.pdf")); + + await expect(rerunPolicy(target)).resolves.toEqual({ + ok: true, + tracked: false, + }); + expect(runStoredPolicy).toHaveBeenCalled(); + expect(getRun("run-1")).toBeUndefined(); + }); + + it("records nothing when the run was refused, so no phantom sits in the feed", async () => { + getStirlingFile.mockResolvedValue(new File(["%PDF-1.7"], "invoice.pdf")); + runStoredPolicy.mockRejectedValue(new Error("refused")); + + await rerunPolicy(target); + + expect(getRun("run-1")).toBeUndefined(); + }); + + it("reports the document is gone rather than submitting nothing", async () => { + getStirlingFile.mockResolvedValue(null); + + await expect(rerunPolicy(target)).resolves.toEqual({ + ok: false, + reason: "missingFile", + }); + expect(runStoredPolicy).not.toHaveBeenCalled(); + }); + + it("treats a browser that will not answer for the file as not having it", async () => { + // Same outcome for the reader either way, and an exception here is not theirs to see. + getStirlingFile.mockRejectedValue(new Error("storage unavailable")); + + await expect(rerunPolicy(target)).resolves.toEqual({ + ok: false, + reason: "missingFile", + }); + }); +}); + +describe("rerunPolicyOnDocument", () => { + const unlocked = new File(["%PDF-1.7"], "invoice.pdf"); + + it("submits bytes the caller already holds, still under the original reference", async () => { + await expect( + rerunPolicyOnDocument(target, unlocked, "f-unlocked"), + ).resolves.toEqual({ + ok: true, + tracked: true, + }); + expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [unlocked], "f-1"); + // Nothing was read from storage: the unlocked document is not there and never will be. + expect(getStirlingFile).not.toHaveBeenCalled(); + }); + + it("attributes the run to the adopted document, not the one the failure named", async () => { + // Two references, deliberately: the server gets the failure's, so a repeat folds onto the same + // incident; the run store gets the adopted one, so the output versions the unlocked document the + // user is now looking at rather than the encrypted original they still have. + await rerunPolicyOnDocument(target, unlocked, "f-unlocked"); + + expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [unlocked], "f-1"); + expect(getRun("run-1")).toMatchObject({ fileId: "f-unlocked" }); + expect(isDispatched("security", "f-unlocked")).toBe(true); + }); + + it("runs untracked rather than filing the output against the wrong document, and admits it", async () => { + // No workspace id came back from the adoption. Recording it against the failure's reference + // would version the encrypted original, which is not what the run produced. So it goes + // unrecorded, and `tracked` carries that outward: the caller needs it to keep the failure open, + // since an unpolled run delivers nothing however well the submission went. + await expect( + rerunPolicyOnDocument(target, unlocked, null), + ).resolves.toEqual({ ok: true, tracked: false }); + + expect(runStoredPolicy).toHaveBeenCalled(); + expect(getRun("run-1")).toBeUndefined(); + }); + + it("carries the server's own words when it refuses", async () => { + runStoredPolicy.mockRejectedValue({ + response: { data: "That policy is no longer enabled." }, + }); + + await expect( + rerunPolicyOnDocument(target, unlocked, "f-unlocked"), + ).resolves.toEqual({ + ok: false, + reason: "rejected", + message: "That policy is no longer enabled.", + }); + }); + + it("reads the message out of a structured error body too", async () => { + runStoredPolicy.mockRejectedValue({ + response: { data: { message: "Job queue is full." } }, + }); + + await expect( + rerunPolicyOnDocument(target, unlocked, "f-unlocked"), + ).resolves.toEqual({ + ok: false, + reason: "rejected", + message: "Job queue is full.", + }); + }); + + it("says nothing rather than something unreadable, leaving the wording to the caller", async () => { + runStoredPolicy.mockRejectedValue(new Error("Network Error")); + + await expect( + rerunPolicyOnDocument(target, unlocked, "f-unlocked"), + ).resolves.toEqual({ + ok: false, + reason: "rejected", + message: null, + }); + }); +}); diff --git a/frontend/editor/src/proprietary/services/notificationPolicyRetry.ts b/frontend/editor/src/proprietary/services/notificationPolicyRetry.ts new file mode 100644 index 0000000000..7f242cbe87 --- /dev/null +++ b/frontend/editor/src/proprietary/services/notificationPolicyRetry.ts @@ -0,0 +1,153 @@ +import { recordRunStart } from "@app/components/policies/policyRunStore"; +import { fileStorage } from "@app/services/fileStorage"; +import { loadPolicies } from "@app/services/policyStorage"; +import { + resolvePolicyRunTarget, + runStoredPolicy, +} from "@app/services/policyApi"; +import type { FileId } from "@app/types/file"; + +/** + * Re-running the stored policy that a notification says failed. + * + * Nothing is stashed for this: the row names the policy and the workspace's own reference to the + * document, and the bytes are in this browser's storage under that same reference, so the whole retry + * is derivable from the row. A tool retry cannot be, which is why `notificationRetry` has a stash. + * + * Not a reuse of the auto-run controller, which is a hook. This makes the same single call the + * auto-run makes and hands it to the same run store, so from there it is like any other run. + */ + +/** The document, and the policy to put it back through. Both read straight off the notification. */ +export interface PolicyRetryTarget { + policyId: string; + /** + * The workspace reference the failing run was filed against. Sent back unchanged so the server folds + * a repeat failure onto the same incident. + */ + fileId: string; +} + +/** + * What became of a re-run, so the caller can say it in the reader's own language. The wording belongs to + * the component layer, which has `t`. + * + * `ok` alone is not enough to act on. A run that went but could not be recorded has no one polling it, so + * its output never reaches this workspace: nothing about the failure is demonstrably fixed, however + * cleanly the submission itself went. `tracked` is that difference, and the caller must not close a row + * on the strength of `ok` without it. + */ +export type PolicyRerunOutcome = + /** In the store, so `usePolicyAutoRun` polls it to terminal and imports what it produced. */ + | { ok: true; tracked: true } + /** Running on the server, with nothing here to collect it. See {@link submit}. */ + | { ok: true; tracked: false } + | { ok: false; reason: "missingFile" } + /** The server refused the run. `message` is its own, or null when it gave nothing usable. */ + | { ok: false; reason: "rejected"; message: string | null }; + +/** Re-run on the document still in this browser's storage, under the reference the failure named. */ +export async function rerunPolicy( + target: PolicyRetryTarget, +): Promise { + let document: File | null = null; + try { + document = await fileStorage.getStirlingFile(target.fileId as FileId); + } catch { + // Treated as absent: a browser that will not answer for the file cannot supply its bytes either. + document = null; + } + if (!document) return { ok: false, reason: "missingFile" }; + + // The document that failed is still the one in the workspace, so the output belongs to it. + return submit(target, document, target.fileId); +} + +/** + * Re-run on bytes the caller already holds: the just-unlocked document, which is not in storage under + * the failing run's reference and never will be. + * + * @param workspaceFileId the workspace file this run's output belongs to, which is the ADOPTED document + * rather than the one the failure named. Two references on purpose: the server gets the failure's, + * so a repeat folds onto the same incident, and the run store gets this one, so the output versions + * the document now in front of the user. Null when the adoption produced no id, in which case the + * run still goes untracked rather than being filed against the wrong document. + */ +export async function rerunPolicyOnDocument( + target: PolicyRetryTarget, + document: File, + workspaceFileId: string | null, +): Promise { + return submit(target, document, workspaceFileId); +} + +/** + * Fire the run, then record it where every other run is recorded. The recording is what makes the retry + * visible: `usePolicyAutoRun` polls every run in the store to completion and imports its outputs, and + * that follows from the run being in the store with a real category, hence the lookup below. + * + * Two things can stop the recording without stopping the run: a local cache that cannot place the policy, + * and an adoption that produced no workspace id. Neither is worth refusing the retry over, since the + * server-side effect is real, but neither is a delivered result either. Both are reported as untracked so + * the caller can say so and leave the failure open rather than closing a row whose output is not coming. + */ +async function submit( + target: PolicyRetryTarget, + document: File, + workspaceFileId: string | null, +): Promise { + // Resolved before the run, so a lookup that throws cannot leave a live run unrecorded. + const categoryId = categoryForPolicy(target.policyId); + const runTarget = resolvePolicyRunTarget(); + + let runId: string; + try { + runId = await runStoredPolicy(target.policyId, [document], target.fileId); + } catch (error) { + return { ok: false, reason: "rejected", message: rejectionMessage(error) }; + } + + // Nothing to file it under, or nothing to file it against. The run itself already went, so it is left + // to run: refusing it now would only add a wasted submission to an undeliverable one. + if (!categoryId || !workspaceFileId) return { ok: true, tracked: false }; + + // Marks (category, file) dispatched as it records, same as any other run: the pair has already run + // once, and this is that run again rather than a new one to dispatch later. + recordRunStart({ + runId, + categoryId, + fileId: workspaceFileId, + fileName: document.name, + fileSize: document.size, + target: runTarget, + status: "PENDING", + outputs: [], + error: null, + startedAt: Date.now(), + }); + return { ok: true, tracked: true }; +} + +/** + * The category whose configured policy this is. The non-hook read rather than `usePolicies`, which needs + * the app-config and team contexts the bell's shell may not have. Same cache the auto-run's reconcile + * writes, so the same answer. + */ +function categoryForPolicy(policyId: string): string | undefined { + try { + return Object.entries(loadPolicies()).find( + ([, state]) => state.backendId === policyId, + )?.[0]; + } catch { + return undefined; + } +} + +/** What the server said, when it said anything readable. Nothing is interpolated here. */ +function rejectionMessage(error: unknown): string | null { + const data = (error as { response?: { data?: unknown } })?.response?.data; + if (typeof data === "string" && data.trim() !== "") return data; + + const message = (data as { message?: unknown } | undefined)?.message; + return typeof message === "string" && message.trim() !== "" ? message : null; +}