Compare commits

...
Author SHA1 Message Date
EthanHealy01 e30974f75f Fix frontend CI: type-correct + format the classification tests
classificationLabelTargets.test.ts passed plain string ids where FileId (a
branded string) is required, failing typecheck:all; loosen the test stub builder.
Also apply Prettier to usePolicyFileBadges.test.ts.
2026-07-18 16:58:25 +01:00
EthanHealy01 171d61536c Classification: tag the current leaf, and settle empty-output runs
Fixes two review findings on the async-classification change:

- Labels were stamped on the exact file the run executed on. If the user edited
  during the async run (forking a new leaf), the tags landed on the superseded
  version and never showed on the file they were working with. Now resolve the
  run file's live descendants (the current leaf) and tag those, falling back to
  the run file itself.
- A classification run that COMPLETED with no outputs never settled (import gate
  required outputs), pinning it in-flight forever. Classification now settles
  even with empty outputs (nothing to tag).
2026-07-18 15:09:51 +01:00
EthanHealy01 57f3bdd01c Stop classification blocking the viewer enforcement overlay
The viewer's PolicyEnforcementOverlay derives its enforcing state from a
separate run-store filter in Viewer.tsx (POLICY_IN_FLIGHT_STATUSES), not the
enforcing badge flag the rest of the change gates on — so classification still
triggered the overlay there. Exclude classification runs from that filter too.
2026-07-18 00:07:33 +01:00
EthanHealy01 72f1376537 Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into async-classification-no-block 2026-07-17 20:09:48 +01:00
EthanHealy01 6e4f19ebac Trim comments to essentials 2026-07-17 17:58:10 +01:00
EthanHealy01 737be8eedc Pin classification last when a policy order is persisted
There's no reorder UI today, but the execution sort already forces classification
last. Enforce the same at the point an order is persisted (usePolicies.reorderPolicies)
so a future reorder UI can never store or display classification anywhere but last —
keeping what the user sees consistent with when it actually runs.
2026-07-17 17:36:07 +01:00
EthanHealy01 aca8d196c3 Make classification async: non-blocking, no version bump, runs last
Classification is metadata-only — it reads a document and records labels,
unlike enforcement policies (redact, sanitize, …) which rewrite the file.
Previously it ran in the enforcement chain like any other policy: it blocked
viewing/editing behind the 'Enforcing policy…' overlay, forked a new versioned
child (recorded as an 'automate' step in version history), and could run before
other policies — letting the user in, then a later policy would fork a version
and drop their edits.

Now classification:
- Never marks a file 'enforcing', so it never blocks viewing/editing.
- Imports as metadata only: it stamps the labels onto the existing stub in
  place (workspace + storage), with no versioned child and no history entry.
- Always runs LAST in the chain, so every enforcement policy finishes forking
  its versions before the user is let in.

