feat(sync): מוני-ריצה מובחנים ל-sync-case-status — "אין מה לעשות" מובחן מ"שבור" (legal-ai #617) #12
@@ -23,6 +23,10 @@ export default {
|
||||
"plugin.state.write",
|
||||
"jobs.schedule",
|
||||
"activity.log.write",
|
||||
// נדרש ל-`ctx.metrics.write` — סיכום-ריצת הג'וב `sync-case-status`
|
||||
// (legal-ai issue #617). זו הדרך היחידה לכתוב ל-`plugin_logs`
|
||||
// (level='metric'), הטבלה שפאנל "Recent Logs" בדף-הפלאגין מרנדר.
|
||||
"metrics.write",
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"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,
|
||||
} 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,15 +850,54 @@ 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 });
|
||||
|
||||
const counters = newSyncRunCounters();
|
||||
let failure: unknown;
|
||||
|
||||
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();
|
||||
@@ -886,6 +933,7 @@ const plugin = definePlugin({
|
||||
legalCase.status,
|
||||
);
|
||||
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)",
|
||||
{
|
||||
@@ -901,6 +949,10 @@ const plugin = definePlugin({
|
||||
pickSyncTargetIssue(candidates);
|
||||
|
||||
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,
|
||||
@@ -911,7 +963,12 @@ const plugin = definePlugin({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.status === targetStatus) continue;
|
||||
counters.matched++;
|
||||
|
||||
if (target.status === targetStatus) {
|
||||
recordDecline(counters, "already_matching");
|
||||
continue;
|
||||
}
|
||||
|
||||
const label =
|
||||
labelFor(statusModel.statuses, legalCase.status) ??
|
||||
@@ -927,17 +984,20 @@ const plugin = definePlugin({
|
||||
`📋 ${label}`,
|
||||
target.companyId,
|
||||
);
|
||||
counters.written++;
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user