9 Commits

Author SHA1 Message Date
b24d816cc0 chore(int9): שער-סטטי ל-INV-INT9 — issues.update({status}) רק במסלול sync-case-status (legal-ai #618)
ל-`issue.status` כותב לגיטימי אחד בפלאגין: הג'וב `sync-case-status`
(`ctx.jobs.register("sync-case-status", …)` ב-`src/worker.ts`). עד היום
האכיפה הייתה code-review בלבד — וזה בדיוק מה שאפשר את הבאג של legal-ai
‏#446 (flip-flop `done → in_progress`), כותב שני שנכנס בלי שאיש שם לב.

`scripts/int9-guard.mjs` — Node ESM חסר-תלויות. טוקנייזר תו-תו
(מחרוזות/תבניות עם `${}` מקונן/הערות/רגקס-ליטרלים) בונה מסכת "קוד רגיל",
ומעליה: כל `jobs.register("sync-case-status", …)` מגדיר **טווח-תווים מותר**
(איזון-סוגריים), וכל `issues.update(…)` שרשימת-הארגומנטים שלו מכילה את
המזהה `status` חייב לשבת בתוכו.

הגרעיניות היא טווח-קוד ולא רשימת-קבצים (הדפוס של `leak_guard.py` ב-legal-ai)
במכוון: הקריאה המותרת היחידה וכל קריאה עתידית אסורה יושבות באותו
`worker.ts`. הטווח נגזר מהמבנה האמיתי, ולכן שורד מעבר של הג'וב לקובץ אחר —
ושינוי שם-הג'וב מפיל את השער בקול במקום לפתוח חור שקט.

חריגה מכוונת: `// noqa: INT9 — <נימוק>`. `noqa: INT9` בלי נימוק הוא הפרה
בעצמו — אין השתקה שקטה. כשל-פענוח (סוגר שלא נסגר) מפיל את השער ולא נבלע.

`--self-test` מריץ 5 פיקסצ'רים (`scripts/fixtures/int9/`, מחוץ ל-`src/`
בכוונה) ומאמת ספירת-הפרות מדויקת — הוכחה שהשער נושך.

**ללא חיווט-CI.** ה-runner היחיד על nautilus רשום בהיקף-repo ל-`legal-ai`
בלבד (`action_runner.repo_id = 6`), ולכן job של הריפו הזה לא נאסף לעולם
(נמדד: ריצה 3200 / job 3250 תקוע ב-`queued`, `started_at: 1970-01-01`).
קובץ ה-workflow יושב ב-PR נפרד וחסום, כדי שלא ייכנס ל-`main` שער שמייצר
check תקוע-לנצח בכל PR עתידי. עד אז: `npm run int9:guard` /
`npm run int9:guard:self-test`.

מגבלה מוצהרת: תיל-מעידה סטטי, לא הוכחה — `issues.update(id, patch)` שבו
אובייקט-העדכון נבנה במקום אחר אינו נתפס.