Its labels still surface as tags once it finishes.
2026-07-17 15:46:16 +01:00
9 changed files with 248 additions and 31 deletions
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { classificationLabelTargets } from "@app/components/policies/usePolicyAutoRun";
import type { StirlingFileStub } from "@app/types/fileContext";
// Loosely-typed builder: FileId is a branded string, so accept plain string ids
// in tests and cast — classificationLabelTargets only reads id/parent/sources.
const stub = (s: {
id: string;
parentFileId?: string;
sourceFileIds?: string[];
}): StirlingFileStub => s as unknown as StirlingFileStub;
describe("classificationLabelTargets", () => {
it("targets the run's own file when it's still the leaf", () => {
const stubs = [stub({ id: "a" }), stub({ id: "b" })];
expect(classificationLabelTargets("a", stubs)).toEqual(["a"]);
});
it("targets a descendant leaf when the file was edited during the run", () => {
// "a" was consumed into leaf "a2" (edit forked a new version mid-run).
const stubs = [stub({ id: "a2", sourceFileIds: ["a"] })];
expect(classificationLabelTargets("a", stubs)).toEqual(["a2"]);
});
it("targets a direct child via parentFileId", () => {
const stubs = [stub({ id: "a2", parentFileId: "a" })];
expect(classificationLabelTargets("a", stubs)).toEqual(["a2"]);
});
it("falls back to the run's file id when nothing matches (file closed)", () => {
expect(classificationLabelTargets("a", [stub({ id: "z" })])).toEqual(["a"]);
});
});
@@ -2,21 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
/**
* Batch integration test for the policy auto-run orchestration, at the scale the
* user hit the bug: 61 files uploaded at once, two active upload policies
* (Classification → Security) chained. Drives the REAL policyRunStore + the REAL
* hook effects (dispatch → poll → import → chain), mocking only the IO boundaries
* (network, storage, thumbnail/stub creation).
*
* Proves the invariants the user asked for:
* - 61 files ⇒ exactly 122 runs (61 classification, then 61 security).
* - Delivery is SILENT + in place (consumeFiles called with { silent: true }),
* never adding a second copy — the workspace never grows past 61.
* - No runaway: if the loop guard regressed, the run count would blow past 122
* (or the test would time out), so an exact 122 is a hard regression gate.
* - Closing all files mid-run does NOT re-open them: with the workspace emptied,
* outputs are delivered to storage (persistVersionedOutputs), never re-added
* to the workspace via consumeFiles.
* Batch integration test (61 files, two chained upload policies) driving the real
* store + hook effects, IO mocked. Classification is forced last (see the sort).
*/
const FILE_COUNT = 61;
@@ -61,7 +48,8 @@ vi.mock("@app/contexts/IndexedDBContext", () => ({
vi.mock("@app/hooks/usePolicies", () => ({
usePolicies: () => ({
policies: {
// Classification runs first (order 0), Security second (order 1).
// Classification is configured first (order 0) but is FORCED to run last
// by the orchestrator; Security (order 1) therefore runs first.
classification: {
configured: true,
status: "active",
@@ -102,7 +90,9 @@ vi.mock("@app/services/fileStubHelpers", () => ({
createStirlingFilesAndStubs: mocks.createStirlingFilesAndStubs,
}));
vi.mock("@app/services/fileClassification", () => ({
readClassificationLabelsFromFile: vi.fn().mockResolvedValue(null),
// Classification always resolves labels here, so the metadata-only import path
// stamps them onto the stub.
readClassificationLabelsFromFile: vi.fn().mockResolvedValue(["Invoice"]),
}));
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
@@ -150,7 +140,7 @@ beforeEach(() => {
mocks.persistVersionedOutputs.mockImplementation(async () => {
mocks.persistCalls += 1;
});
mocks.updateFileMetadata.mockResolvedValue(false);
mocks.updateFileMetadata.mockResolvedValue(true);
mocks.downloadPolicyOutput.mockResolvedValue(
new Blob(["x"], { type: "application/pdf" }),
);
@@ -220,8 +210,8 @@ async function runUntilSettled(expectedRuns: number) {
});
}
describe("policy auto-run — 61-file batch through a Classification → Security chain", () => {
it("produces exactly 122 runs (61 classification, then 61 security)", async () => {
describe("policy auto-run — 61-file batch through a Security → Classification chain", () => {
it("produces exactly 122 runs (61 security, then 61 classification)", async () => {
await runUntilSettled(FILE_COUNT * 2);
const classification = latestRuns.filter(
@@ -234,15 +224,20 @@ describe("policy auto-run — 61-file batch through a Classification → Securit
expect(latestRuns).toHaveLength(FILE_COUNT * 2);
});
it("delivers every output SILENTLY in place — workspace never grows past 61", async () => {
it("versions on Security in place, tags on Classification — workspace never grows past 61", async () => {
await runUntilSettled(FILE_COUNT * 2);
// 122 deliveries, all silent (background), none via the disruptive path.
expect(mocks.consumeSilentCalls).toBe(FILE_COUNT * 2);
// Only the 61 Security runs fork a version, and every one silently in place.
expect(mocks.consumeSilentCalls).toBe(FILE_COUNT);
expect(mocks.consumeNonSilentCalls).toBe(0);
// Classification never forks a version — it only stamps labels onto the stub.
expect(mocks.updateStirlingFileStub).toHaveBeenCalledTimes(FILE_COUNT);
for (const call of mocks.updateStirlingFileStub.mock.calls) {
expect(call[1]).toEqual({ classificationLabels: ["Invoice"] });
}
// Never added as brand-new files either.
expect(mocks.addFilesCalls).toBe(0);
// In-place versioning: each file replaced twice, count unchanged.
// In-place versioning + metadata-only tagging: count unchanged.
expect(mocks.workspace).toHaveLength(FILE_COUNT);
});
@@ -268,8 +263,8 @@ describe("policy auto-run — 61-file batch through a Classification → Securit
);
});
// Still fully processed (chain intact), but delivered to STORAGE, never
// re-added to the workbench — the workspace stays empty.
// Still fully processed (chain intact), but Security's versions went to
// STORAGE, never re-added to the workbench — the workspace stays empty.
expect(latestRuns).toHaveLength(FILE_COUNT * 2);
expect(mocks.workspace).toHaveLength(0);
expect(mocks.consumeSilentCalls).toBe(0);
@@ -39,6 +39,7 @@ import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
import type { FileId } from "@app/types/file";
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
import { readClassificationLabelsFromFile } from "@app/services/fileClassification";
import { isClassificationCategory } from "@app/data/policyCategories";
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
import type { PoliciesByCategory } from "@app/types/policies";
import { usePolicies } from "@app/hooks/usePolicies";
@@ -170,7 +171,14 @@ export function usePolicyAutoRun(): void {
s.sources.includes("editor")) &&
(s.runOn ?? "upload") === "upload",
)
.sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0))
// Classification runs last: it's non-blocking, so an enforcement policy
// running after it would fork a new version and drop the user's edits.
.sort(([idA, a], [idB, b]) => {
const ca = isClassificationCategory(idA) ? 1 : 0;
const cb = isClassificationCategory(idB) ? 1 : 0;
if (ca !== cb) return ca - cb;
return (a.order ?? 0) - (b.order ?? 0);
})
.map(([id]) => id),
[policies],
);
@@ -325,15 +333,31 @@ export function usePolicyAutoRun(): void {
// so the enforced file appears in the app rather than only on the backend.
useEffect(() => {
for (const run of runs) {
const classification = isClassificationCategory(run.categoryId);
if (
run.status !== "COMPLETED" ||
run.imported ||
!run.outputs?.length ||
importing.current.has(run.runId)
importing.current.has(run.runId) ||
// Classification settles even with no outputs (nothing to tag); other
// policies need an output to import.
(!run.outputs?.length && !classification)
) {
continue;
}
importing.current.add(run.runId);
// Classification is metadata-only: stamp labels onto the current leaf of
// the file it ran on (no version fork). See importClassificationLabels.
if (classification) {
const targetIds = classificationLabelTargets(
run.fileId,
fileStubsRef.current,
);
void importClassificationLabels(run, targetIds, {
updateStirlingFileStub,
bumpRevision,
}).finally(() => importing.current.delete(run.runId));
continue;
}
// Honour the policy's output mode: a new file, or a new version of the
// input file it ran on (needs that input's stub, still in the workspace).
const outputMode = policies[run.categoryId]?.outputMode ?? "new_version";
@@ -496,6 +520,79 @@ function categoryForPolicy(
)?.[0];
}
interface ClassificationImportContext {
updateStirlingFileStub: (
fileId: FileId,
updates: Partial<StirlingFileStub>,
) => void;
bumpRevision: () => void;
}
/** Workspace stubs to tag with a classification run's labels: the file it ran
* on plus any live descendants, so an edit made during the async run (which
* forks a new leaf) still shows the tags. Falls back to the run's own file. */
export function classificationLabelTargets(
runFileId: string,
stubs: ReadonlyArray<StirlingFileStub>,
): FileId[] {
const targets = stubs
.filter(
(s) =>
(s.id as string) === runFileId ||
s.parentFileId === runFileId ||
s.sourceFileIds?.includes(runFileId as FileId),
)
.map((s) => s.id as FileId);
return targets.length > 0 ? targets : [runFileId as FileId];
}
/**
* Stamp a classification run's labels onto the target stubs in place (workspace
* + storage) — no versioned child, no history entry, only tags.
*/
async function importClassificationLabels(
run: PolicyRunRecord,
targetIds: FileId[],
ctx: ClassificationImportContext,
): Promise<void> {
if (targetIds.length === 0) {
// Server-reconciled run with no local input link — nothing to tag.
updateRun(run.runId, { imported: true });
return;
}
// Read labels from the returned PDF. A non-404 failure is transient — bail and
// retry next tick; a 404 (output aged out) just skips that output. Empty
// outputs (nothing to read) fall through and settle the run below.
let labels: string[] | null = null;
for (const out of run.outputs) {
try {
const blob = await downloadPolicyOutput(out.fileId, run.target);
const file = new File([blob], out.fileName ?? run.fileName, {
type: blob.type || "application/pdf",
});
labels = await readClassificationLabelsFromFile(file);
if (labels && labels.length > 0) break;
} catch (err) {
if (!isNotFoundError(err)) return; // transient — retry on a later tick.
}
}
if (labels && labels.length > 0) {
const updates = { classificationLabels: labels };
let mutated = false;
for (const id of targetIds) {
ctx.updateStirlingFileStub(id, updates);
if (await fileStorage.updateFileMetadata(id, updates)) mutated = true;
}
if (mutated) ctx.bumpRevision();
}
// Settle either way so it stops re-importing. No outputFileIds: classification
// produces no workspace file, so it gets no version badge.
updateRun(run.runId, {
imported: true,
importedFileIds: run.outputs.map((o) => o.fileId),
});
}
/**
* Fetch a completed run's not-yet-imported output files and deliver them to the
* workspace. Per-output, via allSettled: each output is tracked once delivered,
@@ -8,6 +8,7 @@ import {
usePolicyRuns,
type PolicyRunRecord,
} from "@app/components/policies/policyRunStore";
import { isClassificationCategory } from "@app/data/policyCategories";
import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay";
type SignatureOverlayPassThrough = Pick<
@@ -29,6 +30,8 @@ const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => {
? allRuns.filter(
(r: PolicyRunRecord) =>
r.fileId === activeFileId &&
// Classification runs async and must never block the viewer.
!isClassificationCategory(r.categoryId) &&
(POLICY_IN_FLIGHT_STATUSES.includes(r.status) || r.retrying === true),
)
: [];
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import {
isClassificationCategory,
pinClassificationLast,
} from "@app/data/policyCategories";
describe("isClassificationCategory", () => {
it("recognises the classification category and nothing else", () => {
expect(isClassificationCategory("classification")).toBe(true);
expect(isClassificationCategory("security")).toBe(false);
expect(isClassificationCategory("")).toBe(false);
});
});
describe("pinClassificationLast", () => {
it("moves classification to the end, preserving other order", () => {
expect(
pinClassificationLast(["classification", "security", "compliance"]),
).toEqual(["security", "compliance", "classification"]);
});
it("leaves an order without classification untouched", () => {
expect(pinClassificationLast(["security", "compliance"])).toEqual([
"security",
"compliance",
]);
});
it("is a no-op when classification is already last", () => {
expect(pinClassificationLast(["security", "classification"])).toEqual([
"security",
"classification",
]);
});
it("handles classification as the only policy", () => {
expect(pinClassificationLast(["classification"])).toEqual([
"classification",
]);
});
});
@@ -0,0 +1,21 @@
/** The classification policy's catalog category id. */
export const CLASSIFICATION_CATEGORY_ID = "classification";
/**
* Classification is metadata-only: it runs async (never blocks), never forks a
* version, and always runs last. This predicate gates that special handling.
*/
export function isClassificationCategory(categoryId: string): boolean {
return categoryId === CLASSIFICATION_CATEGORY_ID;
}
/**
* Move classification to the end of an execution order (others keep their order),
* so a persisted/displayed order can't place it anywhere but last.
*/
export function pinClassificationLast(orderedCategoryIds: string[]): string[] {
return [
...orderedCategoryIds.filter((id) => !isClassificationCategory(id)),
...orderedCategoryIds.filter((id) => isClassificationCategory(id)),
];
}
@@ -35,6 +35,7 @@ import {
removePolicy,
} from "@app/services/policyBackend";
import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi";
import { pinClassificationLast } from "@app/data/policyCategories";
import type { PolicyToStore } from "@app/services/policyPipeline";
import type {
PoliciesByCategory,
@@ -326,9 +327,12 @@ export function usePolicies() {
* first for an instant re-render; the next reconcile re-reads the server order.
*/
const reorderPolicies = useCallback((orderedCategoryIds: string[]) => {
persistPolicyOrder(orderedCategoryIds);
// Pin classification last so the persisted/server order matches execution
// (it always runs last — see usePolicyAutoRun).
const ordered = pinClassificationLast(orderedCategoryIds);
persistPolicyOrder(ordered);
const current = loadPolicies();
const backendIds = orderedCategoryIds
const backendIds = ordered
.map((categoryId) => current[categoryId]?.backendId)
.filter((id): id is string => !!id);
if (backendIds.length > 0) {
@@ -6,6 +6,7 @@ const NOW = 1_000_000;
const labels = new Map([
["security", "Security"],
["watermark", "Watermark"],
["classification", "Classification"],
]);
function run(overrides: Partial<PolicyRunRecord>): PolicyRunRecord {
@@ -180,4 +181,22 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", (
);
expect(enforcingOn(map, "in")).toBe(false);
});
it("never marks a classification run enforcing (it runs fully async)", () => {
// Classification is metadata-only, so even mid-run it must not block the
// file — no enforcing spinner, no gated actions.
const map = buildPolicyBadgeMap(
[
run({
categoryId: "classification",
status: "RUNNING",
outputFileIds: [],
}),
],
[{ id: "in" }],
labels,
NOW,
);
expect(enforcingOn(map, "in")).toBe(false);
});
});
@@ -4,6 +4,7 @@ import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
import { useAllFiles } from "@app/contexts/FileContext";
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import { policyAccentVar } from "@app/components/policies/policyStatus";
import { isClassificationCategory } from "@app/data/policyCategories";
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
/** How long after a run a badge counts as "recent" (drives the one-off glow).
@@ -107,6 +108,9 @@ export function buildPolicyBadgeMap(
// status alone would drop the badge during that async gap.
for (const run of runs) {
if (!run.fileId) continue;
// Classification runs async and must never mark the file "enforcing" (that
// flag blocks viewing/editing); it only surfaces labels when done.
if (isClassificationCategory(run.categoryId)) continue;
const settled =
run.imported || run.status === "FAILED" || run.status === "CANCELLED";
if (settled && !run.retrying) continue;