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:
145
src/sync-run-summary.ts
Normal file
145
src/sync-run-summary.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user