Refs ezer-mishpati/legal-ai#618

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 04:40:19 +03:00
314745d7a7 Merge pull request 'fix(sync): onWebhook statusLabels נגזרת מ-/api/status-model (legal-ai #616)' (#13) from fix/616-onwebhook-status-labels into main 2026-09-01 20:34:59 +00:00
510f276717 fix(sync): onWebhook גוזר תוויות-סטטוס מ-/api/status-model (legal-ai #616)
מפת-התוויות העברית הקשיחה ב-onWebhook הייתה מקור-האמת השני לאותו enum
של סטטוסי-תיק, והספיקה לסטות: חסרו בה analyst_verified ו-research_complete
(שנוספו ל-case_status_model.py ב-30.6), והיו בה חמישה מפתחות מתים —
uploading, brainstorming, drafting, in_progress ו-qa_failed. #604 תיקן את
המפה הראשונה (CASE_STATUS_TO_ISSUE_STATUS); זו השנייה.

- statusLabels נמחקת; התווית נגזרת מ-GET /api/status-model דרך labelFor()
  הקיימת — אותו SSOT שהג'וב sync-case-status כבר צורך (G2).
- resolveStatusLabel() חדשה ב-sync-target.ts (המודול הטהור): מרכיבה את
  labelFor ומחזירה גם את **סיבת** הנפילה-לגולמי, כדי שההיגיון יהיה
  בר-בדיקה — worker.ts אינו ניתן ל-import בטסט (runWorker ברמת-המודול).
- אין בליעה שקטה (כלל-הנדסה §6): סטטוס שנטען ואינו במודל → logger.warn עם
  מספר-התיק והסטטוס; מודל שלא נטען כלל (כשל-רשת או LegalApi חסר) → שני
  logger.error נבדלים. בכל המקרים התגובה עדיין מתפרסמת והערת-ה-CEO עדיין
  נורית — תווית חסרה אינה מפילה webhook.
- oldStatus מתורגם אף הוא לתווית (היה מודפס כמפתח-אנגלית בתוך משפט עברי).
- legalApi עשה hoist ליד pluginCtx ומועבר מפורשות ל-handleCaseStatusWebhook
  — אותו מופע, לא שני.
- AC2: הוסרו שתי רשימות-הסטטוסים הקשיחות הנוספות ב-worker.ts — ה-enum
  המיושן ב-legal_case_update (חסם 8 סטטוסים חוקיים והתיר את in_progress
  שאינו case.status; המניפסט כבר מגדיר את השדה כמחרוזת חופשית, והוולידציה
  האמיתית היא server-side) והמנייה בתיאור legal_case_list.

Invariants: G2 (מקור-אמת אחד לתוויות, בשני הצרכנים) · G1 (נרמול-במקור —
הפלאגין מושך מה-SSOT במקום להחזיק העתק סטטי) · G12 (legal-ai לא נגע כלל;
אפס סמל ספציפי-Paperclip נכנס אליו) · X7 INV-INT9 (onWebhook עדיין אינו
כותב issue.status — המשבצת השמורה נשארת שמורה) · כלל-הנדסה §6.

tsc --noEmit נקי · biome check src/ נקי · node --test 39/39.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:33:15 +03:00
a6bd02c237 Merge pull request 'feat(sync): מוני-ריצה מובחנים ל-sync-case-status — "אין מה לעשות" מובחן מ"שבור" (legal-ai #617)' (#12) from feat/617-sync-status-run-counters into main 2026-08-26 13:53:24 +00:00
237d829844 feat(sync): מוני-ריצה מובחנים ל-sync-case-status (legal-ai #617)
הג'וב סיים עד כה בשורה אחת — `casesChecked` — שאינה מבחינה בין "אין מה
לעשות", "נדחה בכוונה" ו"נפל". 13,872 ריצות דיווחו בריאות ירוקה, ובפועל
היו בהן 389 כתיבות בלבד; האבחנה ב-#604 נבנתה על היעדר-ראיה.

כל ריצה פולטת מעתה: scanned · matched · written · declined{no_linked_issues,
no_writable_root, ambiguous_writable_roots, unknown_status, already_matching}.
הפליטה יושבת ב-`finally` וזורקת את השגיאה המקורית הלאה, כך שריצה שנפלה
נשארת `failed` ב-`plugin_job_runs` **וגם** נושאת את המונים שהספיקה לצבור.

שני משטחים קיימים וצורכים, שנמדדו:
- `ctx.logger.info` → stdout של pm2 — שם בוצע בפועל האבחון של #626/#637.
- `ctx.metrics.write` → `plugin_logs` (level='metric') — הטבלה שפאנל
  "Recent Logs" בדף-הפלאגין מרנדר, וניתנת לשאילתת-SQL (AC4).

המונים בתוך מחרוזת-ההודעה ולא רק ב-meta, כי שני המשטחים מתעלמים מ-meta
בפועל (הפאנל מרנדר createdAt/level/message בלבד; ומ-pino נמדד ששדה
`{error:…}` נופל). נדרשה הרשאת `metrics.write` ב-manifest — זו הדרך
היחידה לכתוב ל-`plugin_logs`.

אפס שינוי בהתנהגות-הכתיבה: `sync-target.ts` לא נגע כלל, ובכלל זה
`NON_WRITABLE_STATUSES` (הכרעת-יו"ר, #626).

Closes ezer-mishpati/legal-ai#617

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 16:51:48 +03:00
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
15 changed files with 2434 additions and 562 deletions

View File

@@ -14,7 +14,9 @@
"format": "biome format --write src/",
"format:check": "biome format src/",
"biome": "biome check src/",
"biome:fix": "biome check --write src/"
"biome:fix": "biome check --write src/",
"int9:guard": "node scripts/int9-guard.mjs",
"int9:guard:self-test": "node scripts/int9-guard.mjs --self-test"
},
"dependencies": {
"@paperclipai/plugin-sdk": "^2026.722.0",

View File

@@ -0,0 +1,16 @@
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
//
// קריאה ל-issues.update({status}) בתוך jobs.register("sync-case-status", …)
// — המסלול המותר היחיד. כולל תבנית עם סוגריים לא-מאוזנים בתוך תבנית-מחרוזת
// והערה, כדי לבחון בפועל את הטוקנייזר (מחרוזת/הערה לא נספרות ב-matchParen).
export function register(ctx: any, api: any) {
ctx.jobs.register("sync-case-status", async (job: unknown) => {
// סוגר בודד בהערה — לא אמור לבלבל את חישוב-הטווח: )
const label = `📋 (${api.name}`;
const target = await api.pickTarget();
await ctx.issues.update(target.id, { status: label }, target.companyId);
});
}

View File

@@ -0,0 +1,15 @@
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
//
// שתי קריאות ל-issues.update שאינן נוגעות ב-status כלל — לא הפרה.
// השנייה בודקת שגבול-מילה אמיתי: "statusLabel" אינו תואם ל-/\bstatus\b/
// (אין גבול-מילה בין ה-s של status ל-L של Label).
export async function renameOnly(ctx: any, target: any, statusLabel: string) {
await ctx.issues.update(target.id, { title: "x" }, target.companyId);
await ctx.issues.update(
target.id,
{ description: statusLabel },
target.companyId,
);
}

View File

@@ -0,0 +1,9 @@
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
//
// קריאה ל-issues.update({status}) מחוץ ל-sync-case-status, עם noqa בלי נימוק —
// עדיין הפרה (AC3: חריגה חייבת להיות מנומקת בכתב).
export async function sloppyBackfill(ctx: any, target: any) {
// noqa: INT9
await ctx.issues.update(target.id, { status: "done" }, target.companyId);
}

View File

@@ -0,0 +1,9 @@
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
//
// קריאה ל-issues.update({status}) מחוץ ל-sync-case-status, עם חריגה מנומקת —
// מותר.
export async function migrationBackfill(ctx: any, target: any) {
// noqa: INT9 — מיגרציה חד-פעמית לתיקון סטטוסים תקועים מלפני #618, מאושרת ע"י דפנה
await ctx.issues.update(target.id, { status: "done" }, target.companyId);
}

View File

@@ -0,0 +1,16 @@
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
//
// קריאה ל-issues.update({status}) מחוץ לכל jobs.register("sync-case-status", …)
// — זו בדיוק ההפרה שהשער אמור לתפוס.
export async function badHandler(ctx: any, target: any) {
await ctx.jobs.register("stale-case-reminder", async (_job: unknown) => {
ctx.logger.info("stale-case-reminder: starting");
});
await ctx.issues.update(
target.id,
{ status: "in_progress" },
target.companyId,
);
}

546
scripts/int9-guard.mjs Normal file
View File

@@ -0,0 +1,546 @@
#!/usr/bin/env node
/**
* INV-INT9 guard — enforce single write-ownership of `issue.status`
* (legal-ai docs/spec/X7-paperclip-client-params.md §4 INV-INT9).
*
* `issues.status` has exactly one legitimate writer in this plugin: the
* `sync-case-status` scheduled job (`ctx.jobs.register("sync-case-status", …)`
* in `src/worker.ts`), which mirrors legal-ai's case status onto the linked
* Paperclip issue. Any OTHER `ctx.issues.update(...)` call that touches
* `status` is a second writer racing the sync job — exactly the flip-flop
* (`done` → `in_progress`) bug class legal-ai issue #618 exists to close off
* for good, before a second call site is ever added.
*
* IN SCOPE: every `.ts`/`.tsx` file under `src/` (including `*.test.ts` —
* there is no false positive there today, and the noqa escape hatch below
* covers the day there is one).
*
* OUT OF SCOPE: this is NOT a general call-graph analysis. It is a
* structural, single-pass lexer over the source text — no type information,
* no cross-file resolution. It answers exactly one question per call site:
* "does the character range of this `ctx.issues.update(...)` call sit
* inside the character range of the one whitelisted
* `ctx.jobs.register("sync-case-status", …)` call?". Deliberately NOT a
* per-file allowlist (the pattern `scripts/leak_guard.py` in legal-ai uses)
* — that shape does not fit here, because the one legitimate call site and
* every future illegitimate one live in the very same file (`worker.ts`).
* The granularity has to be a code RANGE derived from the real structure,
* not a file name.
*
* Escape hatch (G1 — fix at the source, don't paper over a false positive):
* a call can be justified in place with
* // noqa: INT9 — <reason, at least 3 characters>
* on the line immediately above the call, or on any line spanned by its
* argument list. A bare `// noqa: INT9` with no reason is itself a
* violation (AC3 — exceptions must be justified in writing, not silent).
*
* Usage:
* node scripts/int9-guard.mjs # scan src/**\/*.{ts,tsx}; exit 1 on any violation
* node scripts/int9-guard.mjs <path>... # scan only the given files/directories
* node scripts/int9-guard.mjs --self-test # run the fixture suite (scripts/fixtures/int9/), prove the gate bites
*
* NOT WIRED INTO CI YET: the Gitea Actions runner on this server is
* registered at *repository* scope for `legal-ai` only
* (`action_runner.repo_id = 6`), so any job queued for `plugin-legal-ai` is
* never dispatched — measured on legal-ai issue #618, run 3200 / job 3250
* sat in `queued` with `started_at: 1970-01-01`. Until a runner is
* registered for this repo, run this by hand:
* npm run int9:guard
* npm run int9:guard:self-test
* The workflow file itself (`.gitea/workflows/int9-guard.yaml`) lives in a
* separate, deliberately-blocked PR, so merging it does not leave a
* permanently-pending check on every future PR in this repo.
*
* Zero dependencies (node:fs / node:path / node:process only) — no `npm ci`
* needed in CI; the runner image ships node 24.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import process from "node:process";
const REPO_ROOT = resolve(import.meta.dirname, "..");
const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
const SOURCE_EXT = new Set([".ts", ".tsx"]);
const SPEC_REF = "docs/spec/X7-paperclip-client-params.md §4 INV-INT9";
const NOQA_RE = /\/\/\s*noqa\s*:\s*INT9\b(?:\s*[-—]\s*(.*))?/i;
const REGEX_PREV_CHARS = new Set("(,=:[!&|?{};+-*%~^<>".split(""));
const REGEX_PREV_WORDS = new Set([
"return",
"typeof",
"case",
"in",
"of",
"new",
"delete",
"void",
"instanceof",
"do",
"else",
"yield",
"await",
]);
// ---------------------------------------------------------------------
// Tokenizer — classify every character as "plain code" or not, so that
// structural regexes and paren-matching never see string/template
// contents, comments, or regex-literal bodies.
// ---------------------------------------------------------------------
/** @returns {Uint8Array} mask[i] === 1 iff text[i] is plain code. */
function classify(text) {
const n = text.length;
const mask = new Uint8Array(n);
/** @type {Array<{kind: "template"} | {kind: "exprCode", depth: number}>} */
const stack = [];
let state = "code";
let lastSignificant = "";
let lastWord = "";
let i = 0;
function noteCodeChar(ch) {
if (/\s/.test(ch)) return;
lastSignificant = ch;
if (/[A-Za-z0-9_$]/.test(ch)) {
lastWord += ch;
} else {
lastWord = "";
}
}
while (i < n) {
const ch = text[i];
if (state === "code") {
const next = text[i + 1];
if (ch === "/" && next === "/") {
state = "lcomment";
i += 2;
continue;
}
if (ch === "/" && next === "*") {
state = "bcomment";
i += 2;
continue;
}
if (ch === "'") {
state = "squote";
i += 1;
continue;
}
if (ch === '"') {
state = "dquote";
i += 1;
continue;
}
if (ch === "`") {
stack.push({ kind: "template" });
state = "template";
i += 1;
continue;
}
if (ch === "/") {
const isRegex =
lastSignificant === "" ||
REGEX_PREV_CHARS.has(lastSignificant) ||
REGEX_PREV_WORDS.has(lastWord);
if (isRegex) {
state = "regex";
i += 1;
continue;
}
// else: division — falls through to plain-code handling below.
}
mask[i] = 1;
const top = stack[stack.length - 1];
if (ch === "{" && top && top.kind === "exprCode") {
top.depth += 1;
} else if (ch === "}" && top && top.kind === "exprCode") {
top.depth -= 1;
if (top.depth === 0) {
stack.pop();
state = "template";
}
}
noteCodeChar(ch);
i += 1;
continue;
}
if (state === "squote" || state === "dquote") {
const quote = state === "squote" ? "'" : '"';
if (ch === "\\") {
i += 2;
continue;
}
if (ch === quote) {
state = "code";
noteCodeChar(quote);
i += 1;
continue;
}
if (ch === "\n") {
// Unterminated string — bail back to code rather than eat the file.
state = "code";
i += 1;
continue;
}
i += 1;
continue;
}
if (state === "template") {
if (ch === "\\") {
i += 2;
continue;
}
if (ch === "`") {
stack.pop();
state = "code";
i += 1;
continue;
}
if (ch === "$" && text[i + 1] === "{") {
stack.push({ kind: "exprCode", depth: 1 });
state = "code";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "lcomment") {
if (ch === "\n") {
state = "code";
i += 1;
continue;
}
i += 1;
continue;
}
if (state === "bcomment") {
if (ch === "*" && text[i + 1] === "/") {
state = "code";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "regex") {
if (ch === "\\") {
i += 2;
continue;
}
if (ch === "[") {
state = "regexclass";
i += 1;
continue;
}
if (ch === "/") {
i += 1;
while (i < n && /[a-z]/i.test(text[i])) i += 1;
state = "code";
continue;
}
if (ch === "\n") {
// Unterminated regex — bail back to code rather than eat the file.
state = "code";
i += 1;
continue;
}
i += 1;
continue;
}
if (state === "regexclass") {
if (ch === "\\") {
i += 2;
continue;
}
if (ch === "]") {
state = "regex";
i += 1;
continue;
}
i += 1;
continue;
}
// Unreachable, but never silently drop a character.
i += 1;
}
return mask;
}
/** Yield regex matches whose full span lies entirely in plain-code region. */
function* matchAllCode(text, mask, pattern) {
for (const m of text.matchAll(pattern)) {
const start = m.index;
const end = start + m[0].length;
let allCode = true;
for (let i = start; i < end; i++) {
if (!mask[i]) {
allCode = false;
break;
}
}
if (allCode) yield m;
}
}
/** Index of the `)` matching the `(` at `openIndex`, counting only plain-code parens. */
function matchParen(text, mask, openIndex, filePath) {
let depth = 0;
for (let i = openIndex; i < text.length; i++) {
if (!mask[i]) continue;
if (text[i] === "(") depth += 1;
else if (text[i] === ")") {
depth -= 1;
if (depth === 0) return i;
}
}
throw new Error(`${filePath}: פענוח נכשל — סוגר לא נסגר`);
}
function buildLineStarts(text) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text[i] === "\n") starts.push(i + 1);
}
return starts;
}
function lineOfOffset(lineStarts, idx) {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= idx) lo = mid;
else hi = mid - 1;
}
return lo; // 0-based line number
}
const STATUS_WORD_RE = /\bstatus\b/;
const JOBS_REGISTER_RE = /jobs\.register\s*\(/g;
const ISSUES_UPDATE_RE = /issues\.update\s*\(/g;
const SYNC_CASE_STATUS_LITERAL_RE = /^\s*["']sync-case-status["']/;
/**
* Scan one file's source text and return a list of violation objects
* ({ file, line, message }). Throws if a `(` is never closed (see
* matchParen) — the caller must NOT treat that as "no violations".
*/
function scanFile(filePath, text) {
const mask = classify(text);
const lines = text.split("\n");
const lineStarts = buildLineStarts(text);
const rel = filePath;
const allowedRanges = [];
for (const m of matchAllCode(text, mask, JOBS_REGISTER_RE)) {
const openIdx = m.index + m[0].length - 1;
const after = text.slice(openIdx + 1, openIdx + 60);
if (SYNC_CASE_STATUS_LITERAL_RE.test(after)) {
const closeIdx = matchParen(text, mask, openIdx, rel);
allowedRanges.push([openIdx, closeIdx]);
}
}
const violations = [];
for (const m of matchAllCode(text, mask, ISSUES_UPDATE_RE)) {
const openIdx = m.index + m[0].length - 1;
const closeIdx = matchParen(text, mask, openIdx, rel);
const content = text.slice(openIdx + 1, closeIdx);
if (!STATUS_WORD_RE.test(content)) continue; // not a status-write call
const inAllowedRange = allowedRanges.some(
([s, e]) => openIdx >= s && openIdx <= e,
);
if (inAllowedRange) continue;
const callStartLine = lineOfOffset(lineStarts, m.index);
const callEndLine = lineOfOffset(lineStarts, closeIdx);
let noqaMatch;
for (let ln = callStartLine - 1; ln <= callEndLine && !noqaMatch; ln += 1) {
if (ln < 0 || ln >= lines.length) continue;
const m2 = NOQA_RE.exec(lines[ln]);
if (m2) noqaMatch = m2;
}
const reportLine = callStartLine + 1; // 1-based for humans
if (noqaMatch) {
const reason = (noqaMatch[1] ?? "").trim();
if (reason.length >= 3) continue; // justified exception — allowed
violations.push({
file: rel,
line: reportLine,
message: "noqa: INT9 ללא נימוק — חריגה חייבת להיות מנומקת בכתב (AC3)",
});
continue;
}
violations.push({
file: rel,
line: reportLine,
message:
`issues.update({status}) מחוץ למסלול sync-case-status (INV-INT9). ` +
`ראה ${SPEC_REF}. חריגה מוצדקת → // noqa: INT9 — <נימוק>.`,
});
}
return violations;
}
// ---------------------------------------------------------------------
// File collection
// ---------------------------------------------------------------------
function walk(dir, out) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, out);
} else if (SOURCE_EXT.has(entry.name.slice(entry.name.lastIndexOf(".")))) {
out.push(full);
}
}
return out;
}
function collectDefault() {
const srcDir = join(REPO_ROOT, "src");
return walk(srcDir, []);
}
function collectFromArgs(args) {
const out = [];
for (const a of args) {
const p = resolve(a);
const st = statSync(p);
if (st.isDirectory()) {
walk(p, out);
} else {
out.push(p);
}
}
return out;
}
function runGate(files) {
const violations = [];
for (const abs of files) {
const rel = relative(REPO_ROOT, abs);
const text = readFileSync(abs, "utf-8");
violations.push(...scanFile(rel, text));
}
return violations;
}
function reportGate(violations, scannedCount) {
if (violations.length > 0) {
process.stderr.write(
`✗ INV-INT9 gate — issue.status write-ownership violated ` +
`(${violations.length} finding(s)):\n\n`,
);
for (const v of violations) {
process.stderr.write(`${v.file}:${v.line}: ${v.message}\n`);
}
process.stderr.write(`\nSee ${SPEC_REF}.\n`);
return 1;
}
process.stdout.write(
`✓ INV-INT9 gate: מסלול-כתיבה יחיד ל-issue.status ` +
`(${scannedCount} קבצים נסרקו).\n`,
);
return 0;
}
// ---------------------------------------------------------------------
// Self-test
// ---------------------------------------------------------------------
const SELF_TEST_EXPECTED = {
"violating.ts": 1,
"allowed-inside-job.ts": 0,
"noqa-justified.ts": 0,
"noqa-bare.ts": 1,
"non-status-update.ts": 0,
};
function runSelfTest() {
const fixturesDir = join(REPO_ROOT, "scripts", "fixtures", "int9");
let pass = 0;
let total = 0;
const failures = [];
for (const [name, expected] of Object.entries(SELF_TEST_EXPECTED)) {
total += 1;
const abs = join(fixturesDir, name);
let violations;
try {
const text = readFileSync(abs, "utf-8");
violations = scanFile(name, text);
} catch (err) {
failures.push(
`${name}: ציפינו ${expected} הפרות, אבל הסריקה זרקה שגיאה: ${String(err)}`,
);
continue;
}
if (violations.length === expected) {
pass += 1;
} else {
failures.push(
`${name}: ציפינו ${expected} הפרות, התקבלו ${violations.length}` +
(violations.length
? `:\n${violations.map((v) => ` - ${v.file}:${v.line}: ${v.message}`).join("\n")}`
: ""),
);
}
}
if (failures.length > 0) {
process.stderr.write("✗ INV-INT9 self-test נכשל:\n\n");
for (const f of failures) process.stderr.write(`${f}\n`);
return 1;
}
process.stdout.write(
`✓ INV-INT9 self-test: ${pass}/${total} פיקסצ'רים תואמים לציפייה.\n`,
);
return 0;
}
// ---------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------
function main(argv) {
if (argv.includes("--self-test")) {
return runSelfTest();
}
const pathArgs = argv.filter((a) => !a.startsWith("--"));
const files =
pathArgs.length > 0 ? collectFromArgs(pathArgs) : collectDefault();
let violations;
try {
violations = runGate(files);
} catch (err) {
process.stderr.write(`✗ INV-INT9 gate — ${String(err.message ?? err)}\n`);
return 1;
}
return reportGate(violations, files.length);
}
// `exitCode` ולא `process.exit()` — stdout/stderr אל pipe (וזה בדיוק המצב ב-CI)
// אסינכרוניים ב-POSIX, ו-`process.exit` היה עלול לקטוע את דוח-ההפרות עצמו.
process.exitCode = main(process.argv.slice(2));

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

@@ -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",

View 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
View 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),
};
}

