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

View File

@@ -11,6 +11,14 @@ import {
runJobHandler,
} from "./company-scope.js";
import { LegalApi } from "./legal-api.js";
import {
formatSyncRunSummary,
newSyncRunCounters,
recordDecline,
SYNC_RUN_SUMMARY_PREFIX,
type SyncRunCounters,
syncRunSummaryTags,
} from "./sync-run-summary.js";
import {
labelFor,
pickSyncTargetIssue,
@@ -842,102 +850,154 @@ const plugin = definePlugin({
// ── 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.logger.info("Starting case status sync", { runId: job.runId });
// אין `try/catch` בולע כאן — `runJobHandler` (company-scope.ts) הוא
// שמלוגג ו**זורק מחדש**, כדי שהמתזמר ירשום `status:"failed"`
// כשה-scope נדחה (legal-ai issue #637). ה-`try/catch` הישן כאן בלע
// את הכשל, וה-run נרשם `succeeded` בלי לעשות כלום — 13,928 ריצות כוזבות.
await runJobHandler("sync-case-status", ctx.logger, async () => {
const cases = await api.listCases();
const companies = await ctx.companies.list();
if (!companies.length) return;
const statusModel = await api.getStatusModel();
const counters = newSyncRunCounters();
let failure: unknown;
// מעבר אחד על ה-issues של כל חברה: state.get אחד ל-issue (לא לכל
// צירוף תיק×issue), וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש
// כדי לבחור "השורש היחיד". סורק את **כל** החברות (לא רק הראשונה) —
// legal-ai issue #604.
const byCase = new Map<string, SyncCandidate[]>();
for (const company of companies) {
const issues = await ctx.issues.list({ companyId: company.id });
for (const issue of issues) {
const linkedCase = await ctx.state.get({
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
});
if (typeof linkedCase !== "string" || !linkedCase) continue;
const list = byCase.get(linkedCase) ?? [];
list.push({
id: issue.id,
status: issue.status,
parentId: issue.parentId,
companyId: company.id,
});
byCase.set(linkedCase, list);
try {
// אין `try/catch` בולע כאן — `runJobHandler` (company-scope.ts) הוא
// שמלוגג ו**זורק מחדש**, כדי שהמתזמר ירשום `status:"failed"`
// כשה-scope נדחה (legal-ai issue #637). ה-`try/catch` הישן כאן בלע
// את הכשל, וה-run נרשם `succeeded` בלי לעשות כלום — 13,928 ריצות כוזבות.
await runJobHandler("sync-case-status", ctx.logger, async () => {
const cases = await api.listCases();
// נקבע מיד — לפני כל קריאת-מארח שעלולה ליפול. אחרת ריצה
// שנפלה באמצע (או `!companies.length`) הייתה מדווחת scanned=0
// ומאבדת בדיוק את האבחנה שה-issue הזה נועד לתת.
counters.scanned = cases.length;
const companies = await ctx.companies.list();
if (!companies.length) return;
const statusModel = await api.getStatusModel();
// מעבר אחד על ה-issues של כל חברה: state.get אחד ל-issue (לא לכל
// צירוף תיק×issue), וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש
// כדי לבחור "השורש היחיד". סורק את **כל** החברות (לא רק הראשונה) —
// legal-ai issue #604.
const byCase = new Map<string, SyncCandidate[]>();
for (const company of companies) {
const issues = await ctx.issues.list({ companyId: company.id });
for (const issue of issues) {
const linkedCase = await ctx.state.get({
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
});
if (typeof linkedCase !== "string" || !linkedCase) continue;
const list = byCase.get(linkedCase) ?? [];
list.push({
id: issue.id,
status: issue.status,
parentId: issue.parentId,
companyId: company.id,
});
byCase.set(linkedCase, list);
}
}
}
for (const legalCase of cases) {
const targetStatus = resolveIssueStatus(
statusModel.statuses,
legalCase.status,
);
if (targetStatus === null) {
ctx.logger.warn(
"sync-case-status: unknown case status — not in /api/status-model (case_status_model.py)",
{
caseNumber: legalCase.case_number,
status: legalCase.status,
},
for (const legalCase of cases) {
const targetStatus = resolveIssueStatus(
statusModel.statuses,
legalCase.status,
);
continue;
}
if (targetStatus === null) {
recordDecline(counters, "unknown_status");
ctx.logger.warn(
"sync-case-status: unknown case status — not in /api/status-model (case_status_model.py)",
{
caseNumber: legalCase.case_number,
status: legalCase.status,
},
);
continue;
}
const candidates = byCase.get(legalCase.case_number) ?? [];
const { target, reason, writableRoots } =
pickSyncTargetIssue(candidates);
const candidates = byCase.get(legalCase.case_number) ?? [];
const { target, reason, writableRoots } =
pickSyncTargetIssue(candidates);
if (!target) {
// אין בליעה שקטה: מדווח למה לא נכתב כלום.
ctx.logger.info("sync-case-status: no sync 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", {
caseNumber: legalCase.case_number,
reason,
linked: candidates.length,
writableRoots,
});
continue;
}
counters.matched++;
if (target.status === targetStatus) {
recordDecline(counters, "already_matching");
continue;
}
const label =
labelFor(statusModel.statuses, legalCase.status) ??
legalCase.status;
await ctx.issues.update(
target.id,
{ status: targetStatus },
target.companyId,
);
await ctx.issues.createComment(
target.id,
`📋 ${label}`,
target.companyId,
);
counters.written++;
ctx.logger.info("Synced issue status", {
issueId: target.id,
caseNumber: legalCase.case_number,
reason,
linked: candidates.length,
writableRoots,
newStatus: targetStatus,
});
continue;
}
if (target.status === targetStatus) continue;
const label =
labelFor(statusModel.statuses, legalCase.status) ??
legalCase.status;
await ctx.issues.update(
target.id,
{ status: targetStatus },
target.companyId,
);
await ctx.issues.createComment(
target.id,
`📋 ${label}`,
target.companyId,
);
ctx.logger.info("Synced issue status", {
issueId: target.id,
caseNumber: legalCase.case_number,
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) => {