Compare commits
2 Commits
6721c0a4ed
...
602b42ca5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 602b42ca5e | |||
| 378e5657b5 |
134
src/sync-target.test.ts
Normal file
134
src/sync-target.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/// <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 { pickSyncTargetIssue, type SyncCandidate } from "./sync-target.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* הכלל הישן (worker.ts:894 לפני #446): כל issue מקושר שסטטוסו שונה מהיעד —
|
||||||
|
* נכתב, בלי סינון parentId ובלי סינון-סטטוס.
|
||||||
|
*/
|
||||||
|
function legacyTargets(
|
||||||
|
candidates: readonly SyncCandidate[],
|
||||||
|
targetStatus: string,
|
||||||
|
): string[] {
|
||||||
|
return candidates.filter((c) => c.status !== targetStatus).map((c) => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("רגרסיית 8125-09-24 / CMPA-140: 11 ה-issues הממשיים של התיק, אף sub-task done לא נבחר", () => {
|
||||||
|
// נמדד ב-DB החי של Paperclip (2026-08-24) — כל ה-issues המקושרים לתיק
|
||||||
|
// 8125-09-24. CMPA-140 (זה שנדרס בפועל, done→in_progress) הוא **ילד** של
|
||||||
|
// CMPA-139, לא שורש. CMPA-139 עצמו אינו ברשימה בכוונה: נמדד שאין לו
|
||||||
|
// רשומת legal-case-number ב-plugin_state, ולכן הוא לא מגיע כמועמד כלל —
|
||||||
|
// לילד יש הורה שאינו מקושר.
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "CMPA-116", status: "cancelled", parentId: null },
|
||||||
|
{ id: "CMPA-84", status: "cancelled", parentId: null },
|
||||||
|
{ id: "CMPA-86", status: "cancelled", parentId: null },
|
||||||
|
{ id: "CMPA-140", status: "done", parentId: "CMPA-139" }, // זה שנדרס בפועל
|
||||||
|
{ id: "CMPA-141", status: "cancelled", parentId: "CMPA-139" },
|
||||||
|
{ id: "CMPA-85", status: "done", parentId: "CMPA-84" },
|
||||||
|
{ id: "CMPA-146", status: "done", parentId: "CMPA-84" },
|
||||||
|
{ id: "CMPA-147", status: "done", parentId: "CMPA-84" },
|
||||||
|
{ id: "CMPA-87", status: "done", parentId: "CMPA-86" },
|
||||||
|
{ id: "CMPA-88", status: "done", parentId: "CMPA-86" },
|
||||||
|
{ id: "CMPA-89", status: "cancelled", parentId: "CMPA-86" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// סטטוס-התיק היה drafted → in_progress (CASE_STATUS_TO_ISSUE_STATUS ב-worker.ts).
|
||||||
|
const targetStatus = "in_progress";
|
||||||
|
|
||||||
|
// הכלל הישן היה כותב על CMPA-140 — זה הבאג שתועד.
|
||||||
|
assert.ok(legacyTargets(candidates, targetStatus).includes("CMPA-140"));
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
// הכלל החדש אינו כותב על דבר.
|
||||||
|
assert.equal(result.target, null);
|
||||||
|
assert.equal(result.reason, "no_writable_root");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "sub-1", status: "done", parentId: "some-root" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target, null);
|
||||||
|
assert.equal(result.reason, "no_writable_root");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sub-task יחיד פתוח (in_progress) + שורש cancelled: פתוח אינו מספיק, חייב שורש", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "cancelled", parentId: null },
|
||||||
|
{ id: "sub-1", status: "in_progress", parentId: "root-1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target, null);
|
||||||
|
assert.equal(result.reason, "no_writable_root");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("שורש יחיד ב-todo: נבחר", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "todo", parentId: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target?.id, "root-1");
|
||||||
|
assert.equal(result.reason, "ok");
|
||||||
|
assert.equal(result.writableRoots, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("שורש יחיד ב-in_review: לא נכתב (הגנת auto-block)", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "in_review", parentId: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target, null);
|
||||||
|
assert.equal(result.reason, "no_writable_root");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("שורש יחיד ב-blocked: לא נכתב", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "blocked", parentId: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target, null);
|
||||||
|
assert.equal(result.reason, "no_writable_root");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "todo", parentId: null },
|
||||||
|
{ id: "root-2", status: "in_progress", parentId: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target, null);
|
||||||
|
assert.equal(result.reason, "ambiguous_writable_roots");
|
||||||
|
assert.equal(result.writableRoots, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("שורש todo + ילד in_progress: השורש נבחר", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "todo", parentId: null },
|
||||||
|
{ id: "child-1", status: "in_progress", parentId: "root-1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target?.id, "root-1");
|
||||||
|
assert.equal(result.reason, "ok");
|
||||||
|
});
|
||||||
79
src/sync-target.ts
Normal file
79
src/sync-target.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* בחירת יעד-הסנכרון של הג'וב `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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */
|
||||||
|
export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([
|
||||||
|
"done",
|
||||||
|
"cancelled",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* מצב בבעלות Paperclip / בהמתנה-לאדם — **לא** הגדרה שנייה של "סגור".
|
||||||
|
* `in_review` הוא מצב-ההמתנה-ליו"ר המכוון: CEO שמשאיר issue ב-`in_progress`
|
||||||
|
* מקבל auto-block מ-Paperclip תוך דקה, ולכן הוא מעביר ל-`in_review`
|
||||||
|
* (legal-ai/docs/paperclip-quirks.md §3). כתיבת-סטטוס אוטומטית עליו גונבת את
|
||||||
|
* ה-issue מתור-הביקורת של היו"ר ומזמינה קרב-סטטוסים.
|
||||||
|
* `blocked` — כתיבה עליו מסתירה חוסם קיים.
|
||||||
|
*/
|
||||||
|
export const NON_WRITABLE_STATUSES: ReadonlySet<string> = new Set([
|
||||||
|
"in_review",
|
||||||
|
"blocked",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface SyncCandidate {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
parentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
151
src/worker.ts
151
src/worker.ts
@@ -4,6 +4,7 @@ import type {
|
|||||||
} from "@paperclipai/plugin-sdk";
|
} from "@paperclipai/plugin-sdk";
|
||||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||||
import { LegalApi } from "./legal-api.js";
|
import { LegalApi } from "./legal-api.js";
|
||||||
|
import { pickSyncTargetIssue, type SyncCandidate } from "./sync-target.js";
|
||||||
|
|
||||||
// Hoisted so onWebhook can access the context after setup() completes.
|
// Hoisted so onWebhook can access the context after setup() completes.
|
||||||
let pluginCtx: PluginContext | null = null;
|
let pluginCtx: PluginContext | null = null;
|
||||||
@@ -18,6 +19,43 @@ const CEO_AGENT_IDS: Record<string, string> = {
|
|||||||
|
|
||||||
const DEFAULT_LEGAL_API_BASE = "http://localhost:8085";
|
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.
|
* Resolve `legalApiBaseUrl` from company-scoped plugin config.
|
||||||
*
|
*
|
||||||
@@ -845,71 +883,60 @@ const plugin = definePlugin({
|
|||||||
const companyId = companies[0].id;
|
const companyId = companies[0].id;
|
||||||
const issues = await ctx.issues.list({ companyId });
|
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 legalCase of cases) {
|
||||||
for (const issue of issues) {
|
const targetStatus = CASE_STATUS_TO_ISSUE_STATUS[legalCase.status];
|
||||||
const linkedCase = await ctx.state.get({
|
if (!targetStatus) continue;
|
||||||
scopeKind: "issue",
|
|
||||||
scopeId: issue.id,
|
const candidates = byCase.get(legalCase.case_number) ?? [];
|
||||||
stateKey: "legal-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,
|
||||||
});
|
});
|
||||||
|
continue;
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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", {
|
ctx.logger.info("Case status sync completed", {
|
||||||
|
|||||||
@@ -12,5 +12,6 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user