fix(sync): company-scope לג'ובים מתוזמנים — הפסקת בליעת-כשל (legal-ai #637)

הג'וב `sync-case-status` נכשל בכל ריצה על `legalApiBaseUrl unresolved` →
`TypeError: fetch failed`, לפני שהגיע ל-`pickSyncTargetIssue` — ובכל זאת
`plugin_job_runs.status = succeeded`, כי ה-try/catch ב-worker.ts בלע את
הכישלון ל-logger.error. 3,082 מופעי `unresolved` ו-98,571 מופעי
`company context is required` בלוג; כתיבה אחרונה בפועל 2026-07-22.

סיבת-השורש: מאז @paperclipai/server 2026.722.0 אין scope של חברה ב-runJob
(plugin-job-scheduler.js:184 שולח בלי companyId), ולכן כל ctx.config.get /
ctx.issues.* נדחית ב-InvocationScopeDeniedError (-32005).

- `src/company-scope.ts` — הפשטה טהורה (כמו sync-target.ts, ניתנת לטסט בלי
  runWorker): פתרון base-URL עם cache ב-ctx.state + memo ברמת-המודול כדי
  לא לכתוב ל-Postgres בכל בקשה, וזיהוי **מבני** של דחיית-scope לפי
  `err.code` ולא התאמת-מחרוזת על ההודעה.
- `runJobHandler` מלוגג **וזורק מחדש** — המתזמר רושם `status:"failed"`
  במקום `succeeded`. ה-try/catch הבולע הוסר (כלל-הנדסה §6).
- כשה-scope אינו זמין: `CompanyScopeUnavailableError` מפורש במקום ניחוש.

Invariants: כלל-הנדסה §6 (אין בליעה שקטה — זה לב התיקון) · G2 (הפשטה אחת
לכל הג'ובים, לא טיפול פר-ג'וב) · G1 (נרמול במקור).

Closes #637
This commit is contained in:
2026-08-26 16:13:08 +03:00
parent 41305fbb36
commit 3627865bac
3 changed files with 1087 additions and 463 deletions

274
src/company-scope.test.ts Normal file
View 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
View 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;
}

View File

