mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30974f75f | ||
|
|
171d61536c | ||
|
|
57f3bdd01c | ||
|
|
72f1376537 | ||
|
|
6e4f19ebac | ||
|
|
737be8eedc | ||
|
|
aca8d196c3 |
+33
@@ -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"]);
|
||||
});
|
||||
});
|
||||
+21
-26
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user