Files
plugin-legal-ai/src/sync-target.ts
Chaim Marcus 510f276717 fix(sync): onWebhook גוזר תוויות-סטטוס מ-/api/status-model (legal-ai #616)
מפת-התוויות העברית הקשיחה ב-onWebhook הייתה מקור-האמת השני לאותו enum
של סטטוסי-תיק, והספיקה לסטות: חסרו בה analyst_verified ו-research_complete
(שנוספו ל-case_status_model.py ב-30.6), והיו בה חמישה מפתחות מתים —
uploading, brainstorming, drafting, in_progress ו-qa_failed. #604 תיקן את
המפה הראשונה (CASE_STATUS_TO_ISSUE_STATUS); זו השנייה.

- statusLabels נמחקת; התווית נגזרת מ-GET /api/status-model דרך labelFor()
  הקיימת — אותו SSOT שהג'וב sync-case-status כבר צורך (G2).
- resolveStatusLabel() חדשה ב-sync-target.ts (המודול הטהור): מרכיבה את
  labelFor ומחזירה גם את **סיבת** הנפילה-לגולמי, כדי שההיגיון יהיה
  בר-בדיקה — worker.ts אינו ניתן ל-import בטסט (runWorker ברמת-המודול).
- אין בליעה שקטה (כלל-הנדסה §6): סטטוס שנטען ואינו במודל → logger.warn עם
  מספר-התיק והסטטוס; מודל שלא נטען כלל (כשל-רשת או LegalApi חסר) → שני
  logger.error נבדלים. בכל המקרים התגובה עדיין מתפרסמת והערת-ה-CEO עדיין
  נורית — תווית חסרה אינה מפילה webhook.
- oldStatus מתורגם אף הוא לתווית (היה מודפס כמפתח-אנגלית בתוך משפט עברי).
- legalApi עשה hoist ליד pluginCtx ומועבר מפורשות ל-handleCaseStatusWebhook
  — אותו מופע, לא שני.
- AC2: הוסרו שתי רשימות-הסטטוסים הקשיחות הנוספות ב-worker.ts — ה-enum
  המיושן ב-legal_case_update (חסם 8 סטטוסים חוקיים והתיר את in_progress
  שאינו case.status; המניפסט כבר מגדיר את השדה כמחרוזת חופשית, והוולידציה
  האמיתית היא server-side) והמנייה בתיאור legal_case_list.

Invariants: G2 (מקור-אמת אחד לתוויות, בשני הצרכנים) · G1 (נרמול-במקור —
הפלאגין מושך מה-SSOT במקום להחזיק העתק סטטי) · G12 (legal-ai לא נגע כלל;
אפס סמל ספציפי-Paperclip נכנס אליו) · X7 INV-INT9 (onWebhook עדיין אינו
כותב issue.status — המשבצת השמורה נשארת שמורה) · כלל-הנדסה §6.

tsc --noEmit נקי · biome check src/ נקי · node --test 39/39.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:33:15 +03:00

151 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* בחירת יעד-הסנכרון של הג'וב `sync-case-status` (issue #446).
*
* המקור לבאג: הג'וב הישן כתב על **כל** issue מקושר-לתיק, כולל sub-issues
* ו-issues בסטטוסים סגורים/בהמתנה-לאדם. זה החזיר CMPA-140 מ-`done` ל-`in_progress`
* (8125-09-24) — כי sub-task שהושלם עדיין נמנה כ"מקושר".
*
* המודול הזה טהור בכוונה — בלי import מה-SDK ובלי side effects — כדי שאפשר
* יהיה לייבא אותו ישירות בטסט בלי להריץ את `runWorker(plugin, import.meta.url)`
* שקורה בזמן import של worker.ts. ה-import היחיד למטה הוא type-only (`import
* type`), כך שהוא לא נדרס בזמן ריצה ולא שובר את הטוהר הזה.
*/
import type { StatusModelEntry } from "./legal-api.ts";
/** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */
export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([
"done",
"cancelled",
]);
/**
* מצב בבעלות Paperclip — **לא** הגדרה שנייה של "סגור". `blocked` — כתיבה
* עליו מסתירה חוסם קיים.
*
* `in_review` **הוסר מהסט** בהכרעת חיים מ-2026-08-26 (legal-ai issue #626):
* נשאל במפורש אם השחרור נקודתי או שיטתי, והשיב "התכוונתי לשיטתי".
*
* המחיר, בכנות: `in_review` הוא מצב-ההמתנה-ליו"ר המכוון — CEO שמשאיר issue
* ב-`in_progress` מקבל auto-block מ-Paperclip תוך דקה, ולכן מעביר אותו
* ל-`in_review` כדי לחמוק מזה (legal-ai/docs/paperclip-quirks.md §3). מעתה
* issue שממתין לביקורת היו"ר עשוי להידרס בחזרה ל-`in_progress` בריצת-
* הסנכרון הבאה (כל 15 דק') ולרדת מתור-הביקורת. זה מכוון ומתועד, לא תקלה.
*
* מסלול-חזרה: להחזיר `"in_review"` לסט למטה + build + התקנה +
* `pm2 restart paperclip`.
*/
export const NON_WRITABLE_STATUSES: ReadonlySet<string> = new Set(["blocked"]);
export interface SyncCandidate {
id: string;
status: string;
parentId: string | null;
companyId: string;
}
export type SyncTargetReason =
| "ok"
| "no_linked_issues"
| "no_writable_root"
| "ambiguous_writable_roots";
export interface SyncTargetResult {
target: SyncCandidate | null;
reason: SyncTargetReason;
/** כמה שורשים ברי-כתיבה נמצאו — לצורך לוג. */
writableRoots: number;
}
export function isWritableStatus(status: string): boolean {
return (
!CLOSED_ISSUE_STATUSES.has(status) && !NON_WRITABLE_STATUSES.has(status)
);
}
export function pickSyncTargetIssue(
candidates: readonly SyncCandidate[],
): SyncTargetResult {
if (candidates.length === 0) {
return { target: null, reason: "no_linked_issues", writableRoots: 0 };
}
const roots = candidates.filter(
(c) => c.parentId === null && isWritableStatus(c.status),
);
if (roots.length === 1) {
return { target: roots[0], reason: "ok", writableRoots: 1 };
}
if (roots.length === 0) {
return { target: null, reason: "no_writable_root", writableRoots: 0 };
}
return {
target: null,
reason: "ambiguous_writable_roots",
writableRoots: roots.length,
};
}
/**
* Derive the coarse Paperclip issue status for a legal-ai case status, using
* only generic fields already returned by legal-ai's `/api/status-model`
* (the canonical `case_status_model.py` registry) — never a hardcoded list of
* case-status keys. This is what makes a new case status "just work" without
* editing this plugin: `terminal: true` → done; the very first status in the
* ordered list → todo (nothing has started yet); everything else → in_progress.
* Returns `null` when `caseStatus` is not present in `statuses` at all (a
* genuinely unknown status — the caller must log this loudly, never swallow
* it silently; see legal-ai issue #604).
*/
export function resolveIssueStatus(
statuses: readonly StatusModelEntry[],
caseStatus: string,
): "todo" | "in_progress" | "done" | null {
const index = statuses.findIndex((s) => s.key === caseStatus);
if (index === -1) return null;
if (statuses[index].terminal) return "done";
return index === 0 ? "todo" : "in_progress";
}
/** Hebrew label for a case status from the canonical model, or null if unknown. */
export function labelFor(
statuses: readonly StatusModelEntry[],
caseStatus: string,
): string | null {
return statuses.find((s) => s.key === caseStatus)?.label ?? null;
}
/** למה נפלנו לערך-הגולמי, או `null` כשהסטטוס מוכר. הקורא **חייב** לדווח על ערך לא-null. */
export type StatusLabelFallback = "unknown_status" | "model_unavailable" | null;
export interface StatusLabelResolution {
label: string;
fallback: StatusLabelFallback;
}
/**
* התווית להצגה עבור `caseStatus`, יחד עם **סיבת** נפילה-לגולמי אם הייתה.
*
* ההחלטה מופרדת כאן מהדיווח (`ctx.logger`) בכוונה: `worker.ts` אינו ניתן
* ל-import בטסט (הוא מריץ `runWorker(...)` ברמת-המודול), ולכן ההיגיון שחייב
* כיסוי-טסט חי במודול הטהור הזה — בדיוק כמו `pickSyncTargetIssue` (#446)
* ו-`resolveIssueStatus` (#604). legal-ai issue #616.
*
* `statuses === null` = מודל-הסטטוסים לא נטען כלל (כשל-רשת) — נבדל מסטטוס
* שנטען ולא נמצא, כי הן שתי תקלות שונות עם אבחון שונה.
*/
export function resolveStatusLabel(
statuses: readonly StatusModelEntry[] | null,
caseStatus: string,
): StatusLabelResolution {
if (statuses === null) {
return { label: caseStatus, fallback: "model_unavailable" };
}
const label = labelFor(statuses, caseStatus);
if (label === null) {
return { label: caseStatus, fallback: "unknown_status" };
}
return { label, fallback: null };
}