diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx index 725aa16ac5..189945c8d2 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx @@ -48,6 +48,12 @@ const mocks = vi.hoisted(() => ({ meter: vi.fn(), })); +// The second (file-producing) policy's timing, flippable per test to prove classification's local +// pass is independent of when the rewriter runs. +const securityRunOn = vi.hoisted(() => ({ + value: "upload" as "upload" | "export", +})); + vi.mock("@app/hooks/useAiEngineEnabled", () => ({ useAiEngineEnabled: () => true, })); @@ -86,7 +92,7 @@ vi.mock("@app/hooks/usePolicies", () => ({ runsOnEditor: true, status: "active", backendId: "backend-security", - runOn: "upload", + runOn: securityRunOn.value, order: 1, outputMode: "new_version", outputName: "", @@ -176,6 +182,7 @@ beforeEach(() => { mocks.backendOutCounter = 0; mocks.dispatchInFlight = 0; mocks.maxDispatchInFlight = 0; + securityRunOn.value = "upload"; mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({ id: `file-${i}`, @@ -356,4 +363,29 @@ describe("policy auto-run — 61-file batch through a Security → Classificatio expect(mocks.consumeSilentCalls).toBe(0); expect(mocks.persistCalls).toBeGreaterThan(0); }); + + it("classifies uploads even when the other editor policy runs on export, not upload", async () => { + // Repro: with a file-producing policy set to export, the auto-run engine dispatches nothing on + // upload - but classification's local pass is independent and must still run on every upload. + securityRunOn.value = "export"; + + renderHook(() => Harness()); + await act(async () => { + await vi.waitFor( + () => { + const classification = latestRuns.filter( + (r) => r.categoryId === "classification" && r.imported, + ); + expect(classification).toHaveLength(FILE_COUNT); + }, + { timeout: 8000, interval: 20 }, + ); + }); + + // The export policy did not run on upload; nothing versioned in place. + expect(latestRuns.filter((r) => r.categoryId === "security")).toHaveLength( + 0, + ); + expect(mocks.consumeSilentCalls).toBe(0); + }); }); diff --git a/frontend/editor/src/proprietary/services/policyStorage.test.ts b/frontend/editor/src/proprietary/services/policyStorage.test.ts index dd0622a7cb..24f2b37af3 100644 --- a/frontend/editor/src/proprietary/services/policyStorage.test.ts +++ b/frontend/editor/src/proprietary/services/policyStorage.test.ts @@ -60,6 +60,43 @@ describe("policyStorage", () => { expect(p.routing.configured).toBe(false); }); + it("migrates a pre-runsOnEditor row narrowed to non-editor sources off the editor", () => { + // Stored before runsOnEditor existed: no such field, sources exclude the editor. + localStorage.setItem( + "stirling-policies-state", + JSON.stringify({ + security: { configured: true, status: "active", sources: ["s3"] }, + }), + ); + // Without the migration the default (true) would wrongly win. + expect(loadPolicies().security.runsOnEditor).toBe(false); + }); + + it("migrates a pre-runsOnEditor row listing the editor onto the editor", () => { + localStorage.setItem( + "stirling-policies-state", + JSON.stringify({ + security: { configured: true, status: "active", sources: ["editor"] }, + }), + ); + expect(loadPolicies().security.runsOnEditor).toBe(true); + }); + + it("leaves an explicit runsOnEditor untouched", () => { + localStorage.setItem( + "stirling-policies-state", + JSON.stringify({ + security: { + configured: true, + status: "active", + sources: ["editor"], + runsOnEditor: false, + }, + }), + ); + expect(loadPolicies().security.runsOnEditor).toBe(false); + }); + it("fires a change event on update", () => { const cb = vi.fn(); const off = onPoliciesChange(cb); diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts index 91dd4f29be..51d927c729 100644 --- a/frontend/editor/src/proprietary/services/policyStorage.ts +++ b/frontend/editor/src/proprietary/services/policyStorage.ts @@ -55,7 +55,14 @@ export function loadPolicies(): PoliciesByCategory { // category gets a default rather than being undefined. const out: PoliciesByCategory = {}; loadPolicyCatalog().categories.forEach((cat, index) => { - const merged = { ...defaultState(cat.id), ...(parsed[cat.id] ?? {}) }; + const stored = parsed[cat.id]; + const merged = { ...defaultState(cat.id), ...(stored ?? {}) }; + // Migration: a row stored before runsOnEditor existed has no such field, so the default (true) + // would put a tile narrowed to non-editor sources on the editor until the first reconcile lands. + // Derive it from the legacy signal (the editor in its sources), mirroring the decode rule. + if (stored && stored.runsOnEditor === undefined) { + merged.runsOnEditor = (stored.sources ?? []).includes("editor"); + } // Migration: clear the obsolete persisted reviewer email so it re-defaults // to the real signed-in user. if (merged.reviewerEmail === STALE_REVIEWER_EMAIL)