Redesign policy running to be generic with local and server passes

This commit is contained in:
James Brunton
2026-08-28 13:40:01 +01:00
parent 0fd3da7cd2
commit c4a72685c9
8 changed files with 391 additions and 342 deletions
@@ -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;
}
@@ -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<ReturnType<typeof classifyFileHeuristically>>,
): 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();
}
@@ -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<StirlingFileStub>;
/** 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<LocalPassResult | null>;
}
/** 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;
}
@@ -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<Set<string>>(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<ReturnType<typeof classifyFileHeuristically>>,
): 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();
}
@@ -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);
@@ -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> = {}): 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));
@@ -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<Set<string>>(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<ActivePass[]>(() => {
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,
]);
}
@@ -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 [