fix(sync): company-scope לג'ובים מתוזמנים — הפסקת בליעת-כשל (legal-ai #637) #11
274
src/company-scope.test.ts
Normal file
274
src/company-scope.test.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
|
||||||
|
// `node --test`, which requires the literal `.ts` extension (Node's ESM
|
||||||
|
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
|
||||||
|
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||||
|
import {
|
||||||
|
_resetApiBaseCacheMemo,
|
||||||
|
CompanyScopeUnavailableError,
|
||||||
|
isCompanyScopeDenied,
|
||||||
|
type ResolveApiBaseDeps,
|
||||||
|
resolveApiBaseFrom,
|
||||||
|
runJobHandler,
|
||||||
|
} from "./company-scope.ts";
|
||||||
|
|
||||||
|
// ── isCompanyScopeDenied ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("isCompanyScopeDenied: true עבור אובייקט עם code של INVOCATION_SCOPE_DENIED", () => {
|
||||||
|
const err = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
|
||||||
|
assert.equal(isCompanyScopeDenied(err), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isCompanyScopeDenied: false עבור שגיאה רגילה", () => {
|
||||||
|
assert.equal(isCompanyScopeDenied(new Error("boom")), false);
|
||||||
|
assert.equal(isCompanyScopeDenied({ code: -32002 }), false);
|
||||||
|
assert.equal(isCompanyScopeDenied("boom"), false);
|
||||||
|
assert.equal(isCompanyScopeDenied(null), false);
|
||||||
|
assert.equal(isCompanyScopeDenied(undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── runJobHandler ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const calls: Array<{ level: string; message: string; meta?: unknown }> = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
logger: {
|
||||||
|
error(message: string, meta?: Record<string, unknown>) {
|
||||||
|
calls.push({ level: "error", message, meta });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("runJobHandler: הצלחה — לא זורק ולא מלוגג שגיאה", async () => {
|
||||||
|
const { calls, logger } = makeLogger();
|
||||||
|
await runJobHandler("test-job", logger, async () => {});
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runJobHandler: כשל בשגיאה רגילה — מלוגג error וזורק מחדש את המקורית", async () => {
|
||||||
|
const { calls, logger } = makeLogger();
|
||||||
|
const original = new Error("plain failure");
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runJobHandler("test-job", logger, async () => {
|
||||||
|
throw original;
|
||||||
|
}),
|
||||||
|
(err: unknown) => err === original,
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].message, "test-job: job failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runJobHandler: כשל בדחיית-scope — זורק CompanyScopeUnavailableError שמזכירה #637", async () => {
|
||||||
|
const { logger } = makeLogger();
|
||||||
|
const scopeErr = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runJobHandler("sync-case-status", logger, async () => {
|
||||||
|
throw scopeErr;
|
||||||
|
}),
|
||||||
|
(err: unknown) => {
|
||||||
|
assert.ok(err instanceof CompanyScopeUnavailableError);
|
||||||
|
assert.match(err.message, /#637/);
|
||||||
|
assert.equal(err.jobKey, "sync-case-status");
|
||||||
|
assert.equal(err.cause, scopeErr);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runJobHandler: כשל בדחיית-scope — ההודעה כוללת גם #637 וגם את טקסט השגיאה המקורית (זה מה שנכתב ל-plugin_job_runs.error)", async () => {
|
||||||
|
const { logger } = makeLogger();
|
||||||
|
// כמו JsonRpcCallError אמיתי מהמארח (protocol.js): מופע Error שה-`message`
|
||||||
|
// שלו נושא את שם-הפעולה שנדחתה.
|
||||||
|
const scopeErr = Object.assign(new Error("issues.list"), {
|
||||||
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runJobHandler("route-pending-comments", logger, async () => {
|
||||||
|
throw scopeErr;
|
||||||
|
}),
|
||||||
|
(err: unknown) => {
|
||||||
|
assert.ok(err instanceof CompanyScopeUnavailableError);
|
||||||
|
assert.match(err.message, /#637/);
|
||||||
|
assert.match(err.message, /issues\.list/);
|
||||||
|
// method לא ידוע ל-runJobHandler — "unknown" לא אמור להופיע בהודעה.
|
||||||
|
assert.doesNotMatch(err.message, /"unknown"/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── resolveApiBaseFrom ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeDeps(
|
||||||
|
overrides: Partial<ResolveApiBaseDeps> & {
|
||||||
|
configByCompany?: Record<string, Record<string, unknown> | undefined>;
|
||||||
|
deniedCompanyIds?: readonly (string | undefined)[];
|
||||||
|
failingCompanyIds?: readonly (string | undefined)[];
|
||||||
|
} = {},
|
||||||
|
): ResolveApiBaseDeps & {
|
||||||
|
cacheWrites: string[];
|
||||||
|
infoLogs: unknown[];
|
||||||
|
warnLogs: unknown[];
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
configByCompany = {},
|
||||||
|
deniedCompanyIds = [],
|
||||||
|
failingCompanyIds = [],
|
||||||
|
...rest
|
||||||
|
} = overrides;
|
||||||
|
const cacheWrites: string[] = [];
|
||||||
|
const infoLogs: unknown[] = [];
|
||||||
|
const warnLogs: unknown[] = [];
|
||||||
|
let cacheValue: unknown = null;
|
||||||
|
|
||||||
|
const deniedKey = (id?: string) => (id === undefined ? "__undefined__" : id);
|
||||||
|
|
||||||
|
const deps: ResolveApiBaseDeps & {
|
||||||
|
cacheWrites: string[];
|
||||||
|
infoLogs: unknown[];
|
||||||
|
warnLogs: unknown[];
|
||||||
|
} = {
|
||||||
|
knownCompanyIds: [],
|
||||||
|
defaultBaseUrl: "http://localhost:8085",
|
||||||
|
async readConfig(companyId?: string) {
|
||||||
|
const key = deniedKey(companyId);
|
||||||
|
if (deniedCompanyIds.some((d) => deniedKey(d) === key)) {
|
||||||
|
throw { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
|
||||||
|
}
|
||||||
|
if (failingCompanyIds.some((d) => deniedKey(d) === key)) {
|
||||||
|
throw new Error(`boom for ${key}`);
|
||||||
|
}
|
||||||
|
return configByCompany[key] ?? null;
|
||||||
|
},
|
||||||
|
async readCache() {
|
||||||
|
return cacheValue;
|
||||||
|
},
|
||||||
|
async writeCache(url: string) {
|
||||||
|
cacheValue = url;
|
||||||
|
cacheWrites.push(url);
|
||||||
|
},
|
||||||
|
logger: {
|
||||||
|
info(message: string, meta?: Record<string, unknown>) {
|
||||||
|
infoLogs.push({ message, meta });
|
||||||
|
},
|
||||||
|
warn(message: string, meta?: Record<string, unknown>) {
|
||||||
|
warnLogs.push({ message, meta });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cacheWrites,
|
||||||
|
infoLogs,
|
||||||
|
warnLogs,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
// Allow overriding cache seed via readCache in `rest`.
|
||||||
|
return deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: companyId נתון ו-readConfig מחזיר legalApiBaseUrl — מחזיר וכותב cache", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
configByCompany: { "company-a": { legalApiBaseUrl: "https://a.example" } },
|
||||||
|
});
|
||||||
|
const result = await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
|
||||||
|
assert.equal(result, "https://a.example");
|
||||||
|
assert.deepEqual(deps.cacheWrites, ["https://a.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveApiBaseFrom: אין companyId — readConfig(undefined) מוסק ע"י המארח ומחזיר ערך', async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
configByCompany: {
|
||||||
|
__undefined__: { legalApiBaseUrl: "https://derived.example" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await resolveApiBaseFrom(deps);
|
||||||
|
assert.equal(result, "https://derived.example");
|
||||||
|
assert.deepEqual(deps.cacheWrites, ["https://derived.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope, יש cache תקף — מחזיר cache ומלוגג info (לא warn)", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
knownCompanyIds: ["c1", "c2"],
|
||||||
|
deniedCompanyIds: [undefined, "c1", "c2"],
|
||||||
|
});
|
||||||
|
// לדמות מטמון קיים: לעקוף readCache
|
||||||
|
deps.readCache = async () => "https://cached.example";
|
||||||
|
const result = await resolveApiBaseFrom(deps);
|
||||||
|
assert.equal(result, "https://cached.example");
|
||||||
|
assert.equal(deps.warnLogs.length, 0);
|
||||||
|
assert.equal(deps.infoLogs.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope ואין cache — זורק CompanyScopeUnavailableError (לא default)", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
knownCompanyIds: ["c1"],
|
||||||
|
deniedCompanyIds: [undefined, "c1"],
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => resolveApiBaseFrom(deps),
|
||||||
|
(err: unknown) => {
|
||||||
|
assert.ok(err instanceof CompanyScopeUnavailableError);
|
||||||
|
assert.equal(err.method, "config.get");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: readConfig הצליח בכל מקום אך בלי legalApiBaseUrl — default + warn", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
knownCompanyIds: ["c1"],
|
||||||
|
configByCompany: { __undefined__: {}, c1: {} },
|
||||||
|
});
|
||||||
|
const result = await resolveApiBaseFrom(deps);
|
||||||
|
assert.equal(result, deps.defaultBaseUrl);
|
||||||
|
assert.equal(deps.warnLogs.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: readConfig נכשל בשגיאה שאינה דחיית-scope — warn וממשיך לנסות הבא", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
knownCompanyIds: ["c1"],
|
||||||
|
failingCompanyIds: [undefined],
|
||||||
|
configByCompany: { c1: { legalApiBaseUrl: "https://c1.example" } },
|
||||||
|
});
|
||||||
|
const result = await resolveApiBaseFrom(deps);
|
||||||
|
assert.equal(result, "https://c1.example");
|
||||||
|
// warn אחד על הכשל ב-readConfig(undefined) — לא בליעה שקטה
|
||||||
|
assert.ok(deps.warnLogs.length >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── writeCache dedup (מתוזמן פר-בקשה, לא לכתוב cache שלא השתנה) ──────────
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: כותב ל-cache רק כשהערך משתנה בפועל", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
configByCompany: {
|
||||||
|
"company-a": { legalApiBaseUrl: "https://dedup.example" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// שתי פתירות רצופות של אותו URL — writeCache נקרא פעם אחת בלבד.
|
||||||
|
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
|
||||||
|
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
|
||||||
|
assert.deepEqual(deps.cacheWrites, ["https://dedup.example"]);
|
||||||
|
|
||||||
|
// פתירה של URL שונה אחריהן — writeCache נקרא שוב.
|
||||||
|
const deps2 = makeDeps({
|
||||||
|
configByCompany: {
|
||||||
|
"company-b": { legalApiBaseUrl: "https://dedup-2.example" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await resolveApiBaseFrom({ ...deps2, companyId: "company-b" });
|
||||||
|
assert.deepEqual(deps2.cacheWrites, ["https://dedup-2.example"]);
|
||||||
|
});
|
||||||
260
src/company-scope.ts
Normal file
260
src/company-scope.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* הפשטת company-scope לג'ובים מתוזמנים ול-webhook (legal-ai issue #637).
|
||||||
|
*
|
||||||
|
* המקור לבאג: מאז @paperclipai/server 2026.722.0 אין scope של חברה
|
||||||
|
* ל-`runJob`/`handleWebhook` — המארח מדפיס scope רק מ-`params.companyId` /
|
||||||
|
* `performAction.actorContext` / `executeTool.runContext` /
|
||||||
|
* `onEvent.event.companyId` (plugin-worker-manager.js:191-210), ו-
|
||||||
|
* `plugin-job-scheduler.js:184` שולח `runJob` בלי `companyId` כלל. לכן כל
|
||||||
|
* קריאה מתוך ג'וב ל-`ctx.config.get`/`ctx.issues.*`/`ctx.agents.invoke`/
|
||||||
|
* `ctx.events.emit` נדחית ע"י המארח עם `InvocationScopeDeniedError`
|
||||||
|
* (host-client-factory.js:253-293) — קוד `-32005`.
|
||||||
|
*
|
||||||
|
* המודול הזה טהור בכוונה — כמו `sync-target.ts` — כדי שאפשר יהיה לייבא
|
||||||
|
* אותו בטסט בלי להריץ את `runWorker(plugin, import.meta.url)` שקורה בזמן
|
||||||
|
* import של worker.ts. הייבוא היחיד מה-SDK הוא ייבוא-ערך של
|
||||||
|
* `PLUGIN_RPC_ERROR_CODES` — קבוע פרוטוקול נטול side-effects, לא ה-runtime
|
||||||
|
* של הפלאגין עצמו.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||||
|
|
||||||
|
/** מפתח ה-`ctx.state` (scope `instance`) שבו נשמר ה-base URL האחרון שהצלחנו לפתור. */
|
||||||
|
export const LEGAL_API_BASE_CACHE_KEY = "legal-api-base-url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* זיכרון-תהליך (לא `ctx.state`) של ה-base URL האחרון שבאמת נכתב ל-cache.
|
||||||
|
* `LegalApi.request` (`legal-api.ts:19-32`) קורא ל-`resolveBaseUrl()` —
|
||||||
|
* וכך ל-`resolveApiBaseFrom` — בכל בקשת-API בודדת, אבל הערך כמעט לעולם
|
||||||
|
* לא משתנה; בלי המנגנון הזה כל קריאת-כלי/`usePluginData` הייתה מוסיפה
|
||||||
|
* `ctx.state.set` (=upsert ל-Postgres) מיותר.
|
||||||
|
*/
|
||||||
|
let lastWrittenApiBase: string | undefined;
|
||||||
|
|
||||||
|
/** מאפס את זיכרון-המטמון ברמת-המודול. **לשימוש טסטים בלבד.** */
|
||||||
|
export function _resetApiBaseCacheMemo(): void {
|
||||||
|
lastWrittenApiBase = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* האם השגיאה היא דחיית-scope של המארח (קוד `-32005`,
|
||||||
|
* `PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED`). זיהוי **מבני** לפי
|
||||||
|
* שדה `code` — לא התאמת-מחרוזת על `err.message`, שיכולה להישבר בשקט אם
|
||||||
|
* המארח ינסח את ההודעה מחדש.
|
||||||
|
*/
|
||||||
|
export function isCompanyScopeDenied(err: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof err === "object" &&
|
||||||
|
err !== null &&
|
||||||
|
"code" in err &&
|
||||||
|
(err as { code?: unknown }).code ===
|
||||||
|
PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* מחלץ טקסט קריא מ-`cause` שגוי-scope. המארח שולח `JsonRpcCallError`
|
||||||
|
* (מופע `Error` עם `.message` תיאורי מהצד השני), אבל בטסטים/עתידית ייתכן
|
||||||
|
* גם אובייקט-שגיאה פשוט בלי prototype chain — לכן בדיקה מבנית ולא רק
|
||||||
|
* `instanceof Error`.
|
||||||
|
*/
|
||||||
|
function describeCause(cause: unknown): string {
|
||||||
|
if (cause instanceof Error) return cause.message;
|
||||||
|
if (
|
||||||
|
typeof cause === "object" &&
|
||||||
|
cause !== null &&
|
||||||
|
"message" in cause &&
|
||||||
|
typeof (cause as { message?: unknown }).message === "string"
|
||||||
|
) {
|
||||||
|
return (cause as { message: string }).message;
|
||||||
|
}
|
||||||
|
return String(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* נזרקת כש-handler של ג'וב/webhook נתקל בדחיית-scope שאין לו דרך לעקוף
|
||||||
|
* אותה. ההודעה עצמה היא האבחנה — היא מה שיישמר ב-`plugin_job_runs.error`
|
||||||
|
* (או `plugin_webhook_deliveries.error`), ו-`plugin_logs` ריק לחלוטין
|
||||||
|
* (נמדד), כך שזו למעשה עדות-הכשל היחידה שתישאר. **חובה** להעביר `cause`
|
||||||
|
* כשהוא ידוע — המתזמר של Paperclip (`plugin-job-scheduler.js:199`) רושם
|
||||||
|
* ל-DB רק את `err.message`, ואינו מטייל ב-`Error.cause`, כך שכל מידע
|
||||||
|
* שלא נכנס למחרוזת-ההודעה עצמה אובד.
|
||||||
|
*/
|
||||||
|
export class CompanyScopeUnavailableError extends Error {
|
||||||
|
readonly jobKey: string;
|
||||||
|
readonly method: string;
|
||||||
|
|
||||||
|
constructor(jobKey: string, method: string, options?: { cause?: unknown }) {
|
||||||
|
// method="unknown" קורה כש-runJobHandler תופס דחיית-scope בלי לדעת
|
||||||
|
// איזו קריאה נדחתה בפועל (ה-handler לא ציין method מפורש). לא לכתוב
|
||||||
|
// את המילה "unknown" להודעה — במקום זה מסתמכים על ה-cause בלבד.
|
||||||
|
const methodClause =
|
||||||
|
method === "unknown" ? "" : ` הפעולה שנדחתה: "${method}".`;
|
||||||
|
const sourceClause =
|
||||||
|
options?.cause !== undefined
|
||||||
|
? ` מקור: ${describeCause(options.cause)}.`
|
||||||
|
: "";
|
||||||
|
super(
|
||||||
|
`${jobKey}: נדרש הקשר-חברה (company scope) לפעולה זו, אבל ג'וב ` +
|
||||||
|
"מתוזמן/webhook אינו מקבל כזה מ-Paperclip (מאז server " +
|
||||||
|
`2026.722.0).${methodClause}${sourceClause} ראה legal-ai issue #637.`,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
this.name = "CompanyScopeUnavailableError";
|
||||||
|
this.jobKey = jobKey;
|
||||||
|
this.method = method;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* מריץ handler של ג'וב, ובכשל — מלוגג **וזורק מחדש**. המתזמר של Paperclip
|
||||||
|
* (`plugin-job-scheduler.js:194-215`) כבר רושם `status:"failed"` כש-RPC
|
||||||
|
* נדחה; מה שהיה שבור זה ה-handlers שלנו, שבלעו את השגיאה ב-
|
||||||
|
* `catch (err) { ctx.logger.error(...) }` בלי `throw` — כך ה-RPC נפתר
|
||||||
|
* בהצלחה וה-run נרשם `succeeded` למרות שלא עשה כלום (13,928 ריצות כוזבות,
|
||||||
|
* נמדד). ה-README של ה-SDK: "Re-throw from the handler to mark the run as
|
||||||
|
* failed".
|
||||||
|
*
|
||||||
|
* דחיית-scope הופכת ל-`CompanyScopeUnavailableError` מאבחנת; כל שגיאה
|
||||||
|
* אחרת נזרקת כמות-שהיא.
|
||||||
|
*/
|
||||||
|
export async function runJobHandler(
|
||||||
|
jobKey: string,
|
||||||
|
logger: { error(message: string, meta?: Record<string, unknown>): void },
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`${jobKey}: job failed`, { error: String(err) });
|
||||||
|
if (isCompanyScopeDenied(err)) {
|
||||||
|
throw new CompanyScopeUnavailableError(jobKey, "unknown", {
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** תלויות מוזרקות לפתירת `legalApiBaseUrl` — כדי שהלוגיקה תהיה בת-טסט בלי `ctx` אמיתי. */
|
||||||
|
export interface ResolveApiBaseDeps {
|
||||||
|
/** עוטף `ctx.config.get(companyId)`. **לא** בולע — זורק כשהמארח דוחה scope. */
|
||||||
|
readConfig(companyId?: string): Promise<Record<string, unknown> | null>;
|
||||||
|
/** עוטף `ctx.state.get` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
|
||||||
|
readCache(): Promise<unknown>;
|
||||||
|
/** עוטף `ctx.state.set` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
|
||||||
|
writeCache(url: string): Promise<void>;
|
||||||
|
/** מזהי כל החברות המוכרות לפלאגין (`Object.keys(CEO_AGENT_IDS)`). */
|
||||||
|
knownCompanyIds: readonly string[];
|
||||||
|
/** ברירת-המחדל כש-legal-ai לא הוגדר בכלל (אינסטנס לא-מוגדר, לא scope שנדחה). */
|
||||||
|
defaultBaseUrl: string;
|
||||||
|
logger: {
|
||||||
|
info(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
warn(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
};
|
||||||
|
/** ידוע רק כשהמארח מספק אחד (handler בהקשר-חברה) — אופציונלי. */
|
||||||
|
companyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* פותר `legalApiBaseUrl` מ-config בהיקף-חברה, עם נפילה-חזרה למטמון
|
||||||
|
* (`ctx.state`, scope `instance`) כשאין כלל הקשר-חברה זמין (ג'וב/webhook).
|
||||||
|
*
|
||||||
|
* ⚠️ כשכל ניסיונות ה-`readConfig` נדחים ב-scope **ואין** מטמון — זורקים
|
||||||
|
* `CompanyScopeUnavailableError`. **אסור** להחזיר `defaultBaseUrl` במקרה
|
||||||
|
* הזה: זה בדיוק מה שיצר את `TypeError: fetch failed` המטעה — `worker.ts`
|
||||||
|
* הישן בלע את דחיית-ה-scope כ"אין קונפיג" ונפל ל-
|
||||||
|
* `http://localhost:8085` שאין בו מאזין.
|
||||||
|
*/
|
||||||
|
export async function resolveApiBaseFrom(
|
||||||
|
deps: ResolveApiBaseDeps,
|
||||||
|
): Promise<string> {
|
||||||
|
const {
|
||||||
|
readConfig,
|
||||||
|
readCache,
|
||||||
|
writeCache,
|
||||||
|
knownCompanyIds,
|
||||||
|
defaultBaseUrl,
|
||||||
|
logger,
|
||||||
|
companyId,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
let sawScopeDenied = false;
|
||||||
|
|
||||||
|
// כותב ל-cache רק כשהערך השתנה בפועל — ראה `lastWrittenApiBase` למעלה.
|
||||||
|
const writeCacheIfChanged = async (url: string): Promise<void> => {
|
||||||
|
if (url === lastWrittenApiBase) return;
|
||||||
|
await writeCache(url);
|
||||||
|
lastWrittenApiBase = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryRead = async (id?: string): Promise<string | null | undefined> => {
|
||||||
|
let cfg: Record<string, unknown> | null;
|
||||||
|
try {
|
||||||
|
cfg = await readConfig(id);
|
||||||
|
} catch (err) {
|
||||||
|
if (isCompanyScopeDenied(err)) {
|
||||||
|
sawScopeDenied = true;
|
||||||
|
return undefined; // נדחה — נסה את המקור הבא
|
||||||
|
}
|
||||||
|
// שגיאה אחרת: אין בליעה שקטה — מדווחים וממשיכים לנסות את הבא.
|
||||||
|
logger.warn("resolveApiBase: config.get failed", {
|
||||||
|
companyId: id ?? null,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const url = cfg?.legalApiBaseUrl;
|
||||||
|
return typeof url === "string" && url.trim() ? url.trim() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
const scoped = await tryRead(companyId);
|
||||||
|
if (scoped) {
|
||||||
|
await writeCacheIfChanged(scoped);
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// אין companyId ישיר (או שהקריאה מולו נדחתה) — אבל המארח מסיק בעצמו
|
||||||
|
// companyId כשהקריאה מתרחשת בתוך הקשר host-scoped (למשל tool handler).
|
||||||
|
// לנסות לפני נפילה-לניחוש. נשאר בהתנהגות המקורית: מנוסה תמיד, לא רק
|
||||||
|
// כשלא סופק companyId.
|
||||||
|
const derived = await tryRead(undefined);
|
||||||
|
if (derived) {
|
||||||
|
await writeCacheIfChanged(derived);
|
||||||
|
return derived;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ג'וב מתוזמן/webhook ללא הקשר-חברה כלל: לנסות כל חברה מוכרת. בפועל
|
||||||
|
// כולן מצביעות על אותו מופע legal-ai, כך שהפגיעה הראשונה מנצחת.
|
||||||
|
for (const knownCompanyId of knownCompanyIds) {
|
||||||
|
const url = await tryRead(knownCompanyId);
|
||||||
|
if (url) {
|
||||||
|
await writeCacheIfChanged(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawScopeDenied) {
|
||||||
|
// כל הניסיונות נדחו ב-scope (לא "לא-מוגדר") — לנסות את המטמון לפני
|
||||||
|
// שנכשל. מצב-צפוי בג'וב, ולכן `info` ולא `warn`.
|
||||||
|
const cached = await readCache();
|
||||||
|
if (typeof cached === "string" && cached.trim()) {
|
||||||
|
logger.info(
|
||||||
|
"resolveApiBase: config.get denied (no company scope) — using cached legalApiBaseUrl",
|
||||||
|
{ cachedBaseUrl: cached },
|
||||||
|
);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
throw new CompanyScopeUnavailableError("resolveApiBase", "config.get");
|
||||||
|
}
|
||||||
|
|
||||||
|
// כל הקריאות הצליחו (לא נדחו) אך אף אחת לא החזירה ערך — legal-ai פשוט
|
||||||
|
// לא הוגדר עדיין. זה מצב שונה מהותית מדחיית-scope: מותר ליפול לברירת-מחדל.
|
||||||
|
logger.warn("legalApiBaseUrl unresolved — using default", {
|
||||||
|
companyId: companyId ?? null,
|
||||||
|
fallback: defaultBaseUrl,
|
||||||
|
});
|
||||||
|
return defaultBaseUrl;
|
||||||
|
}
|
||||||
1016
src/worker.ts
1016
src/worker.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user