Compare commits
24 Commits
pre-sdk-up
...
7cbb3d1c03
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cbb3d1c03 | |||
| 41305fbb36 | |||
| 511d5c6e34 | |||
| 10d6216bec | |||
| 0a134d0134 | |||
| 602b42ca5e | |||
| 378e5657b5 | |||
| 6721c0a4ed | |||
| 3da37e1519 | |||
| 6294d94bed | |||
| d0669a0878 | |||
| b9906f6047 | |||
| 8d933386d3 | |||
| 0b6117cd7e | |||
| fc5d8725f1 | |||
| b9a5bb6e80 | |||
| 0bf2e46212 | |||
| aeffbe65ee | |||
| 0a8790b088 | |||
| b6bf726570 | |||
| 2d6dd8a072 | |||
| f70023fccc | |||
| 0ca7831c53 | |||
| 6388062cdc |
27
esbuild.ui.config.mjs
Normal file
27
esbuild.ui.config.mjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { build } from "esbuild";
|
||||||
|
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const presets = createPluginBundlerPresets({
|
||||||
|
pluginRoot: __dirname,
|
||||||
|
uiEntry: "src/ui/index.tsx",
|
||||||
|
outdir: "dist",
|
||||||
|
sourcemap: true,
|
||||||
|
minify: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!presets.esbuild.ui) {
|
||||||
|
throw new Error("UI preset missing — check createPluginBundlerPresets input");
|
||||||
|
}
|
||||||
|
|
||||||
|
await build({
|
||||||
|
...presets.esbuild.ui,
|
||||||
|
// Ensure JSX runtime is bundled-resolved at host runtime through React peer.
|
||||||
|
jsx: "automatic",
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[esbuild] UI bundle written to dist/ui/index.js");
|
||||||
529
package-lock.json
generated
529
package-lock.json
generated
@@ -8,11 +8,14 @@
|
|||||||
"name": "@marcusgroup/plugin-legal-ai",
|
"name": "@marcusgroup/plugin-legal-ai",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclipai/plugin-sdk": "^2026.325.0"
|
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||||
|
"react": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.11",
|
"@biomejs/biome": "2.4.11",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +182,455 @@
|
|||||||
"node": ">=14.21.3"
|
"node": ">=14.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@paperclipai/plugin-sdk": {
|
"node_modules/@paperclipai/plugin-sdk": {
|
||||||
"version": "2026.325.0",
|
"version": "2026.722.0",
|
||||||
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.325.0.tgz",
|
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.722.0.tgz",
|
||||||
"integrity": "sha512-axAIK90QVrRihlCXcGoePDc0qaf0weqOwUagWsp88v6T2NXAWXALCzFtY4IipPnaVli6xZe5ZsuC+gTlPXhMKQ==",
|
"integrity": "sha512-2rMJCBo8ZAouuMsapdaY4/l+oHmrXKHW/k/HfPOLCxFng7+g04c5JhoFq+/ptGfNcW3kXzs34y7brviDCml06Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclipai/shared": "2026.325.0",
|
"@paperclipai/shared": "2026.722.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -201,9 +646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@paperclipai/shared": {
|
"node_modules/@paperclipai/shared": {
|
||||||
"version": "2026.325.0",
|
"version": "2026.722.0",
|
||||||
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.325.0.tgz",
|
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.722.0.tgz",
|
||||||
"integrity": "sha512-l0ZeaQhswxjAyJ6G/TdgyrfbEgFKqdkly4zn4QgOPCu+x8j8Gma51Xf45M2Br3ILJlIx93HhT3hAzWKChdxgMg==",
|
"integrity": "sha512-JciI6EbtNwHSeoyN5ZkshwqpyfIluyO46JHp8I7pPCvpJ7Uv7MSZEBs3lKXdXLadFEN6qzfwNmf39xjIFC8isA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
@@ -219,6 +664,74 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
||||||
|
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.0",
|
||||||
|
"@esbuild/android-arm": "0.28.0",
|
||||||
|
"@esbuild/android-arm64": "0.28.0",
|
||||||
|
"@esbuild/android-x64": "0.28.0",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.0",
|
||||||
|
"@esbuild/darwin-x64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.0",
|
||||||
|
"@esbuild/linux-arm": "0.28.0",
|
||||||
|
"@esbuild/linux-arm64": "0.28.0",
|
||||||
|
"@esbuild/linux-ia32": "0.28.0",
|
||||||
|
"@esbuild/linux-loong64": "0.28.0",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.0",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.0",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.0",
|
||||||
|
"@esbuild/linux-s390x": "0.28.0",
|
||||||
|
"@esbuild/linux-x64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.0",
|
||||||
|
"@esbuild/sunos-x64": "0.28.0",
|
||||||
|
"@esbuild/win32-arm64": "0.28.0",
|
||||||
|
"@esbuild/win32-ia32": "0.28.0",
|
||||||
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "19.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
|
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"manifest": "dist/manifest.js"
|
"manifest": "dist/manifest.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc && node esbuild.ui.config.mjs",
|
||||||
|
"build:worker": "tsc",
|
||||||
|
"build:ui": "node esbuild.ui.config.mjs",
|
||||||
"dev": "paperclip-plugin-dev-server",
|
"dev": "paperclip-plugin-dev-server",
|
||||||
"format": "biome format --write src/",
|
"format": "biome format --write src/",
|
||||||
"format:check": "biome format src/",
|
"format:check": "biome format src/",
|
||||||
@@ -15,11 +17,14 @@
|
|||||||
"biome:fix": "biome check --write src/"
|
"biome:fix": "biome check --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclipai/plugin-sdk": "^2026.325.0"
|
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||||
|
"react": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.11",
|
"@biomejs/biome": "2.4.11",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 המקושר"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ export default {
|
|||||||
"issues.read",
|
"issues.read",
|
||||||
"issues.create",
|
"issues.create",
|
||||||
"issues.update",
|
"issues.update",
|
||||||
|
"issue.comments.read",
|
||||||
"issue.comments.create",
|
"issue.comments.create",
|
||||||
|
"issue.interactions.create",
|
||||||
|
"issue.documents.write",
|
||||||
"agent.tools.register",
|
"agent.tools.register",
|
||||||
|
"agents.invoke",
|
||||||
"http.outbound",
|
"http.outbound",
|
||||||
"plugin.state.read",
|
"plugin.state.read",
|
||||||
"plugin.state.write",
|
"plugin.state.write",
|
||||||
@@ -22,9 +26,12 @@ export default {
|
|||||||
"companies.read",
|
"companies.read",
|
||||||
"projects.read",
|
"projects.read",
|
||||||
"webhooks.receive",
|
"webhooks.receive",
|
||||||
|
"ui.detailTab.register",
|
||||||
|
"ui.dashboardWidget.register",
|
||||||
] as const,
|
] as const,
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
worker: "dist/worker.js",
|
worker: "dist/worker.js",
|
||||||
|
ui: "dist/ui",
|
||||||
},
|
},
|
||||||
instanceConfigSchema: {
|
instanceConfigSchema: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
@@ -149,6 +156,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: [
|
||||||
{
|
{
|
||||||
@@ -171,6 +195,13 @@ export default {
|
|||||||
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
|
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
|
||||||
schedule: "0 19 * * 0",
|
schedule: "0 19 * * 0",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
jobKey: "route-pending-comments",
|
||||||
|
displayName: "ערובת מסירת-הערות (sweep)",
|
||||||
|
description:
|
||||||
|
"sweep פיוס: מנתב כל הערת-משתמש שטרם נותבה ל-CEO (at-least-once), כדי שהערה לא תיפול גם אם ה-wakeup התבטל/התלכד",
|
||||||
|
schedule: "*/2 * * * *",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
webhooks: [
|
webhooks: [
|
||||||
{
|
{
|
||||||
@@ -180,4 +211,21 @@ export default {
|
|||||||
"מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר",
|
"מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
ui: {
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
type: "detailTab" as const,
|
||||||
|
id: "legal-case-tab",
|
||||||
|
displayName: "ערר",
|
||||||
|
exportName: "LegalCaseTab",
|
||||||
|
entityTypes: ["issue" as const],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "dashboardWidget" as const,
|
||||||
|
id: "legal-cases-widget",
|
||||||
|
displayName: "תיקי ערר",
|
||||||
|
exportName: "LegalCasesWidget",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
338
src/sync-target.test.ts
Normal file
338
src/sync-target.test.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/// <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,
|
||||||
|
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");
|
||||||
|
});
|
||||||
117
src/sync-target.ts
Normal file
117
src/sync-target.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* בחירת יעד-הסנכרון של הג'וב `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;
|
||||||
|
}
|
||||||
248
src/ui/LegalCaseTab.tsx
Normal file
248
src/ui/LegalCaseTab.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
|
||||||
|
import type {
|
||||||
|
LegalArgument,
|
||||||
|
LegalArgumentsResponse,
|
||||||
|
LegalCaseDetails,
|
||||||
|
MissingPrecedentRow,
|
||||||
|
PrecedentRow,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
padding: 16,
|
||||||
|
fontFamily:
|
||||||
|
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
|
||||||
|
color: "inherit",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionStyle: React.CSSProperties = {
|
||||||
|
marginBottom: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headingStyle: React.CSSProperties = {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
borderBottom: "1px solid rgba(127,127,127,0.25)",
|
||||||
|
paddingBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const metaListStyle: React.CSSProperties = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "max-content 1fr",
|
||||||
|
columnGap: 12,
|
||||||
|
rowGap: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
margin: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailsBlockStyle: React.CSSProperties = {
|
||||||
|
margin: "6px 0",
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: "rgba(127,127,127,0.08)",
|
||||||
|
borderRadius: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ulStyle: React.CSSProperties = {
|
||||||
|
listStyle: "none",
|
||||||
|
paddingInlineStart: 0,
|
||||||
|
margin: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const liStyle: React.CSSProperties = {
|
||||||
|
padding: "4px 0",
|
||||||
|
fontSize: 14,
|
||||||
|
borderBottom: "1px dotted rgba(127,127,127,0.2)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutedStyle: React.CSSProperties = {
|
||||||
|
color: "rgba(127,127,127,0.8)",
|
||||||
|
fontSize: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
function PartySection({
|
||||||
|
party,
|
||||||
|
args,
|
||||||
|
}: {
|
||||||
|
party: string;
|
||||||
|
args: LegalArgument[];
|
||||||
|
}) {
|
||||||
|
if (!args || args.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<strong style={{ fontSize: 14 }}>{party}</strong>
|
||||||
|
{args.map((a, idx) => {
|
||||||
|
const key = a.id ?? `${party}-${a.claim_index ?? idx}`;
|
||||||
|
const title =
|
||||||
|
a.argument_title ||
|
||||||
|
(a.argument_body || "").slice(0, 80) ||
|
||||||
|
"(ללא כותרת)";
|
||||||
|
const body = a.argument_body || "";
|
||||||
|
return (
|
||||||
|
<details key={key} style={detailsBlockStyle}>
|
||||||
|
<summary style={{ cursor: "pointer", fontSize: 14 }}>
|
||||||
|
{title}
|
||||||
|
</summary>
|
||||||
|
{body && (
|
||||||
|
<p style={{ margin: "6px 0 0 0", whiteSpace: "pre-wrap" }}>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LegalCaseTab({ context }: PluginDetailTabProps) {
|
||||||
|
const issueId = context.entityId;
|
||||||
|
|
||||||
|
const summary = usePluginData<LegalCaseDetails | null>("legal-case-summary", {
|
||||||
|
issueId,
|
||||||
|
});
|
||||||
|
const args = usePluginData<LegalArgumentsResponse | null>(
|
||||||
|
"legal-case-arguments",
|
||||||
|
{ issueId },
|
||||||
|
);
|
||||||
|
const precedents = usePluginData<PrecedentRow[] | null>(
|
||||||
|
"legal-case-precedents",
|
||||||
|
{ issueId },
|
||||||
|
);
|
||||||
|
const missing = usePluginData<MissingPrecedentRow[] | null>(
|
||||||
|
"legal-case-missing-precedents",
|
||||||
|
{ issueId },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (summary.loading) {
|
||||||
|
return (
|
||||||
|
<div dir="rtl" style={containerStyle}>
|
||||||
|
<p>טוען נתוני תיק…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.error) {
|
||||||
|
return (
|
||||||
|
<div dir="rtl" style={containerStyle}>
|
||||||
|
<p style={{ color: "crimson" }}>
|
||||||
|
שגיאה בטעינת התיק: {summary.error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary.data) {
|
||||||
|
return (
|
||||||
|
<div dir="rtl" style={containerStyle}>
|
||||||
|
<p style={mutedStyle}>
|
||||||
|
אין תיק ערר מקושר ל-issue זה. ניתן ליצור תיק חדש דרך CEO או דרך כלי
|
||||||
|
המערכת.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseDetails = summary.data;
|
||||||
|
const byParty = args.data?.by_party ?? {};
|
||||||
|
const argsTotal = args.data?.total ?? 0;
|
||||||
|
const precedentsList = precedents.data ?? [];
|
||||||
|
const missingList = missing.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div dir="rtl" style={containerStyle}>
|
||||||
|
<section style={sectionStyle}>
|
||||||
|
<h2 style={{ margin: "0 0 8px 0", fontSize: 20 }}>
|
||||||
|
תיק {caseDetails.case_number}
|
||||||
|
</h2>
|
||||||
|
<div style={metaListStyle}>
|
||||||
|
<span style={mutedStyle}>כותרת:</span>
|
||||||
|
<span>{caseDetails.title}</span>
|
||||||
|
<span style={mutedStyle}>סטטוס:</span>
|
||||||
|
<span>
|
||||||
|
<code>{caseDetails.status}</code>
|
||||||
|
</span>
|
||||||
|
{caseDetails.practice_area && (
|
||||||
|
<>
|
||||||
|
<span style={mutedStyle}>תחום:</span>
|
||||||
|
<span>{caseDetails.practice_area}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{caseDetails.appeal_subtype && (
|
||||||
|
<>
|
||||||
|
<span style={mutedStyle}>סוג ערר:</span>
|
||||||
|
<span>{caseDetails.appeal_subtype}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{caseDetails.expected_outcome && (
|
||||||
|
<>
|
||||||
|
<span style={mutedStyle}>תוצאה צפויה:</span>
|
||||||
|
<span>{caseDetails.expected_outcome}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={sectionStyle}>
|
||||||
|
<h3 style={headingStyle}>טיעונים משפטיים ({argsTotal})</h3>
|
||||||
|
{args.loading && <p style={mutedStyle}>טוען טיעונים…</p>}
|
||||||
|
{args.error && (
|
||||||
|
<p style={{ color: "crimson" }}>שגיאה: {args.error.message}</p>
|
||||||
|
)}
|
||||||
|
{!args.loading && argsTotal === 0 && (
|
||||||
|
<p style={mutedStyle}>לא חולצו עדיין טיעונים מהמסמכים.</p>
|
||||||
|
)}
|
||||||
|
{Object.entries(byParty).map(([party, list]) => (
|
||||||
|
<PartySection key={party} party={party} args={list} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={sectionStyle}>
|
||||||
|
<h3 style={headingStyle}>פסיקה מצורפת ({precedentsList.length})</h3>
|
||||||
|
{precedents.loading && <p style={mutedStyle}>טוען פסיקה…</p>}
|
||||||
|
{precedents.error && (
|
||||||
|
<p style={{ color: "crimson" }}>שגיאה: {precedents.error.message}</p>
|
||||||
|
)}
|
||||||
|
{!precedents.loading && precedentsList.length === 0 && (
|
||||||
|
<p style={mutedStyle}>לא צורפה עדיין פסיקה לתיק.</p>
|
||||||
|
)}
|
||||||
|
{precedentsList.length > 0 && (
|
||||||
|
<ul style={ulStyle}>
|
||||||
|
{precedentsList.map((p) => (
|
||||||
|
<li key={p.id} style={liStyle}>
|
||||||
|
<strong>{p.citation}</strong>
|
||||||
|
{p.practice_area && (
|
||||||
|
<span style={mutedStyle}> — {p.practice_area}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={sectionStyle}>
|
||||||
|
<h3 style={headingStyle}>פסיקה חסרה ({missingList.length})</h3>
|
||||||
|
{missing.loading && <p style={mutedStyle}>טוען רשימת חסרים…</p>}
|
||||||
|
{missing.error && (
|
||||||
|
<p style={{ color: "crimson" }}>שגיאה: {missing.error.message}</p>
|
||||||
|
)}
|
||||||
|
{!missing.loading && missingList.length === 0 && (
|
||||||
|
<p style={mutedStyle}>אין פסיקה חסרה פתוחה בתיק זה.</p>
|
||||||
|
)}
|
||||||
|
{missingList.length > 0 && (
|
||||||
|
<ul style={ulStyle}>
|
||||||
|
{missingList.map((m) => (
|
||||||
|
<li key={m.id} style={liStyle}>
|
||||||
|
<strong>{m.citation}</strong>
|
||||||
|
<span style={mutedStyle}> — {m.status}</span>
|
||||||
|
{m.legal_topic && (
|
||||||
|
<span style={mutedStyle}> ({m.legal_topic})</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/ui/LegalCasesWidget.tsx
Normal file
132
src/ui/LegalCasesWidget.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
|
||||||
|
import type { DashboardStats } from "./types.js";
|
||||||
|
|
||||||
|
const widgetStyle: React.CSSProperties = {
|
||||||
|
padding: 12,
|
||||||
|
fontFamily:
|
||||||
|
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
|
||||||
|
color: "inherit",
|
||||||
|
};
|
||||||
|
|
||||||
|
const headingStyle: React.CSSProperties = {
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalStyle: React.CSSProperties = {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: "4px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ulStyle: React.CSSProperties = {
|
||||||
|
listStyle: "none",
|
||||||
|
paddingInlineStart: 0,
|
||||||
|
margin: 0,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr max-content",
|
||||||
|
rowGap: 2,
|
||||||
|
fontSize: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
color: "rgba(127,127,127,0.85)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
new: "תיק חדש",
|
||||||
|
uploading: "העלאת מסמכים",
|
||||||
|
processing: "עיבוד מסמכים",
|
||||||
|
documents_ready: "מסמכים מוכנים",
|
||||||
|
outcome_set: "תוצאה הוזנה",
|
||||||
|
brainstorming: "סיעור מוחות",
|
||||||
|
direction_approved: "כיוון אושר",
|
||||||
|
drafting: "כתיבה",
|
||||||
|
qa_review: "בדיקת איכות",
|
||||||
|
drafted: "טיוטה מוכנה",
|
||||||
|
exported: "DOCX נוצר",
|
||||||
|
reviewed: "נבדק",
|
||||||
|
final: "סופי",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegalCasesWidget(_props: PluginWidgetProps) {
|
||||||
|
const { data, loading, error } = usePluginData<DashboardStats | null>(
|
||||||
|
"legal-dashboard-stats",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div dir="rtl" style={widgetStyle}>
|
||||||
|
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||||
|
<p style={labelStyle}>טוען…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div dir="rtl" style={widgetStyle}>
|
||||||
|
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||||
|
<p style={{ color: "crimson", fontSize: 13 }}>{error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div dir="rtl" style={widgetStyle}>
|
||||||
|
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||||
|
<p style={labelStyle}>אין נתונים זמינים.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(data.byStatus).sort(([, a], [, b]) => b - a);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div dir="rtl" style={widgetStyle}>
|
||||||
|
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||||
|
<div style={totalStyle}>{data.totalCases}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "rgba(127,127,127,0.85)" }}>
|
||||||
|
סה"כ תיקים פעילים
|
||||||
|
</div>
|
||||||
|
<hr
|
||||||
|
style={{
|
||||||
|
margin: "8px 0",
|
||||||
|
border: 0,
|
||||||
|
borderTop: "1px solid rgba(127,127,127,0.25)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{entries.length > 0 ? (
|
||||||
|
<ul style={ulStyle}>
|
||||||
|
{entries.map(([status, count]) => (
|
||||||
|
<li
|
||||||
|
key={status}
|
||||||
|
style={{ display: "contents" }}
|
||||||
|
aria-label={`${STATUS_LABELS[status] ?? status}: ${count}`}
|
||||||
|
>
|
||||||
|
<span style={labelStyle}>{STATUS_LABELS[status] ?? status}</span>
|
||||||
|
<span>{count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p style={labelStyle}>אין תיקים פעילים.</p>
|
||||||
|
)}
|
||||||
|
<hr
|
||||||
|
style={{
|
||||||
|
margin: "8px 0",
|
||||||
|
border: 0,
|
||||||
|
borderTop: "1px solid rgba(127,127,127,0.25)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p style={{ margin: 0, fontSize: 13 }}>
|
||||||
|
<span style={labelStyle}>פעילות שבועית: </span>
|
||||||
|
<strong>{data.weekActivity}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/ui/index.tsx
Normal file
10
src/ui/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* UI bundle entrypoint for the legal-ai Paperclip plugin.
|
||||||
|
*
|
||||||
|
* Each named export here corresponds to a `slot.exportName` in the manifest's
|
||||||
|
* `ui.slots` declaration. The host loads this module as an ES module and
|
||||||
|
* mounts the matching component into its slot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { LegalCasesWidget } from "./LegalCasesWidget.js";
|
||||||
|
export { LegalCaseTab } from "./LegalCaseTab.js";
|
||||||
72
src/ui/types.ts
Normal file
72
src/ui/types.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for the legal-ai plugin UI bundle.
|
||||||
|
*
|
||||||
|
* Mirrors a minimal subset of the legal-ai backend response shapes so the UI
|
||||||
|
* components can type their props/data without pulling the full LegalApi
|
||||||
|
* client (which is a worker-side dependency).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LegalCaseSummary {
|
||||||
|
case_number: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
practice_area?: string | null;
|
||||||
|
appeal_subtype?: string | null;
|
||||||
|
proceeding_type?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
archived_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalCaseDetails {
|
||||||
|
id: string;
|
||||||
|
case_number: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
practice_area?: string | null;
|
||||||
|
appeal_subtype?: string | null;
|
||||||
|
appellants?: string[];
|
||||||
|
respondents?: string[];
|
||||||
|
subject?: string;
|
||||||
|
property_address?: string;
|
||||||
|
expected_outcome?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalArgument {
|
||||||
|
id?: string;
|
||||||
|
party: string;
|
||||||
|
argument_title?: string;
|
||||||
|
argument_body?: string;
|
||||||
|
claim_index?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalArgumentsResponse {
|
||||||
|
case_number: string;
|
||||||
|
total: number;
|
||||||
|
by_party: Record<string, LegalArgument[]>;
|
||||||
|
arguments: LegalArgument[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrecedentRow {
|
||||||
|
id: string;
|
||||||
|
citation: string;
|
||||||
|
section_id?: string | null;
|
||||||
|
practice_area?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissingPrecedentRow {
|
||||||
|
id: string;
|
||||||
|
citation: string;
|
||||||
|
status: string;
|
||||||
|
legal_topic?: string | null;
|
||||||
|
cited_by_party?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
weekActivity: number;
|
||||||
|
totalCases: number;
|
||||||
|
}
|
||||||
977
src/worker.ts
977
src/worker.ts
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,10 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"declaration": true
|
"declaration": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user