mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
93 lines
3.5 KiB
TypeScript
93 lines
3.5 KiB
TypeScript
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
missingBundleForScript,
|
|
SCRIPT_BUNDLE_MAP,
|
|
} from "../../../packages/ai/src/feature-gate.js";
|
|
|
|
let tempDir: string;
|
|
let savedDataDir: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
savedDataDir = process.env.DATA_DIR;
|
|
tempDir = mkdtempSync(join(tmpdir(), "snapotter-gate-"));
|
|
mkdirSync(join(tempDir, "ai"), { recursive: true });
|
|
process.env.DATA_DIR = tempDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedDataDir === undefined) delete process.env.DATA_DIR;
|
|
else process.env.DATA_DIR = savedDataDir;
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function setInstalled(bundleIds: string[]): void {
|
|
const bundles: Record<string, { version: string; installedAt: string; models: string[] }> = {};
|
|
for (const id of bundleIds) {
|
|
bundles[id] = { version: "1.0.0-test", installedAt: "2026-01-01T00:00:00.000Z", models: [] };
|
|
}
|
|
writeFileSync(join(tempDir, "ai", "installed.json"), JSON.stringify({ bundles }), "utf-8");
|
|
}
|
|
|
|
describe("missingBundleForScript", () => {
|
|
it("returns the bundle when a gated script's bundle is not installed", () => {
|
|
setInstalled([]);
|
|
expect(missingBundleForScript("face_landmarks")).toBe("face-detection");
|
|
expect(missingBundleForScript("remove_bg")).toBe("background-removal");
|
|
});
|
|
|
|
it("returns null when the gated script's bundle is installed", () => {
|
|
setInstalled(["face-detection"]);
|
|
expect(missingBundleForScript("face_landmarks")).toBeNull();
|
|
});
|
|
|
|
it("handles a .py suffix", () => {
|
|
setInstalled(["background-removal"]);
|
|
expect(missingBundleForScript("remove_bg.py")).toBeNull();
|
|
setInstalled([]);
|
|
expect(missingBundleForScript("remove_bg.py")).toBe("background-removal");
|
|
});
|
|
|
|
it("returns null for ungated scripts", () => {
|
|
setInstalled([]);
|
|
expect(missingBundleForScript("doc_text")).toBeNull();
|
|
expect(missingBundleForScript("unknown_script")).toBeNull();
|
|
});
|
|
|
|
it("fails closed when installed.json is missing", () => {
|
|
// No setInstalled(): the file does not exist. Like the dispatcher, an
|
|
// unreadable installed.json reads as "nothing installed", so a gated
|
|
// script is blocked.
|
|
expect(missingBundleForScript("face_landmarks")).toBe("face-detection");
|
|
});
|
|
|
|
it("fails closed when installed.json is corrupt", () => {
|
|
writeFileSync(join(tempDir, "ai", "installed.json"), "{{{not json", "utf-8");
|
|
expect(missingBundleForScript("face_landmarks")).toBe("face-detection");
|
|
});
|
|
|
|
it("gates both of passport-photo's scripts (issue #327)", () => {
|
|
setInstalled(["background-removal"]); // face-detection still missing
|
|
expect(missingBundleForScript("face_landmarks")).toBe("face-detection");
|
|
expect(missingBundleForScript("remove_bg")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("SCRIPT_BUNDLE_MAP drift vs dispatcher.py", () => {
|
|
it("matches TOOL_BUNDLE_MAP in dispatcher.py exactly", () => {
|
|
const src = readFileSync(join(process.cwd(), "packages/ai/python/dispatcher.py"), "utf-8");
|
|
const block = src.match(/TOOL_BUNDLE_MAP\s*=\s*\{([\s\S]*?)\}/);
|
|
if (!block) throw new Error("TOOL_BUNDLE_MAP not found in dispatcher.py");
|
|
|
|
const pythonMap: Record<string, string> = {};
|
|
for (const entry of block[1].matchAll(/"(\w+)"\s*:\s*"([a-z0-9-]+)"/g)) {
|
|
pythonMap[entry[1]] = entry[2];
|
|
}
|
|
|
|
// Both directions: the TS mirror must equal the Python source of truth.
|
|
expect(pythonMap).toEqual(SCRIPT_BUNDLE_MAP);
|
|
});
|
|
});
|