View File

@@ -7,9 +7,11 @@ 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,
resolveStatusLabel,
type SyncCandidate,
} from "./sync-target.ts";
@@ -102,15 +104,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 +129,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" },
@@ -324,3 +337,53 @@ test("רגרסיה: companyId של המועמד-הנבחר שורד את pickSyn
assert.equal(result.target?.id, "issue-b");
assert.equal(result.target?.companyId, "company-b");
});
test("resolveStatusLabel: סטטוס מוכר → תווית מה-SSOT, בלי fallback", () => {
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "new"), {
label: "חדש",
fallback: null,
});
});
test("resolveStatusLabel: סטטוס שאינו במודל → warning-worthy fallback + ערך גולמי, בלי זריקה", () => {
assert.doesNotThrow(() =>
resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"),
);
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"), {
label: "totally_unknown_status",
fallback: "unknown_status",
});
});
test("resolveStatusLabel: מודל לא-זמין (null) → model_unavailable, נבדל מסטטוס-לא-מוכר", () => {
assert.deepEqual(resolveStatusLabel(null, "processing"), {
label: "processing",
fallback: "model_unavailable",
});
});
test("רגרסיה #616: כל הסטטוסים שהיו במפה הקשיחה ועודם חיים מקבלים תווית מה-SSOT", () => {
const survivingKeys = [
"new",
"processing",
"documents_ready",
"outcome_set",
"direction_approved",
"qa_review",
"drafted",
"exported",
"reviewed",
"final",
].filter((key) => STATUS_MODEL.some((s) => s.key === key));
for (const key of survivingKeys) {
const result = resolveStatusLabel(STATUS_MODEL, key);
assert.equal(result.fallback, null, `${key}: expected no fallback`);
assert.equal(
typeof result.label,
"string",
`${key}: expected string label`,
);
assert.notEqual(result.label, "", `${key}: expected non-empty label`);
}
});

