6 Commits

Author SHA1 Message Date
7cbb3d1c03 fix(sync): company-scope לג'ובים מתוזמנים — הפסקת בליעת-כשל (legal-ai #637) (#11)
Co-authored-by: Chaim Marcus <chaim@marcus-law.co.il>
Co-committed-by: Chaim Marcus <chaim@marcus-law.co.il>
2026-08-26 13:13:48 +00:00
41305fbb36 Merge pull request 'feat(sync): שחרור in_review מ-NON_WRITABLE_STATUSES — הכרעת-יו"ר שיטתית (legal-ai #626)' (#10) from feat/626-non-writable-in-review into main 2026-08-26 10:36:55 +00:00
511d5c6e34 feat(sync): שחרור in_review מ-NON_WRITABLE_STATUSES — הכרעת-יו"ר שיטתית (legal-ai #626)
הג'וב sync-case-status יכתוב מעתה גם על שורש-issue ב-in_review. blocked
ו-CLOSED_ISSUE_STATUSES (done/cancelled — הגנת #446) נותרים כפי שהם.
ההערה מעל הסט מתעדת את ההכרעה, את מחירה (issue ממתין-ליו"ר עשוי לרדת
מתור-הביקורת תוך 15 דק') ואת מסלול-החזרה.

Refs ezer-mishpati/legal-ai#626
2026-08-26 13:09:52 +03:00
10d6216bec Merge pull request 'fix(sync): מיפוי-הסטטוסים מ-/api/status-model + סריקת כל החברות (legal-ai #604)' (#9) from fix/604-paperclip-status-sync into main 2026-08-25 22:34:30 +00:00
0a134d0134 fix(sync): מיפוי-הסטטוסים נגזר מ-/api/status-model, וסריקה בכל החברות (legal-ai #604)
שלושה כשלים בג'וב sync-case-status, שהשביתו אותו בשקט מאז 22.7:

1. המפה הקשיחה CASE_STATUS_TO_ISSUE_STATUS נותקה מ-case_status_model.py
   (מקור-האמת בצד legal-ai): חסרו analyst_verified ו-research_complete
   שנוספו ב-851a4fb, ונשארו בה 3 מפתחות מתים (uploading/brainstorming/drafting).
   סטטוס חסר נפל ל-`continue` — בלי לוג ובלי שגיאה. כעת המיפוי נגזר מ-
   GET /api/status-model דרך resolveIssueStatus/labelFor: terminal→done,
   הסטטוס הראשון בסדר→todo, השאר→in_progress. סטטוס חדש בצד legal-ai לא
   דורש עוד עריכה כאן (G2 — מקור-אמת אחד).
2. companies[0] בלבד — מתוך שתי החברות, אחת מעולם לא נסרקה (נמדד: 135 מול
   67 issues מקושרי-תיק). כעת נסרקות כל החברות, וה-companyId של המועמד
   שנבחר הוא זה שנכתב אליו — לא החברה הראשונה גורפת.
3. דילוג שקט על סטטוס לא-מוכר → ctx.logger.warn עם שם-הסטטוס ומספר-התיק.

אימות מול השרת החי: 0 סטיות מול המפה הישנה על כל 10 הסטטוסים שכיסתה,
+2 שכוסו לראשונה. סימולציה על הנתונים החיים: דילוג-שקט 3→0, שורות-לוג 3→6,
כתיבות בפועל 0→0 — כלומר אין נסיגה ל-flip-flop של #446.

לוגיקת בחירת-היעד (#446) לא שונתה: pickSyncTargetIssue, isWritableStatus,
CLOSED_ISSUE_STATUSES ו-NON_WRITABLE_STATUSES זהות; ל-SyncCandidate נוסף שדה
companyId בלבד (passthrough). 8 הטסטים הקיימים עוברים ללא שינוי באסרשנים,
+7 חדשים.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:32:24 +03:00
602b42ca5e Merge pull request 'fix(sync): sync-case-status יכתוב רק על issue-שורש פתוח (legal-ai #446)' (#8) from fix/446-sync-target-root-issue into main 2026-08-24 15:07:36 +00:00
6 changed files with 1435 additions and 561 deletions

274
src/company-scope.test.ts Normal file
View File

@@ -0,0 +1,274 @@
/// <reference types="node" />
import assert from "node:assert/strict";
import { test } from "node:test";
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
// `node --test`, which requires the literal `.ts` extension (Node's ESM
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import {
_resetApiBaseCacheMemo,
CompanyScopeUnavailableError,
isCompanyScopeDenied,
type ResolveApiBaseDeps,
resolveApiBaseFrom,
runJobHandler,
} from "./company-scope.ts";
// ── isCompanyScopeDenied ─────────────────────────────────────────────────
test("isCompanyScopeDenied: true עבור אובייקט עם code של INVOCATION_SCOPE_DENIED", () => {
const err = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
assert.equal(isCompanyScopeDenied(err), true);
});
test("isCompanyScopeDenied: false עבור שגיאה רגילה", () => {
assert.equal(isCompanyScopeDenied(new Error("boom")), false);
assert.equal(isCompanyScopeDenied({ code: -32002 }), false);
assert.equal(isCompanyScopeDenied("boom"), false);
assert.equal(isCompanyScopeDenied(null), false);
assert.equal(isCompanyScopeDenied(undefined), false);
});
// ── runJobHandler ────────────────────────────────────────────────────────
function makeLogger() {
const calls: Array<{ level: string; message: string; meta?: unknown }> = [];
return {
calls,
logger: {
error(message: string, meta?: Record<string, unknown>) {
calls.push({ level: "error", message, meta });
},
},
};
}
test("runJobHandler: הצלחה — לא זורק ולא מלוגג שגיאה", async () => {
const { calls, logger } = makeLogger();
await runJobHandler("test-job", logger, async () => {});
assert.equal(calls.length, 0);
});
test("runJobHandler: כשל בשגיאה רגילה — מלוגג error וזורק מחדש את המקורית", async () => {
const { calls, logger } = makeLogger();
const original = new Error("plain failure");
await assert.rejects(
() =>
runJobHandler("test-job", logger, async () => {
throw original;
}),
(err: unknown) => err === original,
);
assert.equal(calls.length, 1);
assert.equal(calls[0].message, "test-job: job failed");
});
test("runJobHandler: כשל בדחיית-scope — זורק CompanyScopeUnavailableError שמזכירה #637", async () => {
const { logger } = makeLogger();
const scopeErr = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
await assert.rejects(
() =>
runJobHandler("sync-case-status", logger, async () => {
throw scopeErr;
}),
(err: unknown) => {
assert.ok(err instanceof CompanyScopeUnavailableError);
assert.match(err.message, /#637/);
assert.equal(err.jobKey, "sync-case-status");
assert.equal(err.cause, scopeErr);
return true;
},
);
});
test("runJobHandler: כשל בדחיית-scope — ההודעה כוללת גם #637 וגם את טקסט השגיאה המקורית (זה מה שנכתב ל-plugin_job_runs.error)", async () => {
const { logger } = makeLogger();
// כמו JsonRpcCallError אמיתי מהמארח (protocol.js): מופע Error שה-`message`
// שלו נושא את שם-הפעולה שנדחתה.
const scopeErr = Object.assign(new Error("issues.list"), {
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
});
await assert.rejects(
() =>
runJobHandler("route-pending-comments", logger, async () => {
throw scopeErr;
}),
(err: unknown) => {
assert.ok(err instanceof CompanyScopeUnavailableError);
assert.match(err.message, /#637/);
assert.match(err.message, /issues\.list/);
// method לא ידוע ל-runJobHandler — "unknown" לא אמור להופיע בהודעה.
assert.doesNotMatch(err.message, /"unknown"/);
return true;
},
);
});
// ── resolveApiBaseFrom ───────────────────────────────────────────────────
function makeDeps(
overrides: Partial<ResolveApiBaseDeps> & {
configByCompany?: Record<string, Record<string, unknown> | undefined>;
deniedCompanyIds?: readonly (string | undefined)[];
failingCompanyIds?: readonly (string | undefined)[];
} = {},
): ResolveApiBaseDeps & {
cacheWrites: string[];
infoLogs: unknown[];
warnLogs: unknown[];
} {
const {
configByCompany = {},
deniedCompanyIds = [],
failingCompanyIds = [],
...rest
} = overrides;
const cacheWrites: string[] = [];
const infoLogs: unknown[] = [];
const warnLogs: unknown[] = [];
let cacheValue: unknown = null;
const deniedKey = (id?: string) => (id === undefined ? "__undefined__" : id);
const deps: ResolveApiBaseDeps & {
cacheWrites: string[];
infoLogs: unknown[];
warnLogs: unknown[];
} = {
knownCompanyIds: [],
defaultBaseUrl: "http://localhost:8085",
async readConfig(companyId?: string) {
const key = deniedKey(companyId);
if (deniedCompanyIds.some((d) => deniedKey(d) === key)) {
throw { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
}
if (failingCompanyIds.some((d) => deniedKey(d) === key)) {
throw new Error(`boom for ${key}`);
}
return configByCompany[key] ?? null;
},
async readCache() {
return cacheValue;
},
async writeCache(url: string) {
cacheValue = url;
cacheWrites.push(url);
},
logger: {
info(message: string, meta?: Record<string, unknown>) {
infoLogs.push({ message, meta });
},
warn(message: string, meta?: Record<string, unknown>) {
warnLogs.push({ message, meta });
},
},
cacheWrites,
infoLogs,
warnLogs,
...rest,
};
// Allow overriding cache seed via readCache in `rest`.
return deps;
}
test("resolveApiBaseFrom: companyId נתון ו-readConfig מחזיר legalApiBaseUrl — מחזיר וכותב cache", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
configByCompany: { "company-a": { legalApiBaseUrl: "https://a.example" } },
});
const result = await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
assert.equal(result, "https://a.example");
assert.deepEqual(deps.cacheWrites, ["https://a.example"]);
});
test('resolveApiBaseFrom: אין companyId — readConfig(undefined) מוסק ע"י המארח ומחזיר ערך', async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
configByCompany: {
__undefined__: { legalApiBaseUrl: "https://derived.example" },
},
});
const result = await resolveApiBaseFrom(deps);
assert.equal(result, "https://derived.example");
assert.deepEqual(deps.cacheWrites, ["https://derived.example"]);
});
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope, יש cache תקף — מחזיר cache ומלוגג info (לא warn)", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
knownCompanyIds: ["c1", "c2"],
deniedCompanyIds: [undefined, "c1", "c2"],
});
// לדמות מטמון קיים: לעקוף readCache
deps.readCache = async () => "https://cached.example";
const result = await resolveApiBaseFrom(deps);
assert.equal(result, "https://cached.example");
assert.equal(deps.warnLogs.length, 0);
assert.equal(deps.infoLogs.length, 1);
});
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope ואין cache — זורק CompanyScopeUnavailableError (לא default)", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
knownCompanyIds: ["c1"],
deniedCompanyIds: [undefined, "c1"],
});
await assert.rejects(
() => resolveApiBaseFrom(deps),
(err: unknown) => {
assert.ok(err instanceof CompanyScopeUnavailableError);
assert.equal(err.method, "config.get");
return true;
},
);
});
test("resolveApiBaseFrom: readConfig הצליח בכל מקום אך בלי legalApiBaseUrl — default + warn", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
knownCompanyIds: ["c1"],
configByCompany: { __undefined__: {}, c1: {} },
});
const result = await resolveApiBaseFrom(deps);
assert.equal(result, deps.defaultBaseUrl);
assert.equal(deps.warnLogs.length, 1);
});
test("resolveApiBaseFrom: readConfig נכשל בשגיאה שאינה דחיית-scope — warn וממשיך לנסות הבא", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
knownCompanyIds: ["c1"],
failingCompanyIds: [undefined],
configByCompany: { c1: { legalApiBaseUrl: "https://c1.example" } },
});
const result = await resolveApiBaseFrom(deps);
assert.equal(result, "https://c1.example");
// warn אחד על הכשל ב-readConfig(undefined) — לא בליעה שקטה
assert.ok(deps.warnLogs.length >= 1);
});
// ── writeCache dedup (מתוזמן פר-בקשה, לא לכתוב cache שלא השתנה) ──────────
test("resolveApiBaseFrom: כותב ל-cache רק כשהערך משתנה בפועל", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
configByCompany: {
"company-a": { legalApiBaseUrl: "https://dedup.example" },
},
});
// שתי פתירות רצופות של אותו URL — writeCache נקרא פעם אחת בלבד.
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
assert.deepEqual(deps.cacheWrites, ["https://dedup.example"]);
// פתירה של URL שונה אחריהן — writeCache נקרא שוב.
const deps2 = makeDeps({
configByCompany: {
"company-b": { legalApiBaseUrl: "https://dedup-2.example" },
},
});
await resolveApiBaseFrom({ ...deps2, companyId: "company-b" });
assert.deepEqual(deps2.cacheWrites, ["https://dedup-2.example"]);
});

