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

@@ -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} `));
});