fix(ai): enforce the feature gate on the per-request fallback path (#331)

The persistent Python dispatcher rejects scripts whose feature bundle is not
installed, but the per-request fallback (used when the dispatcher is down, e.g.
restarting right after a model repair) spawned scripts directly and bypassed
that gate. Behavior was therefore inconsistent: a gated script would fail under
the dispatcher but run under the fallback -- the "works once after a repair"
symptom from the original report.

- add packages/ai/src/feature-gate.ts: SCRIPT_BUNDLE_MAP + missingBundleForScript,
  mirroring TOOL_BUNDLE_MAP in dispatcher.py, reading the same installed.json and
  failing closed exactly like dispatcher._get_installed_bundles()
- runPerRequest now rejects with "feature_not_installed" (the same message the
  dispatcher path surfaces) when a gated script's bundle is not installed
- unit tests for the gate, plus a drift test pinning the TS map to dispatcher.py

Closes #327
This commit is contained in:
SnapOtter
2026-06-22 23:49:41 +08:00
committed by GitHub
parent 32c1192d63
commit 7a70affac5
5 changed files with 166 additions and 2 deletions
+9
View File
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { context, propagation, SpanStatusCode, trace } from "@opentelemetry/api";
import { missingBundleForScript } from "./feature-gate.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PYTHON_DIR = resolve(__dirname, "../python");
@@ -398,6 +399,14 @@ export class PythonDispatcher {
timeout?: number;
} = {},
): Promise<{ stdout: string; stderr: string }> {
// Mirror the dispatcher's feature gate. The persistent dispatcher rejects
// scripts whose bundle is not installed (in Python); this fallback spawns
// scripts directly, so without the same check it would run them ungated
// when the dispatcher is down (e.g. right after a model repair). Reject
// with the same message the dispatcher path surfaces.
if (missingBundleForScript(scriptName)) {
return Promise.reject(new Error("feature_not_installed"));
}
const scriptPath = resolve(PYTHON_DIR, scriptName);
const timeout =
options.timeout ??
+59
View File
@@ -0,0 +1,59 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Python script name -> required feature bundle.
*
* This MUST mirror TOOL_BUNDLE_MAP in packages/ai/python/dispatcher.py. The
* persistent dispatcher enforces this gate in Python; the per-request fallback
* path (PythonDispatcher.runPerRequest) spawns scripts directly and would
* otherwise bypass it, so it enforces the same gate here. A drift test keeps
* the two maps in sync (tests/unit/ai/feature-gate.test.ts).
*/
export const SCRIPT_BUNDLE_MAP: Record<string, string> = {
remove_bg: "background-removal",
detect_faces: "face-detection",
face_landmarks: "face-detection",
red_eye_removal: "face-detection",
inpaint: "object-eraser-colorize",
outpaint: "object-eraser-colorize",
colorize: "object-eraser-colorize",
upscale: "upscale-enhance",
enhance_faces: "upscale-enhance",
noise_removal: "upscale-enhance",
restore: "photo-restoration",
ocr: "ocr",
ocr_pdf: "ocr",
transcribe: "transcription",
};
/** Bundle ids currently recorded as installed in DATA_DIR/ai/installed.json. */
function installedBundles(): Set<string> {
// Resolve DATA_DIR the same way the Python dispatcher does
// (os.environ.get("DATA_DIR", "/data")).
const installedPath = join(process.env.DATA_DIR || "/data", "ai", "installed.json");
try {
const data = JSON.parse(readFileSync(installedPath, "utf-8")) as {
bundles?: Record<string, unknown>;
};
return new Set(Object.keys(data.bundles ?? {}));
} catch {
// Fail closed, exactly like dispatcher._get_installed_bundles(): a missing
// or unreadable installed.json reads as "nothing installed".
return new Set();
}
}
/**
* The feature bundle a script requires if that bundle is NOT installed, else
* null. Returns null for ungated scripts (e.g. doc-profile scripts) and for
* gated scripts whose bundle is installed. Mirrors the dispatcher's gate so the
* fallback path behaves identically whether or not the dispatcher is running.
*
* Accepts the script name with or without a ".py" suffix.
*/
export function missingBundleForScript(scriptName: string): string | null {
const bundle = SCRIPT_BUNDLE_MAP[scriptName.replace(/\.py$/, "")];
if (!bundle) return null;
return installedBundles().has(bundle) ? null : bundle;
}
+1
View File
@@ -16,6 +16,7 @@ export { blurFaces, detectFaces } from "./face-detection.js";
export { enhanceFaces } from "./face-enhancement.js";
export type { FaceLandmarkPoint, FaceLandmarks, FaceLandmarksResult } from "./face-landmarks.js";
export { detectFaceLandmarks } from "./face-landmarks.js";
export { missingBundleForScript, SCRIPT_BUNDLE_MAP } from "./feature-gate.js";
export { inpaint } from "./inpainting.js";
export { noiseRemoval } from "./noise-removal.js";
export type { PdfOcrOptions, PdfOcrResult } from "./ocr.js";