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

@@ -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",

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

145
src/sync-run-summary.ts Normal file
View 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),
};
}

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) => {