View File

@@ -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;
@@ -110,3 +115,36 @@ export function labelFor(
): string | null {
return statuses.find((s) => s.key === caseStatus)?.label ?? null;
}
/** למה נפלנו לערך-הגולמי, או `null` כשהסטטוס מוכר. הקורא **חייב** לדווח על ערך לא-null. */
export type StatusLabelFallback = "unknown_status" | "model_unavailable" | null;
export interface StatusLabelResolution {
label: string;
fallback: StatusLabelFallback;
}
/**
* התווית להצגה עבור `caseStatus`, יחד עם **סיבת** נפילה-לגולמי אם הייתה.
*
* ההחלטה מופרדת כאן מהדיווח (`ctx.logger`) בכוונה: `worker.ts` אינו ניתן
* ל-import בטסט (הוא מריץ `runWorker(...)` ברמת-המודול), ולכן ההיגיון שחייב
* כיסוי-טסט חי במודול הטהור הזה — בדיוק כמו `pickSyncTargetIssue` (#446)
* ו-`resolveIssueStatus` (#604). legal-ai issue #616.
*
* `statuses === null` = מודל-הסטטוסים לא נטען כלל (כשל-רשת) — נבדל מסטטוס
* שנטען ולא נמצא, כי הן שתי תקלות שונות עם אבחון שונה.
*/
export function resolveStatusLabel(
statuses: readonly StatusModelEntry[] | null,
caseStatus: string,
): StatusLabelResolution {
if (statuses === null) {
return { label: caseStatus, fallback: "model_unavailable" };
}
const label = labelFor(statuses, caseStatus);
if (label === null) {
return { label: caseStatus, fallback: "unknown_status" };
}
return { label, fallback: null };
}

File diff suppressed because it is too large Load Diff