/** * סיכום-ריצה של הג'וב `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), }; }