diff --git a/src/company-scope.test.ts b/src/company-scope.test.ts
new file mode 100644
index 0000000..a94ca86
--- /dev/null
+++ b/src/company-scope.test.ts
@@ -0,0 +1,274 @@
+///
+
+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) {
+ 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 & {
+ configByCompany?: Record | 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) {
+ infoLogs.push({ message, meta });
+ },
+ warn(message: string, meta?: Record) {
+ 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"]);
+});
diff --git a/src/company-scope.ts b/src/company-scope.ts
new file mode 100644
index 0000000..392a980
--- /dev/null
+++ b/src/company-scope.ts
@@ -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): void },
+ fn: () => Promise,
+): Promise {
+ 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 | null>;
+ /** עוטף `ctx.state.get` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
+ readCache(): Promise;
+ /** עוטף `ctx.state.set` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
+ writeCache(url: string): Promise;
+ /** מזהי כל החברות המוכרות לפלאגין (`Object.keys(CEO_AGENT_IDS)`). */
+ knownCompanyIds: readonly string[];
+ /** ברירת-המחדל כש-legal-ai לא הוגדר בכלל (אינסטנס לא-מוגדר, לא scope שנדחה). */
+ defaultBaseUrl: string;
+ logger: {
+ info(message: string, meta?: Record): void;
+ warn(message: string, meta?: Record): 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 {
+ const {
+ readConfig,
+ readCache,
+ writeCache,
+ knownCompanyIds,
+ defaultBaseUrl,
+ logger,
+ companyId,
+ } = deps;
+
+ let sawScopeDenied = false;
+
+ // כותב ל-cache רק כשהערך השתנה בפועל — ראה `lastWrittenApiBase` למעלה.
+ const writeCacheIfChanged = async (url: string): Promise => {
+ if (url === lastWrittenApiBase) return;
+ await writeCache(url);
+ lastWrittenApiBase = url;
+ };
+
+ const tryRead = async (id?: string): Promise => {
+ let cfg: Record | 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;
+}
diff --git a/src/worker.ts b/src/worker.ts
index da2553e..d73530f 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -3,6 +3,13 @@ import type {
PluginWebhookInput,
} 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 {
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
* companyId that handler was given. Tool handlers get `runCtx.companyId`; data
- * handlers get `params.companyId`. Scheduled jobs have no company at all, so
- * they fall back to probing the companies we know about.
+ * handlers get `params.companyId`.
+ *
+ * ⚠️ 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. */
function companyIdOf(params: unknown): string | undefined {
@@ -49,41 +68,24 @@ async function resolveApiBase(
ctx: PluginContext,
companyId?: string,
): Promise {
- const read = async (id?: string): Promise => {
- try {
- const cfg = (await ctx.config.get(id)) as {
- legalApiBaseUrl?: unknown;
- } | null;
- const url = cfg?.legalApiBaseUrl;
- return typeof url === "string" && url.trim() ? url.trim() : null;
- } catch {
- return null;
- }
- };
-
- if (companyId) {
- const scoped = await read(companyId);
- if (scoped) return scoped;
- }
-
- // 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 resolveApiBaseFrom({
+ readConfig: (id) =>
+ ctx.config.get(id) as Promise | null>,
+ readCache: () =>
+ ctx.state.get({
+ scopeKind: "instance",
+ stateKey: LEGAL_API_BASE_CACHE_KEY,
+ }),
+ writeCache: (url) =>
+ ctx.state.set(
+ { scopeKind: "instance", stateKey: LEGAL_API_BASE_CACHE_KEY },
+ url,
+ ),
+ knownCompanyIds: Object.keys(CEO_AGENT_IDS),
+ defaultBaseUrl: DEFAULT_LEGAL_API_BASE,
+ logger: ctx.logger,
+ companyId,
});
- return DEFAULT_LEGAL_API_BASE;
}
const plugin = definePlugin({
@@ -843,7 +845,11 @@ const plugin = definePlugin({
ctx.jobs.register("sync-case-status", async (job) => {
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 companies = await ctx.companies.list();
if (!companies.length) return;
@@ -931,142 +937,154 @@ const plugin = definePlugin({
ctx.logger.info("Case status sync completed", {
casesChecked: cases.length,
});
- } catch (err) {
- ctx.logger.error("Case status sync failed", { error: String(err) });
- }
+ });
});
ctx.jobs.register("stale-case-reminder", async (_job) => {
ctx.logger.info("stale-case-reminder: starting");
- // Scheduled job — no company context; resolveApiBase probes known companies.
- const apiBase = await resolveApiBase(ctx);
- let resp: Awaited>;
- try {
- resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=30`);
- } catch (err) {
- ctx.logger.error("stale-case-reminder: fetch failed", {
- error: String(err),
- });
- return;
- }
- if (!resp.ok) {
- ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
- return;
- }
+ 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 data = (await resp.json()) as {
- cases: Array<{
- case_number: string;
- title: string;
- status: string;
- days_stale: number;
- }>;
- total: number;
- };
+ // כשל-fetch/API אמיתי חייב **לזרוק**, לא `return` בשקט — אחרת
+ // ה-run נרשם `succeeded` בלי שקרה כלום (legal-ai issue #637).
+ let resp: Awaited>;
+ try {
+ resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=30`);
+ } catch (err) {
+ throw new Error(
+ `stale-case-reminder: fetch failed against ${apiBase}: ${String(err)}`,
+ { cause: err },
+ );
+ }
+ if (!resp.ok) {
+ throw new Error(`stale-case-reminder: API error ${resp.status}`);
+ }
- // Build case→issue map once (O(companies × issues)) to avoid N×M RPCs per stale case
- const companies = await ctx.companies.list();
- const caseIssueMap = new Map<
- string,
- { issueId: string; companyId: string }
- >();
- for (const company of companies) {
- const issues = await ctx.issues.list({ companyId: company.id });
- for (const issue of issues) {
- const linkedCase = await ctx.state.get({
- scopeKind: "issue",
- scopeId: issue.id,
- stateKey: "legal-case-number",
- });
- if (linkedCase && typeof linkedCase === "string") {
- caseIssueMap.set(linkedCase, {
- issueId: issue.id,
- companyId: company.id,
+ const data = (await resp.json()) as {
+ cases: Array<{
+ case_number: string;
+ title: string;
+ status: string;
+ days_stale: number;
+ }>;
+ total: number;
+ };
+
+ // Build case→issue map once (O(companies × issues)) to avoid N×M RPCs per stale case
+ const companies = await ctx.companies.list();
+ const caseIssueMap = new Map<
+ string,
+ { issueId: string; companyId: string }
+ >();
+ for (const company of companies) {
+ const issues = await ctx.issues.list({ companyId: company.id });
+ for (const issue of issues) {
+ const linkedCase = await ctx.state.get({
+ scopeKind: "issue",
+ scopeId: issue.id,
+ stateKey: "legal-case-number",
});
+ if (linkedCase && typeof linkedCase === "string") {
+ caseIssueMap.set(linkedCase, {
+ issueId: issue.id,
+ companyId: company.id,
+ });
+ }
}
}
- }
- let reminded = 0;
- for (const staleCase of data.cases) {
- const linked = caseIssueMap.get(staleCase.case_number);
- if (!linked) continue;
+ // אין תיקים תקועים — "אין מה לעשות" תקין, לא כשל. ה-loop למטה
+ // פשוט לא ירוץ בלי `data.cases`.
+ let reminded = 0;
+ for (const staleCase of data.cases) {
+ const linked = caseIssueMap.get(staleCase.case_number);
+ if (!linked) continue;
+
+ await ctx.issues.createComment(
+ linked.issueId,
+ `⚠️ **תיק תקוע ${staleCase.case_number}** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם נדרשת פעולה?`,
+ linked.companyId,
+ );
+ reminded++;
+ ctx.logger.info(
+ `stale-case-reminder: reminded case ${staleCase.case_number} (${staleCase.days_stale}d)`,
+ );
+ }
- await ctx.issues.createComment(
- linked.issueId,
- `⚠️ **תיק תקוע ${staleCase.case_number}** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם נדרשת פעולה?`,
- linked.companyId,
- );
- reminded++;
ctx.logger.info(
- `stale-case-reminder: reminded case ${staleCase.case_number} (${staleCase.days_stale}d)`,
+ `stale-case-reminder: done. ${reminded}/${data.total} cases reminded`,
);
- }
-
- ctx.logger.info(
- `stale-case-reminder: done. ${reminded}/${data.total} cases reminded`,
- );
+ });
});
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
ctx.logger.info("weekly-feedback-analysis: starting");
- // Scheduled job — no company context; resolveApiBase probes known companies.
- const apiBase = await resolveApiBase(ctx);
- const resp = await ctx.http.fetch(
- `${apiBase}/api/chair-feedback/weekly-summary`,
- );
- if (!resp.ok) {
- ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`);
- return;
- }
+ 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 data = (await resp.json()) as {
- summary: string;
- entry_count: number;
- };
-
- if (data.entry_count === 0) {
- ctx.logger.info(
- "weekly-feedback-analysis: no feedback this week, skipping",
+ const resp = await ctx.http.fetch(
+ `${apiBase}/api/chair-feedback/weekly-summary`,
);
- return;
- }
+ if (!resp.ok) {
+ throw new Error(`weekly-feedback-analysis: API error ${resp.status}`);
+ }
- // Pick the first company with a known CEO mapping — decision-lessons.md is
- // shared between companies, so a single CEO invocation is sufficient.
- const companies = await ctx.companies.list();
- const mapped = companies
- .map((c) => ({ company: c, ceoId: CEO_AGENT_IDS[c.id] }))
- .filter((x): x is { company: (typeof companies)[0]; ceoId: string } =>
- Boolean(x.ceoId),
- );
+ const data = (await resp.json()) as {
+ summary: string;
+ entry_count: number;
+ };
- if (mapped.length === 0) {
- ctx.logger.warn(
- "weekly-feedback-analysis: no company has a mapped CEO agent — skipping",
- );
- return;
- }
+ // אין פידבק השבוע — "אין מה לעשות" תקין, לא כשל.
+ if (data.entry_count === 0) {
+ ctx.logger.info(
+ "weekly-feedback-analysis: no feedback this week, skipping",
+ );
+ return;
+ }
- const { company, ceoId } = mapped[0];
+ // Pick the first company with a known CEO mapping — decision-lessons.md is
+ // shared between companies, so a single CEO invocation is sufficient.
+ const companies = await ctx.companies.list();
+ const mapped = companies
+ .map((c) => ({ company: c, ceoId: CEO_AGENT_IDS[c.id] }))
+ .filter((x): x is { company: (typeof companies)[0]; ceoId: string } =>
+ Boolean(x.ceoId),
+ );
- try {
- await ctx.agents.invoke(ceoId, company.id, {
- prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):\n\n${data.summary}\n\nהמשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
- reason: "weekly-feedback-job",
- });
- ctx.logger.info(
- `weekly-feedback-analysis: invoked CEO ${ceoId} (company ${company.id}) with ${data.entry_count} feedback entries`,
- );
- } catch (err) {
- ctx.logger.error("weekly-feedback-analysis: failed to invoke CEO", {
- error: String(err),
- ceoId,
- companyId: company.id,
- });
- }
+ // אין שום חברה עם CEO ממופה — זו תקלת-תצורה (לא "אין מה לעשות"),
+ // ולכן זריקה ולא return שקט.
+ if (mapped.length === 0) {
+ throw new Error(
+ "weekly-feedback-analysis: no company has a mapped CEO agent",
+ );
+ }
+
+ const { company, ceoId } = mapped[0];
+
+ try {
+ await ctx.agents.invoke(ceoId, company.id, {
+ prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):\n\n${data.summary}\n\nהמשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
+ reason: "weekly-feedback-job",
+ });
+ ctx.logger.info(
+ `weekly-feedback-analysis: invoked CEO ${ceoId} (company ${company.id}) with ${data.entry_count} feedback entries`,
+ );
+ } catch (err) {
+ ctx.logger.error("weekly-feedback-analysis: failed to invoke CEO", {
+ error: String(err),
+ ceoId,
+ companyId: company.id,
+ });
+ }
+ });
});
// Reconciliation sweep — the at-least-once guarantee for user comments.
@@ -1079,95 +1097,120 @@ const plugin = definePlugin({
// the fast path stamps the marker, so a healthy comment is never
// double-routed. Runs every 2 minutes (manifest schedule).
ctx.jobs.register("route-pending-comments", async (_job) => {
- let companies: Awaited>;
- try {
- companies = await ctx.companies.list();
- } catch (err) {
- ctx.logger.warn("route-pending-comments: companies.list failed", {
- error: String(err),
- });
- return;
- }
-
- for (const company of companies) {
- const ceoAgentId = CEO_AGENT_IDS[company.id];
- if (!ceoAgentId) continue;
-
- let issues: Awaited>;
+ 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>;
try {
- issues = await ctx.issues.list({ companyId: company.id, limit: 200 });
+ companies = await ctx.companies.list();
} catch (err) {
- ctx.logger.warn("route-pending-comments: issues.list failed", {
- companyId: company.id,
+ if (isCompanyScopeDenied(err)) {
+ throw new CompanyScopeUnavailableError(
+ "route-pending-comments",
+ "companies.list",
+ { cause: err },
+ );
+ }
+ ctx.logger.warn("route-pending-comments: companies.list failed", {
error: String(err),
});
- continue;
+ return;
}
- for (const issue of issues) {
- // Terminal issues need no routing. A fresh user comment on a
- // `done` issue reopens it natively → it becomes active and is
- // caught on the next sweep.
- if (issue.status === "done" || issue.status === "cancelled") continue;
+ for (const company of companies) {
+ const ceoAgentId = CEO_AGENT_IDS[company.id];
+ if (!ceoAgentId) continue;
+ let issues: Awaited>;
try {
- const comments = await ctx.issues.listComments(
- issue.id,
- company.id,
- );
- if (comments.length === 0) continue;
-
- // "Pending" = the chair spoke last among real participants: the
- // newest USER comment is newer than the newest AGENT comment (or
- // no agent has commented yet), so an agent never responded to it.
- // Order-independent (sort by createdAt, do NOT rely on the array
- // order of listComments) and system comments are ignored — an
- // automated "draft ready" line is not an agent response. This
- // targets the exact failure (chair commented, no agent reply) and
- // skips historically-answered comments.
- const ts = (c: { createdAt: string | Date }) =>
- new Date(c.createdAt).getTime();
- let newestUser: (typeof comments)[number] | null = null;
- let newestAgent: (typeof comments)[number] | null = null;
- for (const c of comments) {
- if (c.authorType === "user") {
- if (!newestUser || ts(c) > ts(newestUser)) newestUser = c;
- } else if (c.authorType === "agent") {
- if (!newestAgent || ts(c) > ts(newestAgent)) newestAgent = c;
- }
- }
- if (!newestUser) continue; // no chair comment
- // An agent has already responded after the chair — handled.
- if (newestAgent && ts(newestAgent) >= ts(newestUser)) continue;
-
- const marker = await ctx.state.get({
- scopeKind: "issue",
- scopeId: issue.id,
- stateKey: LAST_ROUTED_COMMENT_KEY,
- });
- // Already routed (CEO may still be working) — don't re-fire.
- if (marker === newestUser.id) continue;
-
- ctx.logger.info(
- "route-pending-comments: re-routing unrouted user comment",
- { issueId: issue.id, commentId: newestUser.id },
- );
- await routeCommentToCeo({
- issue,
+ issues = await ctx.issues.list({
companyId: company.id,
- ceoAgentId,
- commentBody: newestUser.body,
- commentId: newestUser.id,
- source: "sweep",
+ limit: 200,
});
} catch (err) {
- ctx.logger.warn("route-pending-comments: failed for issue", {
- issueId: issue.id,
+ if (isCompanyScopeDenied(err)) {
+ throw new CompanyScopeUnavailableError(
+ "route-pending-comments",
+ "issues.list",
+ { cause: err },
+ );
+ }
+ ctx.logger.warn("route-pending-comments: issues.list failed", {
+ companyId: company.id,
error: String(err),
});
+ continue;
+ }
+
+ for (const issue of issues) {
+ // Terminal issues need no routing. A fresh user comment on a
+ // `done` issue reopens it natively → it becomes active and is
+ // caught on the next sweep.
+ if (issue.status === "done" || issue.status === "cancelled")
+ continue;
+
+ try {
+ const comments = await ctx.issues.listComments(
+ issue.id,
+ company.id,
+ );
+ if (comments.length === 0) continue;
+
+ // "Pending" = the chair spoke last among real participants: the
+ // newest USER comment is newer than the newest AGENT comment (or
+ // no agent has commented yet), so an agent never responded to it.
+ // Order-independent (sort by createdAt, do NOT rely on the array
+ // order of listComments) and system comments are ignored — an
+ // automated "draft ready" line is not an agent response. This
+ // targets the exact failure (chair commented, no agent reply) and
+ // skips historically-answered comments.
+ const ts = (c: { createdAt: string | Date }) =>
+ new Date(c.createdAt).getTime();
+ let newestUser: (typeof comments)[number] | null = null;
+ let newestAgent: (typeof comments)[number] | null = null;
+ for (const c of comments) {
+ if (c.authorType === "user") {
+ if (!newestUser || ts(c) > ts(newestUser)) newestUser = c;
+ } else if (c.authorType === "agent") {
+ if (!newestAgent || ts(c) > ts(newestAgent)) newestAgent = c;
+ }
+ }
+ if (!newestUser) continue; // no chair comment
+ // An agent has already responded after the chair — handled.
+ if (newestAgent && ts(newestAgent) >= ts(newestUser)) continue;
+
+ const marker = await ctx.state.get({
+ scopeKind: "issue",
+ scopeId: issue.id,
+ stateKey: LAST_ROUTED_COMMENT_KEY,
+ });
+ // Already routed (CEO may still be working) — don't re-fire.
+ if (marker === newestUser.id) continue;
+
+ ctx.logger.info(
+ "route-pending-comments: re-routing unrouted user comment",
+ { issueId: issue.id, commentId: newestUser.id },
+ );
+ await routeCommentToCeo({
+ issue,
+ companyId: company.id,
+ ceoAgentId,
+ commentBody: newestUser.body,
+ commentId: newestUser.id,
+ source: "sweep",
+ });
+ } catch (err) {
+ ctx.logger.warn("route-pending-comments: failed for issue", {
+ issueId: issue.id,
+ error: String(err),
+ });
+ }
}
}
- }
+ });
});
// ── Data handlers (UI bridge) ──────────────────────────────────
@@ -1337,12 +1380,34 @@ const plugin = definePlugin({
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): Promise {
+ 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 {
if (!pluginCtx) return; // not yet initialized
- // Idempotency guard: skip duplicate deliveries within 5 minutes
- if (input.requestId) {
- const idempKey = `webhook-idem-${input.requestId}`;
+ // Idempotency guard: skip duplicate deliveries within 5 minutes. The key
+ // is hoisted so the catch-block below can delete it on failure — see there.
+ const idempKey = input.requestId ? `webhook-idem-${input.requestId}` : null;
+ if (idempKey) {
const seenAt = await pluginCtx.state.get({
scopeKind: "instance",
stateKey: idempKey,
@@ -1362,251 +1427,276 @@ const plugin = definePlugin({
);
}
- const { endpointKey, parsedBody } = input;
-
- if (endpointKey !== "case-status") return;
-
- // Webhook payload is a discriminated union by `eventType`:
- // • undefined / "status_change" — legacy case-status update (default)
- // • "missing_precedent_created" — ask Daphna to upload a missing citation
- // • "export_complete" — attach a generated DOCX to the linked issue
- const payload = parsedBody as {
- eventType?: string;
- caseNumber: string;
- companyId: string | null;
- timestamp: string;
- // status_change fields
- oldStatus?: string;
- newStatus?: string;
- // missing_precedent_created fields
- missingPrecedent?: {
- id: string;
- citation: string;
- citedByParty?: string;
- citedByPartyName?: string;
- legalTopic?: string;
- legalIssue?: string;
- };
- // export_complete fields
- docxBase64?: string;
- docxFilename?: string;
- docxTitle?: string;
- };
-
- const { caseNumber, companyId } = payload;
- const eventType = payload.eventType ?? "status_change";
-
- if (!caseNumber || !companyId) {
- pluginCtx.logger.warn("onWebhook: malformed payload", {
- eventType,
- caseNumber,
- companyId,
- });
- return;
- }
-
- pluginCtx.logger.info(
- `Webhook: case ${caseNumber} eventType=${eventType}`,
- {
- companyId,
- },
- );
-
- // 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)
- const issues = await pluginCtx.issues.list({ companyId });
- let linkedIssueId: string | null = null;
- for (const issue of issues) {
- const linkedCase = await pluginCtx.state.get({
- scopeKind: "issue",
- scopeId: issue.id,
- stateKey: "legal-case-number",
- });
- if (linkedCase === caseNumber) {
- linkedIssueId = issue.id;
- break;
- }
- }
-
- if (!linkedIssueId) {
- pluginCtx.logger.warn(
- `onWebhook: no Paperclip issue linked to case ${caseNumber}`,
- );
- return;
- }
-
- // ── Branch by eventType ────────────────────────────────────────
-
- // EVENT: missing_precedent_created — ask Daphna to upload via SDK
- // interaction (ask_user_questions). Avoids plain-text comment for
- // a decision that has a clear set of choices.
- if (eventType === "missing_precedent_created" && payload.missingPrecedent) {
- const mp = payload.missingPrecedent;
- const partyLabel = mp.citedByPartyName || mp.citedByParty || "אחד הצדדים";
- try {
- await pluginCtx.issues.askUserQuestions(
- linkedIssueId,
- {
- continuationPolicy: "wake_assignee_on_accept",
- payload: {
- version: 1,
- title: `פסיקה חסרה בקורפוס: ${mp.citation}`,
- questions: [
- {
- id: `missing_precedent:${mp.id}`,
- prompt: `יש להעלות את ${mp.citation}`,
- helpText: [
- `הציטוט הוזכר על-ידי ${partyLabel} בתיק ${caseNumber}.`,
- mp.legalTopic ? `נושא: ${mp.legalTopic}` : "",
- mp.legalIssue ? `סוגיה: ${mp.legalIssue}` : "",
- "",
- "כדי שהמערכת תוכל לבחון את הטענה מול הפסיקה — יש להעלות את ה-PDF/DOCX לדף /missing-precedents.",
- ]
- .filter(Boolean)
- .join("\n"),
- selectionMode: "single",
- required: true,
- options: [
- { id: "upload", label: "אני מעלה PDF/DOCX" },
- { id: "irrelevant", label: "ההלכה לא רלוונטית" },
- { id: "defer", label: "אכריע מאוחר יותר" },
- ],
- },
- ],
- },
- },
- companyId,
- );
- pluginCtx.logger.info(
- "askUserQuestions: missing_precedent prompt sent",
- {
- caseNumber,
- missingPrecedentId: mp.id,
- },
- );
- } catch (err) {
- pluginCtx.logger.error(
- "askUserQuestions failed for missing_precedent",
- {
- caseNumber,
- missingPrecedentId: mp.id,
- error: String(err),
- },
- );
- }
- return;
- }
-
- // EVENT: export_complete — attach a markdown "final decision"
- // document to the issue, with a link back to the DOCX in legal-ai.
- // The SDK's `documents.upsert` stores text (markdown), so we keep
- // the binary DOCX in legal-ai and reference it; the issue page
- // shows the document as a discoverable artifact.
- if (eventType === "export_complete" && payload.docxFilename) {
- const filename = payload.docxFilename;
- const title = payload.docxTitle || `החלטה סופית — ${caseNumber}`;
- const docxUrl = `https://legal-ai.nautilus.marcusgroup.org/api/cases/${encodeURIComponent(caseNumber)}/export/download?filename=${encodeURIComponent(filename)}`;
- const markdownBody = [
- `# ${title}`,
- "",
- `**תיק:** ${caseNumber}`,
- `**קובץ:** \`${filename}\``,
- `**הופק:** ${payload.timestamp}`,
- "",
- `[הורדה](${docxUrl})`,
- "",
- "---",
- "",
- 'החלטה זו יוצאה אוטומטית ע"י legal-ai והוצמדה ל-issue זה.',
- ].join("\n");
- try {
- await pluginCtx.issues.documents.upsert({
- issueId: linkedIssueId,
- companyId,
- key: `final-decision:${caseNumber}`,
- body: markdownBody,
- title,
- format: "markdown",
- changeSummary: `Auto-attached final DOCX (${filename})`,
- });
- pluginCtx.logger.info("documents.upsert: final-decision attached", {
- caseNumber,
- filename,
- });
- } catch (err) {
- pluginCtx.logger.error("documents.upsert failed for export_complete", {
- caseNumber,
- filename,
- error: String(err),
+ try {
+ await handleCaseStatusWebhook(pluginCtx, input);
+ } catch (err) {
+ // מחיקת סמן-האידמפוטנטיות **לפני** שהשגיאה יוצאת — אחרת מסירה
+ // חוזרת של אותו webhook תוך 5 דק' תדולג בשקט כ"כבר נשלח", למרות
+ // שהעיבוד מעולם לא הושלם בפועל (legal-ai issue #637).
+ if (idempKey) {
+ await pluginCtx.state.delete({
+ scopeKind: "instance",
+ stateKey: idempKey,
});
}
- return;
- }
-
- // EVENT: status_change (default) — legacy behavior.
- const { oldStatus = "", newStatus = "" } = payload;
- if (!newStatus) {
- pluginCtx.logger.warn("onWebhook status_change: missing newStatus", {
- caseNumber,
- });
- return;
- }
-
- // Status label map (Hebrew)
- const statusLabels: Record = {
- new: "📂 תיק חדש",
- uploading: "📤 העלאת מסמכים",
- processing: "⚙️ עיבוד מסמכים",
- documents_ready: "📁 מסמכים מוכנים",
- outcome_set: "🎯 תוצאה הוזנה",
- brainstorming: "💡 סיעור מוחות",
- direction_approved: "✅ כיוון אושר",
- drafting: "✍️ כתיבה בתהליך",
- qa_review: "🔍 בדיקת איכות",
- in_progress: "🔄 בעבודה",
- drafted: "✍️ טיוטה מוכנה",
- qa_failed: "❌ QA נכשל",
- exported: "📄 יוצא ל-DOCX",
- reviewed: "✅ נבדק",
- final: "🎯 סופי",
- };
- const label = statusLabels[newStatus] ?? newStatus;
-
- // Post a Hebrew status comment on the linked issue
- await pluginCtx.issues.createComment(
- linkedIssueId,
- `**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldStatus})`,
- companyId,
- );
-
- // Wake the CEO agent if QA failed
- if (newStatus === "qa_failed") {
- const ceoId = CEO_AGENT_IDS[companyId];
- if (ceoId) {
- try {
- const { runId } = await pluginCtx.agents.invoke(ceoId, companyId, {
- prompt: `תיק ${caseNumber} נכשל בבדיקת QA. עיין בתוצאות QA ותקן את הבעיות לפני שתמשיך.`,
- reason: "qa_failed webhook",
- });
- pluginCtx.logger.info(`Invoked CEO agent for qa_failed`, {
- caseNumber,
- ceoId,
- runId,
- });
- } catch (err) {
- pluginCtx.logger.error("Failed to invoke CEO agent for qa_failed", {
- caseNumber,
- error: String(err),
- });
- }
- } else {
- pluginCtx.logger.warn("onWebhook: no CEO agent mapped for company", {
- companyId,
+ // אין בליעה — הזריקה חייבת להימשך החוצה כדי ש-
+ // `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 {
+ const { endpointKey, parsedBody } = input;
+
+ if (endpointKey !== "case-status") return;
+
+ // Webhook payload is a discriminated union by `eventType`:
+ // • undefined / "status_change" — legacy case-status update (default)
+ // • "missing_precedent_created" — ask Daphna to upload a missing citation
+ // • "export_complete" — attach a generated DOCX to the linked issue
+ const payload = parsedBody as {
+ eventType?: string;
+ caseNumber: string;
+ companyId: string | null;
+ timestamp: string;
+ // status_change fields
+ oldStatus?: string;
+ newStatus?: string;
+ // missing_precedent_created fields
+ missingPrecedent?: {
+ id: string;
+ citation: string;
+ citedByParty?: string;
+ citedByPartyName?: string;
+ legalTopic?: string;
+ legalIssue?: string;
+ };
+ // export_complete fields
+ docxBase64?: string;
+ docxFilename?: string;
+ docxTitle?: string;
+ };
+
+ const { caseNumber, companyId } = payload;
+ const eventType = payload.eventType ?? "status_change";
+
+ if (!caseNumber || !companyId) {
+ pluginCtx.logger.warn("onWebhook: malformed payload", {
+ eventType,
+ caseNumber,
+ companyId,
+ });
+ return;
+ }
+
+ pluginCtx.logger.info(`Webhook: case ${caseNumber} eventType=${eventType}`, {
+ companyId,
+ });
+
+ // 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)
+ const issues = await pluginCtx.issues.list({ companyId });
+ let linkedIssueId: string | null = null;
+ for (const issue of issues) {
+ const linkedCase = await pluginCtx.state.get({
+ scopeKind: "issue",
+ scopeId: issue.id,
+ stateKey: "legal-case-number",
+ });
+ if (linkedCase === caseNumber) {
+ linkedIssueId = issue.id;
+ break;
+ }
+ }
+
+ if (!linkedIssueId) {
+ pluginCtx.logger.warn(
+ `onWebhook: no Paperclip issue linked to case ${caseNumber}`,
+ );
+ return;
+ }
+
+ // ── Branch by eventType ────────────────────────────────────────
+
+ // EVENT: missing_precedent_created — ask Daphna to upload via SDK
+ // interaction (ask_user_questions). Avoids plain-text comment for
+ // a decision that has a clear set of choices.
+ if (eventType === "missing_precedent_created" && payload.missingPrecedent) {
+ const mp = payload.missingPrecedent;
+ const partyLabel = mp.citedByPartyName || mp.citedByParty || "אחד הצדדים";
+ try {
+ await pluginCtx.issues.askUserQuestions(
+ linkedIssueId,
+ {
+ continuationPolicy: "wake_assignee_on_accept",
+ payload: {
+ version: 1,
+ title: `פסיקה חסרה בקורפוס: ${mp.citation}`,
+ questions: [
+ {
+ id: `missing_precedent:${mp.id}`,
+ prompt: `יש להעלות את ${mp.citation}`,
+ helpText: [
+ `הציטוט הוזכר על-ידי ${partyLabel} בתיק ${caseNumber}.`,
+ mp.legalTopic ? `נושא: ${mp.legalTopic}` : "",
+ mp.legalIssue ? `סוגיה: ${mp.legalIssue}` : "",
+ "",
+ "כדי שהמערכת תוכל לבחון את הטענה מול הפסיקה — יש להעלות את ה-PDF/DOCX לדף /missing-precedents.",
+ ]
+ .filter(Boolean)
+ .join("\n"),
+ selectionMode: "single",
+ required: true,
+ options: [
+ { id: "upload", label: "אני מעלה PDF/DOCX" },
+ { id: "irrelevant", label: "ההלכה לא רלוונטית" },
+ { id: "defer", label: "אכריע מאוחר יותר" },
+ ],
+ },
+ ],
+ },
+ },
+ companyId,
+ );
+ pluginCtx.logger.info("askUserQuestions: missing_precedent prompt sent", {
+ caseNumber,
+ missingPrecedentId: mp.id,
+ });
+ } catch (err) {
+ pluginCtx.logger.error("askUserQuestions failed for missing_precedent", {
+ caseNumber,
+ missingPrecedentId: mp.id,
+ error: String(err),
+ });
+ }
+ return;
+ }
+
+ // EVENT: export_complete — attach a markdown "final decision"
+ // document to the issue, with a link back to the DOCX in legal-ai.
+ // The SDK's `documents.upsert` stores text (markdown), so we keep
+ // the binary DOCX in legal-ai and reference it; the issue page
+ // shows the document as a discoverable artifact.
+ if (eventType === "export_complete" && payload.docxFilename) {
+ const filename = payload.docxFilename;
+ const title = payload.docxTitle || `החלטה סופית — ${caseNumber}`;
+ const docxUrl = `https://legal-ai.nautilus.marcusgroup.org/api/cases/${encodeURIComponent(caseNumber)}/export/download?filename=${encodeURIComponent(filename)}`;
+ const markdownBody = [
+ `# ${title}`,
+ "",
+ `**תיק:** ${caseNumber}`,
+ `**קובץ:** \`${filename}\``,
+ `**הופק:** ${payload.timestamp}`,
+ "",
+ `[הורדה](${docxUrl})`,
+ "",
+ "---",
+ "",
+ 'החלטה זו יוצאה אוטומטית ע"י legal-ai והוצמדה ל-issue זה.',
+ ].join("\n");
+ try {
+ await pluginCtx.issues.documents.upsert({
+ issueId: linkedIssueId,
+ companyId,
+ key: `final-decision:${caseNumber}`,
+ body: markdownBody,
+ title,
+ format: "markdown",
+ changeSummary: `Auto-attached final DOCX (${filename})`,
+ });
+ pluginCtx.logger.info("documents.upsert: final-decision attached", {
+ caseNumber,
+ filename,
+ });
+ } catch (err) {
+ pluginCtx.logger.error("documents.upsert failed for export_complete", {
+ caseNumber,
+ filename,
+ error: String(err),
+ });
+ }
+ return;
+ }
+
+ // EVENT: status_change (default) — legacy behavior.
+ const { oldStatus = "", newStatus = "" } = payload;
+ if (!newStatus) {
+ pluginCtx.logger.warn("onWebhook status_change: missing newStatus", {
+ caseNumber,
+ });
+ return;
+ }
+
+ // Status label map (Hebrew)
+ const statusLabels: Record = {
+ new: "📂 תיק חדש",
+ uploading: "📤 העלאת מסמכים",
+ processing: "⚙️ עיבוד מסמכים",
+ documents_ready: "📁 מסמכים מוכנים",
+ outcome_set: "🎯 תוצאה הוזנה",
+ brainstorming: "💡 סיעור מוחות",
+ direction_approved: "✅ כיוון אושר",
+ drafting: "✍️ כתיבה בתהליך",
+ qa_review: "🔍 בדיקת איכות",
+ in_progress: "🔄 בעבודה",
+ drafted: "✍️ טיוטה מוכנה",
+ qa_failed: "❌ QA נכשל",
+ exported: "📄 יוצא ל-DOCX",
+ reviewed: "✅ נבדק",
+ final: "🎯 סופי",
+ };
+ const label = statusLabels[newStatus] ?? newStatus;
+
+ // Post a Hebrew status comment on the linked issue
+ await pluginCtx.issues.createComment(
+ linkedIssueId,
+ `**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldStatus})`,
+ companyId,
+ );
+
+ // Wake the CEO agent if QA failed
+ if (newStatus === "qa_failed") {
+ const ceoId = CEO_AGENT_IDS[companyId];
+ if (ceoId) {
+ try {
+ const { runId } = await pluginCtx.agents.invoke(ceoId, companyId, {
+ prompt: `תיק ${caseNumber} נכשל בבדיקת QA. עיין בתוצאות QA ותקן את הבעיות לפני שתמשיך.`,
+ reason: "qa_failed webhook",
+ });
+ pluginCtx.logger.info(`Invoked CEO agent for qa_failed`, {
+ caseNumber,
+ ceoId,
+ runId,
+ });
+ } catch (err) {
+ pluginCtx.logger.error("Failed to invoke CEO agent for qa_failed", {
+ caseNumber,
+ error: String(err),
+ });
+ }
+ } else {
+ pluginCtx.logger.warn("onWebhook: no CEO agent mapped for company", {
+ companyId,
+ });
+ }
+ }
+}
+
export default plugin;
runWorker(plugin, import.meta.url);