From 237d82984468717c39435e1e40bf192162c78dd6 Mon Sep 17 00:00:00 2001 From: Chaim Marcus Date: Wed, 26 Aug 2026 16:51:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=D7=9E=D7=95=D7=A0=D7=99-=D7=A8?= =?UTF-8?q?=D7=99=D7=A6=D7=94=20=D7=9E=D7=95=D7=91=D7=97=D7=A0=D7=99=D7=9D?= =?UTF-8?q?=20=D7=9C-sync-case-status=20(legal-ai=20#617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הג'וב סיים עד כה בשורה אחת — `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) --- src/manifest.ts | 4 + src/sync-run-summary.test.ts | 292 +++++++++++++++++++++++++++++++++++ src/sync-run-summary.ts | 145 +++++++++++++++++ src/worker.ts | 226 +++++++++++++++++---------- 4 files changed, 584 insertions(+), 83 deletions(-) create mode 100644 src/sync-run-summary.test.ts create mode 100644 src/sync-run-summary.ts diff --git a/src/manifest.ts b/src/manifest.ts index 1ed6441..84bfe8d 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -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", diff --git a/src/sync-run-summary.test.ts b/src/sync-run-summary.test.ts new file mode 100644 index 0000000..1027f3c --- /dev/null +++ b/src/sync-run-summary.test.ts @@ -0,0 +1,292 @@ +/// + +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); + 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} `)); +}); diff --git a/src/sync-run-summary.ts b/src/sync-run-summary.ts new file mode 100644 index 0000000..0648ca2 --- /dev/null +++ b/src/sync-run-summary.ts @@ -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; + +/** בדיקת-הצבה סטטית בלבד — לא נקרא בזמן ריצה, ואינו זקוק לערך. */ +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 { + 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), + }; +} diff --git a/src/worker.ts b/src/worker.ts index d73530f..fbef8e6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -11,6 +11,14 @@ import { runJobHandler, } from "./company-scope.js"; import { LegalApi } from "./legal-api.js"; +import { + formatSyncRunSummary, + newSyncRunCounters, + recordDecline, + SYNC_RUN_SUMMARY_PREFIX, + type SyncRunCounters, + syncRunSummaryTags, +} from "./sync-run-summary.js"; import { labelFor, pickSyncTargetIssue, @@ -842,102 +850,154 @@ const plugin = definePlugin({ // ── Jobs ─────────────────────────────────────────────────────── + /** + * פולט את סיכום-ריצת `sync-case-status` לשני משטחים קיימים וצורכים: + * 1. `ctx.logger.info` → stdout של pm2 (שם בוצע בפועל האבחון של #626/#637). + * 2. `ctx.metrics.write` → טבלת `plugin_logs` (level='metric'), שאותה מרנדר + * פאנל "Recent Logs" בדף-הפלאגין של Paperclip, וניתן לשאול ב-SQL. + * + * ⚠️ הפונקציה **לעולם אינה זורקת** — היא נקראת מתוך `finally`, וזריקה ממנה + * הייתה מחליפה את שגיאת-הריצה המקורית ומסתירה את סיבת-הכשל האמיתית. + * כשל של `metrics.write` מדווח בקול (`logger.error`) עם טקסט-השגיאה **בתוך + * המחרוזת** — כי שדות-`meta` נופלים מפלט-pino (נמדד) — ולא נבלע. + */ + async function emitSyncRunSummary( + counters: SyncRunCounters, + failure: unknown, + ): Promise { + const outcome = failure === undefined ? "ok" : "failed"; + const error = failure === undefined ? undefined : String(failure); + const line = formatSyncRunSummary(counters, { outcome, error }); + const tags = syncRunSummaryTags(counters, { outcome }); + + ctx.logger.info(line, tags); + + try { + await ctx.metrics.write(line, counters.written, tags); + } catch (err) { + ctx.logger.error( + `${SYNC_RUN_SUMMARY_PREFIX}: failed to persist run summary metric: ${String(err)}`, + ); + } + } + ctx.jobs.register("sync-case-status", async (job) => { ctx.logger.info("Starting case status sync", { runId: job.runId }); - // אין `try/catch` בולע כאן — `runJobHandler` (company-scope.ts) הוא - // שמלוגג ו**זורק מחדש**, כדי שהמתזמר ירשום `status:"failed"` - // כשה-scope נדחה (legal-ai issue #637). ה-`try/catch` הישן כאן בלע - // את הכשל, וה-run נרשם `succeeded` בלי לעשות כלום — 13,928 ריצות כוזבות. - await runJobHandler("sync-case-status", ctx.logger, async () => { - const cases = await api.listCases(); - const companies = await ctx.companies.list(); - if (!companies.length) return; - const statusModel = await api.getStatusModel(); + const counters = newSyncRunCounters(); + let failure: unknown; - // מעבר אחד על ה-issues של כל חברה: state.get אחד ל-issue (לא לכל - // צירוף תיק×issue), וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש - // כדי לבחור "השורש היחיד". סורק את **כל** החברות (לא רק הראשונה) — - // legal-ai issue #604. - const byCase = new Map(); - for (const company of companies) { - const issues = await ctx.issues.list({ companyId: company.id }); - for (const issue of issues) { - const linkedCase = await ctx.state.get({ - scopeKind: "issue", - scopeId: issue.id, - stateKey: "legal-case-number", - }); - if (typeof linkedCase !== "string" || !linkedCase) continue; - const list = byCase.get(linkedCase) ?? []; - list.push({ - id: issue.id, - status: issue.status, - parentId: issue.parentId, - companyId: company.id, - }); - byCase.set(linkedCase, list); + try { + // אין `try/catch` בולע כאן — `runJobHandler` (company-scope.ts) הוא + // שמלוגג ו**זורק מחדש**, כדי שהמתזמר ירשום `status:"failed"` + // כשה-scope נדחה (legal-ai issue #637). ה-`try/catch` הישן כאן בלע + // את הכשל, וה-run נרשם `succeeded` בלי לעשות כלום — 13,928 ריצות כוזבות. + await runJobHandler("sync-case-status", ctx.logger, async () => { + const cases = await api.listCases(); + // נקבע מיד — לפני כל קריאת-מארח שעלולה ליפול. אחרת ריצה + // שנפלה באמצע (או `!companies.length`) הייתה מדווחת scanned=0 + // ומאבדת בדיוק את האבחנה שה-issue הזה נועד לתת. + counters.scanned = cases.length; + const companies = await ctx.companies.list(); + if (!companies.length) return; + const statusModel = await api.getStatusModel(); + + // מעבר אחד על ה-issues של כל חברה: state.get אחד ל-issue (לא לכל + // צירוף תיק×issue), וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש + // כדי לבחור "השורש היחיד". סורק את **כל** החברות (לא רק הראשונה) — + // legal-ai issue #604. + const byCase = new Map(); + for (const company of companies) { + const issues = await ctx.issues.list({ companyId: company.id }); + for (const issue of issues) { + const linkedCase = await ctx.state.get({ + scopeKind: "issue", + scopeId: issue.id, + stateKey: "legal-case-number", + }); + if (typeof linkedCase !== "string" || !linkedCase) continue; + const list = byCase.get(linkedCase) ?? []; + list.push({ + id: issue.id, + status: issue.status, + parentId: issue.parentId, + companyId: company.id, + }); + byCase.set(linkedCase, list); + } } - } - for (const legalCase of cases) { - const targetStatus = resolveIssueStatus( - statusModel.statuses, - legalCase.status, - ); - if (targetStatus === null) { - ctx.logger.warn( - "sync-case-status: unknown case status — not in /api/status-model (case_status_model.py)", - { - caseNumber: legalCase.case_number, - status: legalCase.status, - }, + for (const legalCase of cases) { + const targetStatus = resolveIssueStatus( + statusModel.statuses, + legalCase.status, ); - continue; - } + if (targetStatus === null) { + recordDecline(counters, "unknown_status"); + ctx.logger.warn( + "sync-case-status: unknown case status — not in /api/status-model (case_status_model.py)", + { + caseNumber: legalCase.case_number, + status: legalCase.status, + }, + ); + continue; + } - const candidates = byCase.get(legalCase.case_number) ?? []; - const { target, reason, writableRoots } = - pickSyncTargetIssue(candidates); + const candidates = byCase.get(legalCase.case_number) ?? []; + const { target, reason, writableRoots } = + pickSyncTargetIssue(candidates); - if (!target) { - // אין בליעה שקטה: מדווח למה לא נכתב כלום. - ctx.logger.info("sync-case-status: no sync target", { + if (!target) { + // reason כאן הוא SyncTargetReason שאינו "ok" (target===null) — + // תת-קבוצה מובטחת-מהדר של SyncDeclineReason + // (ראה sync-run-summary.ts: SyncTargetDeclineReason). + recordDecline(counters, reason as Exclude); + // אין בליעה שקטה: מדווח למה לא נכתב כלום. + ctx.logger.info("sync-case-status: no sync target", { + caseNumber: legalCase.case_number, + reason, + linked: candidates.length, + writableRoots, + }); + continue; + } + + counters.matched++; + + if (target.status === targetStatus) { + recordDecline(counters, "already_matching"); + continue; + } + + const label = + labelFor(statusModel.statuses, legalCase.status) ?? + legalCase.status; + + await ctx.issues.update( + target.id, + { status: targetStatus }, + target.companyId, + ); + await ctx.issues.createComment( + target.id, + `📋 ${label}`, + target.companyId, + ); + counters.written++; + ctx.logger.info("Synced issue status", { + issueId: target.id, caseNumber: legalCase.case_number, - reason, - linked: candidates.length, - writableRoots, + newStatus: targetStatus, }); - continue; } - - if (target.status === targetStatus) continue; - - const label = - labelFor(statusModel.statuses, legalCase.status) ?? - legalCase.status; - - await ctx.issues.update( - target.id, - { status: targetStatus }, - target.companyId, - ); - await ctx.issues.createComment( - target.id, - `📋 ${label}`, - target.companyId, - ); - ctx.logger.info("Synced issue status", { - issueId: target.id, - caseNumber: legalCase.case_number, - newStatus: targetStatus, - }); - } - - ctx.logger.info("Case status sync completed", { - casesChecked: cases.length, }); - }); + } catch (err) { + failure = err; + throw err; // חובה — אחרת ה-run יירשם succeeded (זה בדיוק הבאג של #637). + } finally { + await emitSyncRunSummary(counters, failure); + } }); ctx.jobs.register("stale-case-reminder", async (_job) => {