From 3627865bac587b16a7abf15518f4dfa2fbe357ca Mon Sep 17 00:00:00 2001 From: Chaim Marcus Date: Wed, 26 Aug 2026 16:13:08 +0300 Subject: [PATCH] =?UTF-8?q?fix(sync):=20company-scope=20=D7=9C=D7=92'?= =?UTF-8?q?=D7=95=D7=91=D7=99=D7=9D=20=D7=9E=D7=AA=D7=95=D7=96=D7=9E=D7=A0?= =?UTF-8?q?=D7=99=D7=9D=20=E2=80=94=20=D7=94=D7=A4=D7=A1=D7=A7=D7=AA=20?= =?UTF-8?q?=D7=91=D7=9C=D7=99=D7=A2=D7=AA-=D7=9B=D7=A9=D7=9C=20(legal-ai?= =?UTF-8?q?=20#637)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הג'וב `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 --- src/company-scope.test.ts | 274 ++++++++++ src/company-scope.ts | 260 ++++++++++ src/worker.ts | 1016 ++++++++++++++++++++----------------- 3 files changed, 1087 insertions(+), 463 deletions(-) create mode 100644 src/company-scope.test.ts create mode 100644 src/company-scope.ts 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);