הג'וב סיים עד כה בשורה אחת — `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>
146 lines
5.6 KiB
TypeScript
146 lines
5.6 KiB
TypeScript
/**
|
||
* סיכום-ריצה של הג'וב `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),
|
||
};
|
||
}
|