@@ -3,6 +3,13 @@ import type {
PluginWebhookInput, PluginWebhookInput,
} from "@paperclipai/plugin-sdk"; } from "@paperclipai/plugin-sdk";
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import {
CompanyScopeUnavailableError,
isCompanyScopeDenied,
LEGAL_API_BASE_CACHE_KEY,
resolveApiBaseFrom,
runJobHandler,
} from "./company-scope.js";
import { LegalApi } from "./legal-api.js"; import { LegalApi } from "./legal-api.js";
import { import {
labelFor, labelFor,
@@ -36,8 +43,20 @@ const DEFAULT_LEGAL_API_BASE = "http://localhost:8085";
* *
* So: never call this from setup(); call it from a handler, passing the * So: never call this from setup(); call it from a handler, passing the
* companyId that handler was given. Tool handlers get `runCtx.companyId`; data * companyId that handler was given. Tool handlers get `runCtx.companyId`; data
* handlers get `params.companyId`. Scheduled jobs have no company at all, so * handlers get `params.companyId`.
* they fall back to probing the companies we know about. *
* ⚠️ Scheduled jobs and `handleWebhook` have **no** company scope at all
* (legal-ai issue #637 — the host derives scope only from `params.companyId` /
* `actorContext` / `runContext` / `event.companyId`, none of which `runJob`
* carries). Every `ctx.config.get()` call from such a handler is rejected with
* `InvocationScopeDeniedError` (code `-32005`) — "probing the companies we
* know about" (the old comment here) never worked; it just swallowed the
* rejection silently and fell through to `DEFAULT_LEGAL_API_BASE`, which has
* nothing listening on it → the misleading `TypeError: fetch failed`. The
* actual logic (including the instance-scoped cache fallback for that case)
* now lives in `resolveApiBaseFrom` (`company-scope.ts`), which this is a
* thin `ctx`-binding wrapper around — kept bare (no try/catch) so a genuine
* scope denial surfaces to the caller instead of being swallowed here.
*/ */
/** Pull the companyId the host passes to `ctx.data` handlers, if present. */ /** Pull the companyId the host passes to `ctx.data` handlers, if present. */
function companyIdOf(params: unknown): string | undefined { function companyIdOf(params: unknown): string | undefined {
@@ -49,41 +68,24 @@ async function resolveApiBase(
ctx: PluginContext, ctx: PluginContext,
companyId?: string, companyId?: string,
): Promise<string> { ): Promise<string> {
const read = async (id?: string): Promise<string | null> => { return resolveApiBaseFrom({
try { readConfig: (id) =>
const cfg = (await ctx.config.get(id)) as { ctx.config.get(id) as Promise<Record<string, unknown> | null>,
legalApiBaseUrl?: unknown; readCache: () =>
} | null; ctx.state.get({
const url = cfg?.legalApiBaseUrl; scopeKind: "instance",
return typeof url === "string" && url.trim() ? url.trim() : null; stateKey: LEGAL_API_BASE_CACHE_KEY,
} catch { }),
return null; writeCache: (url) =>
} ctx.state.set(
}; { scopeKind: "instance", stateKey: LEGAL_API_BASE_CACHE_KEY },
url,
if (companyId) { ),
const scoped = await read(companyId); knownCompanyIds: Object.keys(CEO_AGENT_IDS),
if (scoped) return scoped; defaultBaseUrl: DEFAULT_LEGAL_API_BASE,
} logger: ctx.logger,
companyId,
// No companyId passed in — but the host derives one itself when the call
// happens inside a host-scoped invocation (e.g. a tool handler). Ask before
// falling back to guesswork, so tools honour their own company's config.
const derived = await read(undefined);
if (derived) return derived;
// Genuinely no company (scheduled jobs): try each company we know of. All of
// them point at the same legal-ai instance in practice, so the first hit wins.
for (const knownCompanyId of Object.keys(CEO_AGENT_IDS)) {
const url = await read(knownCompanyId);
if (url) return url;
}
ctx.logger.warn("legalApiBaseUrl unresolved — using default", {
companyId: companyId ?? null,
fallback: DEFAULT_LEGAL_API_BASE,
}); });
return DEFAULT_LEGAL_API_BASE;
} }
const plugin = definePlugin({ const plugin = definePlugin({
@@ -843,7 +845,11 @@ const plugin = definePlugin({
ctx.jobs.register("sync-case-status", async (job) => { ctx.jobs.register("sync-case-status", async (job) => {
ctx.logger.info("Starting case status sync", { runId: job.runId }); ctx.logger.info("Starting case status sync", { runId: job.runId });
try { // אין `try/catch` בולע כאן — `runJobHandler` (company-scope.ts) הוא
// שמלוגג ו**זורק מחדש**, כדי שהמתזמר ירשום `status:"failed"`
// כשה-scope נדחה (legal-ai issue #637). ה-`try/catch` הישן כאן בלע
// את הכשל, וה-run נרשם `succeeded` בלי לעשות כלום — 13,928 ריצות כוזבות.
await runJobHandler("sync-case-status", ctx.logger, async () => {
const cases = await api.listCases(); const cases = await api.listCases();
const companies = await ctx.companies.list(); const companies = await ctx.companies.list();
if (!companies.length) return; if (!companies.length) return;
@@ -931,28 +937,31 @@ const plugin = definePlugin({
ctx.logger.info("Case status sync completed", { ctx.logger.info("Case status sync completed", {
casesChecked: cases.length, casesChecked: cases.length,
}); });
} catch (err) { });
ctx.logger.error("Case status sync failed", { error: String(err) });
}
}); });
ctx.jobs.register("stale-case-reminder", async (_job) => { ctx.jobs.register("stale-case-reminder", async (_job) => {
ctx.logger.info("stale-case-reminder: starting"); ctx.logger.info("stale-case-reminder: starting");
// Scheduled job — no company context; resolveApiBase probes known companies.
await runJobHandler("stale-case-reminder", ctx.logger, async () => {
// Scheduled job — no company scope at all (legal-ai issue #637);
// resolveApiBase falls back to the instance-scoped cache, then
// throws CompanyScopeUnavailableError rather than guessing.
const apiBase = await resolveApiBase(ctx); const apiBase = await resolveApiBase(ctx);
// כשל-fetch/API אמיתי חייב **לזרוק**, לא `return` בשקט — אחרת
// ה-run נרשם `succeeded` בלי שקרה כלום (legal-ai issue #637).
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>; let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
try { try {
resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=30`); resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=30`);
} catch (err) { } catch (err) {
ctx.logger.error("stale-case-reminder: fetch failed", { throw new Error(
error: String(err), `stale-case-reminder: fetch failed against ${apiBase}: ${String(err)}`,
}); { cause: err },
return; );
} }
if (!resp.ok) { if (!resp.ok) {
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`); throw new Error(`stale-case-reminder: API error ${resp.status}`);
return;
} }
const data = (await resp.json()) as { const data = (await resp.json()) as {
@@ -988,6 +997,8 @@ const plugin = definePlugin({
} }
} }
// אין תיקים תקועים — "אין מה לעשות" תקין, לא כשל. ה-loop למטה
// פשוט לא ירוץ בלי `data.cases`.
let reminded = 0; let reminded = 0;
for (const staleCase of data.cases) { for (const staleCase of data.cases) {
const linked = caseIssueMap.get(staleCase.case_number); const linked = caseIssueMap.get(staleCase.case_number);
@@ -1008,18 +1019,22 @@ const plugin = definePlugin({
`stale-case-reminder: done. ${reminded}/${data.total} cases reminded`, `stale-case-reminder: done. ${reminded}/${data.total} cases reminded`,
); );
}); });
});
ctx.jobs.register("weekly-feedback-analysis", async (_job) => { ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
ctx.logger.info("weekly-feedback-analysis: starting"); ctx.logger.info("weekly-feedback-analysis: starting");
// Scheduled job — no company context; resolveApiBase probes known companies.
await runJobHandler("weekly-feedback-analysis", ctx.logger, async () => {
// Scheduled job — no company scope at all (legal-ai issue #637);
// resolveApiBase falls back to the instance-scoped cache, then
// throws CompanyScopeUnavailableError rather than guessing.
const apiBase = await resolveApiBase(ctx); const apiBase = await resolveApiBase(ctx);
const resp = await ctx.http.fetch( const resp = await ctx.http.fetch(
`${apiBase}/api/chair-feedback/weekly-summary`, `${apiBase}/api/chair-feedback/weekly-summary`,
); );
if (!resp.ok) { if (!resp.ok) {
ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`); throw new Error(`weekly-feedback-analysis: API error ${resp.status}`);
return;
} }
const data = (await resp.json()) as { const data = (await resp.json()) as {
@@ -1027,6 +1042,7 @@ const plugin = definePlugin({
entry_count: number; entry_count: number;
}; };
// אין פידבק השבוע — "אין מה לעשות" תקין, לא כשל.
if (data.entry_count === 0) { if (data.entry_count === 0) {
ctx.logger.info( ctx.logger.info(
"weekly-feedback-analysis: no feedback this week, skipping", "weekly-feedback-analysis: no feedback this week, skipping",
@@ -1043,11 +1059,12 @@ const plugin = definePlugin({
Boolean(x.ceoId), Boolean(x.ceoId),
); );
// אין שום חברה עם CEO ממופה — זו תקלת-תצורה (לא "אין מה לעשות"),
// ולכן זריקה ולא return שקט.
if (mapped.length === 0) { if (mapped.length === 0) {
ctx.logger.warn( throw new Error(
"weekly-feedback-analysis: no company has a mapped CEO agent — skipping", "weekly-feedback-analysis: no company has a mapped CEO agent",
); );
return;
} }
const { company, ceoId } = mapped[0]; const { company, ceoId } = mapped[0];
@@ -1068,6 +1085,7 @@ const plugin = definePlugin({
}); });
} }
}); });
});
// Reconciliation sweep — the at-least-once guarantee for user comments. // Reconciliation sweep — the at-least-once guarantee for user comments.
// The event-driven route above is best-effort: a failed/coalesced CEO // The event-driven route above is best-effort: a failed/coalesced CEO
@@ -1079,10 +1097,23 @@ const plugin = definePlugin({
// the fast path stamps the marker, so a healthy comment is never // the fast path stamps the marker, so a healthy comment is never
// double-routed. Runs every 2 minutes (manifest schedule). // double-routed. Runs every 2 minutes (manifest schedule).
ctx.jobs.register("route-pending-comments", async (_job) => { ctx.jobs.register("route-pending-comments", async (_job) => {
await runJobHandler("route-pending-comments", ctx.logger, async () => {
// כשל-מהיר: דחיית-scope (legal-ai issue #637) אינה "שגיאה
// פר-issue" שאפשר להתעלם ממנה — היא אומרת שה-run **כולו** לא
// יכול לעבוד. לזרוק מיד במקום לבלוע ולהמשיך ללולאה על 2
// חברות × 200 issues שכולן ייכשלו זהה. שגיאה אחרת (ולא scope)
// נשארת warn+continue — זו העמידות האמיתית פר-issue.
let companies: Awaited<ReturnType<typeof ctx.companies.list>>; let companies: Awaited<ReturnType<typeof ctx.companies.list>>;
try { try {
companies = await ctx.companies.list(); companies = await ctx.companies.list();
} catch (err) { } catch (err) {
if (isCompanyScopeDenied(err)) {
throw new CompanyScopeUnavailableError(
"route-pending-comments",
"companies.list",
{ cause: err },
);
}
ctx.logger.warn("route-pending-comments: companies.list failed", { ctx.logger.warn("route-pending-comments: companies.list failed", {
error: String(err), error: String(err),
}); });
@@ -1095,8 +1126,18 @@ const plugin = definePlugin({
let issues: Awaited<ReturnType<typeof ctx.issues.list>>; let issues: Awaited<ReturnType<typeof ctx.issues.list>>;
try { try {
issues = await ctx.issues.list({ companyId: company.id, limit: 200 }); issues = await ctx.issues.list({
companyId: company.id,
limit: 200,
});
} catch (err) { } catch (err) {
if (isCompanyScopeDenied(err)) {
throw new CompanyScopeUnavailableError(
"route-pending-comments",
"issues.list",
{ cause: err },
);
}
ctx.logger.warn("route-pending-comments: issues.list failed", { ctx.logger.warn("route-pending-comments: issues.list failed", {
companyId: company.id, companyId: company.id,
error: String(err), error: String(err),
@@ -1108,7 +1149,8 @@ const plugin = definePlugin({
// Terminal issues need no routing. A fresh user comment on a // Terminal issues need no routing. A fresh user comment on a
// `done` issue reopens it natively → it becomes active and is // `done` issue reopens it natively → it becomes active and is
// caught on the next sweep. // caught on the next sweep.
if (issue.status === "done" || issue.status === "cancelled") continue; if (issue.status === "done" || issue.status === "cancelled")
continue;
try { try {
const comments = await ctx.issues.listComments( const comments = await ctx.issues.listComments(
@@ -1169,6 +1211,7 @@ const plugin = definePlugin({
} }
} }
}); });
});
// ── Data handlers (UI bridge) ────────────────────────────────── // ── Data handlers (UI bridge) ──────────────────────────────────
// These back `usePluginData(key, params)` calls from the React UI bundle // These back `usePluginData(key, params)` calls from the React UI bundle
@@ -1337,12 +1380,34 @@ const plugin = definePlugin({
return { status: "ok" as const }; return { status: "ok" as const };
}, },
/**
* נתיב-חימום דטרמיניסטי למטמון `LEGAL_API_BASE_CACHE_KEY` (company-scope.ts):
* ל-`configChanged` יש הקשר-חברה תקין מה-מארח (בניגוד לג'וב מתוזמן/webhook —
* legal-ai issue #637), כך שהכתיבה כאן אף פעם לא נדחית ב-scope. עדיף על
* להסתמך על כך שסוכן יקרא כלי בהקשר-חברה כדי שהמטמון יתמלא — כאן זה קורה
* מיד כשהאופרטור שומר את ה-config, בלי תלות בתזמון-מקרי.
*/
async onConfigChanged(newConfig: Record<string, unknown>): Promise<void> {
if (!pluginCtx) return; // עדיין לפני setup()
const url = (newConfig as { legalApiBaseUrl?: unknown }).legalApiBaseUrl;
if (typeof url === "string" && url.trim()) {
await pluginCtx.state.set(
{ scopeKind: "instance", stateKey: LEGAL_API_BASE_CACHE_KEY },
url.trim(),
);
pluginCtx.logger.info("onConfigChanged: cached legalApiBaseUrl", {
legalApiBaseUrl: url.trim(),
});
}
},
async onWebhook(input: PluginWebhookInput): Promise<void> { async onWebhook(input: PluginWebhookInput): Promise<void> {
if (!pluginCtx) return; // not yet initialized if (!pluginCtx) return; // not yet initialized
// Idempotency guard: skip duplicate deliveries within 5 minutes // Idempotency guard: skip duplicate deliveries within 5 minutes. The key
if (input.requestId) { // is hoisted so the catch-block below can delete it on failure — see there.
const idempKey = `webhook-idem-${input.requestId}`; const idempKey = input.requestId ? `webhook-idem-${input.requestId}` : null;
if (idempKey) {
const seenAt = await pluginCtx.state.get({ const seenAt = await pluginCtx.state.get({
scopeKind: "instance", scopeKind: "instance",
stateKey: idempKey, stateKey: idempKey,
@@ -1362,6 +1427,41 @@ const plugin = definePlugin({
); );
} }
try {
await handleCaseStatusWebhook(pluginCtx, input);
} catch (err) {
// מחיקת סמן-האידמפוטנטיות **לפני** שהשגיאה יוצאת — אחרת מסירה
// חוזרת של אותו webhook תוך 5 דק' תדולג בשקט כ"כבר נשלח", למרות
// שהעיבוד מעולם לא הושלם בפועל (legal-ai issue #637).
if (idempKey) {
await pluginCtx.state.delete({
scopeKind: "instance",
stateKey: idempKey,
});
}
// אין בליעה — הזריקה חייבת להימשך החוצה כדי ש-
// `plugin_webhook_deliveries.error` יישא הודעה מאבחנת (ולא את
// הודעת-המארח האטומה) כשמדובר בדחיית-scope.
if (isCompanyScopeDenied(err)) {
throw new CompanyScopeUnavailableError("onWebhook", "issues.list", {
cause: err,
});
}
throw err;
}
},
});
/**
* גוף-העבודה של webhook `case-status` — מופרד מ-`onWebhook` כך ש-
* `try/catch` שם יכול לעטוף אותו כולו (כולל את דחיית-ה-scope שנתקלת בה
* `pluginCtx.issues.list` למטה — אין ל-webhook הקשר-חברה, כמו לג'וב
* מתוזמן; ראה legal-ai issue #637).
*/
async function handleCaseStatusWebhook(
pluginCtx: PluginContext,
input: PluginWebhookInput,
): Promise<void> {
const { endpointKey, parsedBody } = input; const { endpointKey, parsedBody } = input;
if (endpointKey !== "case-status") return; if (endpointKey !== "case-status") return;
@@ -1405,12 +1505,9 @@ const plugin = definePlugin({
return; return;
} }
pluginCtx.logger.info( pluginCtx.logger.info(`Webhook: case ${caseNumber} eventType=${eventType}`, {
`Webhook: case ${caseNumber} eventType=${eventType}`,
{
companyId, companyId,
}, });
);
// Find the Paperclip issue linked to this case number by scanning plugin state. // Find the Paperclip issue linked to this case number by scanning plugin state.
// State stores: issue.id → case_number (scopeKind=issue, stateKey=legal-case-number) // State stores: issue.id → case_number (scopeKind=issue, stateKey=legal-case-number)
@@ -1477,22 +1574,16 @@ const plugin = definePlugin({
}, },
companyId, companyId,
); );
pluginCtx.logger.info( pluginCtx.logger.info("askUserQuestions: missing_precedent prompt sent", {
"askUserQuestions: missing_precedent prompt sent",
{
caseNumber, caseNumber,
missingPrecedentId: mp.id, missingPrecedentId: mp.id,
}, });
);
} catch (err) { } catch (err) {
pluginCtx.logger.error( pluginCtx.logger.error("askUserQuestions failed for missing_precedent", {
"askUserQuestions failed for missing_precedent",
{
caseNumber, caseNumber,
missingPrecedentId: mp.id, missingPrecedentId: mp.id,
error: String(err), error: String(err),
}, });
);
} }
return; return;
} }
@@ -1605,8 +1696,7 @@ const plugin = definePlugin({
}); });
} }
} }
}, }
});
export default plugin; export default plugin;
runWorker(plugin, import.meta.url); runWorker(plugin, import.meta.url);