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>
This commit is contained in:
2026-08-26 16:51:48 +03:00
parent 7cbb3d1c03
commit 237d829844
4 changed files with 584 additions and 83 deletions

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