260
src/company-scope.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* הפשטת company-scope לג'ובים מתוזמנים ול-webhook (legal-ai issue #637).
*
* המקור לבאג: מאז @paperclipai/server 2026.722.0 אין scope של חברה
* ל-`runJob`/`handleWebhook` — המארח מדפיס scope רק מ-`params.companyId` /
* `performAction.actorContext` / `executeTool.runContext` /
* `onEvent.event.companyId` (plugin-worker-manager.js:191-210), ו-
* `plugin-job-scheduler.js:184` שולח `runJob` בלי `companyId` כלל. לכן כל
* קריאה מתוך ג'וב ל-`ctx.config.get`/`ctx.issues.*`/`ctx.agents.invoke`/
* `ctx.events.emit` נדחית ע"י המארח עם `InvocationScopeDeniedError`
* (host-client-factory.js:253-293) — קוד `-32005`.
*
* המודול הזה טהור בכוונה — כמו `sync-target.ts` — כדי שאפשר יהיה לייבא
* אותו בטסט בלי להריץ את `runWorker(plugin, import.meta.url)` שקורה בזמן
* import של worker.ts. הייבוא היחיד מה-SDK הוא ייבוא-ערך של
* `PLUGIN_RPC_ERROR_CODES` — קבוע פרוטוקול נטול side-effects, לא ה-runtime
* של הפלאגין עצמו.
*/
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
/** מפתח ה-`ctx.state` (scope `instance`) שבו נשמר ה-base URL האחרון שהצלחנו לפתור. */
export const LEGAL_API_BASE_CACHE_KEY = "legal-api-base-url";
/**
* זיכרון-תהליך (לא `ctx.state`) של ה-base URL האחרון שבאמת נכתב ל-cache.
* `LegalApi.request` (`legal-api.ts:19-32`) קורא ל-`resolveBaseUrl()` —
* וכך ל-`resolveApiBaseFrom` — בכל בקשת-API בודדת, אבל הערך כמעט לעולם
* לא משתנה; בלי המנגנון הזה כל קריאת-כלי/`usePluginData` הייתה מוסיפה
* `ctx.state.set` (=upsert ל-Postgres) מיותר.
*/
let lastWrittenApiBase: string | undefined;
/** מאפס את זיכרון-המטמון ברמת-המודול. **לשימוש טסטים בלבד.** */
export function _resetApiBaseCacheMemo(): void {
lastWrittenApiBase = undefined;
}
/**
* האם השגיאה היא דחיית-scope של המארח (קוד `-32005`,
* `PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED`). זיהוי **מבני** לפי
* שדה `code` — לא התאמת-מחרוזת על `err.message`, שיכולה להישבר בשקט אם
* המארח ינסח את ההודעה מחדש.
*/
export function isCompanyScopeDenied(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code?: unknown }).code ===
PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED
);
}
/**
* מחלץ טקסט קריא מ-`cause` שגוי-scope. המארח שולח `JsonRpcCallError`
* (מופע `Error` עם `.message` תיאורי מהצד השני), אבל בטסטים/עתידית ייתכן
* גם אובייקט-שגיאה פשוט בלי prototype chain — לכן בדיקה מבנית ולא רק
* `instanceof Error`.
*/
function describeCause(cause: unknown): string {
if (cause instanceof Error) return cause.message;
if (
typeof cause === "object" &&
cause !== null &&
"message" in cause &&
typeof (cause as { message?: unknown }).message === "string"
) {
return (cause as { message: string }).message;
}
return String(cause);
}
/**
* נזרקת כש-handler של ג'וב/webhook נתקל בדחיית-scope שאין לו דרך לעקוף
* אותה. ההודעה עצמה היא האבחנה — היא מה שיישמר ב-`plugin_job_runs.error`
* (או `plugin_webhook_deliveries.error`), ו-`plugin_logs` ריק לחלוטין
* (נמדד), כך שזו למעשה עדות-הכשל היחידה שתישאר. **חובה** להעביר `cause`
* כשהוא ידוע — המתזמר של Paperclip (`plugin-job-scheduler.js:199`) רושם
* ל-DB רק את `err.message`, ואינו מטייל ב-`Error.cause`, כך שכל מידע
* שלא נכנס למחרוזת-ההודעה עצמה אובד.
*/
export class CompanyScopeUnavailableError extends Error {
readonly jobKey: string;
readonly method: string;
constructor(jobKey: string, method: string, options?: { cause?: unknown }) {
// method="unknown" קורה כש-runJobHandler תופס דחיית-scope בלי לדעת
// איזו קריאה נדחתה בפועל (ה-handler לא ציין method מפורש). לא לכתוב
// את המילה "unknown" להודעה — במקום זה מסתמכים על ה-cause בלבד.
const methodClause =
method === "unknown" ? "" : ` הפעולה שנדחתה: "${method}".`;
const sourceClause =
options?.cause !== undefined
? ` מקור: ${describeCause(options.cause)}.`
: "";
super(
`${jobKey}: נדרש הקשר-חברה (company scope) לפעולה זו, אבל ג'וב ` +
"מתוזמן/webhook אינו מקבל כזה מ-Paperclip (מאז server " +
`2026.722.0).${methodClause}${sourceClause} ראה legal-ai issue #637.`,
options,
);
this.name = "CompanyScopeUnavailableError";
this.jobKey = jobKey;
this.method = method;
}
}
/**
* מריץ handler של ג'וב, ובכשל — מלוגג **וזורק מחדש**. המתזמר של Paperclip
* (`plugin-job-scheduler.js:194-215`) כבר רושם `status:"failed"` כש-RPC
* נדחה; מה שהיה שבור זה ה-handlers שלנו, שבלעו את השגיאה ב-
* `catch (err) { ctx.logger.error(...) }` בלי `throw` — כך ה-RPC נפתר
* בהצלחה וה-run נרשם `succeeded` למרות שלא עשה כלום (13,928 ריצות כוזבות,
* נמדד). ה-README של ה-SDK: "Re-throw from the handler to mark the run as
* failed".
*
* דחיית-scope הופכת ל-`CompanyScopeUnavailableError` מאבחנת; כל שגיאה
* אחרת נזרקת כמות-שהיא.
*/
export async function runJobHandler(
jobKey: string,
logger: { error(message: string, meta?: Record<string, unknown>): void },
fn: () => Promise<void>,
): Promise<void> {
try {
await fn();
} catch (err) {
logger.error(`${jobKey}: job failed`, { error: String(err) });
if (isCompanyScopeDenied(err)) {
throw new CompanyScopeUnavailableError(jobKey, "unknown", {
cause: err,
});
}
throw err;
}
}
/** תלויות מוזרקות לפתירת `legalApiBaseUrl` — כדי שהלוגיקה תהיה בת-טסט בלי `ctx` אמיתי. */
export interface ResolveApiBaseDeps {
/** עוטף `ctx.config.get(companyId)`. **לא** בולע — זורק כשהמארח דוחה scope. */
readConfig(companyId?: string): Promise<Record<string, unknown> | null>;
/** עוטף `ctx.state.get` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
readCache(): Promise<unknown>;
/** עוטף `ctx.state.set` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
writeCache(url: string): Promise<void>;
/** מזהי כל החברות המוכרות לפלאגין (`Object.keys(CEO_AGENT_IDS)`). */
knownCompanyIds: readonly string[];
/** ברירת-המחדל כש-legal-ai לא הוגדר בכלל (אינסטנס לא-מוגדר, לא scope שנדחה). */
defaultBaseUrl: string;
logger: {
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
};
/** ידוע רק כשהמארח מספק אחד (handler בהקשר-חברה) — אופציונלי. */
companyId?: string;
}
/**
* פותר `legalApiBaseUrl` מ-config בהיקף-חברה, עם נפילה-חזרה למטמון
* (`ctx.state`, scope `instance`) כשאין כלל הקשר-חברה זמין (ג'וב/webhook).
*
* ⚠️ כשכל ניסיונות ה-`readConfig` נדחים ב-scope **ואין** מטמון — זורקים
* `CompanyScopeUnavailableError`. **אסור** להחזיר `defaultBaseUrl` במקרה
* הזה: זה בדיוק מה שיצר את `TypeError: fetch failed` המטעה — `worker.ts`
* הישן בלע את דחיית-ה-scope כ"אין קונפיג" ונפל ל-
* `http://localhost:8085` שאין בו מאזין.
*/
export async function resolveApiBaseFrom(
deps: ResolveApiBaseDeps,
): Promise<string> {
const {
readConfig,
readCache,
writeCache,
knownCompanyIds,
defaultBaseUrl,
logger,
companyId,
} = deps;
let sawScopeDenied = false;
// כותב ל-cache רק כשהערך השתנה בפועל — ראה `lastWrittenApiBase` למעלה.
const writeCacheIfChanged = async (url: string): Promise<void> => {
if (url === lastWrittenApiBase) return;
await writeCache(url);
lastWrittenApiBase = url;
};
const tryRead = async (id?: string): Promise<string | null | undefined> => {
let cfg: Record<string, unknown> | null;
try {
cfg = await readConfig(id);
} catch (err) {
if (isCompanyScopeDenied(err)) {
sawScopeDenied = true;
return undefined; // נדחה — נסה את המקור הבא
}
// שגיאה אחרת: אין בליעה שקטה — מדווחים וממשיכים לנסות את הבא.
logger.warn("resolveApiBase: config.get failed", {
companyId: id ?? null,
error: String(err),
});
return undefined;
}
const url = cfg?.legalApiBaseUrl;
return typeof url === "string" && url.trim() ? url.trim() : null;
};
if (companyId) {
const scoped = await tryRead(companyId);
if (scoped) {
await writeCacheIfChanged(scoped);
return scoped;
}
}
// אין companyId ישיר (או שהקריאה מולו נדחתה) — אבל המארח מסיק בעצמו
// companyId כשהקריאה מתרחשת בתוך הקשר host-scoped (למשל tool handler).
// לנסות לפני נפילה-לניחוש. נשאר בהתנהגות המקורית: מנוסה תמיד, לא רק
// כשלא סופק companyId.
const derived = await tryRead(undefined);
if (derived) {
await writeCacheIfChanged(derived);
return derived;
}
// ג'וב מתוזמן/webhook ללא הקשר-חברה כלל: לנסות כל חברה מוכרת. בפועל
// כולן מצביעות על אותו מופע legal-ai, כך שהפגיעה הראשונה מנצחת.
for (const knownCompanyId of knownCompanyIds) {
const url = await tryRead(knownCompanyId);
if (url) {
await writeCacheIfChanged(url);
return url;
}
}
if (sawScopeDenied) {
// כל הניסיונות נדחו ב-scope (לא "לא-מוגדר") — לנסות את המטמון לפני
// שנכשל. מצב-צפוי בג'וב, ולכן `info` ולא `warn`.
const cached = await readCache();
if (typeof cached === "string" && cached.trim()) {
logger.info(
"resolveApiBase: config.get denied (no company scope) — using cached legalApiBaseUrl",
{ cachedBaseUrl: cached },
);
return cached;
}
throw new CompanyScopeUnavailableError("resolveApiBase", "config.get");
}
// כל הקריאות הצליחו (לא נדחו) אך אף אחת לא החזירה ערך — legal-ai פשוט
// לא הוגדר עדיין. זה מצב שונה מהותית מדחיית-scope: מותר ליפול לברירת-מחדל.
logger.warn("legalApiBaseUrl unresolved — using default", {
companyId: companyId ?? null,
fallback: defaultBaseUrl,
});
return defaultBaseUrl;
}

