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:
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} `));
|
||||
});
|
||||
Reference in New Issue
Block a user