feat(sync): מוני-ריצה מובחנים ל-sync-case-status — "אין מה לעשות" מובחן מ"שבור" (legal-ai #617) #12
@@ -23,6 +23,10 @@ export default {
|
|||||||
"plugin.state.write",
|
"plugin.state.write",
|
||||||
"jobs.schedule",
|
"jobs.schedule",
|
||||||
"activity.log.write",
|
"activity.log.write",
|
||||||
|
// נדרש ל-`ctx.metrics.write` — סיכום-ריצת הג'וב `sync-case-status`
|
||||||
|
// (legal-ai issue #617). זו הדרך היחידה לכתוב ל-`plugin_logs`
|
||||||
|
// (level='metric'), הטבלה שפאנל "Recent Logs" בדף-הפלאגין מרנדר.
|
||||||
|
"metrics.write",
|
||||||
"companies.read",
|
"companies.read",
|
||||||
"projects.read",
|
"projects.read",
|
||||||
"webhooks.receive",
|
"webhooks.receive",
|
||||||
|
|||||||
292
src/sync-run-summary.test.ts
Normal file
292
src/sync-run-summary.test.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
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<typeof reason, "ok">);
|
||||||
|
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} `));
|
||||||
|
});
|
||||||
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,14 @@ import {
|
|||||||
runJobHandler,
|
runJobHandler,
|
||||||
} from "./company-scope.js";
|
} from "./company-scope.js";
|
||||||
import { LegalApi } from "./legal-api.js";
|
import { LegalApi } from "./legal-api.js";
|
||||||
|
import {
|
||||||
|
formatSyncRunSummary,
|
||||||
|
newSyncRunCounters,
|
||||||
|
recordDecline,
|
||||||
|
SYNC_RUN_SUMMARY_PREFIX,
|
||||||
|
type SyncRunCounters,
|
||||||
|
syncRunSummaryTags,
|
||||||
|
} from "./sync-run-summary.js";
|
||||||
import {
|
import {
|
||||||
labelFor,
|
labelFor,
|
||||||
pickSyncTargetIssue,
|
pickSyncTargetIssue,
|
||||||
@@ -842,15 +850,54 @@ const plugin = definePlugin({
|
|||||||
|
|
||||||
// ── Jobs ───────────────────────────────────────────────────────
|
// ── 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<void> {
|
||||||
|
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.jobs.register("sync-case-status", async (job) => {
|
||||||
ctx.logger.info("Starting case status sync", { runId: job.runId });
|
ctx.logger.info("Starting case status sync", { runId: job.runId });
|
||||||
|
|
||||||
|
const counters = newSyncRunCounters();
|
||||||
|
let failure: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
// אין `try/catch` בולע כאן — `runJobHandler` (company-scope.ts) הוא
|
// אין `try/catch` בולע כאן — `runJobHandler` (company-scope.ts) הוא
|
||||||
// שמלוגג ו**זורק מחדש**, כדי שהמתזמר ירשום `status:"failed"`
|
// שמלוגג ו**זורק מחדש**, כדי שהמתזמר ירשום `status:"failed"`
|
||||||
// כשה-scope נדחה (legal-ai issue #637). ה-`try/catch` הישן כאן בלע
|
// כשה-scope נדחה (legal-ai issue #637). ה-`try/catch` הישן כאן בלע
|
||||||
// את הכשל, וה-run נרשם `succeeded` בלי לעשות כלום — 13,928 ריצות כוזבות.
|
// את הכשל, וה-run נרשם `succeeded` בלי לעשות כלום — 13,928 ריצות כוזבות.
|
||||||
await runJobHandler("sync-case-status", ctx.logger, async () => {
|
await runJobHandler("sync-case-status", ctx.logger, async () => {
|
||||||
const cases = await api.listCases();
|
const cases = await api.listCases();
|
||||||
|
// נקבע מיד — לפני כל קריאת-מארח שעלולה ליפול. אחרת ריצה
|
||||||
|
// שנפלה באמצע (או `!companies.length`) הייתה מדווחת scanned=0
|
||||||
|
// ומאבדת בדיוק את האבחנה שה-issue הזה נועד לתת.
|
||||||
|
counters.scanned = cases.length;
|
||||||
const companies = await ctx.companies.list();
|
const companies = await ctx.companies.list();
|
||||||
if (!companies.length) return;
|
if (!companies.length) return;
|
||||||
const statusModel = await api.getStatusModel();
|
const statusModel = await api.getStatusModel();
|
||||||
@@ -886,6 +933,7 @@ const plugin = definePlugin({
|
|||||||
legalCase.status,
|
legalCase.status,
|
||||||
);
|
);
|
||||||
if (targetStatus === null) {
|
if (targetStatus === null) {
|
||||||
|
recordDecline(counters, "unknown_status");
|
||||||
ctx.logger.warn(
|
ctx.logger.warn(
|
||||||
"sync-case-status: unknown case status — not in /api/status-model (case_status_model.py)",
|
"sync-case-status: unknown case status — not in /api/status-model (case_status_model.py)",
|
||||||
{
|
{
|
||||||
@@ -901,6 +949,10 @@ const plugin = definePlugin({
|
|||||||
pickSyncTargetIssue(candidates);
|
pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
// reason כאן הוא SyncTargetReason שאינו "ok" (target===null) —
|
||||||
|
// תת-קבוצה מובטחת-מהדר של SyncDeclineReason
|
||||||
|
// (ראה sync-run-summary.ts: SyncTargetDeclineReason).
|
||||||
|
recordDecline(counters, reason as Exclude<typeof reason, "ok">);
|
||||||
// אין בליעה שקטה: מדווח למה לא נכתב כלום.
|
// אין בליעה שקטה: מדווח למה לא נכתב כלום.
|
||||||
ctx.logger.info("sync-case-status: no sync target", {
|
ctx.logger.info("sync-case-status: no sync target", {
|
||||||
caseNumber: legalCase.case_number,
|
caseNumber: legalCase.case_number,
|
||||||
@@ -911,7 +963,12 @@ const plugin = definePlugin({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.status === targetStatus) continue;
|
counters.matched++;
|
||||||
|
|
||||||
|
if (target.status === targetStatus) {
|
||||||
|
recordDecline(counters, "already_matching");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
labelFor(statusModel.statuses, legalCase.status) ??
|
labelFor(statusModel.statuses, legalCase.status) ??
|
||||||
@@ -927,17 +984,20 @@ const plugin = definePlugin({
|
|||||||
`📋 ${label}`,
|
`📋 ${label}`,
|
||||||
target.companyId,
|
target.companyId,
|
||||||
);
|
);
|
||||||
|
counters.written++;
|
||||||
ctx.logger.info("Synced issue status", {
|
ctx.logger.info("Synced issue status", {
|
||||||
issueId: target.id,
|
issueId: target.id,
|
||||||
caseNumber: legalCase.case_number,
|
caseNumber: legalCase.case_number,
|
||||||
newStatus: targetStatus,
|
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) => {
|
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user