diff --git a/src/manifest.ts b/src/manifest.ts
index 1ed6441..84bfe8d 100644
--- a/src/manifest.ts
+++ b/src/manifest.ts
@@ -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",
diff --git a/src/sync-run-summary.test.ts b/src/sync-run-summary.test.ts
new file mode 100644
index 0000000..1027f3c
--- /dev/null
+++ b/src/sync-run-summary.test.ts
@@ -0,0 +1,292 @@
+///
+
+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);
+ 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} `));
+});
diff --git a/src/sync-run-summary.ts b/src/sync-run-summary.ts
new file mode 100644
index 0000000..0648ca2
--- /dev/null
+++ b/src/sync-run-summary.ts
@@ -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;
+
+/** בדיקת-הצבה סטטית בלבד — לא נקרא בזמן ריצה, ואינו זקוק לערך. */
+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 {
+ 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),
+ };
+}
diff --git a/src/worker.ts b/src/worker.ts
index d73530f..fbef8e6 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -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 {
+ 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();
- 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();
+ 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);
+ // אין בליעה שקטה: מדווח למה לא נכתב כלום.
+ 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) => {