From c4a72685c93274ed0e954544bcf5ae6cdb6f0643 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 28 Aug 2026 12:07:38 +0100 Subject: [PATCH] Redesign policy running to be generic with local and server passes --- .../policies/PolicyAutoRunController.tsx | 8 +- .../policies/classificationLocalPass.ts | 169 ++++++++++ .../components/policies/policyLocalPass.ts | 34 ++ .../policies/useClassificationPolicy.ts | 310 ------------------ .../policies/usePolicyAutoRun.batch.test.tsx | 24 +- ...test.tsx => usePolicyLocalPasses.test.tsx} | 27 +- .../policies/usePolicyLocalPasses.ts | 144 ++++++++ .../proprietary/data/classificationPolicy.ts | 17 +- 8 files changed, 391 insertions(+), 342 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/policies/classificationLocalPass.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policyLocalPass.ts delete mode 100644 frontend/editor/src/proprietary/components/policies/useClassificationPolicy.ts rename frontend/editor/src/proprietary/components/policies/{useClassificationPolicy.test.tsx => usePolicyLocalPasses.test.tsx} (93%) create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.ts diff --git a/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx b/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx index 7baf897278..ead0cb1038 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx @@ -1,5 +1,5 @@ import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; -import { useClassificationPolicy } from "@app/components/policies/useClassificationPolicy"; +import { usePolicyLocalPasses } from "@app/components/policies/usePolicyLocalPasses"; /** * Headless controller that drives policy auto-run (enforce every enabled policy @@ -7,9 +7,9 @@ import { useClassificationPolicy } from "@app/components/policies/useClassificat * regardless of whether the policy panel is visible. Renders nothing. */ export function PolicyAutoRunController() { - // File-producing policies and their chain. + // Server-dispatched, file-producing policies and their chain. usePolicyAutoRun(); - // The Classification policy, which runs itself: local pass, then AI escalation when unsure. - useClassificationPolicy(); + // Policies with a browser-side fast path (e.g. classification's heuristic), run generically. + usePolicyLocalPasses(); return null; } diff --git a/frontend/editor/src/proprietary/components/policies/classificationLocalPass.ts b/frontend/editor/src/proprietary/components/policies/classificationLocalPass.ts new file mode 100644 index 0000000000..35940bc08c --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/classificationLocalPass.ts @@ -0,0 +1,169 @@ +/** + * The Classification policy's browser-side fast path, as a {@link LocalPass} the generic local-pass + * engine runs. Everything classification-specific lives here: the heuristic, the label/confidence it + * writes, metering, and the browser-local run it records. The engine only sees the generic result + * (fields to write + whether the AI server run is still needed). + */ + +import { fileStorage } from "@app/services/fileStorage"; +import { classifyFileHeuristically } from "@app/services/heuristic/heuristicClassification"; +import { meterClassificationRun } from "@app/services/classificationMeter"; +import { + isDispatched, + markDispatched, + recordRunStart, + updateRun, +} from "@app/components/policies/policyRunStore"; +import type { FileId } from "@app/types/file"; +import type { StirlingFile } from "@app/types/fileContext"; +import type { HeuristicConfidence } from "@app/services/heuristic/types"; +import { + CLASSIFICATION_CATEGORY_ID, + localVerdictNeedsEscalation, +} from "@app/data/classificationPolicy"; +import type { LocalPass } from "@app/components/policies/policyLocalPass"; + +/** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s). + * The stub can surface in the file list a beat before its bytes are committed. */ +const FILE_WAIT_TRIES = 20; +const FILE_WAIT_MS = 250; + +/** localStorage flag: set to "true" for a full per-file scoring breakdown in the console. */ +const DEBUG_FLAG = "stirling-classification-debug"; + +function isClassificationDebug(): boolean { + try { + return localStorage.getItem(DEBUG_FLAG) === "true"; + } catch { + return false; + } +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const classificationLocalPass: LocalPass = { + // A new document that has not been classified yet. Tool outputs inherit their input's verdict via + // the file reducer, so they are never classified afresh here. + eligible: (stub) => + !stub.derivedFromTool && stub.classificationLabels == null, + run: async (fileId, stub) => { + const verdict = await classifyStub(fileId, stub.name, stub.size ?? 0); + // Bytes never landed (file removed mid-wait): leave unclassified so a reload retries. + if (verdict == null) return null; + return { + stubUpdates: { + classificationLabels: verdict.labels, + classificationConfidence: verdict.confidence, + }, + // A confident local verdict stands; anything less asks the AI engine, which overwrites it. + needsServerRun: localVerdictNeedsEscalation(verdict.confidence), + }; + }, +}; + +/** Classify one file, metering exactly once; null = no verdict, retried later. */ +async function classifyStub( + fileId: FileId, + fileName: string, + fileSize: number, +): Promise<{ labels: string[]; confidence: HeuristicConfidence } | null> { + let file: StirlingFile | null = null; + for (let i = 0; i < FILE_WAIT_TRIES; i++) { + file = await fileStorage.getStirlingFile(fileId).catch(() => null); + if (file) break; + await delay(FILE_WAIT_MS); + } + if (!file) { + console.warn( + `[Classify] ${fileName}: bytes never arrived in storage; will retry on next load`, + ); + return null; + } + const debug = isClassificationDebug(); + const startedAt = performance.now(); + // A local run is still a billable policy run, so it belongs in the activity feed; recorded only + // once the bytes are in hand, so a file whose bytes never land leaves no phantom row. + + // Read before recordRunStart, which takes the dispatch key itself and would otherwise always + // answer "already dispatched", silently stopping metering. + const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`; + recordRunStart({ + runId, + categoryId: CLASSIFICATION_CATEGORY_ID, + fileId: fileId as string, + fileName, + fileSize, + target: "local", + // The heuristic ran in the browser - there is no server run to poll (see the poll effect). + browserLocal: true, + status: "RUNNING", + outputs: [], + error: null, + startedAt: Date.now(), + }); + try { + const result = await classifyFileHeuristically(file, { explain: debug }); + const { labels } = result; + const ms = Math.round(performance.now() - startedAt); + const verdict = + labels.length > 0 + ? labels.join(", ") + : result.isEnglish + ? "no label" + : "no label (not English)"; + console.debug( + `[Classify] ${fileName} -> ${verdict} (${result.confidence}, score ${result.score}, ${ms}ms)` + + (alreadyMetered ? " [heal: not re-metered]" : ""), + ); + if (debug && result.explain) logExplanation(fileName, result); + // Meter on the first classification only; a healing re-run of an undelivered + // result (already dispatched) is not a new billable run. + if (!alreadyMetered) { + meterClassificationRun({ + policyName: "Classification", + documentCount: 1, + labels, + }); + } + markDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + // Labels, no output file - the same settle shape the server-run classification uses. + updateRun(runId, { + status: "COMPLETED", + imported: true, + outputFileIds: [fileId as string], + }); + return { labels, confidence: result.confidence }; + } catch (err) { + // Never persist a verdict for an unreadable file - the failure may be + // environmental, so it must stay eligible to retry (and meter) later. + console.warn(`[Classify] ${fileName}: could not be read, will retry`, err); + updateRun(runId, { + status: "FAILED", + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** Full scoring breakdown, one collapsed console group per file (debug flag only). */ +function logExplanation( + fileName: string, + result: Awaited>, +): void { + const ex = result.explain; + if (!ex) return; + console.groupCollapsed( + `[Classify] ${fileName} scoring (english=${ex.isEnglish}, lowText=${ex.lowText})`, + ); + if (ex.candidates.length === 0) { + console.log("no label scored above zero"); + } + for (const c of ex.candidates) { + console.log( + `${c.id}${c.emit ? "" : " (suppressed)"}: score ${c.score}, ${c.distinct} distinct signals`, + ); + for (const s of c.signals) console.log(` ${s}`); + } + console.groupEnd(); +} diff --git a/frontend/editor/src/proprietary/components/policies/policyLocalPass.ts b/frontend/editor/src/proprietary/components/policies/policyLocalPass.ts new file mode 100644 index 0000000000..a8bfec2c12 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyLocalPass.ts @@ -0,0 +1,34 @@ +/** + * The generic "browser-side fast path" seam. A policy may declare a {@link LocalPass}: cheap local + * work that runs before any server dispatch and can settle a file on its own, or decide the server + * run is still needed. The local-pass engine ({@link ../../hooks/usePolicyLocalPasses}) runs it + * without knowing what it computes; the policy-specific logic lives entirely inside the pass. + */ + +import type { FileId } from "@app/types/file"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy"; +import { classificationLocalPass } from "@app/components/policies/classificationLocalPass"; + +export interface LocalPassResult { + /** Fields to merge onto the file's stub and stored metadata. Opaque to the engine. */ + stubUpdates: Partial; + /** Whether the policy's server run should still be dispatched after this pass. */ + needsServerRun: boolean; +} + +export interface LocalPass { + /** Files this pass should run on (e.g. new documents it has not processed yet). */ + eligible(stub: StirlingFileStub): boolean; + /** + * Do the local work for one file. Returns the stub fields to write and whether the server run is + * still needed, or null if the work could not be done and should be retried later. + */ + run(fileId: FileId, stub: StirlingFileStub): Promise; +} + +/** The local fast path a policy declares, if any. The default (most policies) is none. */ +export function localPassFor(categoryId: string): LocalPass | undefined { + if (categoryId === CLASSIFICATION_CATEGORY_ID) return classificationLocalPass; + return undefined; +} diff --git a/frontend/editor/src/proprietary/components/policies/useClassificationPolicy.ts b/frontend/editor/src/proprietary/components/policies/useClassificationPolicy.ts deleted file mode 100644 index c0f6cbe874..0000000000 --- a/frontend/editor/src/proprietary/components/policies/useClassificationPolicy.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useAllFiles, useFileManagement } from "@app/contexts/FileContext"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; -import { useIndexedDB } from "@app/contexts/IndexedDBContext"; -import { fileStorage } from "@app/services/fileStorage"; -import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled"; -import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled"; -import { scheduleIdle } from "@app/utils/scheduleIdle"; -import { usePolicies } from "@app/hooks/usePolicies"; -import { classifyFileHeuristically } from "@app/services/heuristic/heuristicClassification"; -import { meterClassificationRun } from "@app/services/classificationMeter"; -import { runPolicyOnFile } from "@app/services/policyDispatch"; -import { - isDispatched, - markDispatched, - recordRunStart, - updateRun, - usePolicyRuns, -} from "@app/components/policies/policyRunStore"; -import type { FileId } from "@app/types/file"; -import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; -import type { HeuristicConfidence } from "@app/services/heuristic/types"; -import { - CLASSIFICATION_CATEGORY_ID, - localVerdictNeedsEscalation, - orderedRewritingCategories, -} from "@app/data/classificationPolicy"; - -/** Files classified per idle pass, so a large library drains over several ticks. */ -const CLASSIFY_BATCH = 3; -/** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s). - * The stub can surface in the file list a beat before its bytes are committed. */ -const FILE_WAIT_TRIES = 20; -const FILE_WAIT_MS = 250; - -/** localStorage flag: set to "true" for a full per-file scoring breakdown in the console. */ -const DEBUG_FLAG = "stirling-classification-debug"; - -function isClassificationDebug(): boolean { - try { - return localStorage.getItem(DEBUG_FLAG) === "true"; - } catch { - return false; - } -} - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export function useClassificationPolicy(): void { - const { fileStubs } = useAllFiles(); - const { updateStirlingFileStub } = useFileManagement(); - const { bumpRevision } = useIndexedDB(); - const { policies } = usePolicies(); - const classificationEnabled = useClassificationEnabled(); - const aiEnabled = useAiEngineEnabled(); - // Still waited on: a verdict written before app-config lands would be escalated before it knows - // whether the AI engine is even available. - const { loading: configLoading } = useAppConfig(); - const runs = usePolicyRuns(); - // Read inside the effect without re-firing it every status poll; the effect keys off the file list - // and the settled-outputs signal below instead. - const runsRef = useRef(runs); - runsRef.current = runs; - // Files claimed this session, keyed id+lastModified so a new version is retried once. A claim is - // taken synchronously right before classifying, so overlapping batches never double-classify. - const claimed = useRef>(new Set()); - // Bumped after each batch to drain the next one. - const [tick, setTick] = useState(0); - - // TODO: keyed on the Classification CATEGORY, so a pipeline that merely contains a classify - // step gets no local pass - suppressing one step of a chain is not expressible today. - const policy = policies[CLASSIFICATION_CATEGORY_ID]; - const backendId = policy?.backendId; - // Only when the admin has an active Classification policy - the same gate the file-producing - // policies use in the auto-run engine. - const active = Boolean( - policy?.configured && - policy.status === "active" && - backendId && - policy.runsOnEditor && - (policy.runOn ?? "upload") === "upload", - ); - - // The file-producing policies whose chain classification waits behind: it runs on the last one's - // output, not on an upload a rewrite is about to change. Empty means classification runs on uploads. - const rewriters = useMemo( - () => orderedRewritingCategories(policies), - [policies], - ); - const lastRewriter = rewriters.at(-1); - - // A stable key of the final (last-rewriter) output ids, so the effect re-runs when a chain settles - // a new document but not on every unrelated status poll. - const settledOutputsKey = useMemo(() => { - if (!lastRewriter) return ""; - return runs - .filter((r) => r.categoryId === lastRewriter && r.status === "COMPLETED") - .flatMap((r) => r.outputFileIds ?? []) - .sort() - .join(","); - }, [runs, lastRewriter]); - - useEffect(() => { - // Runs whether or not the AI engine is on: the local pass is the first pass either way. Escalation - // below is what needs the engine. - if (configLoading || !classificationEnabled || !active) { - return; - } - const claimKey = (s: StirlingFileStub) => - `${s.id as string}:${s.lastModified ?? 0}`; - // A document is ours to classify once no rewrite will change it: an upload when nothing rewrites, - // or the output of the last rewriter in a chain (identified by that run, so an upload a rewrite is - // about to change is never picked up early). - const isSettledLeaf = (s: StirlingFileStub): boolean => { - if (!lastRewriter) return !s.derivedFromTool; - return runsRef.current.some( - (r) => - r.categoryId === lastRewriter && - r.status === "COMPLETED" && - (r.outputFileIds ?? []).includes(s.id as string), - ); - }; - // null labels = never classified, retried here; [] = definitive no-label verdict. - const pending = fileStubs - .filter( - (s) => - s.classificationLabels == null && - !claimed.current.has(claimKey(s)) && - isSettledLeaf(s), - ) - .slice(0, CLASSIFY_BATCH); - if (pending.length === 0) return; - let cancelled = false; - const cancelIdle = scheduleIdle(() => { - // Superseded before starting: the newer effect instance owns the queue. - if (cancelled) return; - void (async () => { - let wrote = false; - for (const stub of pending) { - const key = claimKey(stub); - // Re-validate at execution time - another batch may have claimed it since. - if (claimed.current.has(key)) continue; - claimed.current.add(key); - const verdict = await classifyStub( - stub.id, - stub.name, - stub.size ?? 0, - ); - // Bytes never landed (file removed mid-wait): leave unclassified so a - // reload (or new version) retries; the claim stops churn this session. - if (verdict == null) continue; - // Deliver unconditionally - a re-render must never discard a computed - // (and already metered) result. Writes are idempotent. - updateStirlingFileStub(stub.id, { - classificationLabels: verdict.labels, - classificationConfidence: verdict.confidence, - }); - const ok = await fileStorage.updateFileMetadata(stub.id, { - classificationLabels: verdict.labels, - classificationConfidence: verdict.confidence, - }); - if (ok) wrote = true; - // Escalate an unsure verdict to the AI engine, which overwrites it. A chained output jumps - // the dispatch queue so a file mid-flow finishes before new uploads start. - if ( - aiEnabled && - backendId && - localVerdictNeedsEscalation(verdict.confidence) - ) { - void runPolicyOnFile( - CLASSIFICATION_CATEGORY_ID, - backendId, - stub.id, - stub.name, - Boolean(stub.derivedFromTool), - ).catch(() => { - // Backstop: runPolicyOnFile handles its own failures. - }); - } - } - if (wrote) bumpRevision(); - // Drain the next batch; the terminal pass finds nothing pending and stops. - setTick((n) => n + 1); - })(); - }); - return () => { - cancelled = true; - cancelIdle(); - }; - }, [ - fileStubs, - active, - aiEnabled, - backendId, - classificationEnabled, - configLoading, - lastRewriter, - settledOutputsKey, - updateStirlingFileStub, - bumpRevision, - tick, - ]); -} - -/** Classify one file, metering exactly once; null = no verdict, retried later. */ -async function classifyStub( - fileId: FileId, - fileName: string, - fileSize: number, -): Promise<{ labels: string[]; confidence: HeuristicConfidence } | null> { - let file: StirlingFile | null = null; - for (let i = 0; i < FILE_WAIT_TRIES; i++) { - file = await fileStorage.getStirlingFile(fileId).catch(() => null); - if (file) break; - await delay(FILE_WAIT_MS); - } - if (!file) { - console.warn( - `[Classify] ${fileName}: bytes never arrived in storage; will retry on next load`, - ); - return null; - } - const debug = isClassificationDebug(); - const startedAt = performance.now(); - // A local run is still a billable policy run, so it belongs in the activity feed; recorded only - // once the bytes are in hand, so a file whose bytes never land leaves no phantom row. - - // Read before recordRunStart, which takes the dispatch key itself and would otherwise always - // answer "already dispatched", silently stopping metering. - const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId); - const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`; - recordRunStart({ - runId, - categoryId: CLASSIFICATION_CATEGORY_ID, - fileId: fileId as string, - fileName, - fileSize, - target: "local", - // The heuristic ran in the browser - there is no server run to poll (see the poll effect). - browserLocal: true, - status: "RUNNING", - outputs: [], - error: null, - startedAt: Date.now(), - }); - try { - const result = await classifyFileHeuristically(file, { explain: debug }); - const { labels } = result; - const ms = Math.round(performance.now() - startedAt); - const verdict = - labels.length > 0 - ? labels.join(", ") - : result.isEnglish - ? "no label" - : "no label (not English)"; - console.debug( - `[Classify] ${fileName} -> ${verdict} (${result.confidence}, score ${result.score}, ${ms}ms)` + - (alreadyMetered ? " [heal: not re-metered]" : ""), - ); - if (debug && result.explain) logExplanation(fileName, result); - // Meter on the first classification only; a healing re-run of an undelivered - // result (already dispatched) is not a new billable run. - if (!alreadyMetered) { - meterClassificationRun({ - policyName: "Classification", - documentCount: 1, - labels, - }); - } - markDispatched(CLASSIFICATION_CATEGORY_ID, fileId); - // Labels, no output file - the same settle shape the server-run classification uses. - updateRun(runId, { - status: "COMPLETED", - imported: true, - outputFileIds: [fileId as string], - }); - return { labels, confidence: result.confidence }; - } catch (err) { - // Never persist a verdict for an unreadable file - the failure may be - // environmental, so it must stay eligible to retry (and meter) later. - console.warn(`[Classify] ${fileName}: could not be read, will retry`, err); - updateRun(runId, { - status: "FAILED", - error: err instanceof Error ? err.message : String(err), - }); - return null; - } -} - -/** Full scoring breakdown, one collapsed console group per file (debug flag only). */ -function logExplanation( - fileName: string, - result: Awaited>, -): void { - const ex = result.explain; - if (!ex) return; - console.groupCollapsed( - `[Classify] ${fileName} scoring (english=${ex.isEnglish}, lowText=${ex.lowText})`, - ); - if (ex.candidates.length === 0) { - console.log("no label scored above zero"); - } - for (const c of ex.candidates) { - console.log( - `${c.id}${c.emit ? "" : " (suppressed)"}: score ${c.score}, ${c.distinct} distinct signals`, - ); - for (const s of c.signals) console.log(` ${s}`); - } - console.groupEnd(); -} 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 4e0bb891f0..725aa16ac5 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx @@ -124,14 +124,14 @@ vi.mock("@app/services/classificationMeter", () => ({ })); import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; -import { useClassificationPolicy } from "@app/components/policies/useClassificationPolicy"; +import { usePolicyLocalPasses } from "@app/components/policies/usePolicyLocalPasses"; import { usePolicyRuns, resetPolicyRuns, } from "@app/components/policies/policyRunStore"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; -// Run idle callbacks immediately so the classification hook's batches start without timer waits. +// Run idle callbacks immediately so the local-pass engine's batches start without timer waits. vi.stubGlobal("requestIdleCallback", (cb: () => void) => { cb(); return 1; @@ -142,12 +142,15 @@ vi.stubGlobal("cancelIdleCallback", () => {}); let latestRuns: PolicyRunRecord[] = []; function Harness() { usePolicyAutoRun(); - useClassificationPolicy(); + usePolicyLocalPasses(); latestRuns = usePolicyRuns(); return null; } function replaceInWorkspace(inputIds: string[], outputIds: string[]) { + // A versioned output inherits its input's classification verdict, exactly as the real CONSUME_FILES + // reducer does - so a label put on the upload rides forward without re-classifying the output. + const donor = mocks.workspace.find((s) => inputIds.includes(s.id)); mocks.workspace = mocks.workspace .filter((s) => !inputIds.includes(s.id)) .concat( @@ -155,6 +158,8 @@ function replaceInWorkspace(inputIds: string[], outputIds: string[]) { id, name: "doc.pdf", derivedFromTool: true, + classificationLabels: donor?.classificationLabels, + classificationConfidence: donor?.classificationConfidence, })), ); } @@ -335,19 +340,18 @@ describe("policy auto-run — 61-file batch through a Security → Classificatio await act(async () => { await vi.waitFor( () => { - const imported = latestRuns.filter((r) => r.imported).length; - expect(imported).toBe(FILE_COUNT); + const security = latestRuns.filter( + (r) => r.categoryId === "security" && r.imported, + ); + expect(security).toHaveLength(FILE_COUNT); }, { timeout: 8000, interval: 20 }, ); }); // Security's versions went to STORAGE, never re-added to the workbench, so the - // workspace stays empty. Classification needs the file in the workbench to tag, - // so a closed file is left unclassified rather than re-opened. - expect(latestRuns.filter((r) => r.categoryId === "security")).toHaveLength( - FILE_COUNT, - ); + // workspace stays empty. (Classification may have tagged the few files still open + // when the workbench was cleared; the point here is the runner does not re-open them.) expect(mocks.workspace).toHaveLength(0); expect(mocks.consumeSilentCalls).toBe(0); expect(mocks.persistCalls).toBeGreaterThan(0); diff --git a/frontend/editor/src/proprietary/components/policies/useClassificationPolicy.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.test.tsx similarity index 93% rename from frontend/editor/src/proprietary/components/policies/useClassificationPolicy.test.tsx rename to frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.test.tsx index bc9a688d07..800e4f2a37 100644 --- a/frontend/editor/src/proprietary/components/policies/useClassificationPolicy.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.test.tsx @@ -87,7 +87,7 @@ vi.mock("@app/services/classificationMeter", () => ({ meterClassificationRun: (payload: unknown) => mocks.meter(payload), })); -import { useClassificationPolicy } from "@app/components/policies/useClassificationPolicy"; +import { usePolicyLocalPasses } from "@app/components/policies/usePolicyLocalPasses"; // Run idle callbacks immediately so batches start without timer waits. vi.stubGlobal("requestIdleCallback", (cb: () => void) => { @@ -105,7 +105,7 @@ const stub = (id: string, extra: Partial = {}): TestStub => ({ const fakeFile = (id: string) => new File([id], `${id}.pdf`); -describe("useClassificationPolicy delivery", () => { +describe("usePolicyLocalPasses delivery", () => { beforeEach(() => { localStorage.clear(); resetPolicyRuns(); @@ -132,7 +132,7 @@ describe("useClassificationPolicy delivery", () => { labels: [file.name.startsWith("a") ? "invoice" : "resume"], })); - renderHook(() => useClassificationPolicy()); + renderHook(() => usePolicyLocalPasses()); await waitFor(() => expect(mocks.updateStirlingFileStub).toHaveBeenCalledTimes(2), @@ -155,7 +155,7 @@ describe("useClassificationPolicy delivery", () => { ); mocks.workspace = [stub("a")]; - const { rerender } = renderHook(() => useClassificationPolicy()); + const { rerender } = renderHook(() => usePolicyLocalPasses()); await waitFor(() => expect(mocks.classify).toHaveBeenCalledTimes(1)); // A new upload mid-classify re-fires the effect and cancels the in-flight @@ -183,7 +183,7 @@ describe("useClassificationPolicy delivery", () => { mocks.workspace = [stub("plain")]; mocks.classify.mockResolvedValue({ labels: [] }); - renderHook(() => useClassificationPolicy()); + renderHook(() => usePolicyLocalPasses()); await waitFor(() => expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("plain", { @@ -200,7 +200,7 @@ describe("useClassificationPolicy delivery", () => { mocks.workspace = [stub("lost")]; mocks.classify.mockResolvedValue({ labels: ["bank-statement"] }); - renderHook(() => useClassificationPolicy()); + renderHook(() => usePolicyLocalPasses()); await waitFor(() => expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("lost", { @@ -217,7 +217,7 @@ describe("useClassificationPolicy delivery", () => { mocks.workspace = [stub("corrupt")]; mocks.classify.mockRejectedValue(new Error("bad pdf")); - renderHook(() => useClassificationPolicy()); + renderHook(() => usePolicyLocalPasses()); await waitFor(() => expect(warn).toHaveBeenCalledWith( @@ -239,7 +239,7 @@ describe("useClassificationPolicy delivery", () => { mocks.workspace = [stub("early")]; mocks.classify.mockResolvedValue({ labels: ["invoice"] }); - const { rerender } = renderHook(() => useClassificationPolicy()); + const { rerender } = renderHook(() => usePolicyLocalPasses()); await new Promise((r) => setTimeout(r, 50)); expect(mocks.classify).not.toHaveBeenCalled(); @@ -260,7 +260,7 @@ describe("useClassificationPolicy delivery", () => { stub("verdict", { classificationLabels: [] }), ]; - renderHook(() => useClassificationPolicy()); + renderHook(() => usePolicyLocalPasses()); // Nothing to classify; give the (immediate) idle path a beat to prove it. await new Promise((r) => setTimeout(r, 50)); @@ -275,7 +275,7 @@ describe("useClassificationPolicy delivery", () => { confidence: "low", }); - renderHook(() => useClassificationPolicy()); + renderHook(() => usePolicyLocalPasses()); await waitFor(() => expect(mocks.runPolicyOnFile).toHaveBeenCalledWith( @@ -283,7 +283,6 @@ describe("useClassificationPolicy delivery", () => { "backend-classification", "a", "a.pdf", - false, // an upload, not a chained output ), ); // The local verdict is still delivered before escalation. @@ -301,7 +300,7 @@ describe("useClassificationPolicy delivery", () => { confidence: "high", }); - renderHook(() => useClassificationPolicy()); + renderHook(() => usePolicyLocalPasses()); await waitFor(() => expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("a", { @@ -320,7 +319,7 @@ describe("useClassificationPolicy delivery", () => { confidence: "none", }); - renderHook(() => useClassificationPolicy()); + renderHook(() => usePolicyLocalPasses()); await waitFor(() => expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("a", { @@ -342,7 +341,7 @@ describe("useClassificationPolicy delivery", () => { confidence: "low", }); - renderHook(() => useClassificationPolicy()); + renderHook(() => usePolicyLocalPasses()); // Give the (immediate) idle path a beat to prove it stays silent. await new Promise((r) => setTimeout(r, 50)); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.ts b/frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.ts new file mode 100644 index 0000000000..20b419fc93 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.ts @@ -0,0 +1,144 @@ +/** + * Generic engine for policies' browser-side fast paths. Any active editor policy that declares a + * {@link LocalPass} has it run here: eligible files are classified/processed locally, the returned + * fields are written to the stub, and the policy's server run is dispatched only if the pass says it + * is still needed (and the AI engine, if the policy needs it, is on). + */ + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAllFiles, useFileManagement } from "@app/contexts/FileContext"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIndexedDB } from "@app/contexts/IndexedDBContext"; +import { fileStorage } from "@app/services/fileStorage"; +import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled"; +import { scheduleIdle } from "@app/utils/scheduleIdle"; +import { usePolicies } from "@app/hooks/usePolicies"; +import { runPolicyOnFile } from "@app/services/policyDispatch"; +import { + localPassFor, + type LocalPass, +} from "@app/components/policies/policyLocalPass"; +import { policyRequiresAiEngine } from "@app/data/classificationPolicy"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +/** Files processed per idle pass, so a large upload drains over several ticks instead of janking. */ +const LOCAL_PASS_BATCH = 3; + +interface ActivePass { + categoryId: string; + backendId: string; + pass: LocalPass; + /** When true, the server run is skipped while the AI engine is off (nothing to escalate to). */ + requiresAiEngine: boolean; +} + +export function usePolicyLocalPasses(): void { + const { fileStubs } = useAllFiles(); + const { updateStirlingFileStub } = useFileManagement(); + const { bumpRevision } = useIndexedDB(); + const { policies } = usePolicies(); + const aiEnabled = useAiEngineEnabled(); + // Waited on so a verdict is not written and escalated before it is known whether the AI engine + // (which the server run may need) is even available. + const { loading: configLoading } = useAppConfig(); + // Files claimed this session, keyed policy+id+lastModified so a new version is retried once. Claimed + // synchronously right before running, so overlapping batches never double-process. + const claimed = useRef>(new Set()); + // Bumped after each batch to drain the next one. + const [tick, setTick] = useState(0); + + // Active editor upload policies that declare a local fast path. + const passes = useMemo(() => { + const out: ActivePass[] = []; + for (const [categoryId, s] of Object.entries(policies)) { + const active = + s.configured && + s.status === "active" && + s.backendId && + s.runsOnEditor && + (s.runOn ?? "upload") === "upload"; + if (!active) continue; + const pass = localPassFor(categoryId); + if (!pass) continue; + out.push({ + categoryId, + backendId: s.backendId as string, + pass, + requiresAiEngine: policyRequiresAiEngine(categoryId), + }); + } + return out; + }, [policies]); + + useEffect(() => { + if (configLoading || passes.length === 0) return; + const claimKey = (categoryId: string, s: StirlingFileStub) => + `${categoryId}:${s.id as string}:${s.lastModified ?? 0}`; + // Collect one idle batch of pending (pass, file) work across all passes. + const batch: { active: ActivePass; stub: StirlingFileStub }[] = []; + outer: for (const active of passes) { + for (const stub of fileStubs) { + if (batch.length >= LOCAL_PASS_BATCH) break outer; + if (!active.pass.eligible(stub)) continue; + if (claimed.current.has(claimKey(active.categoryId, stub))) continue; + batch.push({ active, stub }); + } + } + if (batch.length === 0) return; + let cancelled = false; + const cancelIdle = scheduleIdle(() => { + // Superseded before starting: the newer effect instance owns the queue. + if (cancelled) return; + void (async () => { + let wrote = false; + for (const { active, stub } of batch) { + const key = claimKey(active.categoryId, stub); + // Re-validate at execution time - another batch may have claimed it since. + if (claimed.current.has(key)) continue; + claimed.current.add(key); + const result = await active.pass.run(stub.id, stub); + // Could not run (e.g. bytes not in storage yet): leave unprocessed so a reload retries. + if (result == null) continue; + // Deliver unconditionally - a re-render must never discard a computed result. Writes are + // idempotent. The engine applies the fields the pass returned without reading them. + updateStirlingFileStub(stub.id, result.stubUpdates); + const ok = await fileStorage.updateFileMetadata( + stub.id, + result.stubUpdates, + ); + if (ok) wrote = true; + // Dispatch the server run only if the pass still wants it, and skip it while an + // AI-engine-dependent policy has no engine to reach. + if ( + result.needsServerRun && + !(active.requiresAiEngine && !aiEnabled) + ) { + void runPolicyOnFile( + active.categoryId, + active.backendId, + stub.id, + stub.name, + ).catch(() => { + // Backstop: runPolicyOnFile handles its own failures. + }); + } + } + if (wrote) bumpRevision(); + // Drain the next batch; the terminal pass finds nothing pending and stops. + setTick((n) => n + 1); + })(); + }); + return () => { + cancelled = true; + cancelIdle(); + }; + }, [ + fileStubs, + passes, + aiEnabled, + configLoading, + updateStirlingFileStub, + bumpRevision, + tick, + ]); +} diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.ts b/frontend/editor/src/proprietary/data/classificationPolicy.ts index e3ea48ab04..cf704d87d3 100644 --- a/frontend/editor/src/proprietary/data/classificationPolicy.ts +++ b/frontend/editor/src/proprietary/data/classificationPolicy.ts @@ -1,8 +1,9 @@ /** - * Everything specific to the built-in Classification policy, in one module. The generic policy - * runner dispatches and chains only file-producing policies (see {@link orderedRewritingCategories}); - * classification runs itself - local heuristic first, escalating an unsure verdict to the AI - so the - * runner never learns it has two ways to run. A second annotating policy is a change here, not there. + * Everything specific to the built-in Classification policy, in one module. The generic policy runner + * dispatches and chains only file-producing policies (see {@link orderedRewritingCategories}), and the + * generic local-pass engine runs whatever browser-side fast path a policy declares. Classification's + * fast path (its heuristic) lives in classificationLocalPass; the capability answers below let the + * generic engines treat it without naming it. A second annotating policy is a change here, not there. * * These are still keyed on the category id rather than a property each policy declares. That is * deliberate for now: policies are becoming pipelines with labels behind a separate enforcement @@ -35,6 +36,14 @@ export function policyDeliversOutputFiles(categoryId: string): boolean { return policyRewritesDocument(categoryId); } +/** + * Whether the policy's server run needs the AI engine. The local-pass engine skips dispatching such + * a run when the engine is off - there is nothing to escalate to, and the local verdict stands. + */ +export function policyRequiresAiEngine(categoryId: string): boolean { + return isClassificationCategory(categoryId); +} + /** Order annotating policies last; everything else keeps the order it was given. */ export function orderRewritesFirst(categoryIds: string[]): string[] { return [