View File

@@ -36,6 +36,10 @@ export class LegalApi {
return this.request("/api/cases"); return this.request("/api/cases");
} }
async getStatusModel(): Promise<StatusModel> {
return this.request("/api/status-model");
}
async getCase(caseNumber: string): Promise<CaseDetails> { async getCase(caseNumber: string): Promise<CaseDetails> {
return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`); return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`);
} }
@@ -184,6 +188,21 @@ export interface CaseSummary {
status: string; status: string;
} }
export interface StatusModelEntry {
key: string;
label: string;
description: string;
phase: string;
selectable: boolean;
terminal: boolean;
on_enter: string | null;
}
export interface StatusModel {
statuses: StatusModelEntry[];
phases: Array<{ key: string; label: string }>;
}
export interface CaseDetails { export interface CaseDetails {
id: string; id: string;
case_number: string; case_number: string;

View File

@@ -5,7 +5,14 @@ import { test } from "node:test";
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via // Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
// `node --test`, which requires the literal `.ts` extension (Node's ESM // `node --test`, which requires the literal `.ts` extension (Node's ESM
// resolver does not remap `.js` specifiers to `.ts` files at runtime). // resolver does not remap `.js` specifiers to `.ts` files at runtime).
import { pickSyncTargetIssue, type SyncCandidate } from "./sync-target.ts"; import type { StatusModelEntry } from "./legal-api.ts";
import {
isWritableStatus,
labelFor,
pickSyncTargetIssue,
resolveIssueStatus,
type SyncCandidate,
} from "./sync-target.ts";
/** /**
* הכלל הישן (worker.ts:894 לפני #446): כל issue מקושר שסטטוסו שונה מהיעד — * הכלל הישן (worker.ts:894 לפני #446): כל issue מקושר שסטטוסו שונה מהיעד —
@@ -25,17 +32,27 @@ test("רגרסיית 8125-09-24 / CMPA-140: 11 ה-issues הממשיים של ה
// רשומת legal-case-number ב-plugin_state, ולכן הוא לא מגיע כמועמד כלל — // רשומת legal-case-number ב-plugin_state, ולכן הוא לא מגיע כמועמד כלל —
// לילד יש הורה שאינו מקושר. // לילד יש הורה שאינו מקושר.
const candidates: SyncCandidate[] = [ const candidates: SyncCandidate[] = [
{ id: "CMPA-116", status: "cancelled", parentId: null }, { id: "CMPA-116", status: "cancelled", parentId: null, companyId: "c1" },
{ id: "CMPA-84", status: "cancelled", parentId: null }, { id: "CMPA-84", status: "cancelled", parentId: null, companyId: "c1" },
{ id: "CMPA-86", status: "cancelled", parentId: null }, { id: "CMPA-86", status: "cancelled", parentId: null, companyId: "c1" },
{ id: "CMPA-140", status: "done", parentId: "CMPA-139" }, // זה שנדרס בפועל { id: "CMPA-140", status: "done", parentId: "CMPA-139", companyId: "c1" }, // זה שנדרס בפועל
{ id: "CMPA-141", status: "cancelled", parentId: "CMPA-139" }, {
{ id: "CMPA-85", status: "done", parentId: "CMPA-84" }, id: "CMPA-141",
{ id: "CMPA-146", status: "done", parentId: "CMPA-84" }, status: "cancelled",
{ id: "CMPA-147", status: "done", parentId: "CMPA-84" }, parentId: "CMPA-139",
{ id: "CMPA-87", status: "done", parentId: "CMPA-86" }, companyId: "c1",
{ id: "CMPA-88", status: "done", parentId: "CMPA-86" }, },
{ id: "CMPA-89", status: "cancelled", parentId: "CMPA-86" }, { id: "CMPA-85", status: "done", parentId: "CMPA-84", companyId: "c1" },
{ id: "CMPA-146", status: "done", parentId: "CMPA-84", companyId: "c1" },
{ id: "CMPA-147", status: "done", parentId: "CMPA-84", companyId: "c1" },
{ id: "CMPA-87", status: "done", parentId: "CMPA-86", companyId: "c1" },
{ id: "CMPA-88", status: "done", parentId: "CMPA-86", companyId: "c1" },
{
id: "CMPA-89",
status: "cancelled",
parentId: "CMPA-86",
companyId: "c1",
},
]; ];
// סטטוס-התיק היה drafted → in_progress (CASE_STATUS_TO_ISSUE_STATUS ב-worker.ts). // סטטוס-התיק היה drafted → in_progress (CASE_STATUS_TO_ISSUE_STATUS ב-worker.ts).
@@ -53,7 +70,7 @@ test("רגרסיית 8125-09-24 / CMPA-140: 11 ה-issues הממשיים של ה
test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר", () => { test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר", () => {
const candidates: SyncCandidate[] = [ const candidates: SyncCandidate[] = [
{ id: "sub-1", status: "done", parentId: "some-root" }, { id: "sub-1", status: "done", parentId: "some-root", companyId: "c1" },
]; ];
const result = pickSyncTargetIssue(candidates); const result = pickSyncTargetIssue(candidates);
@@ -64,8 +81,8 @@ test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר"
test("sub-task יחיד פתוח (in_progress) + שורש cancelled: פתוח אינו מספיק, חייב שורש", () => { test("sub-task יחיד פתוח (in_progress) + שורש cancelled: פתוח אינו מספיק, חייב שורש", () => {
const candidates: SyncCandidate[] = [ const candidates: SyncCandidate[] = [
{ id: "root-1", status: "cancelled", parentId: null }, { id: "root-1", status: "cancelled", parentId: null, companyId: "c1" },
{ id: "sub-1", status: "in_progress", parentId: "root-1" }, { id: "sub-1", status: "in_progress", parentId: "root-1", companyId: "c1" },
]; ];
const result = pickSyncTargetIssue(candidates); const result = pickSyncTargetIssue(candidates);
@@ -76,7 +93,7 @@ test("sub-task יחיד פתוח (in_progress) + שורש cancelled: פתוח א
test("שורש יחיד ב-todo: נבחר", () => { test("שורש יחיד ב-todo: נבחר", () => {
const candidates: SyncCandidate[] = [ const candidates: SyncCandidate[] = [
{ id: "root-1", status: "todo", parentId: null }, { id: "root-1", status: "todo", parentId: null, companyId: "c1" },
]; ];
const result = pickSyncTargetIssue(candidates); const result = pickSyncTargetIssue(candidates);
@@ -86,20 +103,23 @@ test("שורש יחיד ב-todo: נבחר", () => {
assert.equal(result.writableRoots, 1); 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[] = [ const candidates: SyncCandidate[] = [
{ id: "root-1", status: "in_review", parentId: null }, { id: "root-1", status: "in_review", parentId: null, companyId: "c1" },
]; ];
const result = pickSyncTargetIssue(candidates); const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null); assert.equal(result.target?.id, "root-1");
assert.equal(result.reason, "no_writable_root"); assert.equal(result.reason, "ok");
assert.equal(result.writableRoots, 1);
}); });
test("שורש יחיד ב-blocked: לא נכתב", () => { test("שורש יחיד ב-blocked: לא נכתב", () => {
const candidates: SyncCandidate[] = [ const candidates: SyncCandidate[] = [
{ id: "root-1", status: "blocked", parentId: null }, { id: "root-1", status: "blocked", parentId: null, companyId: "c1" },
]; ];
const result = pickSyncTargetIssue(candidates); const result = pickSyncTargetIssue(candidates);
@@ -108,10 +128,18 @@ test("שורש יחיד ב-blocked: לא נכתב", () => {
assert.equal(result.reason, "no_writable_root"); 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, לא נכתב", () => { test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => {
const candidates: SyncCandidate[] = [ const candidates: SyncCandidate[] = [
{ id: "root-1", status: "todo", parentId: null }, { id: "root-1", status: "todo", parentId: null, companyId: "c1" },
{ id: "root-2", status: "in_progress", parentId: null }, { id: "root-2", status: "in_progress", parentId: null, companyId: "c1" },
]; ];
const result = pickSyncTargetIssue(candidates); const result = pickSyncTargetIssue(candidates);
@@ -123,8 +151,13 @@ test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => {
test("שורש todo + ילד in_progress: השורש נבחר", () => { test("שורש todo + ילד in_progress: השורש נבחר", () => {
const candidates: SyncCandidate[] = [ const candidates: SyncCandidate[] = [
{ id: "root-1", status: "todo", parentId: null }, { id: "root-1", status: "todo", parentId: null, companyId: "c1" },
{ id: "child-1", status: "in_progress", parentId: "root-1" }, {
id: "child-1",
status: "in_progress",
parentId: "root-1",
companyId: "c1",
},
]; ];
const result = pickSyncTargetIssue(candidates); const result = pickSyncTargetIssue(candidates);
@@ -132,3 +165,174 @@ test("שורש todo + ילד in_progress: השורש נבחר", () => {
assert.equal(result.target?.id, "root-1"); assert.equal(result.target?.id, "root-1");
assert.equal(result.reason, "ok"); assert.equal(result.reason, "ok");
}); });
/**
* פיקסצ'ר ריאלי של `/api/status-model` (case_status_model.py) — הסדר קובע
* מי הוא "todo" (האינדקס הראשון), ו-`terminal: true` קובע מי "done".
*/
const STATUS_MODEL: StatusModelEntry[] = [
{
key: "new",
label: "חדש",
description: "תיק נפתח, טרם הועלו מסמכים",
phase: "intake",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "processing",
label: "בעיבוד",
description: "מסמכים בעיבוד",
phase: "intake",
selectable: false,
terminal: false,
on_enter: null,
},
{
key: "documents_ready",
label: "מסמכים מוכנים",
description: "העיבוד הושלם",
phase: "intake",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "analyst_verified",
label: 'אומת ע"י אנליסט',
description: "אנליסט אימת את התוכן",
phase: "analysis",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "research_complete",
label: "מחקר הושלם",
description: "מחקר תקדימים הושלם",
phase: "analysis",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "outcome_set",
label: "תוצאה נקבעה",
description: "תוצאה הוזנה",
phase: "analysis",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "direction_approved",
label: "כיוון אושר",
description: "כיוון הכתיבה אושר",
phase: "drafting",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "qa_review",
label: "בדיקת איכות",
description: "בבדיקת איכות",
phase: "drafting",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "drafted",
label: "טיוטה מוכנה",
description: "טיוטה נכתבה",
phase: "drafting",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "exported",
label: "יוצא ל-DOCX",
description: "יוצא כקובץ DOCX",
phase: "review",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "reviewed",
label: 'נבדק ע"י דפנה',
description: "דפנה הגיהה",
phase: "review",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "final",
label: "סופי",
description: "גרסה סופית",
phase: "final",
selectable: true,
terminal: true,
on_enter: null,
},
];
test("resolveIssueStatus: הסטטוס הראשון ברשימה (new) → todo", () => {
assert.equal(resolveIssueStatus(STATUS_MODEL, "new"), "todo");
});
test("resolveIssueStatus: סטטוס terminal (final) → done", () => {
assert.equal(resolveIssueStatus(STATUS_MODEL, "final"), "done");
});
test("resolveIssueStatus: analyst_verified (חסר במפה הישנה) → in_progress", () => {
assert.equal(
resolveIssueStatus(STATUS_MODEL, "analyst_verified"),
"in_progress",
);
});
test("resolveIssueStatus: research_complete → in_progress", () => {
assert.equal(
resolveIssueStatus(STATUS_MODEL, "research_complete"),
"in_progress",
);
});
test("resolveIssueStatus: סטטוס לא-מוכר → null (לא בליעה שקטה)", () => {
assert.equal(
resolveIssueStatus(STATUS_MODEL, "totally_unknown_status"),
null,
);
});
test("labelFor: מחזיר את התווית מהפיקסצ'ר, ו-null לסטטוס לא-מוכר", () => {
assert.equal(labelFor(STATUS_MODEL, "new"), "חדש");
assert.equal(labelFor(STATUS_MODEL, "totally_unknown_status"), null);
});
test("רגרסיה: companyId של המועמד-הנבחר שורד את pickSyncTargetIssue (לא companyId חיצוני קבוע)", () => {
const candidates: SyncCandidate[] = [
{
id: "issue-a",
status: "cancelled",
parentId: null,
companyId: "company-a",
},
{
id: "issue-b",
status: "todo",
parentId: null,
companyId: "company-b",
},
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target?.id, "issue-b");
assert.equal(result.target?.companyId, "company-b");
});

View File

@@ -7,9 +7,12 @@
* *
* המודול הזה טהור בכוונה — בלי import מה-SDK ובלי side effects — כדי שאפשר * המודול הזה טהור בכוונה — בלי import מה-SDK ובלי side effects — כדי שאפשר
* יהיה לייבא אותו ישירות בטסט בלי להריץ את `runWorker(plugin, import.meta.url)` * יהיה לייבא אותו ישירות בטסט בלי להריץ את `runWorker(plugin, import.meta.url)`
* שקורה בזמן import של worker.ts. * שקורה בזמן import של worker.ts. ה-import היחיד למטה הוא type-only (`import
* type`), כך שהוא לא נדרס בזמן ריצה ולא שובר את הטוהר הזה.
*/ */
import type { StatusModelEntry } from "./legal-api.ts";
/** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */ /** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */
export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([ export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([
"done", "done",
@@ -17,22 +20,28 @@ export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([
]); ]);
/** /**
* מצב בבעלות Paperclip / בהמתנה-לאדם — **לא** הגדרה שנייה של "סגור". * מצב בבעלות Paperclip — **לא** הגדרה שנייה של "סגור". `blocked` — כתיבה
* `in_review` הוא מצב-ההמתנה-ליו"ר המכוון: CEO שמשאיר issue ב-`in_progress` * עליו מסתירה חוסם קיים.
* מקבל auto-block מ-Paperclip תוך דקה, ולכן הוא מעביר ל-`in_review` *
* (legal-ai/docs/paperclip-quirks.md §3). כתיבת-סטטוס אוטומטית עליו גונבת את * `in_review` **הוסר מהסט** בהכרעת חיים מ-2026-08-26 (legal-ai issue #626):
* ה-issue מתור-הביקורת של היו"ר ומזמינה קרב-סטטוסים. * נשאל במפורש אם השחרור נקודתי או שיטתי, והשיב "התכוונתי לשיטתי".
* `blocked` — כתיבה עליו מסתירה חוסם קיים. *
* המחיר, בכנות: `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([ export const NON_WRITABLE_STATUSES: ReadonlySet<string> = new Set(["blocked"]);
"in_review",
"blocked",
]);
export interface SyncCandidate { export interface SyncCandidate {
id: string; id: string;
status: string; status: string;
parentId: string | null; parentId: string | null;
companyId: string;
} }
export type SyncTargetReason = export type SyncTargetReason =
@@ -77,3 +86,32 @@ export function pickSyncTargetIssue(
writableRoots: roots.length, writableRoots: roots.length,
}; };
} }
/**
* Derive the coarse Paperclip issue status for a legal-ai case status, using
* only generic fields already returned by legal-ai's `/api/status-model`
* (the canonical `case_status_model.py` registry) — never a hardcoded list of
* case-status keys. This is what makes a new case status "just work" without
* editing this plugin: `terminal: true` → done; the very first status in the
* ordered list → todo (nothing has started yet); everything else → in_progress.
* Returns `null` when `caseStatus` is not present in `statuses` at all (a
* genuinely unknown status — the caller must log this loudly, never swallow
* it silently; see legal-ai issue #604).
*/
export function resolveIssueStatus(
statuses: readonly StatusModelEntry[],
caseStatus: string,
): "todo" | "in_progress" | "done" | null {
const index = statuses.findIndex((s) => s.key === caseStatus);
if (index === -1) return null;
if (statuses[index].terminal) return "done";
return index === 0 ? "todo" : "in_progress";
}
/** Hebrew label for a case status from the canonical model, or null if unknown. */
export function labelFor(
statuses: readonly StatusModelEntry[],
caseStatus: string,
): string | null {
return statuses.find((s) => s.key === caseStatus)?.label ?? null;
}

File diff suppressed because it is too large Load Diff