מפת-התוויות העברית הקשיחה ב-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>
151 lines
6.0 KiB
TypeScript
151 lines
6.0 KiB
TypeScript
/**
|
||
* בחירת יעד-הסנכרון של הג'וב `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 };
|
||
}
|