Compare commits
23 Commits
0bf2e46212
...
chore/618-
| Author | SHA1 | Date | |
|---|---|---|---|
| 76db4aadb5 | |||
| c6c0b76914 | |||
| b24d816cc0 | |||
| 314745d7a7 | |||
| 510f276717 | |||
| a6bd02c237 | |||
| 237d829844 | |||
| 7cbb3d1c03 | |||
| 41305fbb36 | |||
| 511d5c6e34 | |||
| 10d6216bec | |||
| 0a134d0134 | |||
| 602b42ca5e | |||
| 378e5657b5 | |||
| 6721c0a4ed | |||
| 3da37e1519 | |||
| 6294d94bed | |||
| d0669a0878 | |||
| b9906f6047 | |||
| 8d933386d3 | |||
| 0b6117cd7e | |||
| fc5d8725f1 | |||
| b9a5bb6e80 |
41
.gitea/workflows/int9-guard.yaml
Normal file
41
.gitea/workflows/int9-guard.yaml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: INV-INT9 issue.status write-ownership
|
||||||
|
|
||||||
|
# Hard gate for INV-INT9 (legal-ai docs/spec/X7-paperclip-client-params.md
|
||||||
|
# §4): `issue.status` has exactly one legitimate writer — the
|
||||||
|
# `sync-case-status` scheduled job (`ctx.jobs.register("sync-case-status", …)`
|
||||||
|
# in src/worker.ts), which mirrors legal-ai's case status onto the linked
|
||||||
|
# Paperclip issue. Built after legal-ai issue #446, where a second, unnoticed
|
||||||
|
# writer flip-flopped an issue `done → in_progress` — nobody caught it because
|
||||||
|
# nothing enforced write-ownership. This gate makes sure a third writer never
|
||||||
|
# lands the same way again: any `ctx.issues.update(...)` call that touches
|
||||||
|
# `status` outside the `sync-case-status` job fails the build, unless it
|
||||||
|
# carries an explicit, justified `// noqa: INT9 — <reason>` (a bare noqa with
|
||||||
|
# no reason is itself a violation — exceptions must be argued in writing).
|
||||||
|
#
|
||||||
|
# Pure Node, zero dependencies (scripts/int9-guard.mjs) — no `npm ci` needed.
|
||||||
|
#
|
||||||
|
# ⚠️ חסום למיזוג. אין היום runner ל-Gitea Actions בהיקף הריפו הזה: ה-runner
|
||||||
|
# היחיד על nautilus רשום בהיקף-repo ל-`legal-ai` בלבד (`action_runner.repo_id = 6`),
|
||||||
|
# ולכן job שנפתח כאן לא נאסף לעולם — נמדד ב-legal-ai #618: ריצה 3200 / job 3250
|
||||||
|
# ישבה ב-`queued` עם `started_at: 1970-01-01` בזמן שה-runner היה `busy: false`.
|
||||||
|
# ה-PR הזה נשאר פתוח **בכוונה** ומשמש כמדידה: ביום שיירשם runner לריפו הזה הוא
|
||||||
|
# יהפוך לירוק מעצמו, וזו תהיה הראיה ש-AC2 של #618 סופק. עד אז השער רץ ידנית:
|
||||||
|
# `npm run int9:guard` · `npm run int9:guard:self-test`.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
int9-guard:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: INV-INT9 — issue.status write-ownership guard
|
||||||
|
run: node scripts/int9-guard.mjs
|
||||||
|
|
||||||
|
- name: Self-test — הוכחה שהשער נושך (AC5)
|
||||||
|
run: node scripts/int9-guard.mjs --self-test
|
||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "@marcusgroup/plugin-legal-ai",
|
"name": "@marcusgroup/plugin-legal-ai",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclipai/plugin-sdk": "^2026.525.0",
|
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -625,12 +625,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@paperclipai/plugin-sdk": {
|
"node_modules/@paperclipai/plugin-sdk": {
|
||||||
"version": "2026.525.0",
|
"version": "2026.722.0",
|
||||||
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.525.0.tgz",
|
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.722.0.tgz",
|
||||||
"integrity": "sha512-7ivXbFSwH7cS+/2WNbPwQve5vVtLnqgxX5lmqTKn+i3fOY03d5KHaYF2bbI5zeTkAAwwEJ0Nz3rfBWNPguRRUw==",
|
"integrity": "sha512-2rMJCBo8ZAouuMsapdaY4/l+oHmrXKHW/k/HfPOLCxFng7+g04c5JhoFq+/ptGfNcW3kXzs34y7brviDCml06Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclipai/shared": "2026.525.0",
|
"@paperclipai/shared": "2026.722.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -646,9 +646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@paperclipai/shared": {
|
"node_modules/@paperclipai/shared": {
|
||||||
"version": "2026.525.0",
|
"version": "2026.722.0",
|
||||||
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.525.0.tgz",
|
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.722.0.tgz",
|
||||||
"integrity": "sha512-fbVrEx96oxkxXbRBFDLAgW5Z0lRC7Ii+BeCNhKqKRCg9m2IwtP97CGoOeAROdRrvRpP8Neb3vgaaDbDjXT74lQ==",
|
"integrity": "sha512-JciI6EbtNwHSeoyN5ZkshwqpyfIluyO46JHp8I7pPCvpJ7Uv7MSZEBs3lKXdXLadFEN6qzfwNmf39xjIFC8isA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
"format": "biome format --write src/",
|
"format": "biome format --write src/",
|
||||||
"format:check": "biome format src/",
|
"format:check": "biome format src/",
|
||||||
"biome": "biome check src/",
|
"biome": "biome check src/",
|
||||||
"biome:fix": "biome check --write src/"
|
"biome:fix": "biome check --write src/",
|
||||||
|
"int9:guard": "node scripts/int9-guard.mjs",
|
||||||
|
"int9:guard:self-test": "node scripts/int9-guard.mjs --self-test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclipai/plugin-sdk": "^2026.525.0",
|
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
87
plugin.json
87
plugin.json
@@ -1,87 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "marcusgroup.legal-ai",
|
|
||||||
"apiVersion": 1,
|
|
||||||
"version": "0.2.0",
|
|
||||||
"displayName": "Ezer Mishpati - Legal AI",
|
|
||||||
"description": "Integration with legal decision drafting system — case management, semantic search, workflow tracking, QA validation, and learning loop",
|
|
||||||
"author": "Marcus Group",
|
|
||||||
"categories": ["integration"],
|
|
||||||
"minimumHostVersion": "2026.325.0",
|
|
||||||
"capabilities": [
|
|
||||||
"events.subscribe",
|
|
||||||
"issues.read",
|
|
||||||
"issues.create",
|
|
||||||
"issues.update",
|
|
||||||
"issue.comments.create",
|
|
||||||
"issue.comments.read",
|
|
||||||
"agents.read",
|
|
||||||
"agents.invoke",
|
|
||||||
"agent.tools.register",
|
|
||||||
"http.outbound",
|
|
||||||
"plugin.state.read",
|
|
||||||
"plugin.state.write",
|
|
||||||
"jobs.schedule",
|
|
||||||
"activity.log.write",
|
|
||||||
"companies.read",
|
|
||||||
"projects.read",
|
|
||||||
"webhooks.receive"
|
|
||||||
],
|
|
||||||
"entrypoints": {
|
|
||||||
"worker": "dist/worker.js"
|
|
||||||
},
|
|
||||||
"instanceConfigSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"legalApiBaseUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "http://localhost:8085",
|
|
||||||
"description": "Base URL for the Ezer Mishpati API"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tools": [
|
|
||||||
{ "toolKey": "legal_case_list", "displayName": "רשימת תיקי ערר" },
|
|
||||||
{ "toolKey": "legal_case_get", "displayName": "פרטי תיק ערר" },
|
|
||||||
{ "toolKey": "legal_case_create", "displayName": "יצירת תיק ערר" },
|
|
||||||
{ "toolKey": "legal_case_update", "displayName": "עדכון תיק ערר" },
|
|
||||||
{ "toolKey": "legal_case_status", "displayName": "סטטוס תהליך עבודה" },
|
|
||||||
{ "toolKey": "legal_search", "displayName": "חיפוש תקדימים" },
|
|
||||||
{ "toolKey": "legal_case_template", "displayName": "תבנית החלטה" },
|
|
||||||
{ "toolKey": "legal_processing_status", "displayName": "סטטוס עיבוד כללי" },
|
|
||||||
{ "toolKey": "legal_document_list", "displayName": "רשימת מסמכים בתיק" },
|
|
||||||
{ "toolKey": "legal_set_outcome", "displayName": "הזנת תוצאת ערר" },
|
|
||||||
{ "toolKey": "legal_get_claims", "displayName": "טענות מחולצות" },
|
|
||||||
{ "toolKey": "legal_search_case", "displayName": "חיפוש בתוך תיק" },
|
|
||||||
{ "toolKey": "legal_find_similar", "displayName": "תקדימים דומים" },
|
|
||||||
{ "toolKey": "legal_run_qa", "displayName": "בדיקת איכות" },
|
|
||||||
{ "toolKey": "legal_trigger_learning", "displayName": "לולאת למידה" },
|
|
||||||
{ "toolKey": "legal_style_guide", "displayName": "מדריך סגנון" }
|
|
||||||
],
|
|
||||||
"jobs": [
|
|
||||||
{
|
|
||||||
"jobKey": "sync-case-status",
|
|
||||||
"displayName": "סנכרון סטטוס תיקים",
|
|
||||||
"description": "סנכרון סטטוס בין legal-ai ל-Paperclip כל 15 דקות",
|
|
||||||
"schedule": "*/15 * * * *"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"jobKey": "stale-case-reminder",
|
|
||||||
"displayName": "תזכורת תיקים תקועים",
|
|
||||||
"description": "מזהה תיקים שלא עודכנו 3+ ימים ומוסיף תגובה ל-issue",
|
|
||||||
"schedule": "0 8 * * *"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"jobKey": "weekly-feedback-analysis",
|
|
||||||
"displayName": "ניתוח פידבק שבועי",
|
|
||||||
"description": "מסכם פידבק יו\"ר מהשבוע האחרון ומעדכן את decision-lessons.md",
|
|
||||||
"schedule": "0 19 * * 0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"webhooks": [
|
|
||||||
{
|
|
||||||
"endpointKey": "case-status",
|
|
||||||
"displayName": "עדכון סטטוס תיק",
|
|
||||||
"description": "מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
16
scripts/fixtures/int9/allowed-inside-job.ts
Normal file
16
scripts/fixtures/int9/allowed-inside-job.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
|
||||||
|
//
|
||||||
|
// קריאה ל-issues.update({status}) בתוך jobs.register("sync-case-status", …)
|
||||||
|
// — המסלול המותר היחיד. כולל תבנית עם סוגריים לא-מאוזנים בתוך תבנית-מחרוזת
|
||||||
|
// והערה, כדי לבחון בפועל את הטוקנייזר (מחרוזת/הערה לא נספרות ב-matchParen).
|
||||||
|
|
||||||
|
export function register(ctx: any, api: any) {
|
||||||
|
ctx.jobs.register("sync-case-status", async (job: unknown) => {
|
||||||
|
// סוגר בודד בהערה — לא אמור לבלבל את חישוב-הטווח: )
|
||||||
|
const label = `📋 (${api.name}`;
|
||||||
|
|
||||||
|
const target = await api.pickTarget();
|
||||||
|
|
||||||
|
await ctx.issues.update(target.id, { status: label }, target.companyId);
|
||||||
|
});
|
||||||
|
}
|
||||||
15
scripts/fixtures/int9/non-status-update.ts
Normal file
15
scripts/fixtures/int9/non-status-update.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
|
||||||
|
//
|
||||||
|
// שתי קריאות ל-issues.update שאינן נוגעות ב-status כלל — לא הפרה.
|
||||||
|
// השנייה בודקת שגבול-מילה אמיתי: "statusLabel" אינו תואם ל-/\bstatus\b/
|
||||||
|
// (אין גבול-מילה בין ה-s של status ל-L של Label).
|
||||||
|
|
||||||
|
export async function renameOnly(ctx: any, target: any, statusLabel: string) {
|
||||||
|
await ctx.issues.update(target.id, { title: "x" }, target.companyId);
|
||||||
|
|
||||||
|
await ctx.issues.update(
|
||||||
|
target.id,
|
||||||
|
{ description: statusLabel },
|
||||||
|
target.companyId,
|
||||||
|
);
|
||||||
|
}
|
||||||
9
scripts/fixtures/int9/noqa-bare.ts
Normal file
9
scripts/fixtures/int9/noqa-bare.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
|
||||||
|
//
|
||||||
|
// קריאה ל-issues.update({status}) מחוץ ל-sync-case-status, עם noqa בלי נימוק —
|
||||||
|
// עדיין הפרה (AC3: חריגה חייבת להיות מנומקת בכתב).
|
||||||
|
|
||||||
|
export async function sloppyBackfill(ctx: any, target: any) {
|
||||||
|
// noqa: INT9
|
||||||
|
await ctx.issues.update(target.id, { status: "done" }, target.companyId);
|
||||||
|
}
|
||||||
9
scripts/fixtures/int9/noqa-justified.ts
Normal file
9
scripts/fixtures/int9/noqa-justified.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
|
||||||
|
//
|
||||||
|
// קריאה ל-issues.update({status}) מחוץ ל-sync-case-status, עם חריגה מנומקת —
|
||||||
|
// מותר.
|
||||||
|
|
||||||
|
export async function migrationBackfill(ctx: any, target: any) {
|
||||||
|
// noqa: INT9 — מיגרציה חד-פעמית לתיקון סטטוסים תקועים מלפני #618, מאושרת ע"י דפנה
|
||||||
|
await ctx.issues.update(target.id, { status: "done" }, target.companyId);
|
||||||
|
}
|
||||||
16
scripts/fixtures/int9/violating.ts
Normal file
16
scripts/fixtures/int9/violating.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה.
|
||||||
|
//
|
||||||
|
// קריאה ל-issues.update({status}) מחוץ לכל jobs.register("sync-case-status", …)
|
||||||
|
// — זו בדיוק ההפרה שהשער אמור לתפוס.
|
||||||
|
|
||||||
|
export async function badHandler(ctx: any, target: any) {
|
||||||
|
await ctx.jobs.register("stale-case-reminder", async (_job: unknown) => {
|
||||||
|
ctx.logger.info("stale-case-reminder: starting");
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.issues.update(
|
||||||
|
target.id,
|
||||||
|
{ status: "in_progress" },
|
||||||
|
target.companyId,
|
||||||
|
);
|
||||||
|
}
|
||||||
546
scripts/int9-guard.mjs
Normal file
546
scripts/int9-guard.mjs
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* INV-INT9 guard — enforce single write-ownership of `issue.status`
|
||||||
|
* (legal-ai docs/spec/X7-paperclip-client-params.md §4 INV-INT9).
|
||||||
|
*
|
||||||
|
* `issues.status` has exactly one legitimate writer in this plugin: the
|
||||||
|
* `sync-case-status` scheduled job (`ctx.jobs.register("sync-case-status", …)`
|
||||||
|
* in `src/worker.ts`), which mirrors legal-ai's case status onto the linked
|
||||||
|
* Paperclip issue. Any OTHER `ctx.issues.update(...)` call that touches
|
||||||
|
* `status` is a second writer racing the sync job — exactly the flip-flop
|
||||||
|
* (`done` → `in_progress`) bug class legal-ai issue #618 exists to close off
|
||||||
|
* for good, before a second call site is ever added.
|
||||||
|
*
|
||||||
|
* IN SCOPE: every `.ts`/`.tsx` file under `src/` (including `*.test.ts` —
|
||||||
|
* there is no false positive there today, and the noqa escape hatch below
|
||||||
|
* covers the day there is one).
|
||||||
|
*
|
||||||
|
* OUT OF SCOPE: this is NOT a general call-graph analysis. It is a
|
||||||
|
* structural, single-pass lexer over the source text — no type information,
|
||||||
|
* no cross-file resolution. It answers exactly one question per call site:
|
||||||
|
* "does the character range of this `ctx.issues.update(...)` call sit
|
||||||
|
* inside the character range of the one whitelisted
|
||||||
|
* `ctx.jobs.register("sync-case-status", …)` call?". Deliberately NOT a
|
||||||
|
* per-file allowlist (the pattern `scripts/leak_guard.py` in legal-ai uses)
|
||||||
|
* — that shape does not fit here, because the one legitimate call site and
|
||||||
|
* every future illegitimate one live in the very same file (`worker.ts`).
|
||||||
|
* The granularity has to be a code RANGE derived from the real structure,
|
||||||
|
* not a file name.
|
||||||
|
*
|
||||||
|
* Escape hatch (G1 — fix at the source, don't paper over a false positive):
|
||||||
|
* a call can be justified in place with
|
||||||
|
* // noqa: INT9 — <reason, at least 3 characters>
|
||||||
|
* on the line immediately above the call, or on any line spanned by its
|
||||||
|
* argument list. A bare `// noqa: INT9` with no reason is itself a
|
||||||
|
* violation (AC3 — exceptions must be justified in writing, not silent).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/int9-guard.mjs # scan src/**\/*.{ts,tsx}; exit 1 on any violation
|
||||||
|
* node scripts/int9-guard.mjs <path>... # scan only the given files/directories
|
||||||
|
* node scripts/int9-guard.mjs --self-test # run the fixture suite (scripts/fixtures/int9/), prove the gate bites
|
||||||
|
*
|
||||||
|
* NOT WIRED INTO CI YET: the Gitea Actions runner on this server is
|
||||||
|
* registered at *repository* scope for `legal-ai` only
|
||||||
|
* (`action_runner.repo_id = 6`), so any job queued for `plugin-legal-ai` is
|
||||||
|
* never dispatched — measured on legal-ai issue #618, run 3200 / job 3250
|
||||||
|
* sat in `queued` with `started_at: 1970-01-01`. Until a runner is
|
||||||
|
* registered for this repo, run this by hand:
|
||||||
|
* npm run int9:guard
|
||||||
|
* npm run int9:guard:self-test
|
||||||
|
* The workflow file itself (`.gitea/workflows/int9-guard.yaml`) lives in a
|
||||||
|
* separate, deliberately-blocked PR, so merging it does not leave a
|
||||||
|
* permanently-pending check on every future PR in this repo.
|
||||||
|
*
|
||||||
|
* Zero dependencies (node:fs / node:path / node:process only) — no `npm ci`
|
||||||
|
* needed in CI; the runner image ships node 24.
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join, relative, resolve } from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const REPO_ROOT = resolve(import.meta.dirname, "..");
|
||||||
|
const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
|
||||||
|
const SOURCE_EXT = new Set([".ts", ".tsx"]);
|
||||||
|
|
||||||
|
const SPEC_REF = "docs/spec/X7-paperclip-client-params.md §4 INV-INT9";
|
||||||
|
|
||||||
|
const NOQA_RE = /\/\/\s*noqa\s*:\s*INT9\b(?:\s*[-—]\s*(.*))?/i;
|
||||||
|
|
||||||
|
const REGEX_PREV_CHARS = new Set("(,=:[!&|?{};+-*%~^<>".split(""));
|
||||||
|
const REGEX_PREV_WORDS = new Set([
|
||||||
|
"return",
|
||||||
|
"typeof",
|
||||||
|
"case",
|
||||||
|
"in",
|
||||||
|
"of",
|
||||||
|
"new",
|
||||||
|
"delete",
|
||||||
|
"void",
|
||||||
|
"instanceof",
|
||||||
|
"do",
|
||||||
|
"else",
|
||||||
|
"yield",
|
||||||
|
"await",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Tokenizer — classify every character as "plain code" or not, so that
|
||||||
|
// structural regexes and paren-matching never see string/template
|
||||||
|
// contents, comments, or regex-literal bodies.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @returns {Uint8Array} mask[i] === 1 iff text[i] is plain code. */
|
||||||
|
function classify(text) {
|
||||||
|
const n = text.length;
|
||||||
|
const mask = new Uint8Array(n);
|
||||||
|
/** @type {Array<{kind: "template"} | {kind: "exprCode", depth: number}>} */
|
||||||
|
const stack = [];
|
||||||
|
let state = "code";
|
||||||
|
let lastSignificant = "";
|
||||||
|
let lastWord = "";
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
function noteCodeChar(ch) {
|
||||||
|
if (/\s/.test(ch)) return;
|
||||||
|
lastSignificant = ch;
|
||||||
|
if (/[A-Za-z0-9_$]/.test(ch)) {
|
||||||
|
lastWord += ch;
|
||||||
|
} else {
|
||||||
|
lastWord = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const ch = text[i];
|
||||||
|
|
||||||
|
if (state === "code") {
|
||||||
|
const next = text[i + 1];
|
||||||
|
if (ch === "/" && next === "/") {
|
||||||
|
state = "lcomment";
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && next === "*") {
|
||||||
|
state = "bcomment";
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "'") {
|
||||||
|
state = "squote";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
state = "dquote";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "`") {
|
||||||
|
stack.push({ kind: "template" });
|
||||||
|
state = "template";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/") {
|
||||||
|
const isRegex =
|
||||||
|
lastSignificant === "" ||
|
||||||
|
REGEX_PREV_CHARS.has(lastSignificant) ||
|
||||||
|
REGEX_PREV_WORDS.has(lastWord);
|
||||||
|
if (isRegex) {
|
||||||
|
state = "regex";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// else: division — falls through to plain-code handling below.
|
||||||
|
}
|
||||||
|
|
||||||
|
mask[i] = 1;
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (ch === "{" && top && top.kind === "exprCode") {
|
||||||
|
top.depth += 1;
|
||||||
|
} else if (ch === "}" && top && top.kind === "exprCode") {
|
||||||
|
top.depth -= 1;
|
||||||
|
if (top.depth === 0) {
|
||||||
|
stack.pop();
|
||||||
|
state = "template";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noteCodeChar(ch);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "squote" || state === "dquote") {
|
||||||
|
const quote = state === "squote" ? "'" : '"';
|
||||||
|
if (ch === "\\") {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === quote) {
|
||||||
|
state = "code";
|
||||||
|
noteCodeChar(quote);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\n") {
|
||||||
|
// Unterminated string — bail back to code rather than eat the file.
|
||||||
|
state = "code";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "template") {
|
||||||
|
if (ch === "\\") {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "`") {
|
||||||
|
stack.pop();
|
||||||
|
state = "code";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "$" && text[i + 1] === "{") {
|
||||||
|
stack.push({ kind: "exprCode", depth: 1 });
|
||||||
|
state = "code";
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "lcomment") {
|
||||||
|
if (ch === "\n") {
|
||||||
|
state = "code";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "bcomment") {
|
||||||
|
if (ch === "*" && text[i + 1] === "/") {
|
||||||
|
state = "code";
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "regex") {
|
||||||
|
if (ch === "\\") {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "[") {
|
||||||
|
state = "regexclass";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/") {
|
||||||
|
i += 1;
|
||||||
|
while (i < n && /[a-z]/i.test(text[i])) i += 1;
|
||||||
|
state = "code";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\n") {
|
||||||
|
// Unterminated regex — bail back to code rather than eat the file.
|
||||||
|
state = "code";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "regexclass") {
|
||||||
|
if (ch === "\\") {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "]") {
|
||||||
|
state = "regex";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable, but never silently drop a character.
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yield regex matches whose full span lies entirely in plain-code region. */
|
||||||
|
function* matchAllCode(text, mask, pattern) {
|
||||||
|
for (const m of text.matchAll(pattern)) {
|
||||||
|
const start = m.index;
|
||||||
|
const end = start + m[0].length;
|
||||||
|
let allCode = true;
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
if (!mask[i]) {
|
||||||
|
allCode = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allCode) yield m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the `)` matching the `(` at `openIndex`, counting only plain-code parens. */
|
||||||
|
function matchParen(text, mask, openIndex, filePath) {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = openIndex; i < text.length; i++) {
|
||||||
|
if (!mask[i]) continue;
|
||||||
|
if (text[i] === "(") depth += 1;
|
||||||
|
else if (text[i] === ")") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`${filePath}: פענוח נכשל — סוגר לא נסגר`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineStarts(text) {
|
||||||
|
const starts = [0];
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === "\n") starts.push(i + 1);
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineOfOffset(lineStarts, idx) {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = lineStarts.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi + 1) >> 1;
|
||||||
|
if (lineStarts[mid] <= idx) lo = mid;
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return lo; // 0-based line number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_WORD_RE = /\bstatus\b/;
|
||||||
|
const JOBS_REGISTER_RE = /jobs\.register\s*\(/g;
|
||||||
|
const ISSUES_UPDATE_RE = /issues\.update\s*\(/g;
|
||||||
|
const SYNC_CASE_STATUS_LITERAL_RE = /^\s*["']sync-case-status["']/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan one file's source text and return a list of violation objects
|
||||||
|
* ({ file, line, message }). Throws if a `(` is never closed (see
|
||||||
|
* matchParen) — the caller must NOT treat that as "no violations".
|
||||||
|
*/
|
||||||
|
function scanFile(filePath, text) {
|
||||||
|
const mask = classify(text);
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const lineStarts = buildLineStarts(text);
|
||||||
|
const rel = filePath;
|
||||||
|
|
||||||
|
const allowedRanges = [];
|
||||||
|
for (const m of matchAllCode(text, mask, JOBS_REGISTER_RE)) {
|
||||||
|
const openIdx = m.index + m[0].length - 1;
|
||||||
|
const after = text.slice(openIdx + 1, openIdx + 60);
|
||||||
|
if (SYNC_CASE_STATUS_LITERAL_RE.test(after)) {
|
||||||
|
const closeIdx = matchParen(text, mask, openIdx, rel);
|
||||||
|
allowedRanges.push([openIdx, closeIdx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations = [];
|
||||||
|
for (const m of matchAllCode(text, mask, ISSUES_UPDATE_RE)) {
|
||||||
|
const openIdx = m.index + m[0].length - 1;
|
||||||
|
const closeIdx = matchParen(text, mask, openIdx, rel);
|
||||||
|
const content = text.slice(openIdx + 1, closeIdx);
|
||||||
|
if (!STATUS_WORD_RE.test(content)) continue; // not a status-write call
|
||||||
|
|
||||||
|
const inAllowedRange = allowedRanges.some(
|
||||||
|
([s, e]) => openIdx >= s && openIdx <= e,
|
||||||
|
);
|
||||||
|
if (inAllowedRange) continue;
|
||||||
|
|
||||||
|
const callStartLine = lineOfOffset(lineStarts, m.index);
|
||||||
|
const callEndLine = lineOfOffset(lineStarts, closeIdx);
|
||||||
|
|
||||||
|
let noqaMatch;
|
||||||
|
for (let ln = callStartLine - 1; ln <= callEndLine && !noqaMatch; ln += 1) {
|
||||||
|
if (ln < 0 || ln >= lines.length) continue;
|
||||||
|
const m2 = NOQA_RE.exec(lines[ln]);
|
||||||
|
if (m2) noqaMatch = m2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportLine = callStartLine + 1; // 1-based for humans
|
||||||
|
if (noqaMatch) {
|
||||||
|
const reason = (noqaMatch[1] ?? "").trim();
|
||||||
|
if (reason.length >= 3) continue; // justified exception — allowed
|
||||||
|
violations.push({
|
||||||
|
file: rel,
|
||||||
|
line: reportLine,
|
||||||
|
message: "noqa: INT9 ללא נימוק — חריגה חייבת להיות מנומקת בכתב (AC3)",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
violations.push({
|
||||||
|
file: rel,
|
||||||
|
line: reportLine,
|
||||||
|
message:
|
||||||
|
`issues.update({status}) מחוץ למסלול sync-case-status (INV-INT9). ` +
|
||||||
|
`ראה ${SPEC_REF}. חריגה מוצדקת → // noqa: INT9 — <נימוק>.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// File collection
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function walk(dir, out) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (SKIP_DIRS.has(entry.name)) continue;
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(full, out);
|
||||||
|
} else if (SOURCE_EXT.has(entry.name.slice(entry.name.lastIndexOf(".")))) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDefault() {
|
||||||
|
const srcDir = join(REPO_ROOT, "src");
|
||||||
|
return walk(srcDir, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFromArgs(args) {
|
||||||
|
const out = [];
|
||||||
|
for (const a of args) {
|
||||||
|
const p = resolve(a);
|
||||||
|
const st = statSync(p);
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
walk(p, out);
|
||||||
|
} else {
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGate(files) {
|
||||||
|
const violations = [];
|
||||||
|
for (const abs of files) {
|
||||||
|
const rel = relative(REPO_ROOT, abs);
|
||||||
|
const text = readFileSync(abs, "utf-8");
|
||||||
|
violations.push(...scanFile(rel, text));
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportGate(violations, scannedCount) {
|
||||||
|
if (violations.length > 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`✗ INV-INT9 gate — issue.status write-ownership violated ` +
|
||||||
|
`(${violations.length} finding(s)):\n\n`,
|
||||||
|
);
|
||||||
|
for (const v of violations) {
|
||||||
|
process.stderr.write(` • ${v.file}:${v.line}: ${v.message}\n`);
|
||||||
|
}
|
||||||
|
process.stderr.write(`\nSee ${SPEC_REF}.\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
process.stdout.write(
|
||||||
|
`✓ INV-INT9 gate: מסלול-כתיבה יחיד ל-issue.status ` +
|
||||||
|
`(${scannedCount} קבצים נסרקו).\n`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Self-test
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SELF_TEST_EXPECTED = {
|
||||||
|
"violating.ts": 1,
|
||||||
|
"allowed-inside-job.ts": 0,
|
||||||
|
"noqa-justified.ts": 0,
|
||||||
|
"noqa-bare.ts": 1,
|
||||||
|
"non-status-update.ts": 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function runSelfTest() {
|
||||||
|
const fixturesDir = join(REPO_ROOT, "scripts", "fixtures", "int9");
|
||||||
|
let pass = 0;
|
||||||
|
let total = 0;
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
for (const [name, expected] of Object.entries(SELF_TEST_EXPECTED)) {
|
||||||
|
total += 1;
|
||||||
|
const abs = join(fixturesDir, name);
|
||||||
|
let violations;
|
||||||
|
try {
|
||||||
|
const text = readFileSync(abs, "utf-8");
|
||||||
|
violations = scanFile(name, text);
|
||||||
|
} catch (err) {
|
||||||
|
failures.push(
|
||||||
|
`${name}: ציפינו ${expected} הפרות, אבל הסריקה זרקה שגיאה: ${String(err)}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (violations.length === expected) {
|
||||||
|
pass += 1;
|
||||||
|
} else {
|
||||||
|
failures.push(
|
||||||
|
`${name}: ציפינו ${expected} הפרות, התקבלו ${violations.length}` +
|
||||||
|
(violations.length
|
||||||
|
? `:\n${violations.map((v) => ` - ${v.file}:${v.line}: ${v.message}`).join("\n")}`
|
||||||
|
: ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
process.stderr.write("✗ INV-INT9 self-test נכשל:\n\n");
|
||||||
|
for (const f of failures) process.stderr.write(` • ${f}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
process.stdout.write(
|
||||||
|
`✓ INV-INT9 self-test: ${pass}/${total} פיקסצ'רים תואמים לציפייה.\n`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Entry point
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function main(argv) {
|
||||||
|
if (argv.includes("--self-test")) {
|
||||||
|
return runSelfTest();
|
||||||
|
}
|
||||||
|
const pathArgs = argv.filter((a) => !a.startsWith("--"));
|
||||||
|
const files =
|
||||||
|
pathArgs.length > 0 ? collectFromArgs(pathArgs) : collectDefault();
|
||||||
|
let violations;
|
||||||
|
try {
|
||||||
|
violations = runGate(files);
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`✗ INV-INT9 gate — ${String(err.message ?? err)}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return reportGate(violations, files.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `exitCode` ולא `process.exit()` — stdout/stderr אל pipe (וזה בדיוק המצב ב-CI)
|
||||||
|
// אסינכרוניים ב-POSIX, ו-`process.exit` היה עלול לקטוע את דוח-ההפרות עצמו.
|
||||||
|
process.exitCode = main(process.argv.slice(2));
|
||||||
274
src/company-scope.test.ts
Normal file
274
src/company-scope.test.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/// <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 { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||||
|
import {
|
||||||
|
_resetApiBaseCacheMemo,
|
||||||
|
CompanyScopeUnavailableError,
|
||||||
|
isCompanyScopeDenied,
|
||||||
|
type ResolveApiBaseDeps,
|
||||||
|
resolveApiBaseFrom,
|
||||||
|
runJobHandler,
|
||||||
|
} from "./company-scope.ts";
|
||||||
|
|
||||||
|
// ── isCompanyScopeDenied ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("isCompanyScopeDenied: true עבור אובייקט עם code של INVOCATION_SCOPE_DENIED", () => {
|
||||||
|
const err = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
|
||||||
|
assert.equal(isCompanyScopeDenied(err), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isCompanyScopeDenied: false עבור שגיאה רגילה", () => {
|
||||||
|
assert.equal(isCompanyScopeDenied(new Error("boom")), false);
|
||||||
|
assert.equal(isCompanyScopeDenied({ code: -32002 }), false);
|
||||||
|
assert.equal(isCompanyScopeDenied("boom"), false);
|
||||||
|
assert.equal(isCompanyScopeDenied(null), false);
|
||||||
|
assert.equal(isCompanyScopeDenied(undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── runJobHandler ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const calls: Array<{ level: string; message: string; meta?: unknown }> = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
logger: {
|
||||||
|
error(message: string, meta?: Record<string, unknown>) {
|
||||||
|
calls.push({ level: "error", message, meta });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("runJobHandler: הצלחה — לא זורק ולא מלוגג שגיאה", async () => {
|
||||||
|
const { calls, logger } = makeLogger();
|
||||||
|
await runJobHandler("test-job", logger, async () => {});
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runJobHandler: כשל בשגיאה רגילה — מלוגג error וזורק מחדש את המקורית", async () => {
|
||||||
|
const { calls, logger } = makeLogger();
|
||||||
|
const original = new Error("plain failure");
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runJobHandler("test-job", logger, async () => {
|
||||||
|
throw original;
|
||||||
|
}),
|
||||||
|
(err: unknown) => err === original,
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].message, "test-job: job failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runJobHandler: כשל בדחיית-scope — זורק CompanyScopeUnavailableError שמזכירה #637", async () => {
|
||||||
|
const { logger } = makeLogger();
|
||||||
|
const scopeErr = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runJobHandler("sync-case-status", logger, async () => {
|
||||||
|
throw scopeErr;
|
||||||
|
}),
|
||||||
|
(err: unknown) => {
|
||||||
|
assert.ok(err instanceof CompanyScopeUnavailableError);
|
||||||
|
assert.match(err.message, /#637/);
|
||||||
|
assert.equal(err.jobKey, "sync-case-status");
|
||||||
|
assert.equal(err.cause, scopeErr);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runJobHandler: כשל בדחיית-scope — ההודעה כוללת גם #637 וגם את טקסט השגיאה המקורית (זה מה שנכתב ל-plugin_job_runs.error)", async () => {
|
||||||
|
const { logger } = makeLogger();
|
||||||
|
// כמו JsonRpcCallError אמיתי מהמארח (protocol.js): מופע Error שה-`message`
|
||||||
|
// שלו נושא את שם-הפעולה שנדחתה.
|
||||||
|
const scopeErr = Object.assign(new Error("issues.list"), {
|
||||||
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runJobHandler("route-pending-comments", logger, async () => {
|
||||||
|
throw scopeErr;
|
||||||
|
}),
|
||||||
|
(err: unknown) => {
|
||||||
|
assert.ok(err instanceof CompanyScopeUnavailableError);
|
||||||
|
assert.match(err.message, /#637/);
|
||||||
|
assert.match(err.message, /issues\.list/);
|
||||||
|
// method לא ידוע ל-runJobHandler — "unknown" לא אמור להופיע בהודעה.
|
||||||
|
assert.doesNotMatch(err.message, /"unknown"/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── resolveApiBaseFrom ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeDeps(
|
||||||
|
overrides: Partial<ResolveApiBaseDeps> & {
|
||||||
|
configByCompany?: Record<string, Record<string, unknown> | undefined>;
|
||||||
|
deniedCompanyIds?: readonly (string | undefined)[];
|
||||||
|
failingCompanyIds?: readonly (string | undefined)[];
|
||||||
|
} = {},
|
||||||
|
): ResolveApiBaseDeps & {
|
||||||
|
cacheWrites: string[];
|
||||||
|
infoLogs: unknown[];
|
||||||
|
warnLogs: unknown[];
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
configByCompany = {},
|
||||||
|
deniedCompanyIds = [],
|
||||||
|
failingCompanyIds = [],
|
||||||
|
...rest
|
||||||
|
} = overrides;
|
||||||
|
const cacheWrites: string[] = [];
|
||||||
|
const infoLogs: unknown[] = [];
|
||||||
|
const warnLogs: unknown[] = [];
|
||||||
|
let cacheValue: unknown = null;
|
||||||
|
|
||||||
|
const deniedKey = (id?: string) => (id === undefined ? "__undefined__" : id);
|
||||||
|
|
||||||
|
const deps: ResolveApiBaseDeps & {
|
||||||
|
cacheWrites: string[];
|
||||||
|
infoLogs: unknown[];
|
||||||
|
warnLogs: unknown[];
|
||||||
|
} = {
|
||||||
|
knownCompanyIds: [],
|
||||||
|
defaultBaseUrl: "http://localhost:8085",
|
||||||
|
async readConfig(companyId?: string) {
|
||||||
|
const key = deniedKey(companyId);
|
||||||
|
if (deniedCompanyIds.some((d) => deniedKey(d) === key)) {
|
||||||
|
throw { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
|
||||||
|
}
|
||||||
|
if (failingCompanyIds.some((d) => deniedKey(d) === key)) {
|
||||||
|
throw new Error(`boom for ${key}`);
|
||||||
|
}
|
||||||
|
return configByCompany[key] ?? null;
|
||||||
|
},
|
||||||
|
async readCache() {
|
||||||
|
return cacheValue;
|
||||||
|
},
|
||||||
|
async writeCache(url: string) {
|
||||||
|
cacheValue = url;
|
||||||
|
cacheWrites.push(url);
|
||||||
|
},
|
||||||
|
logger: {
|
||||||
|
info(message: string, meta?: Record<string, unknown>) {
|
||||||
|
infoLogs.push({ message, meta });
|
||||||
|
},
|
||||||
|
warn(message: string, meta?: Record<string, unknown>) {
|
||||||
|
warnLogs.push({ message, meta });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cacheWrites,
|
||||||
|
infoLogs,
|
||||||
|
warnLogs,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
// Allow overriding cache seed via readCache in `rest`.
|
||||||
|
return deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: companyId נתון ו-readConfig מחזיר legalApiBaseUrl — מחזיר וכותב cache", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
configByCompany: { "company-a": { legalApiBaseUrl: "https://a.example" } },
|
||||||
|
});
|
||||||
|
const result = await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
|
||||||
|
assert.equal(result, "https://a.example");
|
||||||
|
assert.deepEqual(deps.cacheWrites, ["https://a.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveApiBaseFrom: אין companyId — readConfig(undefined) מוסק ע"י המארח ומחזיר ערך', async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
configByCompany: {
|
||||||
|
__undefined__: { legalApiBaseUrl: "https://derived.example" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await resolveApiBaseFrom(deps);
|
||||||
|
assert.equal(result, "https://derived.example");
|
||||||
|
assert.deepEqual(deps.cacheWrites, ["https://derived.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope, יש cache תקף — מחזיר cache ומלוגג info (לא warn)", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
knownCompanyIds: ["c1", "c2"],
|
||||||
|
deniedCompanyIds: [undefined, "c1", "c2"],
|
||||||
|
});
|
||||||
|
// לדמות מטמון קיים: לעקוף readCache
|
||||||
|
deps.readCache = async () => "https://cached.example";
|
||||||
|
const result = await resolveApiBaseFrom(deps);
|
||||||
|
assert.equal(result, "https://cached.example");
|
||||||
|
assert.equal(deps.warnLogs.length, 0);
|
||||||
|
assert.equal(deps.infoLogs.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope ואין cache — זורק CompanyScopeUnavailableError (לא default)", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
knownCompanyIds: ["c1"],
|
||||||
|
deniedCompanyIds: [undefined, "c1"],
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => resolveApiBaseFrom(deps),
|
||||||
|
(err: unknown) => {
|
||||||
|
assert.ok(err instanceof CompanyScopeUnavailableError);
|
||||||
|
assert.equal(err.method, "config.get");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: readConfig הצליח בכל מקום אך בלי legalApiBaseUrl — default + warn", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
knownCompanyIds: ["c1"],
|
||||||
|
configByCompany: { __undefined__: {}, c1: {} },
|
||||||
|
});
|
||||||
|
const result = await resolveApiBaseFrom(deps);
|
||||||
|
assert.equal(result, deps.defaultBaseUrl);
|
||||||
|
assert.equal(deps.warnLogs.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: readConfig נכשל בשגיאה שאינה דחיית-scope — warn וממשיך לנסות הבא", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
knownCompanyIds: ["c1"],
|
||||||
|
failingCompanyIds: [undefined],
|
||||||
|
configByCompany: { c1: { legalApiBaseUrl: "https://c1.example" } },
|
||||||
|
});
|
||||||
|
const result = await resolveApiBaseFrom(deps);
|
||||||
|
assert.equal(result, "https://c1.example");
|
||||||
|
// warn אחד על הכשל ב-readConfig(undefined) — לא בליעה שקטה
|
||||||
|
assert.ok(deps.warnLogs.length >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── writeCache dedup (מתוזמן פר-בקשה, לא לכתוב cache שלא השתנה) ──────────
|
||||||
|
|
||||||
|
test("resolveApiBaseFrom: כותב ל-cache רק כשהערך משתנה בפועל", async () => {
|
||||||
|
_resetApiBaseCacheMemo();
|
||||||
|
const deps = makeDeps({
|
||||||
|
configByCompany: {
|
||||||
|
"company-a": { legalApiBaseUrl: "https://dedup.example" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// שתי פתירות רצופות של אותו URL — writeCache נקרא פעם אחת בלבד.
|
||||||
|
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
|
||||||
|
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
|
||||||
|
assert.deepEqual(deps.cacheWrites, ["https://dedup.example"]);
|
||||||
|
|
||||||
|
// פתירה של URL שונה אחריהן — writeCache נקרא שוב.
|
||||||
|
const deps2 = makeDeps({
|
||||||
|
configByCompany: {
|
||||||
|
"company-b": { legalApiBaseUrl: "https://dedup-2.example" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await resolveApiBaseFrom({ ...deps2, companyId: "company-b" });
|
||||||
|
assert.deepEqual(deps2.cacheWrites, ["https://dedup-2.example"]);
|
||||||
|
});
|
||||||
260
src/company-scope.ts
Normal file
260
src/company-scope.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* הפשטת company-scope לג'ובים מתוזמנים ול-webhook (legal-ai issue #637).
|
||||||
|
*
|
||||||
|
* המקור לבאג: מאז @paperclipai/server 2026.722.0 אין scope של חברה
|
||||||
|
* ל-`runJob`/`handleWebhook` — המארח מדפיס scope רק מ-`params.companyId` /
|
||||||
|
* `performAction.actorContext` / `executeTool.runContext` /
|
||||||
|
* `onEvent.event.companyId` (plugin-worker-manager.js:191-210), ו-
|
||||||
|
* `plugin-job-scheduler.js:184` שולח `runJob` בלי `companyId` כלל. לכן כל
|
||||||
|
* קריאה מתוך ג'וב ל-`ctx.config.get`/`ctx.issues.*`/`ctx.agents.invoke`/
|
||||||
|
* `ctx.events.emit` נדחית ע"י המארח עם `InvocationScopeDeniedError`
|
||||||
|
* (host-client-factory.js:253-293) — קוד `-32005`.
|
||||||
|
*
|
||||||
|
* המודול הזה טהור בכוונה — כמו `sync-target.ts` — כדי שאפשר יהיה לייבא
|
||||||
|
* אותו בטסט בלי להריץ את `runWorker(plugin, import.meta.url)` שקורה בזמן
|
||||||
|
* import של worker.ts. הייבוא היחיד מה-SDK הוא ייבוא-ערך של
|
||||||
|
* `PLUGIN_RPC_ERROR_CODES` — קבוע פרוטוקול נטול side-effects, לא ה-runtime
|
||||||
|
* של הפלאגין עצמו.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||||
|
|
||||||
|
/** מפתח ה-`ctx.state` (scope `instance`) שבו נשמר ה-base URL האחרון שהצלחנו לפתור. */
|
||||||
|
export const LEGAL_API_BASE_CACHE_KEY = "legal-api-base-url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* זיכרון-תהליך (לא `ctx.state`) של ה-base URL האחרון שבאמת נכתב ל-cache.
|
||||||
|
* `LegalApi.request` (`legal-api.ts:19-32`) קורא ל-`resolveBaseUrl()` —
|
||||||
|
* וכך ל-`resolveApiBaseFrom` — בכל בקשת-API בודדת, אבל הערך כמעט לעולם
|
||||||
|
* לא משתנה; בלי המנגנון הזה כל קריאת-כלי/`usePluginData` הייתה מוסיפה
|
||||||
|
* `ctx.state.set` (=upsert ל-Postgres) מיותר.
|
||||||
|
*/
|
||||||
|
let lastWrittenApiBase: string | undefined;
|
||||||
|
|
||||||
|
/** מאפס את זיכרון-המטמון ברמת-המודול. **לשימוש טסטים בלבד.** */
|
||||||
|
export function _resetApiBaseCacheMemo(): void {
|
||||||
|
lastWrittenApiBase = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* האם השגיאה היא דחיית-scope של המארח (קוד `-32005`,
|
||||||
|
* `PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED`). זיהוי **מבני** לפי
|
||||||
|
* שדה `code` — לא התאמת-מחרוזת על `err.message`, שיכולה להישבר בשקט אם
|
||||||
|
* המארח ינסח את ההודעה מחדש.
|
||||||
|
*/
|
||||||
|
export function isCompanyScopeDenied(err: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof err === "object" &&
|
||||||
|
err !== null &&
|
||||||
|
"code" in err &&
|
||||||
|
(err as { code?: unknown }).code ===
|
||||||
|
PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* מחלץ טקסט קריא מ-`cause` שגוי-scope. המארח שולח `JsonRpcCallError`
|
||||||
|
* (מופע `Error` עם `.message` תיאורי מהצד השני), אבל בטסטים/עתידית ייתכן
|
||||||
|
* גם אובייקט-שגיאה פשוט בלי prototype chain — לכן בדיקה מבנית ולא רק
|
||||||
|
* `instanceof Error`.
|
||||||
|
*/
|
||||||
|
function describeCause(cause: unknown): string {
|
||||||
|
if (cause instanceof Error) return cause.message;
|
||||||
|
if (
|
||||||
|
typeof cause === "object" &&
|
||||||
|
cause !== null &&
|
||||||
|
"message" in cause &&
|
||||||
|
typeof (cause as { message?: unknown }).message === "string"
|
||||||
|
) {
|
||||||
|
return (cause as { message: string }).message;
|
||||||
|
}
|
||||||
|
return String(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* נזרקת כש-handler של ג'וב/webhook נתקל בדחיית-scope שאין לו דרך לעקוף
|
||||||
|
* אותה. ההודעה עצמה היא האבחנה — היא מה שיישמר ב-`plugin_job_runs.error`
|
||||||
|
* (או `plugin_webhook_deliveries.error`), ו-`plugin_logs` ריק לחלוטין
|
||||||
|
* (נמדד), כך שזו למעשה עדות-הכשל היחידה שתישאר. **חובה** להעביר `cause`
|
||||||
|
* כשהוא ידוע — המתזמר של Paperclip (`plugin-job-scheduler.js:199`) רושם
|
||||||
|
* ל-DB רק את `err.message`, ואינו מטייל ב-`Error.cause`, כך שכל מידע
|
||||||
|
* שלא נכנס למחרוזת-ההודעה עצמה אובד.
|
||||||
|
*/
|
||||||
|
export class CompanyScopeUnavailableError extends Error {
|
||||||
|
readonly jobKey: string;
|
||||||
|
readonly method: string;
|
||||||
|
|
||||||
|
constructor(jobKey: string, method: string, options?: { cause?: unknown }) {
|
||||||
|
// method="unknown" קורה כש-runJobHandler תופס דחיית-scope בלי לדעת
|
||||||
|
// איזו קריאה נדחתה בפועל (ה-handler לא ציין method מפורש). לא לכתוב
|
||||||
|
// את המילה "unknown" להודעה — במקום זה מסתמכים על ה-cause בלבד.
|
||||||
|
const methodClause =
|
||||||
|
method === "unknown" ? "" : ` הפעולה שנדחתה: "${method}".`;
|
||||||
|
const sourceClause =
|
||||||
|
options?.cause !== undefined
|
||||||
|
? ` מקור: ${describeCause(options.cause)}.`
|
||||||
|
: "";
|
||||||
|
super(
|
||||||
|
`${jobKey}: נדרש הקשר-חברה (company scope) לפעולה זו, אבל ג'וב ` +
|
||||||
|
"מתוזמן/webhook אינו מקבל כזה מ-Paperclip (מאז server " +
|
||||||
|
`2026.722.0).${methodClause}${sourceClause} ראה legal-ai issue #637.`,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
this.name = "CompanyScopeUnavailableError";
|
||||||
|
this.jobKey = jobKey;
|
||||||
|
this.method = method;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* מריץ handler של ג'וב, ובכשל — מלוגג **וזורק מחדש**. המתזמר של Paperclip
|
||||||
|
* (`plugin-job-scheduler.js:194-215`) כבר רושם `status:"failed"` כש-RPC
|
||||||
|
* נדחה; מה שהיה שבור זה ה-handlers שלנו, שבלעו את השגיאה ב-
|
||||||
|
* `catch (err) { ctx.logger.error(...) }` בלי `throw` — כך ה-RPC נפתר
|
||||||
|
* בהצלחה וה-run נרשם `succeeded` למרות שלא עשה כלום (13,928 ריצות כוזבות,
|
||||||
|
* נמדד). ה-README של ה-SDK: "Re-throw from the handler to mark the run as
|
||||||
|
* failed".
|
||||||
|
*
|
||||||
|
* דחיית-scope הופכת ל-`CompanyScopeUnavailableError` מאבחנת; כל שגיאה
|
||||||
|
* אחרת נזרקת כמות-שהיא.
|
||||||
|
*/
|
||||||
|
export async function runJobHandler(
|
||||||
|
jobKey: string,
|
||||||
|
logger: { error(message: string, meta?: Record<string, unknown>): void },
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`${jobKey}: job failed`, { error: String(err) });
|
||||||
|
if (isCompanyScopeDenied(err)) {
|
||||||
|
throw new CompanyScopeUnavailableError(jobKey, "unknown", {
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** תלויות מוזרקות לפתירת `legalApiBaseUrl` — כדי שהלוגיקה תהיה בת-טסט בלי `ctx` אמיתי. */
|
||||||
|
export interface ResolveApiBaseDeps {
|
||||||
|
/** עוטף `ctx.config.get(companyId)`. **לא** בולע — זורק כשהמארח דוחה scope. */
|
||||||
|
readConfig(companyId?: string): Promise<Record<string, unknown> | null>;
|
||||||
|
/** עוטף `ctx.state.get` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
|
||||||
|
readCache(): Promise<unknown>;
|
||||||
|
/** עוטף `ctx.state.set` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
|
||||||
|
writeCache(url: string): Promise<void>;
|
||||||
|
/** מזהי כל החברות המוכרות לפלאגין (`Object.keys(CEO_AGENT_IDS)`). */
|
||||||
|
knownCompanyIds: readonly string[];
|
||||||
|
/** ברירת-המחדל כש-legal-ai לא הוגדר בכלל (אינסטנס לא-מוגדר, לא scope שנדחה). */
|
||||||
|
defaultBaseUrl: string;
|
||||||
|
logger: {
|
||||||
|
info(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
warn(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
};
|
||||||
|
/** ידוע רק כשהמארח מספק אחד (handler בהקשר-חברה) — אופציונלי. */
|
||||||
|
companyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* פותר `legalApiBaseUrl` מ-config בהיקף-חברה, עם נפילה-חזרה למטמון
|
||||||
|
* (`ctx.state`, scope `instance`) כשאין כלל הקשר-חברה זמין (ג'וב/webhook).
|
||||||
|
*
|
||||||
|
* ⚠️ כשכל ניסיונות ה-`readConfig` נדחים ב-scope **ואין** מטמון — זורקים
|
||||||
|
* `CompanyScopeUnavailableError`. **אסור** להחזיר `defaultBaseUrl` במקרה
|
||||||
|
* הזה: זה בדיוק מה שיצר את `TypeError: fetch failed` המטעה — `worker.ts`
|
||||||
|
* הישן בלע את דחיית-ה-scope כ"אין קונפיג" ונפל ל-
|
||||||
|
* `http://localhost:8085` שאין בו מאזין.
|
||||||
|
*/
|
||||||
|
export async function resolveApiBaseFrom(
|
||||||
|
deps: ResolveApiBaseDeps,
|
||||||
|
): Promise<string> {
|
||||||
|
const {
|
||||||
|
readConfig,
|
||||||
|
readCache,
|
||||||
|
writeCache,
|
||||||
|
knownCompanyIds,
|
||||||
|
defaultBaseUrl,
|
||||||
|
logger,
|
||||||
|
companyId,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
let sawScopeDenied = false;
|
||||||
|
|
||||||
|
// כותב ל-cache רק כשהערך השתנה בפועל — ראה `lastWrittenApiBase` למעלה.
|
||||||
|
const writeCacheIfChanged = async (url: string): Promise<void> => {
|
||||||
|
if (url === lastWrittenApiBase) return;
|
||||||
|
await writeCache(url);
|
||||||
|
lastWrittenApiBase = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryRead = async (id?: string): Promise<string | null | undefined> => {
|
||||||
|
let cfg: Record<string, unknown> | null;
|
||||||
|
try {
|
||||||
|
cfg = await readConfig(id);
|
||||||
|
} catch (err) {
|
||||||
|
if (isCompanyScopeDenied(err)) {
|
||||||
|
sawScopeDenied = true;
|
||||||
|
return undefined; // נדחה — נסה את המקור הבא
|
||||||
|
}
|
||||||
|
// שגיאה אחרת: אין בליעה שקטה — מדווחים וממשיכים לנסות את הבא.
|
||||||
|
logger.warn("resolveApiBase: config.get failed", {
|
||||||
|
companyId: id ?? null,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const url = cfg?.legalApiBaseUrl;
|
||||||
|
return typeof url === "string" && url.trim() ? url.trim() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
const scoped = await tryRead(companyId);
|
||||||
|
if (scoped) {
|
||||||
|
await writeCacheIfChanged(scoped);
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// אין companyId ישיר (או שהקריאה מולו נדחתה) — אבל המארח מסיק בעצמו
|
||||||
|
// companyId כשהקריאה מתרחשת בתוך הקשר host-scoped (למשל tool handler).
|
||||||
|
// לנסות לפני נפילה-לניחוש. נשאר בהתנהגות המקורית: מנוסה תמיד, לא רק
|
||||||
|
// כשלא סופק companyId.
|
||||||
|
const derived = await tryRead(undefined);
|
||||||
|
if (derived) {
|
||||||
|
await writeCacheIfChanged(derived);
|
||||||
|
return derived;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ג'וב מתוזמן/webhook ללא הקשר-חברה כלל: לנסות כל חברה מוכרת. בפועל
|
||||||
|
// כולן מצביעות על אותו מופע legal-ai, כך שהפגיעה הראשונה מנצחת.
|
||||||
|
for (const knownCompanyId of knownCompanyIds) {
|
||||||
|
const url = await tryRead(knownCompanyId);
|
||||||
|
if (url) {
|
||||||
|
await writeCacheIfChanged(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawScopeDenied) {
|
||||||
|
// כל הניסיונות נדחו ב-scope (לא "לא-מוגדר") — לנסות את המטמון לפני
|
||||||
|
// שנכשל. מצב-צפוי בג'וב, ולכן `info` ולא `warn`.
|
||||||
|
const cached = await readCache();
|
||||||
|
if (typeof cached === "string" && cached.trim()) {
|
||||||
|
logger.info(
|
||||||
|
"resolveApiBase: config.get denied (no company scope) — using cached legalApiBaseUrl",
|
||||||
|
{ cachedBaseUrl: cached },
|
||||||
|
);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
throw new CompanyScopeUnavailableError("resolveApiBase", "config.get");
|
||||||
|
}
|
||||||
|
|
||||||
|
// כל הקריאות הצליחו (לא נדחו) אך אף אחת לא החזירה ערך — legal-ai פשוט
|
||||||
|
// לא הוגדר עדיין. זה מצב שונה מהותית מדחיית-scope: מותר ליפול לברירת-מחדל.
|
||||||
|
logger.warn("legalApiBaseUrl unresolved — using default", {
|
||||||
|
companyId: companyId ?? null,
|
||||||
|
fallback: defaultBaseUrl,
|
||||||
|
});
|
||||||
|
return defaultBaseUrl;
|
||||||
|
}
|
||||||
@@ -2,11 +2,23 @@
|
|||||||
* HTTP client for Ezer Mishpati legal-ai REST API.
|
* HTTP client for Ezer Mishpati legal-ai REST API.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the client gets its base URL. A plain string still works; a resolver is
|
||||||
|
* used when the URL comes from company-scoped plugin config, which cannot be
|
||||||
|
* read at worker startup (see worker.ts — `ctx.config.get` needs a company).
|
||||||
|
*/
|
||||||
|
export type BaseUrlSource = string | (() => string | Promise<string>);
|
||||||
|
|
||||||
export class LegalApi {
|
export class LegalApi {
|
||||||
constructor(private baseUrl: string) {}
|
constructor(private baseUrl: BaseUrlSource) {}
|
||||||
|
|
||||||
|
private async resolveBaseUrl(): Promise<string> {
|
||||||
|
return typeof this.baseUrl === "string" ? this.baseUrl : this.baseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
const base = await this.resolveBaseUrl();
|
||||||
|
const res = await fetch(`${base}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -24,6 +36,10 @@ export class LegalApi {
|
|||||||
return this.request("/api/cases");
|
return this.request("/api/cases");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStatusModel(): Promise<StatusModel> {
|
||||||
|
return this.request("/api/status-model");
|
||||||
|
}
|
||||||
|
|
||||||
async getCase(caseNumber: string): Promise<CaseDetails> {
|
async getCase(caseNumber: string): Promise<CaseDetails> {
|
||||||
return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`);
|
return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`);
|
||||||
}
|
}
|
||||||
@@ -148,6 +164,20 @@ export class LegalApi {
|
|||||||
// Return static style guide reference
|
// Return static style guide reference
|
||||||
return "ראה skill-legal-decision/SKILL.md — מדריך סגנון מלא של דפנה תמיר";
|
return "ראה skill-legal-decision/SKILL.md — מדריך סגנון מלא של דפנה תמיר";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent conclusions of prior heartbeat runs across a case's issues (#220,
|
||||||
|
* "seance"). Lets a resuming agent read what earlier sessions concluded
|
||||||
|
* instead of re-deriving context from scratch.
|
||||||
|
*/
|
||||||
|
async getPredecessorForCase(
|
||||||
|
caseNumber: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<PredecessorResponse> {
|
||||||
|
return this.request(
|
||||||
|
`/api/operations/cases/${encodeURIComponent(caseNumber)}/predecessor?limit=${limit}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -158,6 +188,21 @@ export interface CaseSummary {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatusModelEntry {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
phase: string;
|
||||||
|
selectable: boolean;
|
||||||
|
terminal: boolean;
|
||||||
|
on_enter: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusModel {
|
||||||
|
statuses: StatusModelEntry[];
|
||||||
|
phases: Array<{ key: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CaseDetails {
|
export interface CaseDetails {
|
||||||
id: string;
|
id: string;
|
||||||
case_number: string;
|
case_number: string;
|
||||||
@@ -260,3 +305,21 @@ export interface QAResponse {
|
|||||||
}>;
|
}>;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PredecessorRun {
|
||||||
|
run_id: string;
|
||||||
|
status: string;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
session_id: string | null;
|
||||||
|
agent_name: string | null;
|
||||||
|
identifier: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredecessorResponse {
|
||||||
|
ok: boolean;
|
||||||
|
case_number: string;
|
||||||
|
runs: PredecessorRun[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export default {
|
|||||||
"plugin.state.write",
|
"plugin.state.write",
|
||||||
"jobs.schedule",
|
"jobs.schedule",
|
||||||
"activity.log.write",
|
"activity.log.write",
|
||||||
|
// נדרש ל-`ctx.metrics.write` — סיכום-ריצת הג'וב `sync-case-status`
|
||||||
|
// (legal-ai issue #617). זו הדרך היחידה לכתוב ל-`plugin_logs`
|
||||||
|
// (level='metric'), הטבלה שפאנל "Recent Logs" בדף-הפלאגין מרנדר.
|
||||||
|
"metrics.write",
|
||||||
"companies.read",
|
"companies.read",
|
||||||
"projects.read",
|
"projects.read",
|
||||||
"webhooks.receive",
|
"webhooks.receive",
|
||||||
@@ -156,6 +160,23 @@ export default {
|
|||||||
description: "Get overall system processing status",
|
description: "Get overall system processing status",
|
||||||
parametersSchema: { type: "object" as const, properties: {} },
|
parametersSchema: { type: "object" as const, properties: {} },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
toolKey: "legal_predecessor_context",
|
||||||
|
name: "legal_predecessor_context",
|
||||||
|
displayName: "הקשר מריצות קודמות",
|
||||||
|
description:
|
||||||
|
"Recent conclusions from prior heartbeat runs on this case (the 'summary' each run left). Call this when RESUMING work on a case to see what earlier sessions already did/decided — instead of re-deriving context from scratch.",
|
||||||
|
parametersSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
case_number: {
|
||||||
|
type: "string" as const,
|
||||||
|
description: "Case number (e.g. 1043-02-26)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["case_number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
jobs: [
|
jobs: [
|
||||||
{
|
{
|
||||||
|
|||||||
292
src/sync-run-summary.test.ts
Normal file
292
src/sync-run-summary.test.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/// <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 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<typeof reason, "ok">);
|
||||||
|
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} `));
|
||||||
|
});
|
||||||
145
src/sync-run-summary.ts
Normal file
145
src/sync-run-summary.ts
Normal file
@@ -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<SyncTargetReason, "ok">;
|
||||||
|
|
||||||
|
/** בדיקת-הצבה סטטית בלבד — לא נקרא בזמן ריצה, ואינו זקוק לערך. */
|
||||||
|
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<string, string> {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
389
src/sync-target.test.ts
Normal file
389
src/sync-target.test.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
/// <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 type { StatusModelEntry } from "./legal-api.ts";
|
||||||
|
import {
|
||||||
|
isWritableStatus,
|
||||||
|
labelFor,
|
||||||
|
pickSyncTargetIssue,
|
||||||
|
resolveIssueStatus,
|
||||||
|
resolveStatusLabel,
|
||||||
|
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, companyId: "c1" },
|
||||||
|
{ id: "CMPA-84", status: "cancelled", parentId: null, companyId: "c1" },
|
||||||
|
{ id: "CMPA-86", status: "cancelled", parentId: null, companyId: "c1" },
|
||||||
|
{ id: "CMPA-140", status: "done", parentId: "CMPA-139", companyId: "c1" }, // זה שנדרס בפועל
|
||||||
|
{
|
||||||
|
id: "CMPA-141",
|
||||||
|
status: "cancelled",
|
||||||
|
parentId: "CMPA-139",
|
||||||
|
companyId: "c1",
|
||||||
|
},
|
||||||
|
{ id: "CMPA-85", status: "done", parentId: "CMPA-84", companyId: "c1" },
|
||||||
|
{ id: "CMPA-146", status: "done", parentId: "CMPA-84", companyId: "c1" },
|
||||||
|
{ id: "CMPA-147", status: "done", parentId: "CMPA-84", companyId: "c1" },
|
||||||
|
{ id: "CMPA-87", status: "done", parentId: "CMPA-86", companyId: "c1" },
|
||||||
|
{ id: "CMPA-88", status: "done", parentId: "CMPA-86", companyId: "c1" },
|
||||||
|
{
|
||||||
|
id: "CMPA-89",
|
||||||
|
status: "cancelled",
|
||||||
|
parentId: "CMPA-86",
|
||||||
|
companyId: "c1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// סטטוס-התיק היה 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", companyId: "c1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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, companyId: "c1" },
|
||||||
|
{ id: "sub-1", status: "in_progress", parentId: "root-1", companyId: "c1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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, companyId: "c1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target?.id, "root-1");
|
||||||
|
assert.equal(result.reason, "ok");
|
||||||
|
assert.equal(result.writableRoots, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// הכרעת #626 (2026-08-26): in_review כבר לא ב-NON_WRITABLE_STATUSES —
|
||||||
|
// שורש יחיד ב-in_review עכשיו נבחר כיעד-כתיבה, במקום להיחסם.
|
||||||
|
test("שורש יחיד ב-in_review: נבחר (הכרעת #626 — 2026-08-26)", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "in_review", parentId: null, companyId: "c1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target?.id, "root-1");
|
||||||
|
assert.equal(result.reason, "ok");
|
||||||
|
assert.equal(result.writableRoots, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("שורש יחיד ב-blocked: לא נכתב", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "blocked", parentId: null, companyId: "c1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target, null);
|
||||||
|
assert.equal(result.reason, "no_writable_root");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isWritableStatus: הגבול המלא בין בר-כתיבה ללא-בר-כתיבה", () => {
|
||||||
|
assert.equal(isWritableStatus("in_review"), true);
|
||||||
|
assert.equal(isWritableStatus("blocked"), false);
|
||||||
|
assert.equal(isWritableStatus("done"), false);
|
||||||
|
assert.equal(isWritableStatus("cancelled"), false);
|
||||||
|
assert.equal(isWritableStatus("todo"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "todo", parentId: null, companyId: "c1" },
|
||||||
|
{ id: "root-2", status: "in_progress", parentId: null, companyId: "c1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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, companyId: "c1" },
|
||||||
|
{
|
||||||
|
id: "child-1",
|
||||||
|
status: "in_progress",
|
||||||
|
parentId: "root-1",
|
||||||
|
companyId: "c1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target?.id, "root-1");
|
||||||
|
assert.equal(result.reason, "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* פיקסצ'ר ריאלי של `/api/status-model` (case_status_model.py) — הסדר קובע
|
||||||
|
* מי הוא "todo" (האינדקס הראשון), ו-`terminal: true` קובע מי "done".
|
||||||
|
*/
|
||||||
|
const STATUS_MODEL: StatusModelEntry[] = [
|
||||||
|
{
|
||||||
|
key: "new",
|
||||||
|
label: "חדש",
|
||||||
|
description: "תיק נפתח, טרם הועלו מסמכים",
|
||||||
|
phase: "intake",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "processing",
|
||||||
|
label: "בעיבוד",
|
||||||
|
description: "מסמכים בעיבוד",
|
||||||
|
phase: "intake",
|
||||||
|
selectable: false,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "documents_ready",
|
||||||
|
label: "מסמכים מוכנים",
|
||||||
|
description: "העיבוד הושלם",
|
||||||
|
phase: "intake",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "analyst_verified",
|
||||||
|
label: 'אומת ע"י אנליסט',
|
||||||
|
description: "אנליסט אימת את התוכן",
|
||||||
|
phase: "analysis",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "research_complete",
|
||||||
|
label: "מחקר הושלם",
|
||||||
|
description: "מחקר תקדימים הושלם",
|
||||||
|
phase: "analysis",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "outcome_set",
|
||||||
|
label: "תוצאה נקבעה",
|
||||||
|
description: "תוצאה הוזנה",
|
||||||
|
phase: "analysis",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "direction_approved",
|
||||||
|
label: "כיוון אושר",
|
||||||
|
description: "כיוון הכתיבה אושר",
|
||||||
|
phase: "drafting",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "qa_review",
|
||||||
|
label: "בדיקת איכות",
|
||||||
|
description: "בבדיקת איכות",
|
||||||
|
phase: "drafting",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "drafted",
|
||||||
|
label: "טיוטה מוכנה",
|
||||||
|
description: "טיוטה נכתבה",
|
||||||
|
phase: "drafting",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "exported",
|
||||||
|
label: "יוצא ל-DOCX",
|
||||||
|
description: "יוצא כקובץ DOCX",
|
||||||
|
phase: "review",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reviewed",
|
||||||
|
label: 'נבדק ע"י דפנה',
|
||||||
|
description: "דפנה הגיהה",
|
||||||
|
phase: "review",
|
||||||
|
selectable: true,
|
||||||
|
terminal: false,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "final",
|
||||||
|
label: "סופי",
|
||||||
|
description: "גרסה סופית",
|
||||||
|
phase: "final",
|
||||||
|
selectable: true,
|
||||||
|
terminal: true,
|
||||||
|
on_enter: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("resolveIssueStatus: הסטטוס הראשון ברשימה (new) → todo", () => {
|
||||||
|
assert.equal(resolveIssueStatus(STATUS_MODEL, "new"), "todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveIssueStatus: סטטוס terminal (final) → done", () => {
|
||||||
|
assert.equal(resolveIssueStatus(STATUS_MODEL, "final"), "done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveIssueStatus: analyst_verified (חסר במפה הישנה) → in_progress", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveIssueStatus(STATUS_MODEL, "analyst_verified"),
|
||||||
|
"in_progress",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveIssueStatus: research_complete → in_progress", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveIssueStatus(STATUS_MODEL, "research_complete"),
|
||||||
|
"in_progress",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveIssueStatus: סטטוס לא-מוכר → null (לא בליעה שקטה)", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveIssueStatus(STATUS_MODEL, "totally_unknown_status"),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("labelFor: מחזיר את התווית מהפיקסצ'ר, ו-null לסטטוס לא-מוכר", () => {
|
||||||
|
assert.equal(labelFor(STATUS_MODEL, "new"), "חדש");
|
||||||
|
assert.equal(labelFor(STATUS_MODEL, "totally_unknown_status"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("רגרסיה: companyId של המועמד-הנבחר שורד את pickSyncTargetIssue (לא companyId חיצוני קבוע)", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{
|
||||||
|
id: "issue-a",
|
||||||
|
status: "cancelled",
|
||||||
|
parentId: null,
|
||||||
|
companyId: "company-a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue-b",
|
||||||
|
status: "todo",
|
||||||
|
parentId: null,
|
||||||
|
companyId: "company-b",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickSyncTargetIssue(candidates);
|
||||||
|
|
||||||
|
assert.equal(result.target?.id, "issue-b");
|
||||||
|
assert.equal(result.target?.companyId, "company-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveStatusLabel: סטטוס מוכר → תווית מה-SSOT, בלי fallback", () => {
|
||||||
|
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "new"), {
|
||||||
|
label: "חדש",
|
||||||
|
fallback: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveStatusLabel: סטטוס שאינו במודל → warning-worthy fallback + ערך גולמי, בלי זריקה", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"),
|
||||||
|
);
|
||||||
|
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"), {
|
||||||
|
label: "totally_unknown_status",
|
||||||
|
fallback: "unknown_status",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveStatusLabel: מודל לא-זמין (null) → model_unavailable, נבדל מסטטוס-לא-מוכר", () => {
|
||||||
|
assert.deepEqual(resolveStatusLabel(null, "processing"), {
|
||||||
|
label: "processing",
|
||||||
|
fallback: "model_unavailable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("רגרסיה #616: כל הסטטוסים שהיו במפה הקשיחה ועודם חיים מקבלים תווית מה-SSOT", () => {
|
||||||
|
const survivingKeys = [
|
||||||
|
"new",
|
||||||
|
"processing",
|
||||||
|
"documents_ready",
|
||||||
|
"outcome_set",
|
||||||
|
"direction_approved",
|
||||||
|
"qa_review",
|
||||||
|
"drafted",
|
||||||
|
"exported",
|
||||||
|
"reviewed",
|
||||||
|
"final",
|
||||||
|
].filter((key) => STATUS_MODEL.some((s) => s.key === key));
|
||||||
|
|
||||||
|
for (const key of survivingKeys) {
|
||||||
|
const result = resolveStatusLabel(STATUS_MODEL, key);
|
||||||
|
assert.equal(result.fallback, null, `${key}: expected no fallback`);
|
||||||
|
assert.equal(
|
||||||
|
typeof result.label,
|
||||||
|
"string",
|
||||||
|
`${key}: expected string label`,
|
||||||
|
);
|
||||||
|
assert.notEqual(result.label, "", `${key}: expected non-empty label`);
|
||||||
|
}
|
||||||
|
});
|
||||||
150
src/sync-target.ts
Normal file
150
src/sync-target.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* בחירת יעד-הסנכרון של הג'וב `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 };
|
||||||
|
}
|
||||||
593
src/worker.ts
593
src/worker.ts
@@ -3,11 +3,39 @@ import type {
|
|||||||
PluginWebhookInput,
|
PluginWebhookInput,
|
||||||
} from "@paperclipai/plugin-sdk";
|
} from "@paperclipai/plugin-sdk";
|
||||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||||
|
import {
|
||||||
|
CompanyScopeUnavailableError,
|
||||||
|
isCompanyScopeDenied,
|
||||||
|
LEGAL_API_BASE_CACHE_KEY,
|
||||||
|
resolveApiBaseFrom,
|
||||||
|
runJobHandler,
|
||||||
|
} from "./company-scope.js";
|
||||||
|
import type { StatusModelEntry } from "./legal-api.js";
|
||||||
import { LegalApi } from "./legal-api.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,
|
||||||
|
resolveIssueStatus,
|
||||||
|
resolveStatusLabel,
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Hoisted for the same reason as pluginCtx: onWebhook is a sibling of setup()
|
||||||
|
// and has no closure over the LegalApi instance created there. Same instance,
|
||||||
|
// not a second one — see legal-ai issue #616.
|
||||||
|
let legalApi: LegalApi | null = null;
|
||||||
|
|
||||||
// Per-company CEO agent IDs (shared between setup and onWebhook).
|
// Per-company CEO agent IDs (shared between setup and onWebhook).
|
||||||
const CEO_AGENT_IDS: Record<string, string> = {
|
const CEO_AGENT_IDS: Record<string, string> = {
|
||||||
"42a7acd0-30c5-4cbd-ac97-7424f65df294":
|
"42a7acd0-30c5-4cbd-ac97-7424f65df294":
|
||||||
@@ -16,16 +44,75 @@ const CEO_AGENT_IDS: Record<string, string> = {
|
|||||||
"cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה)
|
"cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LEGAL_API_BASE = "http://localhost:8085";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve `legalApiBaseUrl` from company-scoped plugin config.
|
||||||
|
*
|
||||||
|
* ⚠️ Plugin config became company-scoped in @paperclipai/server 2026.722.0
|
||||||
|
* (migration `0164_plugin_config_company_scope`). A bare `ctx.config.get()` now
|
||||||
|
* throws `company context is required` unless the host can derive the company
|
||||||
|
* from the current invocation — which it CANNOT during `setup()`, because
|
||||||
|
* worker initialisation belongs to no company. Reading config at startup is what
|
||||||
|
* made the whole plugin fail to activate on the 722.0 upgrade.
|
||||||
|
*
|
||||||
|
* So: never call this from setup(); call it from a handler, passing the
|
||||||
|
* companyId that handler was given. Tool handlers get `runCtx.companyId`; data
|
||||||
|
* handlers get `params.companyId`.
|
||||||
|
*
|
||||||
|
* ⚠️ Scheduled jobs and `handleWebhook` have **no** company scope at all
|
||||||
|
* (legal-ai issue #637 — the host derives scope only from `params.companyId` /
|
||||||
|
* `actorContext` / `runContext` / `event.companyId`, none of which `runJob`
|
||||||
|
* carries). Every `ctx.config.get()` call from such a handler is rejected with
|
||||||
|
* `InvocationScopeDeniedError` (code `-32005`) — "probing the companies we
|
||||||
|
* know about" (the old comment here) never worked; it just swallowed the
|
||||||
|
* rejection silently and fell through to `DEFAULT_LEGAL_API_BASE`, which has
|
||||||
|
* nothing listening on it → the misleading `TypeError: fetch failed`. The
|
||||||
|
* actual logic (including the instance-scoped cache fallback for that case)
|
||||||
|
* now lives in `resolveApiBaseFrom` (`company-scope.ts`), which this is a
|
||||||
|
* thin `ctx`-binding wrapper around — kept bare (no try/catch) so a genuine
|
||||||
|
* scope denial surfaces to the caller instead of being swallowed here.
|
||||||
|
*/
|
||||||
|
/** Pull the companyId the host passes to `ctx.data` handlers, if present. */
|
||||||
|
function companyIdOf(params: unknown): string | undefined {
|
||||||
|
const id = (params as { companyId?: unknown } | null)?.companyId;
|
||||||
|
return typeof id === "string" && id.trim() ? id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveApiBase(
|
||||||
|
ctx: PluginContext,
|
||||||
|
companyId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return resolveApiBaseFrom({
|
||||||
|
readConfig: (id) =>
|
||||||
|
ctx.config.get(id) as Promise<Record<string, unknown> | null>,
|
||||||
|
readCache: () =>
|
||||||
|
ctx.state.get({
|
||||||
|
scopeKind: "instance",
|
||||||
|
stateKey: LEGAL_API_BASE_CACHE_KEY,
|
||||||
|
}),
|
||||||
|
writeCache: (url) =>
|
||||||
|
ctx.state.set(
|
||||||
|
{ scopeKind: "instance", stateKey: LEGAL_API_BASE_CACHE_KEY },
|
||||||
|
url,
|
||||||
|
),
|
||||||
|
knownCompanyIds: Object.keys(CEO_AGENT_IDS),
|
||||||
|
defaultBaseUrl: DEFAULT_LEGAL_API_BASE,
|
||||||
|
logger: ctx.logger,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = definePlugin({
|
const plugin = definePlugin({
|
||||||
async setup(ctx) {
|
async setup(ctx) {
|
||||||
pluginCtx = ctx; // save for onWebhook
|
pluginCtx = ctx; // save for onWebhook
|
||||||
const config = (await ctx.config.get()) as {
|
|
||||||
legalApiBaseUrl?: string;
|
|
||||||
} | null;
|
|
||||||
const baseUrl = config?.legalApiBaseUrl || "http://localhost:8085";
|
|
||||||
const api = new LegalApi(baseUrl);
|
|
||||||
|
|
||||||
ctx.logger.info("Legal AI plugin starting", { url: baseUrl });
|
// Lazy: the URL is fetched on the first request, from inside a handler
|
||||||
|
// that has company context — not here. See resolveApiBase() above.
|
||||||
|
const api = new LegalApi(() => resolveApiBase(ctx));
|
||||||
|
legalApi = api;
|
||||||
|
|
||||||
|
ctx.logger.info("Legal AI plugin starting");
|
||||||
|
|
||||||
// ── Tools ──────────────────────────────────────────────────────
|
// ── Tools ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -34,7 +121,7 @@ const plugin = definePlugin({
|
|||||||
{
|
{
|
||||||
displayName: "רשימת תיקי ערר",
|
displayName: "רשימת תיקי ערר",
|
||||||
description:
|
description:
|
||||||
"List all appeal cases in the legal system. Returns case number, title, and status (new/in_progress/drafted/reviewed/final).",
|
"List all appeal cases in the legal system. Returns case number, title, and status (canonical case-status keys — see GET /api/status-model).",
|
||||||
parametersSchema: {
|
parametersSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {},
|
properties: {},
|
||||||
@@ -175,10 +262,16 @@ const plugin = definePlugin({
|
|||||||
type: "string",
|
type: "string",
|
||||||
description: "Case number (e.g. 123/24)",
|
description: "Case number (e.g. 123/24)",
|
||||||
},
|
},
|
||||||
|
// ללא enum בכוונה: הרישום קורה ב-setup(), שאין לו הקשר-חברה
|
||||||
|
// ולכן אינו יכול לשלוף את /api/status-model (ראה resolveApiBase
|
||||||
|
// למעלה) — והעתק סטטי היה נסחף שוב. המניפסט (manifest.ts) כבר
|
||||||
|
// מגדיר את השדה כמחרוזת חופשית, והוולידציה האמיתית היא
|
||||||
|
// server-side (case_status_model.py + השומר הקדימה-בלבד
|
||||||
|
// ב-tools/cases.py). legal-ai issue #616.
|
||||||
status: {
|
status: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["new", "in_progress", "drafted", "reviewed", "final"],
|
description:
|
||||||
description: "New case status",
|
"New case status — a canonical key from GET /api/status-model",
|
||||||
},
|
},
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
subject: { type: "string" },
|
subject: { type: "string" },
|
||||||
@@ -507,6 +600,40 @@ const plugin = definePlugin({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ctx.tools.register(
|
||||||
|
"legal_predecessor_context",
|
||||||
|
{
|
||||||
|
displayName: "הקשר מריצות קודמות",
|
||||||
|
description:
|
||||||
|
"Recent conclusions from prior heartbeat runs on this case (the 'summary' each run left). Call this when RESUMING work on a case to see what earlier sessions already did/decided — instead of re-deriving context from scratch. Provide the case number.",
|
||||||
|
parametersSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
case_number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Case number (e.g. 1043-02-26)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["case_number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const { case_number } = params as { case_number: string };
|
||||||
|
const result = await api.getPredecessorForCase(case_number);
|
||||||
|
// Readable digest for the agent — each prior run's conclusion,
|
||||||
|
// newest-first, summaries trimmed to keep the wake context lean.
|
||||||
|
const lines = result.runs.map((r) => {
|
||||||
|
const summary = (r.summary ?? "").trim().slice(0, 600);
|
||||||
|
const when = r.finished_at ?? r.started_at ?? "";
|
||||||
|
return `▸ ${r.agent_name ?? "?"} · ${r.identifier ?? ""} · ${when} · ${r.status}\n${summary}`;
|
||||||
|
});
|
||||||
|
const content = lines.length
|
||||||
|
? lines.join("\n\n")
|
||||||
|
: "אין ריצות קודמות עם מסקנות לתיק זה.";
|
||||||
|
return { content, data: result };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ── Events ─────────────────────────────────────────────────────
|
// ── Events ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
ctx.events.on("issue.created", async (event) => {
|
ctx.events.on("issue.created", async (event) => {
|
||||||
@@ -697,7 +824,16 @@ const plugin = definePlugin({
|
|||||||
const matched = commentId
|
const matched = commentId
|
||||||
? comments.find((c) => c.id === commentId)
|
? comments.find((c) => c.id === commentId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const latest = comments[comments.length - 1];
|
// Newest by createdAt — do NOT rely on listComments array order.
|
||||||
|
const latest = comments.reduce<(typeof comments)[number] | null>(
|
||||||
|
(acc, c) =>
|
||||||
|
!acc ||
|
||||||
|
new Date(c.createdAt).getTime() >
|
||||||
|
new Date(acc.createdAt).getTime()
|
||||||
|
? c
|
||||||
|
: acc,
|
||||||
|
null,
|
||||||
|
);
|
||||||
commentBody =
|
commentBody =
|
||||||
matched?.body ||
|
matched?.body ||
|
||||||
latest?.body ||
|
latest?.body ||
|
||||||
@@ -728,110 +864,178 @@ const plugin = definePlugin({
|
|||||||
|
|
||||||
// ── Jobs ───────────────────────────────────────────────────────
|
// ── 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<void> {
|
||||||
|
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.jobs.register("sync-case-status", async (job) => {
|
||||||
ctx.logger.info("Starting case status sync", { runId: job.runId });
|
ctx.logger.info("Starting case status sync", { runId: job.runId });
|
||||||
|
|
||||||
|
const counters = newSyncRunCounters();
|
||||||
|
let failure: unknown;
|
||||||
|
|
||||||
try {
|
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();
|
const cases = await api.listCases();
|
||||||
|
// נקבע מיד — לפני כל קריאת-מארח שעלולה ליפול. אחרת ריצה
|
||||||
|
// שנפלה באמצע (או `!companies.length`) הייתה מדווחת scanned=0
|
||||||
|
// ומאבדת בדיוק את האבחנה שה-issue הזה נועד לתת.
|
||||||
|
counters.scanned = cases.length;
|
||||||
const companies = await ctx.companies.list();
|
const companies = await ctx.companies.list();
|
||||||
if (!companies.length) return;
|
if (!companies.length) return;
|
||||||
|
const statusModel = await api.getStatusModel();
|
||||||
|
|
||||||
const companyId = companies[0].id;
|
// מעבר אחד על ה-issues של כל חברה: state.get אחד ל-issue (לא לכל
|
||||||
const issues = await ctx.issues.list({ companyId });
|
// צירוף תיק×issue), וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש
|
||||||
|
// כדי לבחור "השורש היחיד". סורק את **כל** החברות (לא רק הראשונה) —
|
||||||
for (const legalCase of cases) {
|
// legal-ai issue #604.
|
||||||
|
const byCase = new Map<string, SyncCandidate[]>();
|
||||||
|
for (const company of companies) {
|
||||||
|
const issues = await ctx.issues.list({ companyId: company.id });
|
||||||
for (const issue of issues) {
|
for (const issue of issues) {
|
||||||
const linkedCase = await ctx.state.get({
|
const linkedCase = await ctx.state.get({
|
||||||
scopeKind: "issue",
|
scopeKind: "issue",
|
||||||
scopeId: issue.id,
|
scopeId: issue.id,
|
||||||
stateKey: "legal-case-number",
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (linkedCase === legalCase.case_number) {
|
for (const legalCase of cases) {
|
||||||
// Map 13 legal-ai statuses to Paperclip issue status
|
const targetStatus = resolveIssueStatus(
|
||||||
const statusMap: Record<string, "todo" | "in_progress" | "done"> =
|
statusModel.statuses,
|
||||||
|
legalCase.status,
|
||||||
|
);
|
||||||
|
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)",
|
||||||
{
|
{
|
||||||
new: "todo",
|
caseNumber: legalCase.case_number,
|
||||||
uploading: "todo",
|
status: legalCase.status,
|
||||||
processing: "in_progress",
|
},
|
||||||
documents_ready: "in_progress",
|
);
|
||||||
outcome_set: "in_progress",
|
continue;
|
||||||
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> = {
|
const candidates = byCase.get(legalCase.case_number) ?? [];
|
||||||
new: "תיק חדש",
|
const { target, reason, writableRoots } =
|
||||||
uploading: "העלאת מסמכים",
|
pickSyncTargetIssue(candidates);
|
||||||
processing: "עיבוד מסמכים",
|
|
||||||
documents_ready: "מסמכים מוכנים — הזן תוצאה",
|
|
||||||
outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות",
|
|
||||||
brainstorming: "גיבוש כיוון בתהליך",
|
|
||||||
direction_approved: "כיוון אושר — מוכן לכתיבה",
|
|
||||||
drafting: "כתיבת החלטה בתהליך",
|
|
||||||
qa_review: "בדיקת איכות",
|
|
||||||
drafted: "טיוטה מוכנה — בדוק ושלח לדפנה",
|
|
||||||
exported: "DOCX נוצר — ממתין לדפנה",
|
|
||||||
reviewed: "דפנה הגיהה — העלה גרסה סופית",
|
|
||||||
final: "גרסה סופית — לולאת למידה",
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetStatus = statusMap[legalCase.status];
|
if (!target) {
|
||||||
const label = statusLabels[legalCase.status] || legalCase.status;
|
// reason כאן הוא SyncTargetReason שאינו "ok" (target===null) —
|
||||||
|
// תת-קבוצה מובטחת-מהדר של SyncDeclineReason
|
||||||
|
// (ראה sync-run-summary.ts: SyncTargetDeclineReason).
|
||||||
|
recordDecline(counters, reason as Exclude<typeof reason, "ok">);
|
||||||
|
// אין בליעה שקטה: מדווח למה לא נכתב כלום.
|
||||||
|
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;
|
||||||
|
|
||||||
if (targetStatus && issue.status !== targetStatus) {
|
|
||||||
await ctx.issues.update(
|
await ctx.issues.update(
|
||||||
issue.id,
|
target.id,
|
||||||
{ status: targetStatus },
|
{ status: targetStatus },
|
||||||
companyId,
|
target.companyId,
|
||||||
);
|
);
|
||||||
await ctx.issues.createComment(
|
await ctx.issues.createComment(
|
||||||
issue.id,
|
target.id,
|
||||||
`📋 ${label}`,
|
`📋 ${label}`,
|
||||||
companyId,
|
target.companyId,
|
||||||
);
|
);
|
||||||
|
counters.written++;
|
||||||
ctx.logger.info("Synced issue status", {
|
ctx.logger.info("Synced issue status", {
|
||||||
issueId: issue.id,
|
issueId: target.id,
|
||||||
caseNumber: legalCase.case_number,
|
caseNumber: legalCase.case_number,
|
||||||
newStatus: targetStatus,
|
newStatus: targetStatus,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.logger.info("Case status sync completed", {
|
|
||||||
casesChecked: cases.length,
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logger.error("Case status sync failed", { error: String(err) });
|
failure = err;
|
||||||
|
throw err; // חובה — אחרת ה-run יירשם succeeded (זה בדיוק הבאג של #637).
|
||||||
|
} finally {
|
||||||
|
await emitSyncRunSummary(counters, failure);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
||||||
ctx.logger.info("stale-case-reminder: starting");
|
ctx.logger.info("stale-case-reminder: starting");
|
||||||
const config = await ctx.config.get();
|
|
||||||
const apiBase =
|
|
||||||
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
|
||||||
|
|
||||||
|
await runJobHandler("stale-case-reminder", ctx.logger, async () => {
|
||||||
|
// Scheduled job — no company scope at all (legal-ai issue #637);
|
||||||
|
// resolveApiBase falls back to the instance-scoped cache, then
|
||||||
|
// throws CompanyScopeUnavailableError rather than guessing.
|
||||||
|
const apiBase = await resolveApiBase(ctx);
|
||||||
|
|
||||||
|
// כשל-fetch/API אמיתי חייב **לזרוק**, לא `return` בשקט — אחרת
|
||||||
|
// ה-run נרשם `succeeded` בלי שקרה כלום (legal-ai issue #637).
|
||||||
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
|
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
|
||||||
try {
|
try {
|
||||||
resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=30`);
|
resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=30`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logger.error("stale-case-reminder: fetch failed", {
|
throw new Error(
|
||||||
error: String(err),
|
`stale-case-reminder: fetch failed against ${apiBase}: ${String(err)}`,
|
||||||
});
|
{ cause: err },
|
||||||
return;
|
);
|
||||||
}
|
}
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
|
throw new Error(`stale-case-reminder: API error ${resp.status}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await resp.json()) as {
|
const data = (await resp.json()) as {
|
||||||
@@ -867,6 +1071,8 @@ const plugin = definePlugin({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// אין תיקים תקועים — "אין מה לעשות" תקין, לא כשל. ה-loop למטה
|
||||||
|
// פשוט לא ירוץ בלי `data.cases`.
|
||||||
let reminded = 0;
|
let reminded = 0;
|
||||||
for (const staleCase of data.cases) {
|
for (const staleCase of data.cases) {
|
||||||
const linked = caseIssueMap.get(staleCase.case_number);
|
const linked = caseIssueMap.get(staleCase.case_number);
|
||||||
@@ -887,19 +1093,22 @@ const plugin = definePlugin({
|
|||||||
`stale-case-reminder: done. ${reminded}/${data.total} cases reminded`,
|
`stale-case-reminder: done. ${reminded}/${data.total} cases reminded`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
|
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
|
||||||
ctx.logger.info("weekly-feedback-analysis: starting");
|
ctx.logger.info("weekly-feedback-analysis: starting");
|
||||||
const config = await ctx.config.get();
|
|
||||||
const apiBase =
|
await runJobHandler("weekly-feedback-analysis", ctx.logger, async () => {
|
||||||
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
// Scheduled job — no company scope at all (legal-ai issue #637);
|
||||||
|
// resolveApiBase falls back to the instance-scoped cache, then
|
||||||
|
// throws CompanyScopeUnavailableError rather than guessing.
|
||||||
|
const apiBase = await resolveApiBase(ctx);
|
||||||
|
|
||||||
const resp = await ctx.http.fetch(
|
const resp = await ctx.http.fetch(
|
||||||
`${apiBase}/api/chair-feedback/weekly-summary`,
|
`${apiBase}/api/chair-feedback/weekly-summary`,
|
||||||
);
|
);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`);
|
throw new Error(`weekly-feedback-analysis: API error ${resp.status}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await resp.json()) as {
|
const data = (await resp.json()) as {
|
||||||
@@ -907,6 +1116,7 @@ const plugin = definePlugin({
|
|||||||
entry_count: number;
|
entry_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// אין פידבק השבוע — "אין מה לעשות" תקין, לא כשל.
|
||||||
if (data.entry_count === 0) {
|
if (data.entry_count === 0) {
|
||||||
ctx.logger.info(
|
ctx.logger.info(
|
||||||
"weekly-feedback-analysis: no feedback this week, skipping",
|
"weekly-feedback-analysis: no feedback this week, skipping",
|
||||||
@@ -923,11 +1133,12 @@ const plugin = definePlugin({
|
|||||||
Boolean(x.ceoId),
|
Boolean(x.ceoId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// אין שום חברה עם CEO ממופה — זו תקלת-תצורה (לא "אין מה לעשות"),
|
||||||
|
// ולכן זריקה ולא return שקט.
|
||||||
if (mapped.length === 0) {
|
if (mapped.length === 0) {
|
||||||
ctx.logger.warn(
|
throw new Error(
|
||||||
"weekly-feedback-analysis: no company has a mapped CEO agent — skipping",
|
"weekly-feedback-analysis: no company has a mapped CEO agent",
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { company, ceoId } = mapped[0];
|
const { company, ceoId } = mapped[0];
|
||||||
@@ -948,6 +1159,7 @@ const plugin = definePlugin({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Reconciliation sweep — the at-least-once guarantee for user comments.
|
// Reconciliation sweep — the at-least-once guarantee for user comments.
|
||||||
// The event-driven route above is best-effort: a failed/coalesced CEO
|
// The event-driven route above is best-effort: a failed/coalesced CEO
|
||||||
@@ -959,10 +1171,23 @@ const plugin = definePlugin({
|
|||||||
// the fast path stamps the marker, so a healthy comment is never
|
// the fast path stamps the marker, so a healthy comment is never
|
||||||
// double-routed. Runs every 2 minutes (manifest schedule).
|
// double-routed. Runs every 2 minutes (manifest schedule).
|
||||||
ctx.jobs.register("route-pending-comments", async (_job) => {
|
ctx.jobs.register("route-pending-comments", async (_job) => {
|
||||||
|
await runJobHandler("route-pending-comments", ctx.logger, async () => {
|
||||||
|
// כשל-מהיר: דחיית-scope (legal-ai issue #637) אינה "שגיאה
|
||||||
|
// פר-issue" שאפשר להתעלם ממנה — היא אומרת שה-run **כולו** לא
|
||||||
|
// יכול לעבוד. לזרוק מיד במקום לבלוע ולהמשיך ללולאה על 2
|
||||||
|
// חברות × 200 issues שכולן ייכשלו זהה. שגיאה אחרת (ולא scope)
|
||||||
|
// נשארת warn+continue — זו העמידות האמיתית פר-issue.
|
||||||
let companies: Awaited<ReturnType<typeof ctx.companies.list>>;
|
let companies: Awaited<ReturnType<typeof ctx.companies.list>>;
|
||||||
try {
|
try {
|
||||||
companies = await ctx.companies.list();
|
companies = await ctx.companies.list();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isCompanyScopeDenied(err)) {
|
||||||
|
throw new CompanyScopeUnavailableError(
|
||||||
|
"route-pending-comments",
|
||||||
|
"companies.list",
|
||||||
|
{ cause: err },
|
||||||
|
);
|
||||||
|
}
|
||||||
ctx.logger.warn("route-pending-comments: companies.list failed", {
|
ctx.logger.warn("route-pending-comments: companies.list failed", {
|
||||||
error: String(err),
|
error: String(err),
|
||||||
});
|
});
|
||||||
@@ -975,8 +1200,18 @@ const plugin = definePlugin({
|
|||||||
|
|
||||||
let issues: Awaited<ReturnType<typeof ctx.issues.list>>;
|
let issues: Awaited<ReturnType<typeof ctx.issues.list>>;
|
||||||
try {
|
try {
|
||||||
issues = await ctx.issues.list({ companyId: company.id, limit: 200 });
|
issues = await ctx.issues.list({
|
||||||
|
companyId: company.id,
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isCompanyScopeDenied(err)) {
|
||||||
|
throw new CompanyScopeUnavailableError(
|
||||||
|
"route-pending-comments",
|
||||||
|
"issues.list",
|
||||||
|
{ cause: err },
|
||||||
|
);
|
||||||
|
}
|
||||||
ctx.logger.warn("route-pending-comments: issues.list failed", {
|
ctx.logger.warn("route-pending-comments: issues.list failed", {
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
error: String(err),
|
error: String(err),
|
||||||
@@ -988,7 +1223,8 @@ const plugin = definePlugin({
|
|||||||
// Terminal issues need no routing. A fresh user comment on a
|
// Terminal issues need no routing. A fresh user comment on a
|
||||||
// `done` issue reopens it natively → it becomes active and is
|
// `done` issue reopens it natively → it becomes active and is
|
||||||
// caught on the next sweep.
|
// caught on the next sweep.
|
||||||
if (issue.status === "done" || issue.status === "cancelled") continue;
|
if (issue.status === "done" || issue.status === "cancelled")
|
||||||
|
continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const comments = await ctx.issues.listComments(
|
const comments = await ctx.issues.listComments(
|
||||||
@@ -997,14 +1233,28 @@ const plugin = definePlugin({
|
|||||||
);
|
);
|
||||||
if (comments.length === 0) continue;
|
if (comments.length === 0) continue;
|
||||||
|
|
||||||
// "Pending" = the conversation's last word is the chair's, with
|
// "Pending" = the chair spoke last among real participants: the
|
||||||
// no agent reply after it. listComments is chronological (newest
|
// newest USER comment is newer than the newest AGENT comment (or
|
||||||
// last). If an agent already responded, the comment was handled —
|
// no agent has commented yet), so an agent never responded to it.
|
||||||
// skip. This both targets the exact failure (chair commented, no
|
// Order-independent (sort by createdAt, do NOT rely on the array
|
||||||
// agent response) and avoids re-routing historically-answered
|
// order of listComments) and system comments are ignored — an
|
||||||
// comments on first run after deploy.
|
// automated "draft ready" line is not an agent response. This
|
||||||
const last = comments[comments.length - 1];
|
// targets the exact failure (chair commented, no agent reply) and
|
||||||
if (last.authorType !== "user") continue;
|
// skips historically-answered comments.
|
||||||
|
const ts = (c: { createdAt: string | Date }) =>
|
||||||
|
new Date(c.createdAt).getTime();
|
||||||
|
let newestUser: (typeof comments)[number] | null = null;
|
||||||
|
let newestAgent: (typeof comments)[number] | null = null;
|
||||||
|
for (const c of comments) {
|
||||||
|
if (c.authorType === "user") {
|
||||||
|
if (!newestUser || ts(c) > ts(newestUser)) newestUser = c;
|
||||||
|
} else if (c.authorType === "agent") {
|
||||||
|
if (!newestAgent || ts(c) > ts(newestAgent)) newestAgent = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!newestUser) continue; // no chair comment
|
||||||
|
// An agent has already responded after the chair — handled.
|
||||||
|
if (newestAgent && ts(newestAgent) >= ts(newestUser)) continue;
|
||||||
|
|
||||||
const marker = await ctx.state.get({
|
const marker = await ctx.state.get({
|
||||||
scopeKind: "issue",
|
scopeKind: "issue",
|
||||||
@@ -1012,18 +1262,18 @@ const plugin = definePlugin({
|
|||||||
stateKey: LAST_ROUTED_COMMENT_KEY,
|
stateKey: LAST_ROUTED_COMMENT_KEY,
|
||||||
});
|
});
|
||||||
// Already routed (CEO may still be working) — don't re-fire.
|
// Already routed (CEO may still be working) — don't re-fire.
|
||||||
if (marker === last.id) continue;
|
if (marker === newestUser.id) continue;
|
||||||
|
|
||||||
ctx.logger.info(
|
ctx.logger.info(
|
||||||
"route-pending-comments: re-routing unrouted user comment",
|
"route-pending-comments: re-routing unrouted user comment",
|
||||||
{ issueId: issue.id, commentId: last.id },
|
{ issueId: issue.id, commentId: newestUser.id },
|
||||||
);
|
);
|
||||||
await routeCommentToCeo({
|
await routeCommentToCeo({
|
||||||
issue,
|
issue,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
ceoAgentId,
|
ceoAgentId,
|
||||||
commentBody: last.body,
|
commentBody: newestUser.body,
|
||||||
commentId: last.id,
|
commentId: newestUser.id,
|
||||||
source: "sweep",
|
source: "sweep",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1035,6 +1285,7 @@ const plugin = definePlugin({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Data handlers (UI bridge) ──────────────────────────────────
|
// ── Data handlers (UI bridge) ──────────────────────────────────
|
||||||
// These back `usePluginData(key, params)` calls from the React UI bundle
|
// These back `usePluginData(key, params)` calls from the React UI bundle
|
||||||
@@ -1072,9 +1323,7 @@ const plugin = definePlugin({
|
|||||||
if (!issueId) return null;
|
if (!issueId) return null;
|
||||||
const caseNumber = await resolveCaseNumber(issueId);
|
const caseNumber = await resolveCaseNumber(issueId);
|
||||||
if (!caseNumber) return null;
|
if (!caseNumber) return null;
|
||||||
const config2 = await ctx.config.get();
|
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||||
const apiBase =
|
|
||||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
|
||||||
try {
|
try {
|
||||||
const resp = await ctx.http.fetch(
|
const resp = await ctx.http.fetch(
|
||||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
|
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
|
||||||
@@ -1111,9 +1360,7 @@ const plugin = definePlugin({
|
|||||||
if (!issueId) return [];
|
if (!issueId) return [];
|
||||||
const caseNumber = await resolveCaseNumber(issueId);
|
const caseNumber = await resolveCaseNumber(issueId);
|
||||||
if (!caseNumber) return [];
|
if (!caseNumber) return [];
|
||||||
const config2 = await ctx.config.get();
|
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||||
const apiBase =
|
|
||||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
|
||||||
try {
|
try {
|
||||||
const resp = await ctx.http.fetch(
|
const resp = await ctx.http.fetch(
|
||||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
|
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
|
||||||
@@ -1149,9 +1396,7 @@ const plugin = definePlugin({
|
|||||||
if (!issueId) return [];
|
if (!issueId) return [];
|
||||||
const caseNumber = await resolveCaseNumber(issueId);
|
const caseNumber = await resolveCaseNumber(issueId);
|
||||||
if (!caseNumber) return [];
|
if (!caseNumber) return [];
|
||||||
const config2 = await ctx.config.get();
|
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||||
const apiBase =
|
|
||||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(`${apiBase}/api/missing-precedents`);
|
const url = new URL(`${apiBase}/api/missing-precedents`);
|
||||||
url.searchParams.set("case_number", caseNumber);
|
url.searchParams.set("case_number", caseNumber);
|
||||||
@@ -1209,12 +1454,34 @@ const plugin = definePlugin({
|
|||||||
return { status: "ok" as const };
|
return { status: "ok" as const };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* נתיב-חימום דטרמיניסטי למטמון `LEGAL_API_BASE_CACHE_KEY` (company-scope.ts):
|
||||||
|
* ל-`configChanged` יש הקשר-חברה תקין מה-מארח (בניגוד לג'וב מתוזמן/webhook —
|
||||||
|
* legal-ai issue #637), כך שהכתיבה כאן אף פעם לא נדחית ב-scope. עדיף על
|
||||||
|
* להסתמך על כך שסוכן יקרא כלי בהקשר-חברה כדי שהמטמון יתמלא — כאן זה קורה
|
||||||
|
* מיד כשהאופרטור שומר את ה-config, בלי תלות בתזמון-מקרי.
|
||||||
|
*/
|
||||||
|
async onConfigChanged(newConfig: Record<string, unknown>): Promise<void> {
|
||||||
|
if (!pluginCtx) return; // עדיין לפני setup()
|
||||||
|
const url = (newConfig as { legalApiBaseUrl?: unknown }).legalApiBaseUrl;
|
||||||
|
if (typeof url === "string" && url.trim()) {
|
||||||
|
await pluginCtx.state.set(
|
||||||
|
{ scopeKind: "instance", stateKey: LEGAL_API_BASE_CACHE_KEY },
|
||||||
|
url.trim(),
|
||||||
|
);
|
||||||
|
pluginCtx.logger.info("onConfigChanged: cached legalApiBaseUrl", {
|
||||||
|
legalApiBaseUrl: url.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async onWebhook(input: PluginWebhookInput): Promise<void> {
|
async onWebhook(input: PluginWebhookInput): Promise<void> {
|
||||||
if (!pluginCtx) return; // not yet initialized
|
if (!pluginCtx) return; // not yet initialized
|
||||||
|
|
||||||
// Idempotency guard: skip duplicate deliveries within 5 minutes
|
// Idempotency guard: skip duplicate deliveries within 5 minutes. The key
|
||||||
if (input.requestId) {
|
// is hoisted so the catch-block below can delete it on failure — see there.
|
||||||
const idempKey = `webhook-idem-${input.requestId}`;
|
const idempKey = input.requestId ? `webhook-idem-${input.requestId}` : null;
|
||||||
|
if (idempKey) {
|
||||||
const seenAt = await pluginCtx.state.get({
|
const seenAt = await pluginCtx.state.get({
|
||||||
scopeKind: "instance",
|
scopeKind: "instance",
|
||||||
stateKey: idempKey,
|
stateKey: idempKey,
|
||||||
@@ -1234,6 +1501,42 @@ const plugin = definePlugin({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleCaseStatusWebhook(pluginCtx, legalApi, input);
|
||||||
|
} catch (err) {
|
||||||
|
// מחיקת סמן-האידמפוטנטיות **לפני** שהשגיאה יוצאת — אחרת מסירה
|
||||||
|
// חוזרת של אותו webhook תוך 5 דק' תדולג בשקט כ"כבר נשלח", למרות
|
||||||
|
// שהעיבוד מעולם לא הושלם בפועל (legal-ai issue #637).
|
||||||
|
if (idempKey) {
|
||||||
|
await pluginCtx.state.delete({
|
||||||
|
scopeKind: "instance",
|
||||||
|
stateKey: idempKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// אין בליעה — הזריקה חייבת להימשך החוצה כדי ש-
|
||||||
|
// `plugin_webhook_deliveries.error` יישא הודעה מאבחנת (ולא את
|
||||||
|
// הודעת-המארח האטומה) כשמדובר בדחיית-scope.
|
||||||
|
if (isCompanyScopeDenied(err)) {
|
||||||
|
throw new CompanyScopeUnavailableError("onWebhook", "issues.list", {
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* גוף-העבודה של webhook `case-status` — מופרד מ-`onWebhook` כך ש-
|
||||||
|
* `try/catch` שם יכול לעטוף אותו כולו (כולל את דחיית-ה-scope שנתקלת בה
|
||||||
|
* `pluginCtx.issues.list` למטה — אין ל-webhook הקשר-חברה, כמו לג'וב
|
||||||
|
* מתוזמן; ראה legal-ai issue #637).
|
||||||
|
*/
|
||||||
|
async function handleCaseStatusWebhook(
|
||||||
|
pluginCtx: PluginContext,
|
||||||
|
legalApi: LegalApi | null,
|
||||||
|
input: PluginWebhookInput,
|
||||||
|
): Promise<void> {
|
||||||
const { endpointKey, parsedBody } = input;
|
const { endpointKey, parsedBody } = input;
|
||||||
|
|
||||||
if (endpointKey !== "case-status") return;
|
if (endpointKey !== "case-status") return;
|
||||||
@@ -1277,12 +1580,9 @@ const plugin = definePlugin({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginCtx.logger.info(
|
pluginCtx.logger.info(`Webhook: case ${caseNumber} eventType=${eventType}`, {
|
||||||
`Webhook: case ${caseNumber} eventType=${eventType}`,
|
|
||||||
{
|
|
||||||
companyId,
|
companyId,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Find the Paperclip issue linked to this case number by scanning plugin state.
|
// Find the Paperclip issue linked to this case number by scanning plugin state.
|
||||||
// State stores: issue.id → case_number (scopeKind=issue, stateKey=legal-case-number)
|
// State stores: issue.id → case_number (scopeKind=issue, stateKey=legal-case-number)
|
||||||
@@ -1349,22 +1649,16 @@ const plugin = definePlugin({
|
|||||||
},
|
},
|
||||||
companyId,
|
companyId,
|
||||||
);
|
);
|
||||||
pluginCtx.logger.info(
|
pluginCtx.logger.info("askUserQuestions: missing_precedent prompt sent", {
|
||||||
"askUserQuestions: missing_precedent prompt sent",
|
|
||||||
{
|
|
||||||
caseNumber,
|
caseNumber,
|
||||||
missingPrecedentId: mp.id,
|
missingPrecedentId: mp.id,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pluginCtx.logger.error(
|
pluginCtx.logger.error("askUserQuestions failed for missing_precedent", {
|
||||||
"askUserQuestions failed for missing_precedent",
|
|
||||||
{
|
|
||||||
caseNumber,
|
caseNumber,
|
||||||
missingPrecedentId: mp.id,
|
missingPrecedentId: mp.id,
|
||||||
error: String(err),
|
error: String(err),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1424,30 +1718,48 @@ const plugin = definePlugin({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status label map (Hebrew)
|
// התוויות נגזרות מ-`GET /api/status-model` — אותו SSOT שהג'וב
|
||||||
const statusLabels: Record<string, string> = {
|
// `sync-case-status` כבר צורך (#604). המפה הקשיחה שהייתה כאן כבר סטתה:
|
||||||
new: "📂 תיק חדש",
|
// חסרו בה מפתחות חדשים מהמודל, והיו בה מפתחות מתים שכבר לא קיימים בו.
|
||||||
uploading: "📤 העלאת מסמכים",
|
// legal-ai issue #616.
|
||||||
processing: "⚙️ עיבוד מסמכים",
|
let statuses: StatusModelEntry[] | null = null;
|
||||||
documents_ready: "📁 מסמכים מוכנים",
|
if (!legalApi) {
|
||||||
outcome_set: "🎯 תוצאה הוזנה",
|
// לא אמור לקרות (מושם ב-setup, כמו pluginCtx) — אבל אם קרה, זו נפילה
|
||||||
brainstorming: "💡 סיעור מוחות",
|
// לערך-גולמי שחייבת עקבה, בדיוק כמו כשל-הרשת שמתחתיה.
|
||||||
direction_approved: "✅ כיוון אושר",
|
pluginCtx.logger.error(
|
||||||
drafting: "✍️ כתיבה בתהליך",
|
"onWebhook: LegalApi unavailable — labels fall back to raw keys",
|
||||||
qa_review: "🔍 בדיקת איכות",
|
{ caseNumber, newStatus },
|
||||||
in_progress: "🔄 בעבודה",
|
);
|
||||||
drafted: "✍️ טיוטה מוכנה",
|
} else {
|
||||||
qa_failed: "❌ QA נכשל",
|
try {
|
||||||
exported: "📄 יוצא ל-DOCX",
|
statuses = (await legalApi.getStatusModel()).statuses;
|
||||||
reviewed: "✅ נבדק",
|
} catch (err) {
|
||||||
final: "🎯 סופי",
|
pluginCtx.logger.error(
|
||||||
};
|
"onWebhook: status-model fetch failed — labels fall back to raw keys",
|
||||||
const label = statusLabels[newStatus] ?? newStatus;
|
{ caseNumber, newStatus, error: String(err) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolved = resolveStatusLabel(statuses, newStatus);
|
||||||
|
if (resolved.fallback === "unknown_status") {
|
||||||
|
// לא בליעה שקטה: הסטטוס נטען ופשוט אינו במודל הקנוני.
|
||||||
|
pluginCtx.logger.warn(
|
||||||
|
"onWebhook: status not in canonical status-model — using raw key as label",
|
||||||
|
{ caseNumber, oldStatus, newStatus },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// `model_unavailable` כבר דווח כ-error למעלה — אין הכפלת-דיווח.
|
||||||
|
const label = resolved.label;
|
||||||
|
// `oldStatus` מתורגם אף הוא, אך **בלי** לוג: הוא ריק לגיטימית בתיק-חדש
|
||||||
|
// ועשוי להיות מפתח היסטורי שכבר אינו במודל — warn עליו היה רעש כרוני.
|
||||||
|
const oldLabel = oldStatus
|
||||||
|
? resolveStatusLabel(statuses, oldStatus).label
|
||||||
|
: oldStatus;
|
||||||
|
|
||||||
// Post a Hebrew status comment on the linked issue
|
// Post a Hebrew status comment on the linked issue
|
||||||
await pluginCtx.issues.createComment(
|
await pluginCtx.issues.createComment(
|
||||||
linkedIssueId,
|
linkedIssueId,
|
||||||
`**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldStatus})`,
|
`**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldLabel})`,
|
||||||
companyId,
|
companyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1477,8 +1789,7 @@ const plugin = definePlugin({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
|
||||||
export default plugin;
|
export default plugin;
|
||||||
runWorker(plugin, import.meta.url);
|
runWorker(plugin, import.meta.url);
|
||||||
|
|||||||
@@ -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