Compare commits
6 Commits
fix/604-pa
...
a6bd02c237
| Author | SHA1 | Date | |
|---|---|---|---|
| a6bd02c237 | |||
| 237d829844 | |||
| 7cbb3d1c03 | |||
| 41305fbb36 | |||
| 511d5c6e34 | |||
| 10d6216bec |
274
src/company-scope.test.ts
Normal file
274
src/company-scope.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
|
||||
// `node --test`, which requires the literal `.ts` extension (Node's ESM
|
||||
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
_resetApiBaseCacheMemo,
|
||||
CompanyScopeUnavailableError,
|
||||
isCompanyScopeDenied,
|
||||
type ResolveApiBaseDeps,
|
||||
resolveApiBaseFrom,
|
||||
runJobHandler,
|
||||
} from "./company-scope.ts";
|
||||
|
||||
// ── isCompanyScopeDenied ─────────────────────────────────────────────────
|
||||
|
||||
test("isCompanyScopeDenied: true עבור אובייקט עם code של INVOCATION_SCOPE_DENIED", () => {
|
||||
const err = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
|
||||
assert.equal(isCompanyScopeDenied(err), true);
|
||||
});
|
||||
|
||||
test("isCompanyScopeDenied: false עבור שגיאה רגילה", () => {
|
||||
assert.equal(isCompanyScopeDenied(new Error("boom")), false);
|
||||
assert.equal(isCompanyScopeDenied({ code: -32002 }), false);
|
||||
assert.equal(isCompanyScopeDenied("boom"), false);
|
||||
assert.equal(isCompanyScopeDenied(null), false);
|
||||
assert.equal(isCompanyScopeDenied(undefined), false);
|
||||
});
|
||||
|
||||
// ── runJobHandler ────────────────────────────────────────────────────────
|
||||
|
||||
function makeLogger() {
|
||||
const calls: Array<{ level: string; message: string; meta?: unknown }> = [];
|
||||
return {
|
||||
calls,
|
||||
logger: {
|
||||
error(message: string, meta?: Record<string, unknown>) {
|
||||
calls.push({ level: "error", message, meta });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("runJobHandler: הצלחה — לא זורק ולא מלוגג שגיאה", async () => {
|
||||
const { calls, logger } = makeLogger();
|
||||
await runJobHandler("test-job", logger, async () => {});
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("runJobHandler: כשל בשגיאה רגילה — מלוגג error וזורק מחדש את המקורית", async () => {
|
||||
const { calls, logger } = makeLogger();
|
||||
const original = new Error("plain failure");
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runJobHandler("test-job", logger, async () => {
|
||||
throw original;
|
||||
}),
|
||||
(err: unknown) => err === original,
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].message, "test-job: job failed");
|
||||
});
|
||||
|
||||
test("runJobHandler: כשל בדחיית-scope — זורק CompanyScopeUnavailableError שמזכירה #637", async () => {
|
||||
const { logger } = makeLogger();
|
||||
const scopeErr = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runJobHandler("sync-case-status", logger, async () => {
|
||||
throw scopeErr;
|
||||
}),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof CompanyScopeUnavailableError);
|
||||
assert.match(err.message, /#637/);
|
||||
assert.equal(err.jobKey, "sync-case-status");
|
||||
assert.equal(err.cause, scopeErr);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("runJobHandler: כשל בדחיית-scope — ההודעה כוללת גם #637 וגם את טקסט השגיאה המקורית (זה מה שנכתב ל-plugin_job_runs.error)", async () => {
|
||||
const { logger } = makeLogger();
|
||||
// כמו JsonRpcCallError אמיתי מהמארח (protocol.js): מופע Error שה-`message`
|
||||
// שלו נושא את שם-הפעולה שנדחתה.
|
||||
const scopeErr = Object.assign(new Error("issues.list"), {
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
});
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runJobHandler("route-pending-comments", logger, async () => {
|
||||
throw scopeErr;
|
||||
}),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof CompanyScopeUnavailableError);
|
||||
assert.match(err.message, /#637/);
|
||||
assert.match(err.message, /issues\.list/);
|
||||
// method לא ידוע ל-runJobHandler — "unknown" לא אמור להופיע בהודעה.
|
||||
assert.doesNotMatch(err.message, /"unknown"/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── resolveApiBaseFrom ───────────────────────────────────────────────────
|
||||
|
||||
function makeDeps(
|
||||
overrides: Partial<ResolveApiBaseDeps> & {
|
||||
configByCompany?: Record<string, Record<string, unknown> | undefined>;
|
||||
deniedCompanyIds?: readonly (string | undefined)[];
|
||||
failingCompanyIds?: readonly (string | undefined)[];
|
||||
} = {},
|
||||
): ResolveApiBaseDeps & {
|
||||
cacheWrites: string[];
|
||||
infoLogs: unknown[];
|
||||
warnLogs: unknown[];
|
||||
} {
|
||||
const {
|
||||
configByCompany = {},
|
||||
deniedCompanyIds = [],
|
||||
failingCompanyIds = [],
|
||||
...rest
|
||||
} = overrides;
|
||||
const cacheWrites: string[] = [];
|
||||
const infoLogs: unknown[] = [];
|
||||
const warnLogs: unknown[] = [];
|
||||
let cacheValue: unknown = null;
|
||||
|
||||
const deniedKey = (id?: string) => (id === undefined ? "__undefined__" : id);
|
||||
|
||||
const deps: ResolveApiBaseDeps & {
|
||||
cacheWrites: string[];
|
||||
infoLogs: unknown[];
|
||||
warnLogs: unknown[];
|
||||
} = {
|
||||
knownCompanyIds: [],
|
||||
defaultBaseUrl: "http://localhost:8085",
|
||||
async readConfig(companyId?: string) {
|
||||
const key = deniedKey(companyId);
|
||||
if (deniedCompanyIds.some((d) => deniedKey(d) === key)) {
|
||||
throw { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
|
||||
}
|
||||
if (failingCompanyIds.some((d) => deniedKey(d) === key)) {
|
||||
throw new Error(`boom for ${key}`);
|
||||
}
|
||||
return configByCompany[key] ?? null;
|
||||
},
|
||||
async readCache() {
|
||||
return cacheValue;
|
||||
},
|
||||
async writeCache(url: string) {
|
||||
cacheValue = url;
|
||||
cacheWrites.push(url);
|
||||
},
|
||||
logger: {
|
||||
info(message: string, meta?: Record<string, unknown>) {
|
||||
infoLogs.push({ message, meta });
|
||||
},
|
||||
warn(message: string, meta?: Record<string, unknown>) {
|
||||
warnLogs.push({ message, meta });
|
||||
},
|
||||
},
|
||||
cacheWrites,
|
||||
infoLogs,
|
||||
warnLogs,
|
||||
...rest,
|
||||
};
|
||||
// Allow overriding cache seed via readCache in `rest`.
|
||||
return deps;
|
||||
}
|
||||
|
||||
test("resolveApiBaseFrom: companyId נתון ו-readConfig מחזיר legalApiBaseUrl — מחזיר וכותב cache", async () => {
|
||||
_resetApiBaseCacheMemo();
|
||||
const deps = makeDeps({
|
||||
configByCompany: { "company-a": { legalApiBaseUrl: "https://a.example" } },
|
||||
});
|
||||
const result = await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
|
||||
assert.equal(result, "https://a.example");
|
||||
assert.deepEqual(deps.cacheWrites, ["https://a.example"]);
|
||||
});
|
||||
|
||||
test('resolveApiBaseFrom: אין companyId — readConfig(undefined) מוסק ע"י המארח ומחזיר ערך', async () => {
|
||||
_resetApiBaseCacheMemo();
|
||||
const deps = makeDeps({
|
||||
configByCompany: {
|
||||
__undefined__: { legalApiBaseUrl: "https://derived.example" },
|
||||
},
|
||||
});
|
||||
const result = await resolveApiBaseFrom(deps);
|
||||
assert.equal(result, "https://derived.example");
|
||||
assert.deepEqual(deps.cacheWrites, ["https://derived.example"]);
|
||||
});
|
||||
|
||||
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope, יש cache תקף — מחזיר cache ומלוגג info (לא warn)", async () => {
|
||||
_resetApiBaseCacheMemo();
|
||||
const deps = makeDeps({
|
||||
knownCompanyIds: ["c1", "c2"],
|
||||
deniedCompanyIds: [undefined, "c1", "c2"],
|
||||
});
|
||||
// לדמות מטמון קיים: לעקוף readCache
|
||||
deps.readCache = async () => "https://cached.example";
|
||||
const result = await resolveApiBaseFrom(deps);
|
||||
assert.equal(result, "https://cached.example");
|
||||
assert.equal(deps.warnLogs.length, 0);
|
||||
assert.equal(deps.infoLogs.length, 1);
|
||||
});
|
||||
|
||||
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope ואין cache — זורק CompanyScopeUnavailableError (לא default)", async () => {
|
||||
_resetApiBaseCacheMemo();
|
||||
const deps = makeDeps({
|
||||
knownCompanyIds: ["c1"],
|
||||
deniedCompanyIds: [undefined, "c1"],
|
||||
});
|
||||
await assert.rejects(
|
||||
() => resolveApiBaseFrom(deps),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof CompanyScopeUnavailableError);
|
||||
assert.equal(err.method, "config.get");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveApiBaseFrom: readConfig הצליח בכל מקום אך בלי legalApiBaseUrl — default + warn", async () => {
|
||||
_resetApiBaseCacheMemo();
|
||||
const deps = makeDeps({
|
||||
knownCompanyIds: ["c1"],
|
||||
configByCompany: { __undefined__: {}, c1: {} },
|
||||
});
|
||||
const result = await resolveApiBaseFrom(deps);
|
||||
assert.equal(result, deps.defaultBaseUrl);
|
||||
assert.equal(deps.warnLogs.length, 1);
|
||||
});
|
||||
|
||||
test("resolveApiBaseFrom: readConfig נכשל בשגיאה שאינה דחיית-scope — warn וממשיך לנסות הבא", async () => {
|
||||
_resetApiBaseCacheMemo();
|
||||
const deps = makeDeps({
|
||||
knownCompanyIds: ["c1"],
|
||||
failingCompanyIds: [undefined],
|
||||
configByCompany: { c1: { legalApiBaseUrl: "https://c1.example" } },
|
||||
});
|
||||
const result = await resolveApiBaseFrom(deps);
|
||||
assert.equal(result, "https://c1.example");
|
||||
// warn אחד על הכשל ב-readConfig(undefined) — לא בליעה שקטה
|
||||
assert.ok(deps.warnLogs.length >= 1);
|
||||
});
|
||||
|
||||
// ── writeCache dedup (מתוזמן פר-בקשה, לא לכתוב cache שלא השתנה) ──────────
|
||||
|
||||
test("resolveApiBaseFrom: כותב ל-cache רק כשהערך משתנה בפועל", async () => {
|
||||
_resetApiBaseCacheMemo();
|
||||
const deps = makeDeps({
|
||||
configByCompany: {
|
||||
"company-a": { legalApiBaseUrl: "https://dedup.example" },
|
||||
},
|
||||
});
|
||||
|
||||
// שתי פתירות רצופות של אותו URL — writeCache נקרא פעם אחת בלבד.
|
||||
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
|
||||
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
|
||||
assert.deepEqual(deps.cacheWrites, ["https://dedup.example"]);
|
||||
|
||||
// פתירה של URL שונה אחריהן — writeCache נקרא שוב.
|
||||
const deps2 = makeDeps({
|
||||
configByCompany: {
|
||||
"company-b": { legalApiBaseUrl: "https://dedup-2.example" },
|
||||
},
|
||||
});
|
||||
await resolveApiBaseFrom({ ...deps2, companyId: "company-b" });
|
||||
assert.deepEqual(deps2.cacheWrites, ["https://dedup-2.example"]);
|
||||
});
|
||||
260
src/company-scope.ts
Normal file
260
src/company-scope.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* הפשטת company-scope לג'ובים מתוזמנים ול-webhook (legal-ai issue #637).
|
||||
*
|
||||
* המקור לבאג: מאז @paperclipai/server 2026.722.0 אין scope של חברה
|
||||
* ל-`runJob`/`handleWebhook` — המארח מדפיס scope רק מ-`params.companyId` /
|
||||
* `performAction.actorContext` / `executeTool.runContext` /
|
||||
* `onEvent.event.companyId` (plugin-worker-manager.js:191-210), ו-
|
||||
* `plugin-job-scheduler.js:184` שולח `runJob` בלי `companyId` כלל. לכן כל
|
||||
* קריאה מתוך ג'וב ל-`ctx.config.get`/`ctx.issues.*`/`ctx.agents.invoke`/
|
||||
* `ctx.events.emit` נדחית ע"י המארח עם `InvocationScopeDeniedError`
|
||||
* (host-client-factory.js:253-293) — קוד `-32005`.
|
||||
*
|
||||
* המודול הזה טהור בכוונה — כמו `sync-target.ts` — כדי שאפשר יהיה לייבא
|
||||
* אותו בטסט בלי להריץ את `runWorker(plugin, import.meta.url)` שקורה בזמן
|
||||
* import של worker.ts. הייבוא היחיד מה-SDK הוא ייבוא-ערך של
|
||||
* `PLUGIN_RPC_ERROR_CODES` — קבוע פרוטוקול נטול side-effects, לא ה-runtime
|
||||
* של הפלאגין עצמו.
|
||||
*/
|
||||
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||
|
||||
/** מפתח ה-`ctx.state` (scope `instance`) שבו נשמר ה-base URL האחרון שהצלחנו לפתור. */
|
||||
export const LEGAL_API_BASE_CACHE_KEY = "legal-api-base-url";
|
||||
|
||||
/**
|
||||
* זיכרון-תהליך (לא `ctx.state`) של ה-base URL האחרון שבאמת נכתב ל-cache.
|
||||
* `LegalApi.request` (`legal-api.ts:19-32`) קורא ל-`resolveBaseUrl()` —
|
||||
* וכך ל-`resolveApiBaseFrom` — בכל בקשת-API בודדת, אבל הערך כמעט לעולם
|
||||
* לא משתנה; בלי המנגנון הזה כל קריאת-כלי/`usePluginData` הייתה מוסיפה
|
||||
* `ctx.state.set` (=upsert ל-Postgres) מיותר.
|
||||
*/
|
||||
let lastWrittenApiBase: string | undefined;
|
||||
|
||||
/** מאפס את זיכרון-המטמון ברמת-המודול. **לשימוש טסטים בלבד.** */
|
||||
export function _resetApiBaseCacheMemo(): void {
|
||||
lastWrittenApiBase = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* האם השגיאה היא דחיית-scope של המארח (קוד `-32005`,
|
||||
* `PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED`). זיהוי **מבני** לפי
|
||||
* שדה `code` — לא התאמת-מחרוזת על `err.message`, שיכולה להישבר בשקט אם
|
||||
* המארח ינסח את ההודעה מחדש.
|
||||
*/
|
||||
export function isCompanyScopeDenied(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code ===
|
||||
PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* מחלץ טקסט קריא מ-`cause` שגוי-scope. המארח שולח `JsonRpcCallError`
|
||||
* (מופע `Error` עם `.message` תיאורי מהצד השני), אבל בטסטים/עתידית ייתכן
|
||||
* גם אובייקט-שגיאה פשוט בלי prototype chain — לכן בדיקה מבנית ולא רק
|
||||
* `instanceof Error`.
|
||||
*/
|
||||
function describeCause(cause: unknown): string {
|
||||
if (cause instanceof Error) return cause.message;
|
||||
if (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"message" in cause &&
|
||||
typeof (cause as { message?: unknown }).message === "string"
|
||||
) {
|
||||
return (cause as { message: string }).message;
|
||||
}
|
||||
return String(cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* נזרקת כש-handler של ג'וב/webhook נתקל בדחיית-scope שאין לו דרך לעקוף
|
||||
* אותה. ההודעה עצמה היא האבחנה — היא מה שיישמר ב-`plugin_job_runs.error`
|
||||
* (או `plugin_webhook_deliveries.error`), ו-`plugin_logs` ריק לחלוטין
|
||||
* (נמדד), כך שזו למעשה עדות-הכשל היחידה שתישאר. **חובה** להעביר `cause`
|
||||
* כשהוא ידוע — המתזמר של Paperclip (`plugin-job-scheduler.js:199`) רושם
|
||||
* ל-DB רק את `err.message`, ואינו מטייל ב-`Error.cause`, כך שכל מידע
|
||||
* שלא נכנס למחרוזת-ההודעה עצמה אובד.
|
||||
*/
|
||||
export class CompanyScopeUnavailableError extends Error {
|
||||
readonly jobKey: string;
|
||||
readonly method: string;
|
||||
|
||||
constructor(jobKey: string, method: string, options?: { cause?: unknown }) {
|
||||
// method="unknown" קורה כש-runJobHandler תופס דחיית-scope בלי לדעת
|
||||
// איזו קריאה נדחתה בפועל (ה-handler לא ציין method מפורש). לא לכתוב
|
||||
// את המילה "unknown" להודעה — במקום זה מסתמכים על ה-cause בלבד.
|
||||
const methodClause =
|
||||
method === "unknown" ? "" : ` הפעולה שנדחתה: "${method}".`;
|
||||
const sourceClause =
|
||||
options?.cause !== undefined
|
||||
? ` מקור: ${describeCause(options.cause)}.`
|
||||
: "";
|
||||
super(
|
||||
`${jobKey}: נדרש הקשר-חברה (company scope) לפעולה זו, אבל ג'וב ` +
|
||||
"מתוזמן/webhook אינו מקבל כזה מ-Paperclip (מאז server " +
|
||||
`2026.722.0).${methodClause}${sourceClause} ראה legal-ai issue #637.`,
|
||||
options,
|
||||
);
|
||||
this.name = "CompanyScopeUnavailableError";
|
||||
this.jobKey = jobKey;
|
||||
this.method = method;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* מריץ handler של ג'וב, ובכשל — מלוגג **וזורק מחדש**. המתזמר של Paperclip
|
||||
* (`plugin-job-scheduler.js:194-215`) כבר רושם `status:"failed"` כש-RPC
|
||||
* נדחה; מה שהיה שבור זה ה-handlers שלנו, שבלעו את השגיאה ב-
|
||||
* `catch (err) { ctx.logger.error(...) }` בלי `throw` — כך ה-RPC נפתר
|
||||
* בהצלחה וה-run נרשם `succeeded` למרות שלא עשה כלום (13,928 ריצות כוזבות,
|
||||
* נמדד). ה-README של ה-SDK: "Re-throw from the handler to mark the run as
|
||||
* failed".
|
||||
*
|
||||
* דחיית-scope הופכת ל-`CompanyScopeUnavailableError` מאבחנת; כל שגיאה
|
||||
* אחרת נזרקת כמות-שהיא.
|
||||
*/
|
||||
export async function runJobHandler(
|
||||
jobKey: string,
|
||||
logger: { error(message: string, meta?: Record<string, unknown>): void },
|
||||
fn: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
logger.error(`${jobKey}: job failed`, { error: String(err) });
|
||||
if (isCompanyScopeDenied(err)) {
|
||||
throw new CompanyScopeUnavailableError(jobKey, "unknown", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** תלויות מוזרקות לפתירת `legalApiBaseUrl` — כדי שהלוגיקה תהיה בת-טסט בלי `ctx` אמיתי. */
|
||||
export interface ResolveApiBaseDeps {
|
||||
/** עוטף `ctx.config.get(companyId)`. **לא** בולע — זורק כשהמארח דוחה scope. */
|
||||
readConfig(companyId?: string): Promise<Record<string, unknown> | null>;
|
||||
/** עוטף `ctx.state.get` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
|
||||
readCache(): Promise<unknown>;
|
||||
/** עוטף `ctx.state.set` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
|
||||
writeCache(url: string): Promise<void>;
|
||||
/** מזהי כל החברות המוכרות לפלאגין (`Object.keys(CEO_AGENT_IDS)`). */
|
||||
knownCompanyIds: readonly string[];
|
||||
/** ברירת-המחדל כש-legal-ai לא הוגדר בכלל (אינסטנס לא-מוגדר, לא scope שנדחה). */
|
||||
defaultBaseUrl: string;
|
||||
logger: {
|
||||
info(message: string, meta?: Record<string, unknown>): void;
|
||||
warn(message: string, meta?: Record<string, unknown>): void;
|
||||
};
|
||||
/** ידוע רק כשהמארח מספק אחד (handler בהקשר-חברה) — אופציונלי. */
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* פותר `legalApiBaseUrl` מ-config בהיקף-חברה, עם נפילה-חזרה למטמון
|
||||
* (`ctx.state`, scope `instance`) כשאין כלל הקשר-חברה זמין (ג'וב/webhook).
|
||||
*
|
||||
* ⚠️ כשכל ניסיונות ה-`readConfig` נדחים ב-scope **ואין** מטמון — זורקים
|
||||
* `CompanyScopeUnavailableError`. **אסור** להחזיר `defaultBaseUrl` במקרה
|
||||
* הזה: זה בדיוק מה שיצר את `TypeError: fetch failed` המטעה — `worker.ts`
|
||||
* הישן בלע את דחיית-ה-scope כ"אין קונפיג" ונפל ל-
|
||||
* `http://localhost:8085` שאין בו מאזין.
|
||||
*/
|
||||
export async function resolveApiBaseFrom(
|
||||
deps: ResolveApiBaseDeps,
|
||||
): Promise<string> {
|
||||
const {
|
||||
readConfig,
|
||||
readCache,
|
||||
writeCache,
|
||||
knownCompanyIds,
|
||||
defaultBaseUrl,
|
||||
logger,
|
||||
companyId,
|
||||
} = deps;
|
||||
|
||||
let sawScopeDenied = false;
|
||||
|
||||
// כותב ל-cache רק כשהערך השתנה בפועל — ראה `lastWrittenApiBase` למעלה.
|
||||
const writeCacheIfChanged = async (url: string): Promise<void> => {
|
||||
if (url === lastWrittenApiBase) return;
|
||||
await writeCache(url);
|
||||
lastWrittenApiBase = url;
|
||||
};
|
||||
|
||||
const tryRead = async (id?: string): Promise<string | null | undefined> => {
|
||||
let cfg: Record<string, unknown> | null;
|
||||
try {
|
||||
cfg = await readConfig(id);
|
||||
} catch (err) {
|
||||
if (isCompanyScopeDenied(err)) {
|
||||
sawScopeDenied = true;
|
||||
return undefined; // נדחה — נסה את המקור הבא
|
||||
}
|
||||
// שגיאה אחרת: אין בליעה שקטה — מדווחים וממשיכים לנסות את הבא.
|
||||
logger.warn("resolveApiBase: config.get failed", {
|
||||
companyId: id ?? null,
|
||||
error: String(err),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
const url = cfg?.legalApiBaseUrl;
|
||||
return typeof url === "string" && url.trim() ? url.trim() : null;
|
||||
};
|
||||
|
||||
if (companyId) {
|
||||
const scoped = await tryRead(companyId);
|
||||
if (scoped) {
|
||||
await writeCacheIfChanged(scoped);
|
||||
return scoped;
|
||||
}
|
||||
}
|
||||
|
||||
// אין companyId ישיר (או שהקריאה מולו נדחתה) — אבל המארח מסיק בעצמו
|
||||
// companyId כשהקריאה מתרחשת בתוך הקשר host-scoped (למשל tool handler).
|
||||
// לנסות לפני נפילה-לניחוש. נשאר בהתנהגות המקורית: מנוסה תמיד, לא רק
|
||||
// כשלא סופק companyId.
|
||||
const derived = await tryRead(undefined);
|
||||
if (derived) {
|
||||
await writeCacheIfChanged(derived);
|
||||
return derived;
|
||||
}
|
||||
|
||||
// ג'וב מתוזמן/webhook ללא הקשר-חברה כלל: לנסות כל חברה מוכרת. בפועל
|
||||
// כולן מצביעות על אותו מופע legal-ai, כך שהפגיעה הראשונה מנצחת.
|
||||
for (const knownCompanyId of knownCompanyIds) {
|
||||
const url = await tryRead(knownCompanyId);
|
||||
if (url) {
|
||||
await writeCacheIfChanged(url);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
if (sawScopeDenied) {
|
||||
// כל הניסיונות נדחו ב-scope (לא "לא-מוגדר") — לנסות את המטמון לפני
|
||||
// שנכשל. מצב-צפוי בג'וב, ולכן `info` ולא `warn`.
|
||||
const cached = await readCache();
|
||||
if (typeof cached === "string" && cached.trim()) {
|
||||
logger.info(
|
||||
"resolveApiBase: config.get denied (no company scope) — using cached legalApiBaseUrl",
|
||||
{ cachedBaseUrl: cached },
|
||||
);
|
||||
return cached;
|
||||
}
|
||||
throw new CompanyScopeUnavailableError("resolveApiBase", "config.get");
|
||||
}
|
||||
|
||||
// כל הקריאות הצליחו (לא נדחו) אך אף אחת לא החזירה ערך — legal-ai פשוט
|
||||
// לא הוגדר עדיין. זה מצב שונה מהותית מדחיית-scope: מותר ליפול לברירת-מחדל.
|
||||
logger.warn("legalApiBaseUrl unresolved — using default", {
|
||||
companyId: companyId ?? null,
|
||||
fallback: defaultBaseUrl,
|
||||
});
|
||||
return defaultBaseUrl;
|
||||
}
|
||||
@@ -23,6 +23,10 @@ export default {
|
||||
"plugin.state.write",
|
||||
"jobs.schedule",
|
||||
"activity.log.write",
|
||||
// נדרש ל-`ctx.metrics.write` — סיכום-ריצת הג'וב `sync-case-status`
|
||||
// (legal-ai issue #617). זו הדרך היחידה לכתוב ל-`plugin_logs`
|
||||
// (level='metric'), הטבלה שפאנל "Recent Logs" בדף-הפלאגין מרנדר.
|
||||
"metrics.write",
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"webhooks.receive",
|
||||
|
||||
292
src/sync-run-summary.test.ts
Normal file
292
src/sync-run-summary.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
|
||||
// `node --test`, which requires the literal `.ts` extension (Node's ESM
|
||||
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
|
||||
import type { StatusModelEntry } from "./legal-api.ts";
|
||||
import {
|
||||
declinedTotal,
|
||||
formatSyncRunSummary,
|
||||
MAX_SUMMARY_LENGTH,
|
||||
newSyncRunCounters,
|
||||
recordDecline,
|
||||
SYNC_RUN_SUMMARY_PREFIX,
|
||||
type SyncRunCounters,
|
||||
syncRunSummaryTags,
|
||||
} from "./sync-run-summary.ts";
|
||||
import {
|
||||
pickSyncTargetIssue,
|
||||
resolveIssueStatus,
|
||||
type SyncCandidate,
|
||||
} from "./sync-target.ts";
|
||||
|
||||
test("AC2: שלוש המחרוזות (ריק, נדחה, נכשל) שונות זו מזו", () => {
|
||||
const empty = newSyncRunCounters();
|
||||
const emptyLine = formatSyncRunSummary(empty, { outcome: "ok" });
|
||||
|
||||
const declined = newSyncRunCounters();
|
||||
declined.scanned = 12;
|
||||
// matched נשאר 0 — 12 התיקים נדחו ב-`no_writable_root`, כלומר
|
||||
// `pickSyncTargetIssue` לא בחר להם יעד כלל. מצב עקבי עם האינvariant.
|
||||
for (let i = 0; i < 12; i++) recordDecline(declined, "no_writable_root");
|
||||
const declinedLine = formatSyncRunSummary(declined, { outcome: "ok" });
|
||||
|
||||
const failed = newSyncRunCounters();
|
||||
failed.scanned = 3;
|
||||
const failedLine = formatSyncRunSummary(failed, {
|
||||
outcome: "failed",
|
||||
error: "boom",
|
||||
});
|
||||
|
||||
assert.notEqual(emptyLine, declinedLine);
|
||||
assert.notEqual(emptyLine, failedLine);
|
||||
assert.notEqual(declinedLine, failedLine);
|
||||
|
||||
// כל השלוש כתובות=0
|
||||
assert.match(emptyLine, /written=0/);
|
||||
assert.match(declinedLine, /written=0/);
|
||||
assert.match(failedLine, /written=0/);
|
||||
|
||||
assert.match(emptyLine, /run=ok scanned=0 matched=0 written=0 declined=0/);
|
||||
assert.match(
|
||||
declinedLine,
|
||||
/run=ok scanned=12 matched=0 written=0 declined=12 \[no_linked_issues=0 no_writable_root=12 ambiguous_writable_roots=0 unknown_status=0 already_matching=0\]/,
|
||||
);
|
||||
assert.match(failedLine, /run=failed scanned=3.*error=boom/);
|
||||
});
|
||||
|
||||
test("מיפוי SyncTargetReason → דלי: כל reason מעלה את הדלי הנכון בלבד", () => {
|
||||
const noLinked = newSyncRunCounters();
|
||||
recordDecline(noLinked, "no_linked_issues");
|
||||
assert.equal(noLinked.declined.no_linked_issues, 1);
|
||||
assert.equal(declinedTotal(noLinked), 1);
|
||||
|
||||
const noRoot = newSyncRunCounters();
|
||||
recordDecline(noRoot, "no_writable_root");
|
||||
assert.equal(noRoot.declined.no_writable_root, 1);
|
||||
assert.equal(declinedTotal(noRoot), 1);
|
||||
|
||||
const ambiguous = newSyncRunCounters();
|
||||
recordDecline(ambiguous, "ambiguous_writable_roots");
|
||||
assert.equal(ambiguous.declined.ambiguous_writable_roots, 1);
|
||||
assert.equal(declinedTotal(ambiguous), 1);
|
||||
|
||||
// כל אחד מהשלושה לא נגע בדליים האחרים.
|
||||
for (const c of [noLinked, noRoot, ambiguous]) {
|
||||
const total =
|
||||
c.declined.no_linked_issues +
|
||||
c.declined.no_writable_root +
|
||||
c.declined.ambiguous_writable_roots +
|
||||
c.declined.unknown_status +
|
||||
c.declined.already_matching;
|
||||
assert.equal(total, 1);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* פיקסצ'ר מצומצם של `/api/status-model` — מספיק כדי ש-`resolveIssueStatus`
|
||||
* יחזיר todo/in_progress/done/null.
|
||||
*/
|
||||
const STATUS_MODEL: StatusModelEntry[] = [
|
||||
{
|
||||
key: "new",
|
||||
label: "חדש",
|
||||
description: "",
|
||||
phase: "intake",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "drafted",
|
||||
label: "טיוטה מוכנה",
|
||||
description: "",
|
||||
phase: "drafting",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "final",
|
||||
label: "סופי",
|
||||
description: "",
|
||||
phase: "final",
|
||||
selectable: true,
|
||||
terminal: true,
|
||||
on_enter: null,
|
||||
},
|
||||
];
|
||||
|
||||
interface SimulatedCase {
|
||||
case_number: string;
|
||||
status: string;
|
||||
candidates: readonly SyncCandidate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* מחקה את לולאת `worker.ts` (sync-case-status) — אותה סדרת-החלטות בדיוק,
|
||||
* באמצעות `pickSyncTargetIssue`/`resolveIssueStatus` האמיתיים. יש תקדים
|
||||
* לחיקוי-לולאה כזה ב-`sync-target.test.ts` (`legacyTargets`).
|
||||
*/
|
||||
function simulateSyncRun(cases: readonly SimulatedCase[]): SyncRunCounters {
|
||||
const counters = newSyncRunCounters();
|
||||
counters.scanned = cases.length;
|
||||
|
||||
for (const legalCase of cases) {
|
||||
const targetStatus = resolveIssueStatus(STATUS_MODEL, legalCase.status);
|
||||
if (targetStatus === null) {
|
||||
recordDecline(counters, "unknown_status");
|
||||
continue;
|
||||
}
|
||||
|
||||
const { target, reason } = pickSyncTargetIssue(legalCase.candidates);
|
||||
if (!target) {
|
||||
// reason כאן הוא SyncTargetReason שאינו "ok" (target===null) —
|
||||
// תת-קבוצה מובטחת-מהדר של SyncDeclineReason (ראה sync-run-summary.ts).
|
||||
recordDecline(counters, reason as Exclude<typeof reason, "ok">);
|
||||
continue;
|
||||
}
|
||||
|
||||
counters.matched++;
|
||||
|
||||
if (target.status === targetStatus) {
|
||||
recordDecline(counters, "already_matching");
|
||||
continue;
|
||||
}
|
||||
|
||||
counters.written++;
|
||||
}
|
||||
|
||||
return counters;
|
||||
}
|
||||
|
||||
test("אינvariant: matched === written + declined.already_matching, ו-scanned מתפרק במלואו", () => {
|
||||
const cases: SimulatedCase[] = [
|
||||
// scanned, unknown_status
|
||||
{ case_number: "c-unknown", status: "totally_unknown", candidates: [] },
|
||||
// no_linked_issues
|
||||
{ case_number: "c-nolink", status: "new", candidates: [] },
|
||||
// no_writable_root
|
||||
{
|
||||
case_number: "c-noroot",
|
||||
status: "new",
|
||||
candidates: [
|
||||
{ id: "i-1", status: "blocked", parentId: null, companyId: "c1" },
|
||||
],
|
||||
},
|
||||
// ambiguous_writable_roots
|
||||
{
|
||||
case_number: "c-ambiguous",
|
||||
status: "new",
|
||||
candidates: [
|
||||
{ id: "i-2", status: "todo", parentId: null, companyId: "c1" },
|
||||
{ id: "i-3", status: "in_progress", parentId: null, companyId: "c1" },
|
||||
],
|
||||
},
|
||||
// matched + already_matching (target.status === targetStatus === todo)
|
||||
{
|
||||
case_number: "c-matching",
|
||||
status: "new",
|
||||
candidates: [
|
||||
{ id: "i-4", status: "todo", parentId: null, companyId: "c1" },
|
||||
],
|
||||
},
|
||||
// matched + written (target.status !== targetStatus)
|
||||
{
|
||||
case_number: "c-written",
|
||||
status: "final",
|
||||
candidates: [
|
||||
{ id: "i-5", status: "in_progress", parentId: null, companyId: "c1" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const counters = simulateSyncRun(cases);
|
||||
|
||||
assert.equal(counters.scanned, 6);
|
||||
assert.equal(counters.matched, 2); // c-matching + c-written
|
||||
assert.equal(counters.written, 1); // c-written בלבד
|
||||
assert.equal(counters.declined.already_matching, 1); // c-matching
|
||||
assert.equal(
|
||||
counters.matched,
|
||||
counters.written + counters.declined.already_matching,
|
||||
);
|
||||
assert.equal(
|
||||
counters.scanned,
|
||||
counters.matched +
|
||||
counters.declined.no_linked_issues +
|
||||
counters.declined.no_writable_root +
|
||||
counters.declined.ambiguous_writable_roots +
|
||||
counters.declined.unknown_status,
|
||||
);
|
||||
assert.equal(counters.declined.unknown_status, 1);
|
||||
assert.equal(counters.declined.no_linked_issues, 1);
|
||||
assert.equal(counters.declined.no_writable_root, 1);
|
||||
assert.equal(counters.declined.ambiguous_writable_roots, 1);
|
||||
});
|
||||
|
||||
test("חסם-אורך: שגיאה בת 5000 תווים לא שוברת את מבנה השורה", () => {
|
||||
const counters = newSyncRunCounters();
|
||||
counters.scanned = 42;
|
||||
counters.matched = 3;
|
||||
counters.written = 1;
|
||||
recordDecline(counters, "already_matching");
|
||||
recordDecline(counters, "already_matching");
|
||||
|
||||
const longError = "x".repeat(5000);
|
||||
const line = formatSyncRunSummary(counters, {
|
||||
outcome: "failed",
|
||||
error: longError,
|
||||
});
|
||||
|
||||
assert.ok(line.length <= MAX_SUMMARY_LENGTH);
|
||||
assert.match(line, /scanned=42/);
|
||||
assert.match(line, /written=1/);
|
||||
assert.match(line, /already_matching=2/);
|
||||
assert.ok(line.endsWith("…"));
|
||||
});
|
||||
|
||||
test("tags: כל הערכים מחרוזות, וכל תשעת המונים + outcome נוכחים", () => {
|
||||
const counters = newSyncRunCounters();
|
||||
counters.scanned = 5;
|
||||
counters.matched = 2;
|
||||
counters.written = 1;
|
||||
recordDecline(counters, "no_linked_issues");
|
||||
|
||||
const tags = syncRunSummaryTags(counters, { outcome: "ok" });
|
||||
|
||||
for (const value of Object.values(tags)) {
|
||||
assert.equal(typeof value, "string");
|
||||
}
|
||||
|
||||
const expectedKeys = [
|
||||
"outcome",
|
||||
"scanned",
|
||||
"matched",
|
||||
"written",
|
||||
"declined",
|
||||
"no_linked_issues",
|
||||
"no_writable_root",
|
||||
"ambiguous_writable_roots",
|
||||
"unknown_status",
|
||||
"already_matching",
|
||||
];
|
||||
for (const key of expectedKeys) {
|
||||
assert.ok(key in tags, `missing tag key: ${key}`);
|
||||
}
|
||||
assert.equal(Object.keys(tags).length, expectedKeys.length);
|
||||
});
|
||||
|
||||
test("קידומת יציבה: כל שורה מתחילה ב-SYNC_RUN_SUMMARY_PREFIX", () => {
|
||||
const counters = newSyncRunCounters();
|
||||
const okLine = formatSyncRunSummary(counters, { outcome: "ok" });
|
||||
const failedLine = formatSyncRunSummary(counters, {
|
||||
outcome: "failed",
|
||||
error: "x",
|
||||
});
|
||||
|
||||
assert.ok(okLine.startsWith(`${SYNC_RUN_SUMMARY_PREFIX} `));
|
||||
assert.ok(failedLine.startsWith(`${SYNC_RUN_SUMMARY_PREFIX} `));
|
||||
});
|
||||
145
src/sync-run-summary.ts
Normal file
145
src/sync-run-summary.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* סיכום-ריצה של הג'וב `sync-case-status` (legal-ai issue #617).
|
||||
*
|
||||
* המקור לצורך: הג'וב היה מסיים בשורת-לוג יחידה
|
||||
* (`"Case status sync completed", { casesChecked: cases.length }`) שאינה
|
||||
* מבחינה בין "אין מה לעשות", "נדחה בכוונה" (`pickSyncTargetIssue`,
|
||||
* `resolveIssueStatus`) ו"נכשל". המודול הזה טהור בכוונה — בלי import
|
||||
* מה-SDK ובלי side effects — באותה רוח בדיוק כמו `sync-target.ts`, כדי
|
||||
* שאפשר יהיה לייבא אותו בטסט בלי להריץ את `runWorker(...)`.
|
||||
*
|
||||
* ⚠️ **למה המונים בתוך מחרוזת-ההודעה ולא רק ב-`meta`:** נמדד ששני
|
||||
* המשטחים היחידים שבהם סיכום-ריצה נראה לעין-אדם מתעלמים מ-`meta`.
|
||||
* (1) `ctx.logger.*` מגיע רק ל-stdout של pm2 (pino) — ונמדד ש-`meta`
|
||||
* נופל שם בפועל (שדה `{error: …}` שהקוד מעביר לא הופיע בשורה).
|
||||
* (2) `ctx.metrics.write` נכתב ל-`plugin_logs` (level='metric'), וזו
|
||||
* הטבלה שפאנל "Recent Logs" בדף-הפלאגין מרנדר — אבל הפאנל מרנדר רק
|
||||
* `createdAt`/`level`/`message`, לא `meta`.
|
||||
* לכן כל מונה חייב להופיע במחרוזת עצמה כדי שיהיה נראה בכל מקום שבו
|
||||
* הסיכום בפועל נצפה.
|
||||
*/
|
||||
|
||||
import type { SyncTargetReason } from "./sync-target.ts";
|
||||
|
||||
/** הקידומת היציבה של כל שורת-סיכום — עליה נשען `WHERE message LIKE`. */
|
||||
export const SYNC_RUN_SUMMARY_PREFIX = "sync-case-status";
|
||||
|
||||
/** חסם אורך — `MAX_METRIC_NAME_LENGTH` של המארח (plugin-host-services.js:258). */
|
||||
export const MAX_SUMMARY_LENGTH = 500;
|
||||
|
||||
export interface SyncDeclineCounters {
|
||||
no_linked_issues: number;
|
||||
no_writable_root: number;
|
||||
ambiguous_writable_roots: number;
|
||||
unknown_status: number;
|
||||
already_matching: number;
|
||||
}
|
||||
|
||||
export interface SyncRunCounters {
|
||||
scanned: number;
|
||||
matched: number;
|
||||
written: number;
|
||||
declined: SyncDeclineCounters;
|
||||
}
|
||||
|
||||
export type SyncDeclineReason = keyof SyncDeclineCounters;
|
||||
|
||||
/**
|
||||
* שלושת ה-reasons של `pickSyncTargetIssue` שאינם `"ok"` הם תת-קבוצה של
|
||||
* `SyncDeclineReason` — ונאכף כאן במהדר, לא רק בתיעוד: אם `sync-target.ts`
|
||||
* יוסיף `SyncTargetReason` חדש בלי דלי-מונה תואם כאן, שורת בדיקת-ההצבה
|
||||
* הבאה תיכשל ב-`tsc` (הטיפוס בפועל לא יעמוד באילוץ `extends`), ולא
|
||||
* תיבלע בשקט.
|
||||
*/
|
||||
export type SyncTargetDeclineReason = Exclude<SyncTargetReason, "ok">;
|
||||
|
||||
/** בדיקת-הצבה סטטית בלבד — לא נקרא בזמן ריצה, ואינו זקוק לערך. */
|
||||
type AssertExtends<_Sub extends _Super, _Super> = true;
|
||||
type _syncTargetDeclineReasonIsSubsetOfSyncDeclineReason = AssertExtends<
|
||||
SyncTargetDeclineReason,
|
||||
SyncDeclineReason
|
||||
>;
|
||||
|
||||
export function newSyncRunCounters(): SyncRunCounters {
|
||||
return {
|
||||
scanned: 0,
|
||||
matched: 0,
|
||||
written: 0,
|
||||
declined: {
|
||||
no_linked_issues: 0,
|
||||
no_writable_root: 0,
|
||||
ambiguous_writable_roots: 0,
|
||||
unknown_status: 0,
|
||||
already_matching: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordDecline(
|
||||
counters: SyncRunCounters,
|
||||
reason: SyncDeclineReason,
|
||||
): void {
|
||||
counters.declined[reason]++;
|
||||
}
|
||||
|
||||
export function declinedTotal(counters: SyncRunCounters): number {
|
||||
const { declined } = counters;
|
||||
return (
|
||||
declined.no_linked_issues +
|
||||
declined.no_writable_root +
|
||||
declined.ambiguous_writable_roots +
|
||||
declined.unknown_status +
|
||||
declined.already_matching
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSyncRunSummary(
|
||||
counters: SyncRunCounters,
|
||||
opts: { outcome: "ok" | "failed"; error?: string },
|
||||
): string {
|
||||
const { declined } = counters;
|
||||
const head =
|
||||
`${SYNC_RUN_SUMMARY_PREFIX} run=${opts.outcome} scanned=${counters.scanned} ` +
|
||||
`matched=${counters.matched} written=${counters.written} ` +
|
||||
`declined=${declinedTotal(counters)} ` +
|
||||
`[no_linked_issues=${declined.no_linked_issues} ` +
|
||||
`no_writable_root=${declined.no_writable_root} ` +
|
||||
`ambiguous_writable_roots=${declined.ambiguous_writable_roots} ` +
|
||||
`unknown_status=${declined.unknown_status} ` +
|
||||
`already_matching=${declined.already_matching}]`;
|
||||
|
||||
if (opts.error === undefined) return head;
|
||||
|
||||
const full = `${head} error=${opts.error}`;
|
||||
if (full.length <= MAX_SUMMARY_LENGTH) return full;
|
||||
|
||||
// חיתוך נופל רק על זנב-השגיאה — המונים תמיד שלמים וקריאים.
|
||||
const ellipsis = "…";
|
||||
const errorPrefixLen = `${head} error=`.length;
|
||||
const budget = MAX_SUMMARY_LENGTH - errorPrefixLen - ellipsis.length;
|
||||
if (budget <= 0) {
|
||||
// אין מקום אפילו לתו אחד של error — נחתך ה-head עצמו (מקרה קיצון
|
||||
// תיאורטי: זה יקרה רק אם head לבדו כבר עובר את MAX_SUMMARY_LENGTH).
|
||||
return head.slice(0, MAX_SUMMARY_LENGTH - ellipsis.length) + ellipsis;
|
||||
}
|
||||
return `${head} error=${opts.error.slice(0, budget)}${ellipsis}`;
|
||||
}
|
||||
|
||||
export function syncRunSummaryTags(
|
||||
counters: SyncRunCounters,
|
||||
opts: { outcome: "ok" | "failed" },
|
||||
): Record<string, string> {
|
||||
const { declined } = counters;
|
||||
return {
|
||||
outcome: opts.outcome,
|
||||
scanned: String(counters.scanned),
|
||||
matched: String(counters.matched),
|
||||
written: String(counters.written),
|
||||
declined: String(declinedTotal(counters)),
|
||||
no_linked_issues: String(declined.no_linked_issues),
|
||||
no_writable_root: String(declined.no_writable_root),
|
||||
ambiguous_writable_roots: String(declined.ambiguous_writable_roots),
|
||||
unknown_status: String(declined.unknown_status),
|
||||
already_matching: String(declined.already_matching),
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { test } from "node:test";
|
||||
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
|
||||
import type { StatusModelEntry } from "./legal-api.ts";
|
||||
import {
|
||||
isWritableStatus,
|
||||
labelFor,
|
||||
pickSyncTargetIssue,
|
||||
resolveIssueStatus,
|
||||
@@ -102,15 +103,18 @@ test("שורש יחיד ב-todo: נבחר", () => {
|
||||
assert.equal(result.writableRoots, 1);
|
||||
});
|
||||
|
||||
test("שורש יחיד ב-in_review: לא נכתב (הגנת auto-block)", () => {
|
||||
// הכרעת #626 (2026-08-26): in_review כבר לא ב-NON_WRITABLE_STATUSES —
|
||||
// שורש יחיד ב-in_review עכשיו נבחר כיעד-כתיבה, במקום להיחסם.
|
||||
test("שורש יחיד ב-in_review: נבחר (הכרעת #626 — 2026-08-26)", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "in_review", parentId: null, companyId: "c1" },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target, null);
|
||||
assert.equal(result.reason, "no_writable_root");
|
||||
assert.equal(result.target?.id, "root-1");
|
||||
assert.equal(result.reason, "ok");
|
||||
assert.equal(result.writableRoots, 1);
|
||||
});
|
||||
|
||||
test("שורש יחיד ב-blocked: לא נכתב", () => {
|
||||
@@ -124,6 +128,14 @@ test("שורש יחיד ב-blocked: לא נכתב", () => {
|
||||
assert.equal(result.reason, "no_writable_root");
|
||||
});
|
||||
|
||||
test("isWritableStatus: הגבול המלא בין בר-כתיבה ללא-בר-כתיבה", () => {
|
||||
assert.equal(isWritableStatus("in_review"), true);
|
||||
assert.equal(isWritableStatus("blocked"), false);
|
||||
assert.equal(isWritableStatus("done"), false);
|
||||
assert.equal(isWritableStatus("cancelled"), false);
|
||||
assert.equal(isWritableStatus("todo"), true);
|
||||
});
|
||||
|
||||
test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "todo", parentId: null, companyId: "c1" },
|
||||
|
||||
@@ -20,17 +20,22 @@ export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* מצב בבעלות Paperclip / בהמתנה-לאדם — **לא** הגדרה שנייה של "סגור".
|
||||
* `in_review` הוא מצב-ההמתנה-ליו"ר המכוון: CEO שמשאיר issue ב-`in_progress`
|
||||
* מקבל auto-block מ-Paperclip תוך דקה, ולכן הוא מעביר ל-`in_review`
|
||||
* (legal-ai/docs/paperclip-quirks.md §3). כתיבת-סטטוס אוטומטית עליו גונבת את
|
||||
* ה-issue מתור-הביקורת של היו"ר ומזמינה קרב-סטטוסים.
|
||||
* `blocked` — כתיבה עליו מסתירה חוסם קיים.
|
||||
* מצב בבעלות Paperclip — **לא** הגדרה שנייה של "סגור". `blocked` — כתיבה
|
||||
* עליו מסתירה חוסם קיים.
|
||||
*
|
||||
* `in_review` **הוסר מהסט** בהכרעת חיים מ-2026-08-26 (legal-ai issue #626):
|
||||
* נשאל במפורש אם השחרור נקודתי או שיטתי, והשיב "התכוונתי לשיטתי".
|
||||
*
|
||||
* המחיר, בכנות: `in_review` הוא מצב-ההמתנה-ליו"ר המכוון — CEO שמשאיר issue
|
||||
* ב-`in_progress` מקבל auto-block מ-Paperclip תוך דקה, ולכן מעביר אותו
|
||||
* ל-`in_review` כדי לחמוק מזה (legal-ai/docs/paperclip-quirks.md §3). מעתה
|
||||
* issue שממתין לביקורת היו"ר עשוי להידרס בחזרה ל-`in_progress` בריצת-
|
||||
* הסנכרון הבאה (כל 15 דק') ולרדת מתור-הביקורת. זה מכוון ומתועד, לא תקלה.
|
||||
*
|
||||
* מסלול-חזרה: להחזיר `"in_review"` לסט למטה + build + התקנה +
|
||||
* `pm2 restart paperclip`.
|
||||
*/
|
||||
export const NON_WRITABLE_STATUSES: ReadonlySet<string> = new Set([
|
||||
"in_review",
|
||||
"blocked",
|
||||
]);
|
||||
export const NON_WRITABLE_STATUSES: ReadonlySet<string> = new Set(["blocked"]);
|
||||
|
||||
export interface SyncCandidate {
|
||||
id: string;
|
||||
|
||||
302
src/worker.ts
302
src/worker.ts
@@ -3,7 +3,22 @@ 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 {
|
||||
formatSyncRunSummary,
|
||||
newSyncRunCounters,
|
||||
recordDecline,
|
||||
SYNC_RUN_SUMMARY_PREFIX,
|
||||
type SyncRunCounters,
|
||||
syncRunSummaryTags,
|
||||
} from "./sync-run-summary.js";
|
||||
import {
|
||||
labelFor,
|
||||
pickSyncTargetIssue,
|
||||
@@ -36,8 +51,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 +76,24 @@ async function resolveApiBase(
|
||||
ctx: PluginContext,
|
||||
companyId?: string,
|
||||
): Promise<string> {
|
||||
const read = async (id?: string): Promise<string | null> => {
|
||||
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<Record<string, unknown> | 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({
|
||||
@@ -840,11 +850,54 @@ const plugin = definePlugin({
|
||||
|
||||
// ── Jobs ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* פולט את סיכום-ריצת `sync-case-status` לשני משטחים קיימים וצורכים:
|
||||
* 1. `ctx.logger.info` → stdout של pm2 (שם בוצע בפועל האבחון של #626/#637).
|
||||
* 2. `ctx.metrics.write` → טבלת `plugin_logs` (level='metric'), שאותה מרנדר
|
||||
* פאנל "Recent Logs" בדף-הפלאגין של Paperclip, וניתן לשאול ב-SQL.
|
||||
*
|
||||
* ⚠️ הפונקציה **לעולם אינה זורקת** — היא נקראת מתוך `finally`, וזריקה ממנה
|
||||
* הייתה מחליפה את שגיאת-הריצה המקורית ומסתירה את סיבת-הכשל האמיתית.
|
||||
* כשל של `metrics.write` מדווח בקול (`logger.error`) עם טקסט-השגיאה **בתוך
|
||||
* המחרוזת** — כי שדות-`meta` נופלים מפלט-pino (נמדד) — ולא נבלע.
|
||||
*/
|
||||
async function emitSyncRunSummary(
|
||||
counters: SyncRunCounters,
|
||||
failure: unknown,
|
||||
): Promise<void> {
|
||||
const outcome = failure === undefined ? "ok" : "failed";
|
||||
const error = failure === undefined ? undefined : String(failure);
|
||||
const line = formatSyncRunSummary(counters, { outcome, error });
|
||||
const tags = syncRunSummaryTags(counters, { outcome });
|
||||
|
||||
ctx.logger.info(line, tags);
|
||||
|
||||
try {
|
||||
await ctx.metrics.write(line, counters.written, tags);
|
||||
} catch (err) {
|
||||
ctx.logger.error(
|
||||
`${SYNC_RUN_SUMMARY_PREFIX}: failed to persist run summary metric: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.jobs.register("sync-case-status", async (job) => {
|
||||
ctx.logger.info("Starting case status sync", { runId: job.runId });
|
||||
|
||||
const counters = newSyncRunCounters();
|
||||
let failure: unknown;
|
||||
|
||||
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();
|
||||
// נקבע מיד — לפני כל קריאת-מארח שעלולה ליפול. אחרת ריצה
|
||||
// שנפלה באמצע (או `!companies.length`) הייתה מדווחת scanned=0
|
||||
// ומאבדת בדיוק את האבחנה שה-issue הזה נועד לתת.
|
||||
counters.scanned = cases.length;
|
||||
const companies = await ctx.companies.list();
|
||||
if (!companies.length) return;
|
||||
const statusModel = await api.getStatusModel();
|
||||
@@ -880,6 +933,7 @@ const plugin = definePlugin({
|
||||
legalCase.status,
|
||||
);
|
||||
if (targetStatus === null) {
|
||||
recordDecline(counters, "unknown_status");
|
||||
ctx.logger.warn(
|
||||
"sync-case-status: unknown case status — not in /api/status-model (case_status_model.py)",
|
||||
{
|
||||
@@ -895,6 +949,10 @@ const plugin = definePlugin({
|
||||
pickSyncTargetIssue(candidates);
|
||||
|
||||
if (!target) {
|
||||
// reason כאן הוא SyncTargetReason שאינו "ok" (target===null) —
|
||||
// תת-קבוצה מובטחת-מהדר של SyncDeclineReason
|
||||
// (ראה sync-run-summary.ts: SyncTargetDeclineReason).
|
||||
recordDecline(counters, reason as Exclude<typeof reason, "ok">);
|
||||
// אין בליעה שקטה: מדווח למה לא נכתב כלום.
|
||||
ctx.logger.info("sync-case-status: no sync target", {
|
||||
caseNumber: legalCase.case_number,
|
||||
@@ -905,7 +963,12 @@ const plugin = definePlugin({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.status === targetStatus) continue;
|
||||
counters.matched++;
|
||||
|
||||
if (target.status === targetStatus) {
|
||||
recordDecline(counters, "already_matching");
|
||||
continue;
|
||||
}
|
||||
|
||||
const label =
|
||||
labelFor(statusModel.statuses, legalCase.status) ??
|
||||
@@ -921,38 +984,44 @@ const plugin = definePlugin({
|
||||
`📋 ${label}`,
|
||||
target.companyId,
|
||||
);
|
||||
counters.written++;
|
||||
ctx.logger.info("Synced issue status", {
|
||||
issueId: target.id,
|
||||
caseNumber: legalCase.case_number,
|
||||
newStatus: targetStatus,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info("Case status sync completed", {
|
||||
casesChecked: cases.length,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.error("Case status sync failed", { error: String(err) });
|
||||
failure = err;
|
||||
throw err; // חובה — אחרת ה-run יירשם succeeded (זה בדיוק הבאג של #637).
|
||||
} finally {
|
||||
await emitSyncRunSummary(counters, failure);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
||||
ctx.logger.info("stale-case-reminder: starting");
|
||||
// Scheduled job — no company context; resolveApiBase probes known companies.
|
||||
|
||||
await runJobHandler("stale-case-reminder", ctx.logger, async () => {
|
||||
// Scheduled job — no company scope at all (legal-ai issue #637);
|
||||
// resolveApiBase falls back to the instance-scoped cache, then
|
||||
// throws CompanyScopeUnavailableError rather than guessing.
|
||||
const apiBase = await resolveApiBase(ctx);
|
||||
|
||||
// כשל-fetch/API אמיתי חייב **לזרוק**, לא `return` בשקט — אחרת
|
||||
// ה-run נרשם `succeeded` בלי שקרה כלום (legal-ai issue #637).
|
||||
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
|
||||
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;
|
||||
throw new Error(
|
||||
`stale-case-reminder: fetch failed against ${apiBase}: ${String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
|
||||
return;
|
||||
throw new Error(`stale-case-reminder: API error ${resp.status}`);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as {
|
||||
@@ -988,6 +1057,8 @@ const plugin = definePlugin({
|
||||
}
|
||||
}
|
||||
|
||||
// אין תיקים תקועים — "אין מה לעשות" תקין, לא כשל. ה-loop למטה
|
||||
// פשוט לא ירוץ בלי `data.cases`.
|
||||
let reminded = 0;
|
||||
for (const staleCase of data.cases) {
|
||||
const linked = caseIssueMap.get(staleCase.case_number);
|
||||
@@ -1008,18 +1079,22 @@ const plugin = definePlugin({
|
||||
`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.
|
||||
|
||||
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 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;
|
||||
throw new Error(`weekly-feedback-analysis: API error ${resp.status}`);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as {
|
||||
@@ -1027,6 +1102,7 @@ const plugin = definePlugin({
|
||||
entry_count: number;
|
||||
};
|
||||
|
||||
// אין פידבק השבוע — "אין מה לעשות" תקין, לא כשל.
|
||||
if (data.entry_count === 0) {
|
||||
ctx.logger.info(
|
||||
"weekly-feedback-analysis: no feedback this week, skipping",
|
||||
@@ -1043,11 +1119,12 @@ const plugin = definePlugin({
|
||||
Boolean(x.ceoId),
|
||||
);
|
||||
|
||||
// אין שום חברה עם CEO ממופה — זו תקלת-תצורה (לא "אין מה לעשות"),
|
||||
// ולכן זריקה ולא return שקט.
|
||||
if (mapped.length === 0) {
|
||||
ctx.logger.warn(
|
||||
"weekly-feedback-analysis: no company has a mapped CEO agent — skipping",
|
||||
throw new Error(
|
||||
"weekly-feedback-analysis: no company has a mapped CEO agent",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { company, ceoId } = mapped[0];
|
||||
@@ -1068,6 +1145,7 @@ const plugin = definePlugin({
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reconciliation sweep — the at-least-once guarantee for user comments.
|
||||
// The event-driven route above is best-effort: a failed/coalesced CEO
|
||||
@@ -1079,10 +1157,23 @@ 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) => {
|
||||
await runJobHandler("route-pending-comments", ctx.logger, async () => {
|
||||
// כשל-מהיר: דחיית-scope (legal-ai issue #637) אינה "שגיאה
|
||||
// פר-issue" שאפשר להתעלם ממנה — היא אומרת שה-run **כולו** לא
|
||||
// יכול לעבוד. לזרוק מיד במקום לבלוע ולהמשיך ללולאה על 2
|
||||
// חברות × 200 issues שכולן ייכשלו זהה. שגיאה אחרת (ולא scope)
|
||||
// נשארת warn+continue — זו העמידות האמיתית פר-issue.
|
||||
let companies: Awaited<ReturnType<typeof ctx.companies.list>>;
|
||||
try {
|
||||
companies = await ctx.companies.list();
|
||||
} catch (err) {
|
||||
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),
|
||||
});
|
||||
@@ -1095,8 +1186,18 @@ const plugin = definePlugin({
|
||||
|
||||
let issues: Awaited<ReturnType<typeof ctx.issues.list>>;
|
||||
try {
|
||||
issues = await ctx.issues.list({ companyId: company.id, limit: 200 });
|
||||
issues = await ctx.issues.list({
|
||||
companyId: company.id,
|
||||
limit: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
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),
|
||||
@@ -1108,7 +1209,8 @@ const plugin = definePlugin({
|
||||
// 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;
|
||||
if (issue.status === "done" || issue.status === "cancelled")
|
||||
continue;
|
||||
|
||||
try {
|
||||
const comments = await ctx.issues.listComments(
|
||||
@@ -1169,6 +1271,7 @@ const plugin = definePlugin({
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Data handlers (UI bridge) ──────────────────────────────────
|
||||
// These back `usePluginData(key, params)` calls from the React UI bundle
|
||||
@@ -1337,12 +1440,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<string, unknown>): Promise<void> {
|
||||
if (!pluginCtx) return; // עדיין לפני setup()
|
||||
const url = (newConfig as { legalApiBaseUrl?: unknown }).legalApiBaseUrl;
|
||||
if (typeof url === "string" && url.trim()) {
|
||||
await pluginCtx.state.set(
|
||||
{ scopeKind: "instance", stateKey: LEGAL_API_BASE_CACHE_KEY },
|
||||
url.trim(),
|
||||
);
|
||||
pluginCtx.logger.info("onConfigChanged: cached legalApiBaseUrl", {
|
||||
legalApiBaseUrl: url.trim(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async onWebhook(input: PluginWebhookInput): Promise<void> {
|
||||
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,6 +1487,41 @@ const plugin = definePlugin({
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await handleCaseStatusWebhook(pluginCtx, input);
|
||||
} catch (err) {
|
||||
// מחיקת סמן-האידמפוטנטיות **לפני** שהשגיאה יוצאת — אחרת מסירה
|
||||
// חוזרת של אותו webhook תוך 5 דק' תדולג בשקט כ"כבר נשלח", למרות
|
||||
// שהעיבוד מעולם לא הושלם בפועל (legal-ai issue #637).
|
||||
if (idempKey) {
|
||||
await pluginCtx.state.delete({
|
||||
scopeKind: "instance",
|
||||
stateKey: idempKey,
|
||||
});
|
||||
}
|
||||
// אין בליעה — הזריקה חייבת להימשך החוצה כדי ש-
|
||||
// `plugin_webhook_deliveries.error` יישא הודעה מאבחנת (ולא את
|
||||
// הודעת-המארח האטומה) כשמדובר בדחיית-scope.
|
||||
if (isCompanyScopeDenied(err)) {
|
||||
throw new CompanyScopeUnavailableError("onWebhook", "issues.list", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* גוף-העבודה של webhook `case-status` — מופרד מ-`onWebhook` כך ש-
|
||||
* `try/catch` שם יכול לעטוף אותו כולו (כולל את דחיית-ה-scope שנתקלת בה
|
||||
* `pluginCtx.issues.list` למטה — אין ל-webhook הקשר-חברה, כמו לג'וב
|
||||
* מתוזמן; ראה legal-ai issue #637).
|
||||
*/
|
||||
async function handleCaseStatusWebhook(
|
||||
pluginCtx: PluginContext,
|
||||
input: PluginWebhookInput,
|
||||
): Promise<void> {
|
||||
const { endpointKey, parsedBody } = input;
|
||||
|
||||
if (endpointKey !== "case-status") return;
|
||||
@@ -1405,12 +1565,9 @@ const plugin = definePlugin({
|
||||
return;
|
||||
}
|
||||
|
||||
pluginCtx.logger.info(
|
||||
`Webhook: case ${caseNumber} eventType=${eventType}`,
|
||||
{
|
||||
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)
|
||||
@@ -1477,22 +1634,16 @@ const plugin = definePlugin({
|
||||
},
|
||||
companyId,
|
||||
);
|
||||
pluginCtx.logger.info(
|
||||
"askUserQuestions: missing_precedent prompt sent",
|
||||
{
|
||||
pluginCtx.logger.info("askUserQuestions: missing_precedent prompt sent", {
|
||||
caseNumber,
|
||||
missingPrecedentId: mp.id,
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
pluginCtx.logger.error(
|
||||
"askUserQuestions failed for missing_precedent",
|
||||
{
|
||||
pluginCtx.logger.error("askUserQuestions failed for missing_precedent", {
|
||||
caseNumber,
|
||||
missingPrecedentId: mp.id,
|
||||
error: String(err),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1605,8 +1756,7 @@ const plugin = definePlugin({
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
|
||||
Reference in New Issue
Block a user