fix(sync): סנכרון-הסטטוס יכתוב רק על issue-שורש פתוח, לא על sub-task שנסגר (#446)

הג'וב sync-case-status כתב על כל issue מקושר-לתיק שסטטוסו שונה מהיעד — השומר
היחיד היה 'issue.status !== targetStatus'. לכן הוא החזיר sub-tasks שנסגרו
(done) ל-in_progress, כפי שתועד ב-CMPA-140 / תיק 8125-09-24.

בחירת-היעד חולצה ל-src/sync-target.ts: נבחר שורש יחיד (parentId === null)
שאינו done/cancelled ואינו in_review/blocked; אפס או יותר-מאחד → לא נכתב
דבר, ונרשם לוג עם הסיבה. שני הקבועים מובחנים בכוונה — CLOSED_ISSUE_STATUSES
הוא ההגדרה היחידה של 'סגור' (מראה של legal-ai/web/paperclip_client.py:561),
ו-NON_WRITABLE_STATUSES הוא 'מצב בבעלות Paperclip/בהמתנה-לאדם': כתיבת
in_progress על issue ב-in_review מזמינה auto-block תוך דקה
(legal-ai/docs/paperclip-quirks.md §3) וגונבת אותו מתור-הביקורת של היו"ר.

הלולאה הכפולה הוחלפה במעבר אחד + קיבוץ למפת case_number→מועמדים; הקיבוץ
נדרש כדי לבחור 'השורש היחיד', וכתוצר-לוואי O(NxM)→O(M) קריאות state.get.

מבחן-רגרסיה (node --test, ללא תלויות חדשות) משחזר את 11 ה-issues שנמדדו
לתיק 8125-09-24 ומוכיח את הדלתא: הכלל הישן היה כותב על CMPA-140, החדש לא.

בבעלות ריפו זה בהחלטת פאנל — העברת הלוגיקה ל-legal-ai נפסלה כי היא מוסיפה
UPDATE ישיר ל-DB של Paperclip, בהפרת INV-INT4 (GAP-24/25).
This commit is contained in:
2026-08-24 07:41:32 +03:00
parent 6721c0a4ed
commit 378e5657b5
4 changed files with 304 additions and 63 deletions

View File

@@ -4,6 +4,7 @@ import type {
} from "@paperclipai/plugin-sdk";
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import { LegalApi } from "./legal-api.js";
import { pickSyncTargetIssue, type SyncCandidate } from "./sync-target.js";
// Hoisted so onWebhook can access the context after setup() completes.
let pluginCtx: PluginContext | null = null;
@@ -18,6 +19,43 @@ const CEO_AGENT_IDS: Record<string, string> = {
const DEFAULT_LEGAL_API_BASE = "http://localhost:8085";
// Map 13 legal-ai case statuses to Paperclip issue status (used by the
// `sync-case-status` job).
const CASE_STATUS_TO_ISSUE_STATUS: Record<
string,
"todo" | "in_progress" | "done"
> = {
new: "todo",
uploading: "todo",
processing: "in_progress",
documents_ready: "in_progress",
outcome_set: "in_progress",
brainstorming: "in_progress",
direction_approved: "in_progress",
drafting: "in_progress",
qa_review: "in_progress",
drafted: "in_progress",
exported: "in_progress",
reviewed: "in_progress",
final: "done",
};
const CASE_STATUS_LABELS: Record<string, string> = {
new: "תיק חדש",
uploading: "העלאת מסמכים",
processing: "עיבוד מסמכים",
documents_ready: "מסמכים מוכנים — הזן תוצאה",
outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות",
brainstorming: "גיבוש כיוון בתהליך",
direction_approved: "כיוון אושר — מוכן לכתיבה",
drafting: "כתיבת החלטה בתהליך",
qa_review: "בדיקת איכות",
drafted: "טיוטה מוכנה — בדוק ושלח לדפנה",
exported: "DOCX נוצר — ממתין לדפנה",
reviewed: "דפנה הגיהה — העלה גרסה סופית",
final: "גרסה סופית — לולאת למידה",
};
/**
* Resolve `legalApiBaseUrl` from company-scoped plugin config.
*
@@ -845,71 +883,60 @@ const plugin = definePlugin({
const companyId = companies[0].id;
const issues = await ctx.issues.list({ companyId });
// מעבר אחד על ה-issues: state.get אחד ל-issue (לא לכל צירוף תיק×issue),
// וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש כדי לבחור "השורש היחיד".
const byCase = new Map<string, SyncCandidate[]>();
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,
});
byCase.set(linkedCase, list);
}
for (const legalCase of cases) {
for (const issue of issues) {
const linkedCase = await ctx.state.get({
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
const targetStatus = CASE_STATUS_TO_ISSUE_STATUS[legalCase.status];
if (!targetStatus) continue;
const candidates = byCase.get(legalCase.case_number) ?? [];
const { target, reason, writableRoots } =
pickSyncTargetIssue(candidates);
if (!target) {
// אין בליעה שקטה: מדווח למה לא נכתב כלום.
ctx.logger.info("sync-case-status: no sync target", {
caseNumber: legalCase.case_number,
reason,
linked: candidates.length,
writableRoots,
});
if (linkedCase === legalCase.case_number) {
// Map 13 legal-ai statuses to Paperclip issue status
const statusMap: Record<string, "todo" | "in_progress" | "done"> =
{
new: "todo",
uploading: "todo",
processing: "in_progress",
documents_ready: "in_progress",
outcome_set: "in_progress",
brainstorming: "in_progress",
direction_approved: "in_progress",
drafting: "in_progress",
qa_review: "in_progress",
drafted: "in_progress",
exported: "in_progress",
reviewed: "in_progress",
final: "done",
};
const statusLabels: Record<string, string> = {
new: "תיק חדש",
uploading: "העלאת מסמכים",
processing: "עיבוד מסמכים",
documents_ready: "מסמכים מוכנים — הזן תוצאה",
outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות",
brainstorming: "גיבוש כיוון בתהליך",
direction_approved: "כיוון אושר — מוכן לכתיבה",
drafting: "כתיבת החלטה בתהליך",
qa_review: "בדיקת איכות",
drafted: "טיוטה מוכנה — בדוק ושלח לדפנה",
exported: "DOCX נוצר — ממתין לדפנה",
reviewed: "דפנה הגיהה — העלה גרסה סופית",
final: "גרסה סופית — לולאת למידה",
};
const targetStatus = statusMap[legalCase.status];
const label = statusLabels[legalCase.status] || legalCase.status;
if (targetStatus && issue.status !== targetStatus) {
await ctx.issues.update(
issue.id,
{ status: targetStatus },
companyId,
);
await ctx.issues.createComment(
issue.id,
`📋 ${label}`,
companyId,
);
ctx.logger.info("Synced issue status", {
issueId: issue.id,
caseNumber: legalCase.case_number,
newStatus: targetStatus,
});
}
}
continue;
}
if (target.status === targetStatus) continue;
const label =
CASE_STATUS_LABELS[legalCase.status] || legalCase.status;
await ctx.issues.update(
target.id,
{ status: targetStatus },
companyId,
);
await ctx.issues.createComment(target.id, `📋 ${label}`, companyId);
ctx.logger.info("Synced issue status", {
issueId: target.id,
caseNumber: legalCase.case_number,
newStatus: targetStatus,
});
}
ctx.logger.info("Case status sync completed", {