Merge remote-tracking branch 'origin/main' into prsync/7407

This commit is contained in:
Anthony Stirling
2026-08-11 12:11:56 +01:00
309 changed files with 17534 additions and 5592 deletions
-2
View File
@@ -119,8 +119,6 @@
"extensions": [
"elagil.pre-commit-helper", // Support for pre-commit hooks to enforce code quality
"josevseb.google-java-format-for-vs-code", // Google Java code formatter to follow the Google Java Style Guide
"ms-python.black-formatter", // Python code formatter using Black
"ms-python.flake8", // Flake8 linter for Python to enforce code quality
"ms-python.python", // Official Microsoft Python extension with IntelliSense, debugging, and Jupyter support
"ms-vscode-remote.vscode-remote-extensionpack", // Remote Development Pack for SSH, WSL, and Containers
// "Oracle.oracle-java", // Oracle Java extension with additional features for Java development
+9 -28
View File
@@ -19,9 +19,9 @@ import argparse
import glob
import os
import re
import tomllib # Python 3.11+ (stdlib)
from pathlib import Path
import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files
@@ -133,11 +133,7 @@ def update_missing_keys(reference_file, file_list, branch=""):
file_path = Path(file_path)
language_dir = file_path.parent.name
reference_lang_dir = reference_file.parent.name
if (
language_dir == reference_lang_dir
or file_path.suffix != ".toml"
or file_path.parents[1].name != "locales"
):
if language_dir == reference_lang_dir or file_path.suffix != ".toml" or file_path.parents[1].name != "locales":
print(f"Skipping file: {file_path}")
continue
@@ -198,9 +194,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
# Verify that file is within the expected directory
if not absolute_path.is_relative_to(base_dir):
has_differences = True
report.append(
f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n"
)
report.append(f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n")
continue
# Verify file size before processing
@@ -214,10 +208,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if basename_current_file == basename_reference_file and locale_dir == "en-US":
continue
if (
file_normpath.suffix != ".toml"
or basename_current_file != "translation.toml"
):
if file_normpath.suffix != ".toml" or basename_current_file != "translation.toml":
continue
only_reference_file = False
@@ -261,9 +252,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
)
report.append("")
report.append(" Use the following command to remove them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`"
)
report.append(f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`")
report.append("")
if extra_keys_list:
report.append(
@@ -271,9 +260,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
)
report.append("")
report.append(" Use the following command to add them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`"
)
report.append(f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`")
report.append("")
if missing_keys_list or extra_keys_list:
@@ -288,9 +275,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
output = "\n".join(
[
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
for key, first, duplicate in find_duplicate_keys(
branch_path / file_normpath
)
for key, first, duplicate in find_duplicate_keys(branch_path / file_normpath)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
@@ -313,18 +298,14 @@ def check_for_differences(reference_file, file_list, branch, actor):
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
report.append(f"Thanks @{actor} for your help in keeping the translations up to date.")
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Find missing keys in TOML translation files"
)
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
parser.add_argument(
"--actor",
required=False,
-9
View File
@@ -1,9 +0,0 @@
pip
setuptools
WeasyPrint
pdf2image
pillow
unoserver
opencv-python-headless
pre-commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
-528
View File
@@ -1,528 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in'
#
# WARNING: pip install will require the following package to be hashed.
# Consider using a hashable URL like https://github.com/jazzband/pip-tools/archive/SOMECOMMIT.zip
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
# via
# -r .github/scripts/requirements_dev.in
# fonttools
cffi==2.1.0 \
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
# via weasyprint
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
# via pre-commit
cssselect2==0.9.0 \
--hash=sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563 \
--hash=sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb
# via weasyprint
distlib==0.4.3 \
--hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \
--hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed
# via virtualenv
filelock==3.30.0 \
--hash=sha256:1774e682dbe443bd60f9609162fc596e2c80dc84ffc2957068953406d0520090 \
--hash=sha256:40632998f0772e64183bb819f086a1b9def6be1090cf1dcb9d45f46806ef279b
# via
# python-discovery
# virtualenv
fonttools==4.63.0 \
--hash=sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69 \
--hash=sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c \
--hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac \
--hash=sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096 \
--hash=sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d \
--hash=sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68 \
--hash=sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616 \
--hash=sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78 \
--hash=sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f \
--hash=sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b \
--hash=sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b \
--hash=sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02 \
--hash=sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d \
--hash=sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f \
--hash=sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 \
--hash=sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272 \
--hash=sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49 \
--hash=sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419 \
--hash=sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001 \
--hash=sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03 \
--hash=sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196 \
--hash=sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9 \
--hash=sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e \
--hash=sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5 \
--hash=sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007 \
--hash=sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380 \
--hash=sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8 \
--hash=sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27 \
--hash=sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40 \
--hash=sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e \
--hash=sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0 \
--hash=sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263 \
--hash=sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb \
--hash=sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94 \
--hash=sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b \
--hash=sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6 \
--hash=sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579 \
--hash=sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4 \
--hash=sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59 \
--hash=sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0 \
--hash=sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e \
--hash=sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be \
--hash=sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd \
--hash=sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18 \
--hash=sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22 \
--hash=sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0 \
--hash=sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b \
--hash=sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b \
--hash=sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af \
--hash=sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745
# via weasyprint
identify==2.6.19 \
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
# via pre-commit
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pre-commit
numpy==2.4.6 \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
# via opencv-python-headless
opencv-python-headless==5.0.0.93 \
--hash=sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f \
--hash=sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4 \
--hash=sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f \
--hash=sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00 \
--hash=sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e \
--hash=sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4 \
--hash=sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c \
--hash=sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9 \
--hash=sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37
# via -r .github/scripts/requirements_dev.in
pdf2image==1.17.0 \
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
# via -r .github/scripts/requirements_dev.in
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
# via
# -r .github/scripts/requirements_dev.in
# pdf2image
# weasyprint
platformdirs==4.10.0 \
--hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \
--hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a
# via
# python-discovery
# virtualenv
pre-commit==4.6.0 \
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
# via -r .github/scripts/requirements_dev.in
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pydyf==0.12.1 \
--hash=sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc \
--hash=sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095
# via weasyprint
pyphen==0.17.2 \
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
# via weasyprint
python-discovery==1.4.4 \
--hash=sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3 \
--hash=sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe
# via virtualenv
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via pre-commit
tinycss2==1.5.1 \
--hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \
--hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957
# via
# cssselect2
# weasyprint
tinyhtml5==2.1.0 \
--hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \
--hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a
# via weasyprint
unoserver==3.7 \
--hash=sha256:b05f9578506ac7374ae1b314c3a79528636c542ac78220a9ce99110584ca424b \
--hash=sha256:fc44e6808071c9d2957e705ecf1742cea8a582aa5d5cc23babf36bb332ec6e8e
# via -r .github/scripts/requirements_dev.in
virtualenv==21.6.1 \
--hash=sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128 \
--hash=sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b
# via pre-commit
weasyprint==69.0 \
--hash=sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6 \
--hash=sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c
# via -r .github/scripts/requirements_dev.in
webencodings==0.5.1 \
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
--hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
# via
# cssselect2
# tinycss2
# tinyhtml5
zopfli==0.4.3 \
--hash=sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d \
--hash=sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef \
--hash=sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3 \
--hash=sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6 \
--hash=sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc \
--hash=sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42 \
--hash=sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e \
--hash=sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073 \
--hash=sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357 \
--hash=sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe \
--hash=sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94 \
--hash=sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206 \
--hash=sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e
# via fonttools
# The following packages are considered to be unsafe in a requirements file:
pip==26.1.2 \
--hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
--hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
# via -r .github/scripts/requirements_dev.in
setuptools==83.0.0 \
--hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \
--hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3
# via -r .github/scripts/requirements_dev.in
@@ -1,2 +0,0 @@
tomlkit
tomli-w
@@ -1,14 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
#
tomli-w==1.2.0 \
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
# via -r .github/scripts/requirements_sync_readme.in
tomlkit==0.15.0 \
--hash=sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738 \
--hash=sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3
# via -r .github/scripts/requirements_sync_readme.in
+6 -7
View File
@@ -4,19 +4,18 @@
Usage: verify-updater-signatures.py <dir-to-scan> [tauri.conf.json]
"""
import binascii
import sys
import json
import base64
import binascii
import hashlib
import json
import sys
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
ART_ROOT = Path(sys.argv[1])
CONF = Path(
sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json"
)
CONF = Path(sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json")
def load_pubkey():
@@ -264,7 +264,7 @@ jobs:
if: needs.check-comment.outputs.enable_prototypes == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./engine
context: .
file: ./engine/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-engine
+11
View File
@@ -31,6 +31,9 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: ai-engine
- name: Install Task
@@ -96,6 +99,14 @@ jobs:
echo "============================================"
exit 1
- name: Build engine production image
if: always()
run: docker build --file engine/Dockerfile --tag stirling-pdf-engine:ci .
- name: Build engine development image
if: always()
run: docker build --file engine/Dockerfile.dev --tag stirling-pdf-engine-dev:ci .
- name: Remove engine check comment on success
if: steps.engine-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
+8 -6
View File
@@ -192,12 +192,14 @@ jobs:
retention-days: 3
if-no-files-found: warn
- name: Install defusedxml for coverage summary
# coverage-summary.py parses JaCoCo XML through defusedxml to
# silence security scanners that pattern-match on the stdlib
# xml.etree.ElementTree.parse call.
- name: Install uv
if: always() && matrix.flavor == 'saas'
run: python -m pip install --quiet defusedxml
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: JaCoCo coverage step summary
# Only the saas leg posts the JUnit summary - it's a strict
@@ -206,7 +208,7 @@ jobs:
# near-identical tables crowding out the aggregate report.
if: always() && matrix.flavor == 'saas'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Backend JUnit coverage (JDK ${{ matrix.jdk-version }})" \
--jacoco "common=app/common/build/reports/jacoco/test/jacocoTestReport.xml" \
--jacoco "core=app/core/build/reports/jacoco/test/jacocoTestReport.xml" \
+9 -8
View File
@@ -329,15 +329,16 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: ./testing/cucumber/requirements.txt
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Install behave test deps
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
uv sync --project engine --locked --group cucumber
- name: Build the multi-node image
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" build
@@ -360,10 +361,10 @@ jobs:
- name: Run multi-node regression (implemented guarantees)
working-directory: testing/cucumber
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
run: uv run --project ../../engine --locked --group cucumber python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
- name: Run multi-node failover (destructive)
working-directory: testing/cucumber
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
run: uv run --project ../../engine --locked --group cucumber python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
- name: Dump node logs on failure
if: failure()
working-directory: testing/compose
@@ -34,6 +34,9 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: generated-models
- name: Set up JDK 25
+8 -5
View File
@@ -195,20 +195,23 @@ jobs:
console.log(`Reference file path: ${referenceFilePath}`);
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
uv sync --project engine --locked --group tools
- name: Run Python script to check files
id: run-check
run: |
echo "Running Python script to check TOML files..."
python .github/scripts/check_language_toml.py \
uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py \
--actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \
--branch "pr-branch" \
+9 -11
View File
@@ -57,15 +57,13 @@ jobs:
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage scripts
# Both coverage-summary.py and coverage-matrix.py parse JaCoCo
# XML through defusedxml - see the script headers for context.
run: python -m pip install --quiet defusedxml
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
# Pattern matches every artifact this PR's producers might upload:
# jacoco-exec-junit-jdk-25 (uploaded only by the saas
@@ -158,7 +156,7 @@ jobs:
# ("how much of the backend do real user flows cover?").
if: steps.inventory.outputs.found_e2e == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Real user-flow backend coverage (e2e:live + cucumber)" \
--jacoco "merged=build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml" \
--github-step-summary
@@ -169,7 +167,7 @@ jobs:
# is meaningless when one is a strict superset of the other.
if: steps.inventory.outputs.found_all == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Combined backend coverage (JUnit + e2e:live + cucumber)" \
--jacoco "merged=build/reports/jacoco/aggregate-all/jacocoTestReport.xml" \
--github-step-summary
@@ -224,7 +222,7 @@ jobs:
# generated above) plus whichever frontend artifacts landed.
# Every input is optional; missing ones render as "-".
run: |
python scripts/coverage-matrix.py \
uv run --project engine --locked --group tools python scripts/coverage-matrix.py \
${{ steps.inventory.outputs.found_all == 'true' && '--jacoco-all build/reports/jacoco/aggregate-all/jacocoTestReport.xml' || '' }} \
${{ steps.inventory.outputs.found_e2e == 'true' && '--jacoco-e2e build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml' || '' }} \
--vitest matrix-inputs/vitest/coverage-summary.json \
+9 -14
View File
@@ -69,16 +69,17 @@ jobs:
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Pip requirements
- name: Install Cucumber and coverage dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
uv sync --project engine --locked --group cucumber --group tools
- name: Extract JaCoCo agent for cucumber coverage
# Stages build/jacoco/jacocoagent.jar where the coverage override
@@ -121,16 +122,10 @@ jobs:
echo "report=false" >> "$GITHUB_OUTPUT"
fi
- name: Install defusedxml for coverage summary
# coverage-summary.py parses JaCoCo XML through defusedxml -
# see the script header for context.
if: always() && steps.cucumber-coverage.outputs.report == 'true'
run: python -m pip install --quiet defusedxml
- name: Cucumber coverage step summary
if: always() && steps.cucumber-coverage.outputs.report == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Cucumber (docker) JaCoCo coverage" \
--jacoco "cucumber=build/reports/jacoco/cucumber/jacocoTestReport.xml" \
--github-step-summary
+13 -32
View File
@@ -119,20 +119,18 @@ jobs:
echo "::warning::No e2e:live .exec found at .test-state/playwright/jacoco.exec; skipping report"
echo "report=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Python for coverage summary
if: always() && steps.live-coverage.outputs.report == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
if: always()
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
# coverage-summary.py uses defusedxml instead of stdlib xml.etree
# to dodge XXE / billion-laughs scanner findings.
if: always() && steps.live-coverage.outputs.report == 'true'
run: python -m pip install --quiet defusedxml
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: e2e:live coverage step summary
if: always() && steps.live-coverage.outputs.report == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Playwright (live backend) JaCoCo coverage" \
--jacoco "e2e-live=build/reports/jacoco/e2e-live/jacocoTestReport.xml" \
--github-step-summary
@@ -155,23 +153,6 @@ jobs:
retention-days: 7
if-no-files-found: warn
- name: Set up Python for frontend coverage summary
# Separate from the backend-coverage python step because the
# frontend path doesn't depend on a JaCoCo report - it produces
# a summary even on backend failure, as long as some Playwright
# tests ran far enough to dump V8 coverage.
if: always()
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install defusedxml for frontend coverage summary
# Idempotent re-install: the backend-coverage step may have
# installed it already, but this leg can run on its own when the
# backend report step skips (e.g. .exec missing).
if: always()
run: python -m pip install --quiet defusedxml
- name: Aggregate Playwright frontend (V8) coverage
# Rolls per-test V8 dumps from the test-base fixture into one
# vitest-shaped coverage-summary.json. Tolerates a missing dump
@@ -182,7 +163,7 @@ jobs:
run: |
if [ -d .test-state/playwright/coverage-pw ] && \
find .test-state/playwright/coverage-pw -name '*.json' -type f | grep -q .; then
python scripts/playwright-coverage-summary.py \
uv run --project engine --locked --group tools python scripts/playwright-coverage-summary.py \
.test-state/playwright/coverage-pw \
--out .test-state/playwright/coverage-pw-summary/coverage-summary.json
echo "summary=true" >> "$GITHUB_OUTPUT"
@@ -194,10 +175,10 @@ jobs:
- name: Playwright frontend coverage step summary
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
run: |
python scripts/coverage-summary.py \
--title "Playwright (live) frontend coverage" \
--vitest .test-state/playwright/coverage-pw-summary/coverage-summary.json \
--github-step-summary
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Playwright (live) frontend coverage" \
--vitest .test-state/playwright/coverage-pw-summary/coverage-summary.json \
--github-step-summary
- name: Upload Playwright frontend coverage
# Bundle both the aggregated summary and the raw V8 dumps so
+19 -6
View File
@@ -2,7 +2,8 @@ name: Playwright E2E (stubbed)
# Reusable workflow called from build.yml. Backend-free Playwright suite —
# fast, no Spring Boot required. Runs against the `stubbed` project which
# mocks API responses in the browser.
# mocks API responses in the browser. Fans out one job per browser
# (chromium/firefox/webkit) so all three run in parallel on their own runner.
on:
workflow_call:
@@ -11,7 +12,19 @@ permissions:
jobs:
playwright-e2e:
name: playwright-e2e (${{ matrix.browser }})
runs-on: ubuntu-latest
strategy:
# One browser breaking must not mask a failure in another - report all.
fail-fast: false
matrix:
include:
- browser: chromium
project: stubbed
- browser: firefox
project: stubbed-firefox
- browser: webkit
project: stubbed-webkit
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
@@ -27,16 +40,16 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Install Playwright (${{ matrix.browser }})
run: task e2e:install -- ${{ matrix.browser }}
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
- name: Run stubbed E2E tests (${{ matrix.browser }})
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
@@ -50,6 +63,6 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
name: playwright-report-stubbed-${{ matrix.browser }}-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 7
+7 -9
View File
@@ -115,20 +115,18 @@ jobs:
id: frontend-coverage
continue-on-error: true
run: task frontend:test:coverage
- name: Set up Python for coverage summary
- name: Install uv
if: always()
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
# See coverage-summary.py header - it parses XML through defusedxml
# to dodge the stdlib parser's exposure to XXE / billion-laughs.
if: always()
run: python -m pip install --quiet defusedxml
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Vitest coverage step summary
if: always()
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Frontend Vitest coverage" \
--vitest frontend/editor/coverage/coverage-summary.json \
--github-step-summary
+8 -2
View File
@@ -887,10 +887,16 @@ jobs:
# Gate publish on valid updater sigs. Runs after the review upload (so
# artifacts survive for debugging) and before action-gh-release.
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Verify updater signatures
run: |
python3 -m pip install --quiet 'cryptography==44.0.0'
python3 .github/scripts/verify-updater-signatures.py \
uv run --project engine --locked --only-group updater-signatures python .github/scripts/verify-updater-signatures.py \
./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json
# workflow_dispatch path requires platform=='all' so a single-platform
+4 -3
View File
@@ -59,9 +59,10 @@ jobs:
# the story itself — a shared component, a theme token — still surfaces within
# a day.
a11y-all-stories:
name: a11y (every story)
name: a11y (every story, light + dark)
runs-on: ubuntu-latest
timeout-minutes: 60
# Two full sweeps (one per theme), each ~30 minutes of browser time.
timeout-minutes: 120
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
@@ -81,7 +82,7 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: a11y gate (every story)
- name: a11y gate (every story, light + dark)
run: task frontend:storybook:a11y
- name: Upload scan reports
+3
View File
@@ -28,6 +28,9 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: pre-commit
- name: Install Task
+9 -12
View File
@@ -51,28 +51,25 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: sync-files
- name: Install Python dependencies
run: |
uv sync --project engine --locked --group tools
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
- name: Sort translation TOML files
run: |
@@ -85,7 +82,7 @@ jobs:
- name: Sync README.md
run: |
python scripts/counter_translation_v3.py
uv run --project engine --locked --group tools python scripts/counter_translation_v3.py
- name: Run git add
run: |
+9
View File
@@ -15,6 +15,15 @@ tasks:
cmds:
- npx playwright test --project=stubbed {{.CLI_ARGS}}
stubbed-project:
desc: "Run the stubbed E2E suite for a single Playwright project"
dir: frontend/editor
deps: [ ':frontend:prepare' ]
vars:
PROJECT: '{{.PROJECT | default "stubbed"}}'
cmds:
- npx playwright test --project={{.PROJECT}} {{.CLI_ARGS}}
live:
desc: "Run live E2E tests"
summary: |
+40 -13
View File
@@ -2,22 +2,49 @@ version: '3'
tasks:
install:
desc: "Install engine dependencies"
desc: "Install engine runtime and development dependencies"
run: once
cmds:
- uv python install 3.13.8
- uv sync
- uv sync --locked --group engine --group engine-dev
sources:
- uv.lock
- pyproject.toml
status:
- test -d .venv
lock:
desc: "Update the engine lockfile from project metadata"
cmds:
- uv lock
lock:upgrade:
desc: "Upgrade allowed engine dependencies and update the lockfile"
cmds:
- uv lock --upgrade
lock:check:
desc: "Check whether the engine lockfile is current"
cmds:
- uv lock --check
update:
desc: "Upgrade engine dependencies and synchronize the environment"
cmds:
- task: lock:upgrade
- uv sync --locked --group engine --group engine-dev
update:all:
desc: "Upgrade all Python dependency groups and synchronize the environment"
cmds:
- task: lock:upgrade
- uv sync --locked --all-groups
prepare:
desc: "Set up engine .env from template"
deps: [install]
cmds:
- uv run scripts/setup_env.py
- uv run --locked --group engine --group engine-dev scripts/setup_env.py
sources:
- scripts/setup_env.py
generates:
@@ -33,7 +60,7 @@ tasks:
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
- uv run --locked --group engine uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
dev:
desc: "Start engine dev server with hot reload"
@@ -45,43 +72,43 @@ tasks:
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload
- uv run --locked --group engine --group engine-dev uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload
lint:
desc: "Run linting"
deps: [install]
cmds:
- uv run ruff check .
- uv run --locked --group engine --group engine-dev ruff check .
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- uv run ruff check . --fix
- uv run --locked --group engine --group engine-dev ruff check . --fix
format:
desc: "Auto-fix code formatting"
deps: [install]
cmds:
- uv run ruff format .
- uv run --locked --group engine --group engine-dev ruff format .
format:check:
desc: "Check code formatting"
deps: [install]
cmds:
- uv run ruff format . --diff
- uv run --locked --group engine --group engine-dev ruff format . --diff
typecheck:
desc: "Run type checking"
deps: [install]
cmds:
- uv run pyright . --warnings
- uv run --locked --group engine --group engine-dev pyright . --warnings
test:
desc: "Run tests"
deps: [prepare]
cmds:
- uv run pytest tests
- uv run --locked --group engine --group engine-dev pytest tests
fix:
desc: "Auto-fix lint + format"
@@ -102,7 +129,7 @@ tasks:
desc: "Generate tool_models.py from Java OpenAPI spec (SwaggerDoc.json)"
deps: [install, ":backend:swagger"]
cmds:
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
- uv run --locked --group engine --group engine-dev python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
sources:
- ../SwaggerDoc.json
- scripts/generate_tool_models.py
@@ -114,7 +141,7 @@ tasks:
desc: "Fail if the committed tool models are out of date"
deps: [install, ":backend:swagger"]
cmds:
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
- uv run --locked --group engine --group engine-dev python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
clean:
desc: "Clean build artifacts"
+8 -2
View File
@@ -211,11 +211,13 @@ tasks:
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
storybook:a11y:
desc: "a11y regression gate over every story: fail only on NEW axe violations"
desc: "a11y regression gate over every story, light and dark: fail only on NEW axe violations"
deps: [prepare, storybook:browser]
cmds:
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
storybook:a11y:changed:
desc: "a11y gate over the stories this branch affects (default base origin/main)"
@@ -244,13 +246,17 @@ tasks:
fi
node .storybook/a11y-scan.mjs {{.CHANGED}}
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CHANGED}}
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
storybook:a11y:record:
desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)"
desc: "Re-record both a11y baselines (run after intentionally fixing/adding violations)"
deps: [prepare, storybook:browser]
cmds:
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record --baseline .storybook/a11y-baseline.dark.json
# ============================================================
# Code quality
+17 -13
View File
@@ -45,6 +45,10 @@ vars:
# which owns the version and caches the binary here.
GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}'
env:
# Keep repository-wide checks isolated from the engine runtime environment.
UV_PROJECT_ENVIRONMENT: '.venv-pre-commit'
tasks:
default:
desc: "Check formatting, spelling, and secrets across the repo"
@@ -76,19 +80,19 @@ tasks:
desc: "Install the pinned pre-commit Python tools"
run: once
cmds:
- uv sync --project scripts/pre-commit --locked
- uv sync --project engine --locked --group pre-commit
sources:
- scripts/pre-commit/uv.lock
- scripts/pre-commit/pyproject.toml
- engine/uv.lock
- engine/pyproject.toml
status:
- test -d scripts/pre-commit/.venv
- test -d engine/.venv-pre-commit
clean:
desc: "Remove the cached gitleaks binary and the tool virtualenv"
desc: "Remove the cached gitleaks binary and the pre-commit virtualenv"
cmds:
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
- cmd: rm -rf engine/.venv-pre-commit .task/bin/gitleaks
platforms: [linux, darwin]
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
- cmd: cmd /c "rmdir /s /q engine\.venv-pre-commit & del /q .task\bin\gitleaks.exe"
platforms: [windows]
ignore_error: true
@@ -97,26 +101,26 @@ tasks:
ruff:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
- uv run --project engine --locked --group pre-commit ruff check --isolated --line-length=120 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
ruff-format:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
- uv run --project engine --locked --group pre-commit ruff format --isolated --line-length=120 {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
codespell:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
- uv run --project engine --locked --group pre-commit codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
toml-sort:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
whitespace:
cmds:
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
gitleaks:
deps: [gitleaks-bin]
@@ -130,4 +134,4 @@ tasks:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
cmds:
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/install_gitleaks.py
+1 -2
View File
@@ -2,8 +2,6 @@
"recommendations": [
"elagil.pre-commit-helper", // Support for pre-commit hooks to enforce code quality
"josevseb.google-java-format-for-vs-code", // Google Java code formatter to follow the Google Java Style Guide
"ms-python.black-formatter", // Python code formatter using Black
"ms-python.flake8", // Flake8 linter for Python to enforce code quality
"ms-python.python", // Official Microsoft Python extension with IntelliSense, debugging, and Jupyter support
"ms-vscode-remote.vscode-remote-extensionpack", // Remote Development Pack for SSH, WSL, and Containers
// "Oracle.oracle-java", // Oracle Java extension with additional features for Java development
@@ -19,6 +17,7 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"tamasfe.even-better-toml", // TOML language support and formatting
"oxc.oxc-vscode", // Oxc (oxlint) extension for JavaScript/TypeScript linting
]
}
+2 -1
View File
@@ -20,8 +20,9 @@
"editor.defaultFormatter": "vscode.json-language-features"
},
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
"editor.defaultFormatter": "charliermarsh.ruff"
},
"ruff.configuration": "${workspaceFolder}/engine/pyproject.toml",
"[gradle-kotlin-dsl]": {
"editor.defaultFormatter": "vscjava.vscode-gradle"
},
@@ -1,5 +1,6 @@
package stirling.software.common.config.swagger;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
@@ -18,6 +19,7 @@ import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOCase;
import stirling.software.common.model.tool.ToolIOWhen;
import stirling.software.common.service.ToolIOParameterDefaults;
/**
* Publishes each {@link ToolIO} into the spec as {@code x-stirling-io}, which is how the frontend
@@ -49,40 +51,42 @@ public class ToolIOOperationCustomizer
if (declaration == null) {
return operation;
}
operation.addExtension(EXTENSION_NAME, toExtension(declaration));
operation.addExtension(EXTENSION_NAME, toExtension(declaration, handlerMethod.getMethod()));
operation.setDescription(appendSummaryLine(operation.getDescription(), declaration));
return operation;
}
private static Map<String, Object> toExtension(ToolIO declaration) {
private static Map<String, Object> toExtension(ToolIO declaration, Method handler) {
Map<String, Object> extension = new LinkedHashMap<>();
extension.put("accepts", names(declaration.accepts()));
extension.put("produces", declaration.produces().name());
extension.put("arity", declaration.arity().name());
if (declaration.cases().length > 0) {
extension.put("cases", cases(declaration));
extension.put("cases", cases(declaration, handler));
}
return extension;
}
private static List<Map<String, Object>> cases(ToolIO declaration) {
return Arrays.stream(declaration.cases()).map(ToolIOOperationCustomizer::toCase).toList();
private static List<Map<String, Object>> cases(ToolIO declaration, Method handler) {
return Arrays.stream(declaration.cases()).map(rule -> toCase(rule, handler)).toList();
}
private static Map<String, Object> toCase(ToolIOCase rule) {
private static Map<String, Object> toCase(ToolIOCase rule, Method handler) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put(
"when",
Arrays.stream(rule.when()).map(ToolIOOperationCustomizer::toCondition).toList());
entry.put("when", Arrays.stream(rule.when()).map(c -> toCondition(c, handler)).toList());
entry.put("produces", rule.produces().name());
entry.put("arity", rule.arity().name());
return entry;
}
private static Map<String, Object> toCondition(ToolIOWhen condition) {
private static Map<String, Object> toCondition(ToolIOWhen condition, Method handler) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("param", condition.param());
entry.put("matches", List.of(condition.matches()));
// The default the endpoint uses when this parameter is absent, so a step that never sends
// it still resolves. Omitted when the parameter is required with none.
ToolIOParameterDefaults.resolve(handler, condition.param())
.ifPresent(value -> entry.put("default", value));
return entry;
}
@@ -5,13 +5,18 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/** The runtime form of a {@link ToolIO} declaration, read off a handler method once at startup. */
public record ToolIOSpec(
Set<ToolFormat> accepts, ToolFormat produces, ToolArity arity, List<Case> cases) {
public record When(String param, List<String> matches) {
/**
* @param paramDefault the value used when the parameter is absent, or null when it has no
* default - an absent parameter then leaves the case unresolved rather than defaulted.
*/
public record When(String param, List<String> matches, String paramDefault) {
boolean holdsFor(Object value) {
String normalised = normalise(value);
@@ -19,6 +24,14 @@ public record ToolIOSpec(
}
}
/** Supplies the default value a request parameter takes when a caller omits it. */
@FunctionalInterface
public interface ParameterDefaults {
Optional<String> defaultFor(String param);
ParameterDefaults NONE = param -> Optional.empty();
}
/**
* Both sides of a condition are normalised at comparison, not at construction: the declaration
* reaches the frontend and the engine as published data, and normalising only one side there
@@ -44,25 +57,33 @@ public record ToolIOSpec(
}
public static ToolIOSpec from(ToolIO annotation) {
return from(annotation, ParameterDefaults.NONE);
}
public static ToolIOSpec from(ToolIO annotation, ParameterDefaults defaults) {
return new ToolIOSpec(
new LinkedHashSet<>(Arrays.asList(annotation.accepts())),
annotation.produces(),
annotation.arity(),
Arrays.stream(annotation.cases()).map(ToolIOSpec::toCase).toList());
Arrays.stream(annotation.cases()).map(rule -> toCase(rule, defaults)).toList());
}
private static Case toCase(ToolIOCase rule) {
List<When> when = Arrays.stream(rule.when()).map(ToolIOSpec::toWhen).toList();
private static Case toCase(ToolIOCase rule, ParameterDefaults defaults) {
List<When> when = Arrays.stream(rule.when()).map(c -> toWhen(c, defaults)).toList();
return new Case(when, rule.produces(), rule.arity());
}
private static When toWhen(ToolIOWhen condition) {
return new When(condition.param(), List.of(condition.matches()));
private static When toWhen(ToolIOWhen condition, ParameterDefaults defaults) {
return new When(
condition.param(),
List.of(condition.matches()),
defaults.defaultFor(condition.param()).orElse(null));
}
/**
* First matching {@link Case} wins. If none match but one reads a parameter we cannot see, the
* declared output comes back uncertain: a value we never saw might have picked another branch.
* First matching {@link Case} wins. A parameter the caller omitted resolves to its declared
* default; only a parameter with no default leaves the output uncertain, since an unseen value
* might then have picked another branch.
*
* @param parameters the step's configured parameters, or null when not known
*/
@@ -71,12 +92,17 @@ public record ToolIOSpec(
for (Case rule : cases) {
boolean allHold = true;
for (When condition : rule.when()) {
if (parameters == null || !parameters.containsKey(condition.param())) {
Object value;
if (parameters != null && parameters.containsKey(condition.param())) {
value = parameters.get(condition.param());
} else if (condition.paramDefault() != null) {
value = condition.paramDefault();
} else {
sawUnknownParam = true;
allHold = false;
continue;
}
allHold &= condition.holdsFor(parameters.get(condition.param()));
allHold &= condition.holdsFor(value);
}
if (allHold) {
return new Output(rule.produces(), rule.arity(), true);
@@ -0,0 +1,92 @@
package stirling.software.common.service;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Optional;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
/**
* The value a request parameter takes when the caller omits it, read from the request model so a
* {@code @ToolIOCase} can be resolved even for a step that never sends the parameter it branches
* on. The default is read from the field so it cannot drift.
*/
@Slf4j
public final class ToolIOParameterDefaults {
// Swagger's sentinel for an unset @Schema string member; not a real default value.
private static final String SCHEMA_UNSET = "##default";
private ToolIOParameterDefaults() {}
/**
* The default {@code param} resolves to when absent, or empty when the parameter is required
* with no declared default - in which case an unset value leaves the output genuinely unknown
* rather than defaulted, and the chain reports it as uncertain.
*
* <p>Precedence: an explicit {@code @Schema(defaultValue)}, then the field's own value (a
* primitive's language default, or an initializer), then the empty string for an optional field
* left null, and finally empty for a required field with none of the above.
*/
public static Optional<String> resolve(Method handler, String param) {
for (Parameter parameter : handler.getParameters()) {
Field field = findField(parameter.getType(), param);
if (field != null) {
return fromField(parameter.getType(), field);
}
}
return Optional.empty();
}
private static Optional<String> fromField(Class<?> owner, Field field) {
Schema schema = field.getAnnotation(Schema.class);
if (schema != null
&& !schema.defaultValue().isEmpty()
&& !SCHEMA_UNSET.equals(schema.defaultValue())) {
return Optional.of(schema.defaultValue());
}
Object value = readField(owner, field);
if (value != null) {
return Optional.of(String.valueOf(value));
}
return isRequired(field, schema) ? Optional.empty() : Optional.of("");
}
private static Object readField(Class<?> owner, Field field) {
try {
Object instance = owner.getDeclaredConstructor().newInstance();
field.setAccessible(true);
return field.get(instance);
} catch (ReflectiveOperationException | RuntimeException e) {
// A request model we cannot instantiate leaves the default unknown, which the check
// treats conservatively as uncertain. Never break startup over it.
log.warn("Could not read default of {}.{}", owner.getSimpleName(), field.getName(), e);
return null;
}
}
private static boolean isRequired(Field field, Schema schema) {
if (schema != null && schema.requiredMode() == Schema.RequiredMode.REQUIRED) {
return true;
}
return field.isAnnotationPresent(NotNull.class)
|| field.isAnnotationPresent(NotBlank.class);
}
private static Field findField(Class<?> type, String name) {
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
try {
return c.getDeclaredField(name);
} catch (NoSuchFieldException ignored) {
// Try the superclass; request models extend a shared file-input base.
}
}
return null;
}
}
@@ -67,7 +67,10 @@ public class ToolIORegistry implements ToolMetadataService, ToolIOSource {
if (annotation == null) {
return;
}
ToolIOSpec spec = ToolIOSpec.from(annotation);
Method method = handler.getMethod();
ToolIOSpec spec =
ToolIOSpec.from(
annotation, param -> ToolIOParameterDefaults.resolve(method, param));
for (String pattern : extractPatterns(info)) {
target.put(pattern, spec);
}
@@ -101,7 +101,12 @@ class ToolChainValidatorConformanceTest {
for (JsonNode match : condition.get("matches")) {
matches.add(match.asString());
}
when.add(new ToolIOSpec.When(condition.get("param").asString(), matches));
JsonNode paramDefault = condition.get("default");
when.add(
new ToolIOSpec.When(
condition.get("param").asString(),
matches,
paramDefault == null ? null : paramDefault.asString()));
}
cases.add(
new ToolIOSpec.Case(
@@ -61,6 +61,7 @@ public class PdfOverlayController {
int overlayPos = request.getOverlayPosition();
MultipartFile[] overlayFiles = request.getOverlayFiles();
validateOverlayFiles(overlayFiles);
File[] overlayPdfFiles = new File[overlayFiles.length];
List<File> tempFiles = new ArrayList<>(); // List to keep track of temporary files
@@ -120,10 +121,29 @@ public class PdfOverlayController {
}
}
// Both fields are declared required, but @ModelAttribute binding leaves them null when the
// caller omits them, which would otherwise surface as a 500 instead of a 400.
private void validateOverlayFiles(MultipartFile[] overlayFiles) {
if (overlayFiles == null || overlayFiles.length == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFilesRequired", "At least one overlay file is required");
}
for (MultipartFile overlayFile : overlayFiles) {
if (overlayFile == null || overlayFile.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFileEmpty", "Overlay files must not be empty");
}
}
}
private Map<Integer, String> prepareOverlayGuide(
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
throws IOException {
Map<Integer, String> overlayGuide = new HashMap<>();
if (mode == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null");
}
switch (mode) {
case "SequentialOverlay":
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
@@ -16,6 +16,7 @@ To adjust the DPI resolution for rendering PDF pages:
import argparse
import os
from pdf2image import convert_from_path
from PIL import Image
@@ -149,9 +150,7 @@ def main(pdf_image_path, output_dir, dpi=300, single_images_flag=False):
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Convert a PDF file to WebP images.")
parser.add_argument("pdf_path", help="The path to the input PDF file.")
parser.add_argument(
"output_dir", help="The directory where the WebP images should be saved."
)
parser.add_argument("output_dir", help="The directory where the WebP images should be saved.")
parser.add_argument(
"--dpi",
type=int,
@@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOSpec;
import stirling.software.common.service.ToolIOParameterDefaults;
/**
* Every document-transforming endpoint must declare its I/O, or it becomes a hole in the
@@ -233,6 +234,32 @@ class ToolIODeclarationCoverageTest {
assertEquals(Set.of("ps", "pcl", "xps"), declared);
}
@Test
void anAbsentParameterResolvesToItsRequestModelDefault() {
// A pipeline step often omits a parameter a case branches on. The default is read from the
// request model, so the output resolves anyway instead of coming back uncertain.
// Auto Rotate never sends dryRun; its default (false) means the JSON branch cannot fire.
assertEquals(
ToolFormat.PDF,
spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).format());
assertTrue(spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).certain());
// Change Permissions posts to add-password with no password fields; both default to blank,
// so the unencrypted branch fires and it is not mistaken for producing an encrypted PDF.
assertEquals(
ToolFormat.PDF,
spec("/api/v1/security/add-password").resolveOutput(Map.of()).format());
assertTrue(spec("/api/v1/security/add-password").resolveOutput(Map.of()).certain());
}
@Test
void aRequiredParameterWithNoDefaultStaysUncertainWhenAbsent() {
// pdf/text branches on outputFormat, which is required with no default. Absent, its output
// is genuinely txt-or-rtf-dependent, so it must remain uncertain rather than assume TEXT.
assertFalse(spec("/api/v1/convert/pdf/text").resolveOutput(Map.of()).certain());
}
@Test
void onlyRemovePasswordAcceptsAnEncryptedDocument() {
assertTrue(
@@ -288,7 +315,11 @@ class ToolIODeclarationCoverageTest {
}
required.add(full);
if (declaration != null) {
declared.put(full, ToolIOSpec.from(declaration));
declared.put(
full,
ToolIOSpec.from(
declaration,
param -> ToolIOParameterDefaults.resolve(method, param)));
}
}
}
@@ -0,0 +1,39 @@
package stirling.software.proprietary.failure;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
/**
* "Seen, and I own it." Moves {@code NEW} to {@code ACKNOWLEDGED} so the row stops counting as
* unread while staying in the open list. Touches only the status, not the document, ledger or run.
*
* <p>Re-acknowledging is a no-op that keeps the original actor and timestamp, so the first person
* to pick it up stays credited.
*/
@Component
@RequiredArgsConstructor
public class AcknowledgeAction implements FailureAction {
private final FileRunEventStore store;
@Override
public FailureActionId id() {
return FailureActionId.ACKNOWLEDGE;
}
@Override
public FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor) {
// Guarded on NEW so a racing acknowledger cannot re-stamp the row, and so re-acknowledging
// returns the first actor's row rather than taking their credit.
return store.applyStatusOnce(
event.id(),
event.teamId(),
FileRunEventStatus.ACKNOWLEDGED,
actor,
List.of(FileRunEventStatus.NEW));
}
}
@@ -0,0 +1,32 @@
package stirling.software.proprietary.failure;
import java.util.Map;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
/**
* "No remediation will happen; close it." Terminal. Named Dismiss rather than Reject because it
* closes the incident, not the document: nothing is deleted or delivered, the ledger row is left
* alone, and a different file failing the same way still opens its own incident.
*
* <p>Recurrences of this exact failure fold onto the dismissed row, which is what makes "stop
* showing me this" hold for a source that re-lists the same failing file each sweep.
*/
@Component
@RequiredArgsConstructor
public class DismissAction implements FailureAction {
private final FileRunEventStore store;
@Override
public FailureActionId id() {
return FailureActionId.DISMISS;
}
@Override
public FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor) {
return store.applyStatus(event.id(), event.teamId(), FileRunEventStatus.DISMISSED, actor);
}
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.failure;
import java.util.Map;
/**
* The behaviour behind one {@link FailureActionId}. Implementations are Spring beans injected as a
* {@code List} and resolved by id, the pattern already used for {@code InputSource}, {@code
* PolicyOutputSink} and {@code PolicyTrigger}.
*
* <p>Keeping behaviour out of the registry keeps that pure data, so a new kind ships by declaring
* an action id that already has a handler.
*/
public interface FailureAction {
FailureActionId id();
/**
* Apply the action and return the updated event. {@code inputs} carries whatever the action
* declared it needs, which is nothing for the two that exist today. Implementations leave the
* document, ledger, run and output destinations alone unless that is the action's purpose.
*/
FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor);
}
@@ -0,0 +1,49 @@
package stirling.software.proprietary.failure;
import lombok.Getter;
/**
* Why an action could not be dispatched. Carries a {@link Reason} rather than an HTTP status, so
* the service stays web-agnostic and the controller owns the mapping.
*/
@Getter
public class FailureActionException extends RuntimeException {
public enum Reason {
/**
* No such event, it belongs to another team, or the caller's team did not resolve. One
* reason for all three, so the response does not vary with which it was. Unrelated to
* {@link FailureKind#UNKNOWN}, which is an unclassified failure rather than a refused
* action.
*/
EVENT_NOT_FOUND,
/** The action id is not in the vocabulary at all, or has no registered handler. */
ACTION_NOT_RECOGNISED,
/**
* The action exists but this kind does not declare it, so an incoherent pairing (releasing
* a document whose destination is what failed) cannot be dispatched even by hand.
*
* <p>Unreachable today: both kinds declare both actions, so no request can trip this guard
* until a kind ships with a restricted action set. Declared now because the guard must
* exist before that kind does, not after.
*/
ACTION_NOT_DECLARED,
/** The event is already closed, so no further transition is possible. */
ALREADY_CLOSED
}
private final Reason reason;
public FailureActionException(Reason reason, String message) {
this(reason, message, null);
}
/** For a refusal that follows from a lower-level failure, so its stack is not dropped. */
public FailureActionException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.failure;
/**
* The actions a {@link FailureKind} may declare. Both are incident dispositions: they change how
* the event is shown and touch nothing else, which is what makes them valid for every kind
* including {@link FailureKind#UNKNOWN}, and why there is no {@code APPROVE} yet.
*/
public enum FailureActionId {
ACKNOWLEDGE,
DISMISS
}
@@ -0,0 +1,68 @@
package stirling.software.proprietary.failure;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
/**
* Resolves a {@link FailureActionId} to the bean that implements it. The startup check is the
* point: because kinds declare action ids as data, one could name an action nobody implements,
* which would otherwise show up as a button that 400s rather than as a failed boot.
*/
@Slf4j
@Service
public class FailureActionRegistry {
private final Map<FailureActionId, FailureAction> byId = new EnumMap<>(FailureActionId.class);
public FailureActionRegistry(List<FailureAction> actions) {
for (FailureAction action : actions) {
FailureAction clash = byId.put(action.id(), action);
if (clash != null) {
throw new IllegalStateException(
"Two handlers registered for action "
+ action.id()
+ ": "
+ clash.getClass().getName()
+ " and "
+ action.getClass().getName());
}
}
}
/**
* Fail fast if any kind declares an action with no handler, naming every gap rather than the
* first, so one boot tells you everything that is missing.
*/
@PostConstruct
void verifyEveryDeclaredActionHasAHandler() {
List<String> gaps =
Arrays.stream(FailureKind.values())
.flatMap(
kind ->
kind.getActions().stream()
.filter(action -> !byId.containsKey(action))
.map(action -> kind.getId() + " -> " + action))
.toList();
if (!gaps.isEmpty()) {
throw new IllegalStateException(
"Failure kinds declare actions with no registered handler: " + gaps);
}
log.debug(
"Failure action registry initialised with {} handler(s) for {} kind(s)",
byId.size(),
FailureKind.values().length);
}
public Optional<FailureAction> find(FailureActionId id) {
return Optional.ofNullable(byId.get(id));
}
}
@@ -0,0 +1,109 @@
package stirling.software.proprietary.failure;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientResponseException;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.ExceptionUtils;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Maps a thrown failure onto a {@link FailureKind}. A tool's 4xx arrives as a {@link
* RestClientResponseException} whose body is the Problem Details document carrying {@code
* errorCode}, so this matches on codes rather than exception messages.
*
* <p>Anything unrecognised becomes {@link FailureKind#UNKNOWN}, so every failed run still gets a
* record.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FailureClassifier {
/** Set by {@code GlobalExceptionHandler#createProblemDetailResponse}. */
private static final String ERROR_CODE_PROPERTY = "errorCode";
/**
* Depth bound on the cause chain. The JDK forbids self-causation but not a longer cycle (A
* caused by B caused by A), which an unbounded walk would spin on. Real chains are a handful
* deep.
*/
private static final int MAX_CAUSE_DEPTH = 16;
private final ObjectMapper objectMapper;
/**
* Refuse to start on an ambiguous registry: two kinds claiming one code would make {@link
* #classify} depend on declaration order. Checked here because this is what resolves codes to
* kinds, and at boot so the message names the codes rather than arriving as a class-init error.
*/
@PostConstruct
void verifyNoErrorCodeIsClaimedTwice() {
List<String> duplicates = FailureKind.duplicateErrorCodes();
if (!duplicates.isEmpty()) {
throw new IllegalStateException(
"Error codes claimed by more than one failure kind: " + duplicates);
}
}
/** Never null, never throws. A classifier that can fail would lose the failure it describes. */
public FailureKind classify(Throwable throwable) {
Throwable current = throwable;
for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) {
FailureKind resolved = classifyOne(current);
if (resolved != FailureKind.UNKNOWN) {
return resolved;
}
Throwable cause = current.getCause();
current = cause == current ? null : cause;
}
return FailureKind.UNKNOWN;
}
private FailureKind classifyOne(Throwable throwable) {
// A tool step's 4xx/5xx: the Problem Details body names the error code.
if (throwable instanceof RestClientResponseException responseException) {
String code = errorCodeFromBody(responseException);
if (code != null) {
return FailureKind.byErrorCode(code).orElse(FailureKind.UNKNOWN);
}
}
// Thrown in-process (not over loopback): the exception carries its own code.
if (throwable instanceof ExceptionUtils.ErrorCodeProvider provider) {
return FailureKind.byErrorCode(provider.getErrorCode()).orElse(FailureKind.UNKNOWN);
}
return FailureKind.UNKNOWN;
}
/**
* Pull {@code errorCode} from a Problem Details body, or null when the body is absent, not
* JSON, or has no such property (an entitlement sentinel has {@code error} instead).
*/
private String errorCodeFromBody(RestClientResponseException exception) {
String body = exception.getResponseBodyAsString();
if (body == null || body.isBlank()) {
return null;
}
try {
JsonNode root = objectMapper.readTree(body);
JsonNode code = root.get(ERROR_CODE_PROPERTY);
if (code == null || !code.isTextual()) {
return null;
}
String text = code.asString();
return text.isBlank() ? null : text;
} catch (JacksonException e) {
log.debug("Downstream error body was not JSON; classifying as UNKNOWN");
return null;
}
}
}
@@ -0,0 +1,236 @@
package stirling.software.proprietary.failure;
import static stirling.software.proprietary.failure.FailureActionId.ACKNOWLEDGE;
import static stirling.software.proprietary.failure.FailureActionId.DISMISS;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import lombok.AccessLevel;
import lombok.Getter;
/**
* The registry of failure kinds, described as data: a stable id, i18n keys and an English fallback
* like {@code ExceptionUtils.ErrorCode}, plus the facets a review surface needs.
*
* <p>Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a
* new kind ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every
* failed run a record, and kinds get promoted out of it as production shows what occurs.
*/
@Getter
public enum FailureKind {
INPUT_PASSWORD_PROTECTED(
FailureStage.INPUT,
FailureSeverity.ERROR,
FailureRemedy.NEEDS_USER_INPUT,
FailureScope.FILE,
errorCodes("E004"),
fallback("This document is password-protected, so the pipeline could not read it."),
offer(ACKNOWLEDGE),
offer(DISMISS, "dismissSkipFile")),
UNKNOWN(
FailureStage.INTERNAL,
FailureSeverity.ERROR,
FailureRemedy.PERMANENT,
FailureScope.RUN,
noErrorCodes(),
fallback("This run failed for a reason Stirling does not yet recognise."),
offer(ACKNOWLEDGE),
offer(DISMISS));
private static final String KEY_PREFIX = "portal.failures.kind.";
private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
/**
* Every claimed {@code ErrorCode}, to the kind claiming it. Indexed once rather than scanned
* per lookup, so a duplicate cannot be resolved by declaration order without anyone noticing. A
* duplicate is refused at boot; see {@link #duplicateErrorCodes()}.
*/
private static final Map<String, FailureKind> BY_ERROR_CODE = indexErrorCodes();
private final FailureStage stage;
private final FailureSeverity severity;
private final FailureRemedy remedy;
private final FailureScope scope;
/** English fallback, used when the client has no translation for {@link #getTitleKey()}. */
private final String defaultTitle;
/** Codes from {@code ExceptionUtils.ErrorCode} that map onto this kind; may be empty. */
private final List<String> errorCodes;
/**
* What the review surface may offer, in display order. Never empty. No generated accessor,
* because {@link Offer} is private; read via {@link #getActions()} or {@link #labelKeyFor}.
*/
@Getter(AccessLevel.NONE)
private final List<Offer> offers;
FailureKind(
FailureStage stage,
FailureSeverity severity,
FailureRemedy remedy,
FailureScope scope,
List<String> errorCodes,
String defaultTitle,
Offer... offers) {
this.stage = stage;
this.severity = severity;
this.remedy = remedy;
this.scope = scope;
this.errorCodes = List.copyOf(errorCodes);
this.defaultTitle = defaultTitle;
this.offers = List.of(offers);
}
/**
* One action this kind offers, with the key to label it by. One ordered list rather than ids
* plus a parallel map of overrides, which could disagree with each other.
*
* @param labelKeySuffix key under {@code portal.failures.action.}, or null for the generic
* label
*/
private record Offer(FailureActionId id, String labelKeySuffix) {}
/** An action labelled by this kind's own wording, where the generic label reads badly. */
private static Offer offer(FailureActionId id, String labelKeySuffix) {
return new Offer(id, labelKeySuffix);
}
/** An action labelled by the shared wording for that action. */
private static Offer offer(FailureActionId id) {
return new Offer(id, null);
}
/**
* The {@code ErrorCode}s this kind claims. Java has no named arguments, so these factories
* exist to label the two constructor arguments whose types do not already name themselves.
*/
private static List<String> errorCodes(String... codes) {
return List.of(codes);
}
/** Claims no {@code ErrorCode}: reached only through the classifier's fallback. */
private static List<String> noErrorCodes() {
return List.of();
}
/** The English text shown when the client has no translation for this kind. */
private static String fallback(String englishTitle) {
return englishTitle;
}
/** Stable wire id. Never renamed once shipped: persisted rows reference it. */
public String getId() {
return name();
}
public String getTitleKey() {
return KEY_PREFIX + lowerCamelId() + ".title";
}
public String getDescriptionKey() {
return KEY_PREFIX + lowerCamelId() + ".description";
}
/** The actions this kind offers, in display order. */
public List<FailureActionId> getActions() {
return offers.stream().map(Offer::id).toList();
}
/** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */
public boolean declares(FailureActionId action) {
return offers.stream().anyMatch(offer -> offer.id() == action);
}
/** The label key for {@code action}: this kind's own wording, else the generic one. */
public String labelKeyFor(FailureActionId action) {
return offers.stream()
.filter(offer -> offer.id() == action && offer.labelKeySuffix() != null)
.map(offer -> ACTION_KEY_PREFIX + offer.labelKeySuffix())
.findFirst()
.orElseGet(() -> genericLabelKey(action));
}
/** The label key used when a kind supplies no wording of its own. */
static String genericLabelKey(FailureActionId action) {
return ACTION_KEY_PREFIX + toLowerCamel(action.name());
}
/**
* Lookup by wire id. Empty rather than throwing: ids arrive from persisted rows and clients.
*/
public static Optional<FailureKind> byId(String id) {
if (id == null || id.isBlank()) {
return Optional.empty();
}
return Arrays.stream(values()).filter(kind -> kind.name().equals(id)).findFirst();
}
private static Map<String, FailureKind> indexErrorCodes() {
Map<String, FailureKind> index = new HashMap<>();
for (FailureKind kind : values()) {
for (String code : kind.errorCodes) {
index.putIfAbsent(code, kind);
}
}
return Map.copyOf(index);
}
/**
* Codes claimed by more than one kind, which would make classification depend on declaration
* order. Empty in a well-formed registry.
*
* <p>Reported for a caller to act on rather than thrown from class init, where it would arrive
* as an {@code ExceptionInInitializerError} blamed on whatever touched the enum first, then as
* {@code NoClassDefFoundError} everywhere after. {@link FailureClassifier} refuses to start.
*/
static List<String> duplicateErrorCodes() {
return duplicatesIn(Arrays.stream(values()).flatMap(kind -> kind.errorCodes.stream()));
}
/**
* The codes appearing more than once, first-seen order. Split out from the registry because the
* registry is a closed enum: this is the only seam at which the detection itself can be shown
* to find anything.
*/
static List<String> duplicatesIn(Stream<String> codes) {
Set<String> seen = new HashSet<>();
return codes.filter(code -> !seen.add(code)).distinct().toList();
}
/** The kind claiming {@code errorCode}, if any. Empty for a code no kind has adopted yet. */
public static Optional<FailureKind> byErrorCode(String errorCode) {
if (errorCode == null || errorCode.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(BY_ERROR_CODE.get(errorCode));
}
private String lowerCamelId() {
return toLowerCamel(name());
}
/**
* {@code INPUT_PASSWORD_PROTECTED} to {@code inputPasswordProtected}, for i18n key building.
*/
private static String toLowerCamel(String screamingSnake) {
String[] parts = screamingSnake.toLowerCase(Locale.ROOT).split("_");
StringBuilder out = new StringBuilder(parts[0]);
for (int i = 1; i < parts.length; i++) {
if (parts[i].isEmpty()) {
continue;
}
out.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));
}
return out.toString();
}
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.failure;
import java.util.List;
/**
* Wire shape of one registry entry, served so a client can describe kinds it was not built with.
* Also a feature probe: a build without the proprietary module has no such route, so a 404 means
* there is no failure tracking here.
*/
public record FailureKindView(
String id,
FailureStage stage,
FailureSeverity severity,
FailureRemedy remedy,
FailureScope scope,
List<String> errorCodes,
String titleKey,
String descriptionKey,
String defaultTitle,
List<ActionDeclaration> actions) {
public static FailureKindView of(FailureKind kind) {
return new FailureKindView(
kind.getId(),
kind.getStage(),
kind.getSeverity(),
kind.getRemedy(),
kind.getScope(),
kind.getErrorCodes(),
kind.getTitleKey(),
kind.getDescriptionKey(),
kind.getDefaultTitle(),
kind.getActions().stream()
.map(
action ->
new ActionDeclaration(
action.name(), kind.labelKeyFor(action)))
.toList());
}
/** An action this kind offers, with the label key to render it by. */
public record ActionDeclaration(String id, String labelKey) {}
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.failure;
/**
* What kind of run failed, not where it was started from. A policy is a policy whether a schedule
* fired it or someone clicked Run in the editor, so the two questions are answered separately:
* {@code actor} names the person for an attended run and {@code sourceId} names the folder, bucket
* or webhook for an unattended one.
*/
public enum FailureOrigin {
/** One tool called directly, with no policy around it. Today that means the editor. */
TOOL,
/** The policy engine ran it, however the run was triggered. */
POLICY,
/**
* The watched-folder pipeline that predates policies. Declared ahead of its producer: nothing
* writes this yet, because that pipeline records no failures at all. Instrumenting it is its
* own piece of work.
*/
PIPELINE
}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.failure;
/**
* What intervention would clear this failure. Advisory metadata for the review surface; nothing
* branches on it server-side yet.
*/
public enum FailureRemedy {
TRANSIENT,
NEEDS_USER_INPUT,
NEEDS_FILE_FIX,
NEEDS_CONFIG_FIX,
NEEDS_SERVER_FIX,
PERMANENT
}
@@ -0,0 +1,13 @@
package stirling.software.proprietary.failure;
/**
* What the failure is about, which is what the dedup key groups repeats by: one file, run, policy,
* source, or the whole server.
*/
public enum FailureScope {
FILE,
RUN,
POLICY,
SOURCE,
SERVER
}
@@ -0,0 +1,8 @@
package stirling.software.proprietary.failure;
/** How loudly a failure kind should be surfaced. */
public enum FailureSeverity {
ERROR,
WARNING,
INFO
}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.failure;
/**
* Where in a run's life the failure happened: reading the document ({@code INPUT}), a tool step or
* the engine ({@code INTERNAL}), delivery ({@code OUTPUT}), a gate refusing it ({@code BLOCKED}),
* or never admitted ({@code NEVER_RAN}).
*
* <p>All five declared up front so a later kind needs no enum change, which would strand the value
* already snapshotted on existing rows.
*/
public enum FailureStage {
INPUT,
INTERNAL,
OUTPUT,
BLOCKED,
NEVER_RAN
}
@@ -0,0 +1,59 @@
package stirling.software.proprietary.failure;
import java.time.Instant;
/**
* Read model for one recorded incident: the entity's fields with the registry kind already
* resolved.
*
* <p>The facets are the row's own snapshots and can differ from {@code kind}'s current facets on an
* old row, which is the point of snapshotting, so read them here rather than from {@code kind}.
*/
public record FileRunEvent(
String id,
Long teamId,
String actor,
FailureKind kind,
FailureStage stage,
FailureSeverity severity,
FailureScope scope,
FailureOrigin origin,
String policyId,
String runId,
String fileId,
String detail,
String dedupKey,
int occurrences,
FileRunEventStatus status,
String statusActor,
Instant statusAt,
Instant createdAt,
Instant lastSeenAt) {
/**
* Project an entity. An unrecognised {@code kindId} (written by a newer build, or since
* removed) falls back to {@link FailureKind#UNKNOWN}, so the row stays readable and actionable.
*/
public static FileRunEvent of(FileRunEventEntity entity) {
return new FileRunEvent(
entity.getId(),
entity.getTeamId(),
entity.getActor(),
FailureKind.byId(entity.getKindId()).orElse(FailureKind.UNKNOWN),
entity.getStage(),
entity.getSeverity(),
entity.getScope(),
entity.getOrigin(),
entity.getPolicyId(),
entity.getRunId(),
entity.getFileId(),
entity.getDetail(),
entity.getDedupKey(),
entity.getOccurrences(),
entity.getStatus(),
entity.getStatusActor(),
entity.getStatusAt(),
entity.getCreatedAt(),
entity.getLastSeenAt());
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.failure;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read and triage recorded failures for the caller's team. Note the absence of a team parameter:
* the team comes from the authenticated principal, never the request.
*
* <p>Reviewing failures is a leader-level capability, gated the same way policy editing is: see
* {@link #requireFailureReviewAllowed()}.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/file-run-events")
@Hidden
@RequiredArgsConstructor
@Tag(name = "File run events", description = "Recorded policy and pipeline failures")
public class FileRunEventController {
/** Cap on one page, so a client cannot ask for the whole table. */
private static final int MAX_LIMIT = 200;
private static final int DEFAULT_LIMIT = 50;
private final FileRunEventService service;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
@GetMapping
@Operation(
summary = "List recorded failures",
description =
"Failures recorded for the caller's team, newest first. Each row carries its"
+ " available actions already resolved.")
public FileRunEventsResponse list(
// Spring's converter 400s on a value outside the enum, so no hand-rolled parse.
@RequestParam(required = false) FileRunEventStatus status,
@RequestParam(required = false) String kindId,
@RequestParam(required = false) Integer limit) {
requireFailureReviewAllowed();
int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
List<FileRunEventView> events =
// The kind filter is part of the query, before the limit is applied: filtering an
// already-limited page could return nothing while matching rows exist.
service.list(status, kindId, cappedLimit).stream()
.map(event -> FileRunEventView.of(event, service.availableActions(event)))
.toList();
return new FileRunEventsResponse(events);
}
@PostMapping("/{eventId}/actions/{actionId}")
@Operation(
summary = "Apply an action to a recorded failure",
description =
"Rejected with 400 if the failure's kind does not declare the action, so an"
+ " action that makes no sense for a given failure cannot be applied.")
public FileRunEventView act(
@PathVariable String eventId,
@PathVariable String actionId,
@RequestBody(required = false) ActionRequest request) {
requireFailureReviewAllowed();
Map<String, String> inputs = request == null ? Map.of() : request.safeInputs();
try {
FileRunEvent updated = service.dispatch(eventId, actionId, inputs);
return FileRunEventView.of(updated, service.availableActions(updated));
} catch (FailureActionException e) {
throw new ResponseStatusException(statusFor(e.getReason()), e.getMessage(), e);
}
}
@GetMapping("/kinds")
@Operation(
summary = "List known failure kinds",
description =
"The failure registry. Lets a client describe kinds it was not built with, and"
+ " doubles as the probe for whether failure tracking exists at all.")
public List<FailureKindView> kinds() {
requireFailureReviewAllowed();
return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList();
}
/**
* Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code
* PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who
* manages a team's automation: a member can trigger runs, a leader reviews them.
*
* <p>Login disabled means a single-user deployment with no roles to tell apart, the same
* carve-out the policy endpoints make. Team scoping is separate, and lives in the service.
*/
private void requireFailureReviewAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Recorded failures may only be reviewed by a team leader");
}
}
/**
* A closed row is a conflict rather than a bad request: the request was well-formed and would
* have been valid a moment earlier.
*/
private static HttpStatus statusFor(FailureActionException.Reason reason) {
return switch (reason) {
case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED -> HttpStatus.BAD_REQUEST;
case ALREADY_CLOSED -> HttpStatus.CONFLICT;
};
}
/** Wrapped rather than a bare array so pagination can be added without breaking clients. */
public record FileRunEventsResponse(List<FileRunEventView> events) {}
/** Inputs an action declared it needs. Empty for both actions that exist today. */
public record ActionRequest(Map<String, String> inputs) {
Map<String, String> safeInputs() {
return inputs == null ? Map.of() : inputs;
}
}
}
@@ -0,0 +1,116 @@
package stirling.software.proprietary.failure;
import java.io.Serializable;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* One recorded failure incident. {@code actor} and {@code teamId} are plain values rather than
* foreign keys, matching {@link stirling.software.proprietary.policy.source.SourceEntity}, and the
* facets are snapshots at write time so re-classifying a kind never rewrites an old row.
*
* <p>Holds no document name or content: {@code fileId} is opaque, and {@code detail} is stripped of
* names by {@link RecordFailure}.
*/
@Entity
@Table(
name = "file_run_events",
indexes = {
// the review surface: this team's rows, newest first, optionally filtered by status
@Index(name = "idx_file_run_events_team", columnList = "team_id, status, last_seen_at")
},
// One row per incident, enforced by the database rather than only by the store's read-then-
// insert: two runs failing identically at the same moment would otherwise both find no row
// and both insert, leaving a twin that reappears after the first is dismissed. Doubles as
// the
// rollup's lookup index. Note SQL treats NULLs as distinct, so this does not constrain the
// unteamed rows a login-disabled deployment writes.
uniqueConstraints =
@UniqueConstraint(
name = "uk_file_run_events_dedup",
columnNames = {"team_id", "dedup_key"}))
@NoArgsConstructor
@Getter
@Setter
public class FileRunEventEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "id")
private String id;
@Column(name = "team_id")
private Long teamId;
/** The user whose work produced the failure. Null when login is disabled. */
@Column(name = "actor")
private String actor;
@Column(name = "kind_id", nullable = false)
private String kindId;
@Enumerated(EnumType.STRING)
@Column(name = "stage", nullable = false)
private FailureStage stage;
@Enumerated(EnumType.STRING)
@Column(name = "severity", nullable = false)
private FailureSeverity severity;
@Enumerated(EnumType.STRING)
@Column(name = "scope", nullable = false)
private FailureScope scope;
@Enumerated(EnumType.STRING)
@Column(name = "origin", nullable = false)
private FailureOrigin origin;
@Column(name = "policy_id")
private String policyId;
@Column(name = "run_id")
private String runId;
@Column(name = "file_id")
private String fileId;
@Column(name = "detail", columnDefinition = "text")
private String detail;
@Column(name = "dedup_key", length = 64, nullable = false)
private String dedupKey;
/** How many times this same failure has been seen. Starts at 1; the rollup increments it. */
@Column(name = "occurrences", nullable = false)
private int occurrences;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private FileRunEventStatus status;
/** Who last changed {@link #status}. Null while the row is untouched. */
@Column(name = "status_actor")
private String statusActor;
@Column(name = "status_at")
private Instant statusAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "last_seen_at", nullable = false)
private Instant lastSeenAt;
}
@@ -0,0 +1,104 @@
package stirling.software.proprietary.failure;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity, String> {
/**
* This team's events, newest first, scoped in the query rather than loaded and filtered. A
* {@code null} teamId matches the rows with no team (login disabled), mirroring {@link
* stirling.software.proprietary.policy.source.SourceRepository#findByTeam}, since a plain
* {@code = null} would return nothing.
*/
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and (:kindId is null or e.kindId = :kindId)"
+ " order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeam(
@Param("teamId") Long teamId, @Param("kindId") String kindId, Pageable pageable);
/** As {@link #findByTeam} but restricted to one status, for the review surface's filters. */
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and e.status = :status"
+ " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeamAndStatus(
@Param("teamId") Long teamId,
@Param("status") FileRunEventStatus status,
@Param("kindId") String kindId,
Pageable pageable);
/**
* Fold a repeat into an incident in one statement, against the row's <em>current</em> values. A
* read-modify-save here would be a merge of a possibly stale snapshot: concurrent folds would
* lose counts, and an in-flight fold could overwrite a reviewer's dismiss. Same idiom as {@code
* SourceDocCountRepository#increment} and friends.
*/
@Modifying(clearAutomatically = true)
@Transactional
@Query(
"update FileRunEventEntity e set e.occurrences = e.occurrences + 1,"
+ " e.lastSeenAt = :now, e.detail = coalesce(:detail, e.detail)"
+ " where e.id = :id")
int fold(@Param("id") String id, @Param("now") Instant now, @Param("detail") String detail);
/**
* Reopen a resolved incident whose failure has recurred. Guarded on the current status so only
* {@code RESOLVED} flips; a concurrent dismiss is never overwritten back to {@code NEW}.
*/
@Modifying(clearAutomatically = true)
@Transactional
@Query(
"update FileRunEventEntity e set"
+ " e.status = stirling.software.proprietary.failure.FileRunEventStatus.NEW,"
+ " e.statusActor = null, e.statusAt = null where e.id = :id and e.status ="
+ " stirling.software.proprietary.failure.FileRunEventStatus.RESOLVED")
int reopenIfResolved(@Param("id") String id);
/**
* Apply a status transition only if the row is still in one of {@code allowedFrom}. The guard
* runs in the database, so two racing closes cannot both succeed: the loser updates zero rows.
*/
@Modifying(clearAutomatically = true)
@Transactional
@Query(
"update FileRunEventEntity e set e.status = :target, e.statusActor = :actor,"
+ " e.statusAt = :now where e.id = :id and ((:teamId is null and e.teamId is"
+ " null) or e.teamId = :teamId) and e.status in :allowedFrom")
int applyStatusIf(
@Param("id") String id,
@Param("teamId") Long teamId,
@Param("target") FileRunEventStatus target,
@Param("actor") String actor,
@Param("now") Instant now,
@Param("allowedFrom") Collection<FileRunEventStatus> allowedFrom);
/**
* The most recent row for this exact failure, so the rollup can increment an existing incident
* instead of opening a new one. Team-scoped, so the same failure in two teams stays two rows.
*/
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and e.dedupKey = :dedupKey order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeamAndDedupKey(
@Param("teamId") Long teamId, @Param("dedupKey") String dedupKey, Pageable pageable);
/** One row by id, but only if it belongs to {@code teamId}. */
@Query(
"select e from FileRunEventEntity e where e.id = :id and ((:teamId is null and e.teamId"
+ " is null) or e.teamId = :teamId)")
Optional<FileRunEventEntity> findByIdAndTeam(
@Param("id") String id, @Param("teamId") Long teamId);
}
@@ -0,0 +1,159 @@
package stirling.software.proprietary.failure;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Reads and acts on incidents for the calling user's team.
*
* <p>Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the
* team always comes from the authenticated principal, and scoping applies only when login is
* enabled so single-user deployments keep working. When the team cannot be resolved the caller
* reads nothing; see {@link #scope()}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileRunEventService {
private final FileRunEventStore store;
private final FailureActionRegistry actionRegistry;
private final PolicyManagementAuthority policyManagementAuthority;
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
/** The calling user's events, newest first. Empty when their team cannot be resolved. */
public List<FileRunEvent> list(FileRunEventStatus status, String kindId, int limit) {
TeamScope scope = scope();
if (!scope.permitted()) {
return List.of();
}
return store.list(scope.teamId(), status, kindId, limit);
}
/**
* Dispatch an action against one event.
*
* @throws FailureActionException if the event is not the caller's, the action is unknown, the
* event's kind does not declare the action, or the event is already closed
*/
public FileRunEvent dispatch(String eventId, String actionId, Map<String, String> inputs) {
TeamScope scope = scope();
if (!scope.permitted()) {
// Reported as "no such event", the same as an id from another team, so the response
// does
// not depend on whether the id happens to exist.
throw new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId);
}
FileRunEvent event =
store.find(eventId, scope.teamId())
.orElseThrow(
() ->
new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND,
"No such event: " + eventId));
FailureActionId resolvedId = parseActionId(actionId);
// A kind that does not offer an action cannot have it applied, so an action that makes no
// sense for a failure is unreachable rather than merely unrendered.
if (!event.kind().declares(resolvedId)) {
throw new FailureActionException(
FailureActionException.Reason.ACTION_NOT_DECLARED,
"Kind " + event.kind().getId() + " does not offer action " + resolvedId);
}
if (event.status().terminal()) {
throw new FailureActionException(
FailureActionException.Reason.ALREADY_CLOSED,
"Event " + eventId + " is already " + event.status());
}
FailureAction action =
actionRegistry
.find(resolvedId)
.orElseThrow(
() ->
new FailureActionException(
FailureActionException.Reason.ACTION_NOT_RECOGNISED,
"No handler for action " + resolvedId));
return action.execute(event, inputs == null ? Map.of() : inputs, currentActor());
}
/**
* Which of an event's declared actions are usable right now. Decided per row, so the client
* never renders a button that would be refused.
*/
public List<AvailableAction> availableActions(FileRunEvent event) {
boolean closed = event.status().terminal();
return event.kind().getActions().stream()
.map(
action ->
new AvailableAction(
action,
event.kind().labelKeyFor(action),
!closed,
closed ? "portal.failures.disabled.closed" : null))
.toList();
}
private FailureActionId parseActionId(String actionId) {
for (FailureActionId candidate : FailureActionId.values()) {
if (candidate.name().equals(actionId)) {
return candidate;
}
}
throw new FailureActionException(
FailureActionException.Reason.ACTION_NOT_RECOGNISED, "Unknown action: " + actionId);
}
/**
* Which rows the caller may touch, since a null team id means two different things. Login
* disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's,
* as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads
* nothing, because unteamed rows there are shared by every team's ad-hoc runs.
*/
private TeamScope scope() {
if (!enforced()) {
return TeamScope.of(null);
}
Long teamId = policyManagementAuthority.currentUserTeamId();
return teamId == null ? TeamScope.denied() : TeamScope.of(teamId);
}
/**
* The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted.
*/
private record TeamScope(boolean permitted, Long teamId) {
static TeamScope of(Long teamId) {
return new TeamScope(true, teamId);
}
static TeamScope denied() {
return new TeamScope(false, null);
}
}
private String currentActor() {
return enforced() ? userService.getCurrentUsername() : null;
}
private boolean enforced() {
return applicationProperties.getSecurity().isEnableLogin();
}
/** One action as offered for a specific event, with its resolved availability. */
public record AvailableAction(
FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {}
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.failure;
import java.util.Arrays;
import java.util.List;
/**
* Disposition of one recorded failure. {@code RESOLVED} is declared but not set yet (it becomes
* system-set later); the rollup already defines what a repeat means for it, which is to reopen.
*/
public enum FileRunEventStatus {
NEW(false),
ACKNOWLEDGED(false),
DISMISSED(true),
RESOLVED(true);
/** The statuses a review queue shows by default: everything still needing a decision. */
private static final List<FileRunEventStatus> OPEN =
Arrays.stream(values()).filter(status -> !status.terminal).toList();
/** Whether no further transition is possible: the row is closed. */
private final boolean terminal;
FileRunEventStatus(boolean terminal) {
this.terminal = terminal;
}
public boolean terminal() {
return terminal;
}
public static List<FileRunEventStatus> open() {
return OPEN;
}
}
@@ -0,0 +1,201 @@
package stirling.software.proprietary.failure;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Durable store for recorded incidents. Who may act lives in {@link FileRunEventService}.
*
* <p>{@link #record} folds a repeat into the existing incident rather than inserting again, keyed
* on {@code (teamId, dedupKey)}. That matters for a stateless {@code snapshot} source, which
* re-lists every file on each sweep: the same broken file is one incident, not one per sweep.
* Distinct files keep distinct rows, so a reviewer can still act on any one of them.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileRunEventStore {
/** Only ever need the newest match; the index is ordered so this is a single-row read. */
private static final Pageable NEWEST = PageRequest.of(0, 1);
private final FileRunEventRepository repository;
/**
* Record a failure, folding it into an existing incident when one matches.
*
* <p>Deliberately not {@code @Transactional}: each repository call runs in its own transaction,
* so losing the insert race below leaves no rolled-back transaction to recover from.
*/
public FileRunEvent record(RecordFailure command) {
String dedupKey = command.dedupKey();
Instant now = Instant.now();
Optional<FileRunEvent> folded =
findByDedupKey(command.teamId(), dedupKey)
.flatMap(row -> absorb(row.getId(), command, now));
if (folded.isPresent()) {
return folded.get();
}
try {
return FileRunEvent.of(insert(command, dedupKey, now));
} catch (DataIntegrityViolationException e) {
// The unique constraint fired, so a concurrent writer inserted this incident between
// our read and our insert. Their row is the incident; fold into it instead of failing.
return findByDedupKey(command.teamId(), dedupKey)
.flatMap(row -> absorb(row.getId(), command, now))
.orElseThrow(() -> e);
}
}
private Optional<FileRunEventEntity> findByDedupKey(Long teamId, String dedupKey) {
return repository.findByTeamAndDedupKey(teamId, dedupKey, NEWEST).stream().findFirst();
}
/**
* Fold a repeat into an existing incident. {@code DISMISSED} stays dismissed, so a reviewer's
* "stop showing me this" holds; {@code RESOLVED} reopens, so a recurrence is visible again.
*
* <p>Both steps are single guarded UPDATE statements against the row's current values, never a
* save of the entity we read: that would merge a possibly stale snapshot over concurrent
* writes, losing counts and reverting a dismiss that landed in between.
*
* <p>Empty when the row went away between the caller's read and the fold, so the caller inserts
* instead. Reporting that as an error would lose the incident for a log line.
*/
private Optional<FileRunEvent> absorb(String id, RecordFailure command, Instant now) {
if (repository.fold(id, now, command.detail()) == 0) {
return Optional.empty();
}
repository.reopenIfResolved(id);
return repository.findById(id).map(FileRunEvent::of);
}
private FileRunEventEntity insert(RecordFailure command, String dedupKey, Instant now) {
FailureKind kind = command.kind();
FileRunEventEntity entity = new FileRunEventEntity();
entity.setId(UUID.randomUUID().toString());
entity.setTeamId(command.teamId());
entity.setActor(command.actor());
entity.setKindId(kind.getId());
// Snapshot the facets so a later registry edit does not rewrite what this row meant.
entity.setStage(kind.getStage());
entity.setSeverity(kind.getSeverity());
entity.setScope(kind.getScope());
entity.setOrigin(command.origin());
entity.setPolicyId(command.policyId());
entity.setRunId(command.runId());
entity.setFileId(command.fileId());
entity.setDetail(command.detail());
entity.setDedupKey(dedupKey);
entity.setOccurrences(1);
entity.setStatus(FileRunEventStatus.NEW);
entity.setCreatedAt(now);
entity.setLastSeenAt(now);
// Flushed so a duplicate-key violation surfaces here, inside record()'s catch. A plain
// save() defers the INSERT to commit time once a caller is transactional, and the
// violation would escape the catch as a 500.
return repository.saveAndFlush(entity);
}
/**
* A page of incidents, newest first, optionally narrowed to one status and one kind. The kind
* filter lives in the query, before the limit: filtering a already-limited page could return
* nothing while matching rows exist.
*/
@Transactional(readOnly = true)
public List<FileRunEvent> list(
Long teamId, FileRunEventStatus status, String kindId, int limit) {
Pageable page = PageRequest.of(0, Math.max(1, limit));
List<FileRunEventEntity> rows =
status == null
? repository.findByTeam(teamId, kindId, page)
: repository.findByTeamAndStatus(teamId, status, kindId, page);
return rows.stream().map(FileRunEvent::of).toList();
}
@Transactional(readOnly = true)
public Optional<FileRunEvent> find(String id, Long teamId) {
return repository.findByIdAndTeam(id, teamId).map(FileRunEvent::of);
}
/**
* Transition an open row, refusing a closed one. The service checks {@code terminal()} before
* dispatching, but that check and this write are separate requests under concurrency; the
* guarded UPDATE is what makes two racing closes resolve to one winner.
*
* @throws FailureActionException {@code ALREADY_CLOSED} when the row exists but is terminal,
* {@code EVENT_NOT_FOUND} when it does not exist for this team
*/
@Transactional
public FileRunEvent applyStatus(
String id, Long teamId, FileRunEventStatus target, String actor) {
return applyStatus(id, teamId, target, actor, FileRunEventStatus.open())
.orElseThrow(() -> refusalFor(id, teamId));
}
/**
* As {@link #applyStatus(String, Long, FileRunEventStatus, String)} but with the caller naming
* which current statuses may transition. Empty when the row exists outside {@code allowedFrom}.
*
* <p>The UPDATE runs first and nothing is read beforehand: a pre-read only to classify the
* refusal would be one more thing to race with, and would report a row deleted in between as
* closed rather than missing.
*/
@Transactional
public Optional<FileRunEvent> applyStatus(
String id,
Long teamId,
FileRunEventStatus target,
String actor,
Collection<FileRunEventStatus> allowedFrom) {
if (repository.applyStatusIf(id, teamId, target, actor, Instant.now(), allowedFrom) == 0) {
return Optional.empty();
}
return repository.findByIdAndTeam(id, teamId).map(FileRunEvent::of);
}
/**
* Transition, or accept that someone else already reached {@code target}. The loser of a race
* reads the winner's row back rather than re-stamping it, so the first actor keeps the credit.
*/
@Transactional
public FileRunEvent applyStatusOnce(
String id,
Long teamId,
FileRunEventStatus target,
String actor,
Collection<FileRunEventStatus> allowedFrom) {
return applyStatus(id, teamId, target, actor, allowedFrom)
.or(() -> find(id, teamId).filter(current -> current.status() == target))
.orElseThrow(() -> refusalFor(id, teamId));
}
/**
* Why the guarded UPDATE refused, worked out only once it has. Missing and closed are told
* apart after the fact rather than before, so the answer describes the row the UPDATE saw.
*/
private FailureActionException refusalFor(String id, Long teamId) {
return repository.findByIdAndTeam(id, teamId).isPresent()
? new FailureActionException(
FailureActionException.Reason.ALREADY_CLOSED,
"Event " + id + " is already closed")
// The same reason the service raises for an id it never found, so losing a delete
// race answers 404 like every other "no such event" rather than 400.
: new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + id);
}
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.failure;
import java.util.List;
/**
* Wire shape of one incident. Carries i18n keys plus {@code defaultTitle} rather than rendered
* copy, so the server can ship a new kind without a client release, and {@code actions} arrive
* already resolved so the client needs no rules. No document name: {@code fileId} is an opaque
* reference.
*/
public record FileRunEventView(
String id,
String kindId,
FailureStage stage,
FailureSeverity severity,
FailureScope scope,
FailureOrigin origin,
FailureRemedy remedy,
String titleKey,
String descriptionKey,
String defaultTitle,
String detail,
String policyId,
String runId,
String fileId,
String actor,
int occurrences,
FileRunEventStatus status,
String statusActor,
List<ActionView> actions,
long createdAt,
long lastSeenAt) {
public static FileRunEventView of(
FileRunEvent event, List<FileRunEventService.AvailableAction> actions) {
FailureKind kind = event.kind();
return new FileRunEventView(
event.id(),
kind.getId(),
event.stage(),
event.severity(),
event.scope(),
event.origin(),
kind.getRemedy(),
kind.getTitleKey(),
kind.getDescriptionKey(),
kind.getDefaultTitle(),
event.detail(),
event.policyId(),
event.runId(),
event.fileId(),
event.actor(),
event.occurrences(),
event.status(),
event.statusActor(),
actions.stream().map(ActionView::of).toList(),
event.createdAt() == null ? 0L : event.createdAt().toEpochMilli(),
event.lastSeenAt() == null ? 0L : event.lastSeenAt().toEpochMilli());
}
/** One button, as offered for this specific row. */
public record ActionView(
String id, String labelKey, boolean enabled, String disabledReasonKey) {
static ActionView of(FileRunEventService.AvailableAction action) {
return new ActionView(
action.id().name(),
action.labelKey(),
action.enabled(),
action.disabledReasonKey());
}
}
}
@@ -0,0 +1,79 @@
package stirling.software.proprietary.failure;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* The seam the policy engine calls when a run fails: classify, then record. Best-effort throughout,
* so failing to write a row cannot change the failure the caller already observed.
*
* <p>The team comes from the originating policy rather than the calling thread, which carries only
* an audit principal. An ad-hoc run has no stored policy, so its rows land unteamed.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyFailureRecorder {
private final FailureClassifier classifier;
private final FileRunEventStore store;
private final PolicyStore policyStore;
/**
* Record a failed run, classifying {@code cause}. {@code detail} is the message the run itself
* reported, kept verbatim so an {@link FailureKind#UNKNOWN} row is still diagnosable.
*/
public void recordRunFailure(
String runId,
String policyId,
String actor,
String fileIdentity,
String detail,
Throwable cause) {
record(classifier.classify(cause), runId, policyId, actor, fileIdentity, detail);
}
/**
* Record a failure whose kind is already decided, for paths with no exception to classify (a
* run rejected at admission). Named distinctly rather than overloading {@link
* #recordRunFailure}, whose argument list is otherwise near-identical, so a null cause cannot
* pick the wrong one.
*/
public void recordRunFailureAs(
FailureKind kind, String runId, String policyId, String actor, String detail) {
record(kind, runId, policyId, actor, null, detail);
}
private void record(
FailureKind kind,
String runId,
String policyId,
String actor,
String fileIdentity,
String detail) {
try {
store.record(
RecordFailure.forRun(
kind, teamFor(policyId), actor, policyId, runId, fileIdentity, detail));
} catch (RuntimeException e) {
// Deliberately swallowed: see the class comment.
log.warn("Could not record failure event for run {} (kind {})", runId, kind.getId(), e);
}
}
private Long teamFor(String policyId) {
if (policyId == null || policyId.isBlank()) {
return null;
}
try {
return policyStore.get(policyId).map(policy -> policy.teamId()).orElse(null);
} catch (RuntimeException e) {
log.debug("Could not resolve team for policy {}: {}", policyId, e.getMessage());
return null;
}
}
}
@@ -0,0 +1,117 @@
package stirling.software.proprietary.failure;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/**
* Everything needed to record one failure. Every reference field is nullable, because a failure can
* happen before a file or policy is known and with no user at all (a trigger-fired run on a
* login-disabled deployment). Only {@code kind} and {@code origin} are required.
*/
public record RecordFailure(
FailureKind kind,
FailureOrigin origin,
Long teamId,
String actor,
String policyId,
String runId,
String sourceId,
String fileId,
String detail) {
/** Upper bound on a stored message, so one enormous stack trace cannot fill the column. */
private static final int MAX_DETAIL_LENGTH = 2_000;
public RecordFailure {
if (kind == null) {
throw new IllegalArgumentException("kind is required");
}
if (origin == null) {
throw new IllegalArgumentException("origin is required");
}
// Capped here rather than at each call site, since this record is the only way a row is
// written, and an unclassified failure carries a raw message of unbounded length. Stored
// verbatim otherwise: it is the user's own error about their own file, and hiding parts of
// it makes the row harder to act on without making it meaningfully safer.
detail = truncate(detail);
}
/** A processor-side failure with no file or source context, e.g. a run that failed outright. */
public static RecordFailure forRun(
FailureKind kind,
Long teamId,
String actor,
String policyId,
String runId,
String fileId,
String detail) {
return new RecordFailure(
kind, FailureOrigin.POLICY, teamId, actor, policyId, runId, null, fileId, detail);
}
/**
* What this failure is about, per the row's scope. Two failures sharing a kind and a scope
* reference are the same incident; see {@link #dedupKey()}.
*/
public String scopeRef() {
return switch (kind.getScope()) {
case FILE -> nullToEmpty(policyId) + "|" + fileOrRun();
case RUN -> nullToEmpty(runId);
case POLICY -> nullToEmpty(policyId);
case SOURCE -> nullToEmpty(sourceId);
// One server-wide condition is one incident regardless of which run tripped over it.
case SERVER -> "";
};
}
/**
* The file this failure is about, or the run when the producer could not name one. Without the
* fallback, every file failing the same way under one policy shares a scope reference, and the
* second onwards folds into the first as though one document had failed repeatedly.
*
* <p>Prefixed so a file id and a run id cannot collide.
*/
private String fileOrRun() {
return isBlank(fileId) ? "run:" + nullToEmpty(runId) : "file:" + fileId;
}
/**
* Decides whether this failure repeats an existing incident: SHA-256 of {@code
* kindId|scope|scopeRef}. Hashed so a long scope reference (an S3 key) fits a fixed-width
* index, and deterministic so two nodes converge on one incident. Every input is an enum name
* or an id.
*/
public String dedupKey() {
String raw = kind.getId() + "|" + kind.getScope().name() + "|" + scopeRef();
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(raw.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
// SHA-256 is mandated by the JDK; unreachable outside a broken runtime.
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
private static String truncate(String detail) {
if (detail == null || detail.length() <= MAX_DETAIL_LENGTH) {
return detail;
}
// Leave room for the ellipsis so the cap is the cap, and step back once more rather than
// cutting between the halves of a surrogate pair, which would store invalid UTF-16.
int end = MAX_DETAIL_LENGTH - 1;
if (Character.isHighSurrogate(detail.charAt(end - 1))) {
end--;
}
return detail.substring(0, end) + "";
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
}
@@ -32,6 +32,8 @@ import stirling.software.common.service.ResourceMonitor;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.ExecutorFactory;
import stirling.software.common.util.JobContext;
import stirling.software.proprietary.failure.FailureKind;
import stirling.software.proprietary.failure.PolicyFailureRecorder;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
@@ -72,6 +74,8 @@ public class PolicyEngine {
private final PolicyExecutor stepExecutor;
private final TaskManager taskManager;
private final PolicyRunRegistry registry;
// Durable record of why a run failed. Best-effort by contract: see PolicyFailureRecorder.
private final PolicyFailureRecorder failureRecorder;
private final FileStorage fileStorage;
private final JobOwnershipService jobOwnershipService;
private final List<PolicyOutputSink> outputSinks;
@@ -123,12 +127,26 @@ public class PolicyEngine {
// async
// worker.
String principal = currentActingPrincipal();
return submitForPrincipal(principal, principal, policyId, definition, inputs, listener);
return submitForPrincipal(
principal, principal, policyId, definition, inputs, null, listener);
}
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
public PolicyRunHandle runPolicy(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return runPolicy(policy, inputs, null, listener);
}
/**
* As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, with the source's opaque
* reference to the document being run. Carried so a failure can say which document it was
* about, and so the same document failing again folds into one incident.
*/
public PolicyRunHandle runPolicy(
Policy policy,
PolicyInputs inputs,
String fileIdentity,
PolicyProgressListener listener) {
// Bill the policy owner: trigger-fired runs have no security context, and the async worker
// doesn't inherit the caller's, so the owner (stamped at policy creation) is the reliable
// billing identity — and for org-wide policies the org/owner is meant to pay. But own the
@@ -145,7 +163,7 @@ public class PolicyEngine {
new PipelineDefinition(
policy.name(), policy.steps(), outputResolver.resolve(policy));
return submitForPrincipal(
policy.owner(), fileOwner, policy.id(), definition, inputs, listener);
policy.owner(), fileOwner, policy.id(), definition, inputs, fileIdentity, listener);
}
private PolicyRunHandle submitForPrincipal(
@@ -154,6 +172,7 @@ public class PolicyEngine {
String policyId,
PipelineDefinition definition,
PolicyInputs inputs,
String fileIdentity,
PolicyProgressListener listener) {
// Scope the run id to the current user (this request thread) so the file-download
// ownership check passes. No-op when security is off.
@@ -163,7 +182,7 @@ public class PolicyEngine {
if (policyId != null) {
taskManager.putMetadata(runId, "policyId", policyId);
}
PolicyRun run = new PolicyRun(runId, policyId, definition);
PolicyRun run = new PolicyRun(runId, policyId, definition, fileIdentity);
registry.register(run);
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
PolicyProgressListener tracking = trackingListener(runId, run, listener);
@@ -271,6 +290,7 @@ public class PolicyEngine {
e.getMessage());
run.fail(message);
taskManager.setError(runId, message);
recordFailure(run, message, e);
} catch (RestClientResponseException e) {
// A downstream tool call returned an error status. When it's a structured
// entitlement
@@ -294,17 +314,20 @@ public class PolicyEngine {
run.failWithCode(
message, code, DownstreamEntitlementError.extractSubscribed(e));
taskManager.setError(runId, message);
recordFailure(run, message, e);
} else {
String message = "Policy run failed: " + e.getMessage();
log.error("Policy run {} failed (downstream HTTP error)", runId, e);
run.fail(message);
taskManager.setError(runId, message);
recordFailure(run, message, e);
}
} catch (Exception e) {
String message = "Policy run failed: " + e.getMessage();
log.error("Policy run {} failed", runId, e);
run.fail(message);
taskManager.setError(runId, message);
recordFailure(run, message, e);
} finally {
// Always resolve so stream/await callers unblock.
completion.complete(run);
@@ -322,11 +345,29 @@ public class PolicyEngine {
// Transient admission rejection, not a processing failure (see QUEUE_FULL_CODE).
run.failWithCode(message, QUEUE_FULL_CODE, null);
taskManager.setError(run.getRunId(), message);
// No exception to classify here: nothing was thrown by a tool, the run simply was not
// admitted. Record it explicitly so a run lost to load pressure is still accounted for.
failureRecorder.recordRunFailureAs(
FailureKind.UNKNOWN, run.getRunId(), run.getPolicyId(), null, message);
completion.complete(run);
}
return null;
}
/**
* Record why a run failed. Called after the run's own state transition and task-manager update,
* so a recording problem cannot change the outcome the caller observes.
*/
private void recordFailure(PolicyRun run, String message, Throwable cause) {
failureRecorder.recordRunFailure(
run.getRunId(),
run.getPolicyId(),
MDC.get(AUDIT_PRINCIPAL_MDC_KEY),
run.getFileIdentity(),
message,
cause);
}
private WaitState suspend(PolicyInputRequiredException e) {
List<String> fileIds = new ArrayList<>();
for (Resource resource : e.getPendingFiles()) {
@@ -281,18 +281,33 @@ public class PolicyExecutor {
}
for (Resource file : files) {
if (!matchesType(file, accepted)) {
// Reports the extension rather than the filename, since this message becomes the
// run's error and is persisted on the failure record.
throw new IOException(
"Step "
+ operation
+ " accepts "
+ accepted
+ " but received '"
+ file.getFilename()
+ "'");
+ " but received a '"
+ extensionOf(file)
+ "' file");
}
}
}
/** The file's extension, or {@code unknown} when it has no usable name. */
private static String extensionOf(Resource file) {
String filename = file.getFilename();
if (filename == null) {
return "unknown";
}
int dot = filename.lastIndexOf('.');
if (dot < 0 || dot == filename.length() - 1) {
return "unknown";
}
return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
}
private static boolean matchesType(Resource file, List<String> acceptedExtensions) {
String filename = file.getFilename();
if (filename == null) {
@@ -79,7 +79,7 @@ public class PolicyRunner {
// Generator pipeline: one run with no input. Still fall through to the cleanup
// below so rows recorded for its folder outputs are pruned like anything else,
// instead of accumulating until the policy is deleted.
runIds.add(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
runIds.add(startRun(policy, PolicyInputs.of(List.of()), null, unused -> {}));
}
for (PipelineInput input : inputs) {
String sourceId = input.sourceId();
@@ -167,17 +167,18 @@ public class PolicyRunner {
List<String> runIds = new ArrayList<>();
long docsFed = 0;
for (ResolvedInput unit : work) {
runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
runIds.add(startRun(policy, unit.inputs(), unit.fileIdentity(), unit.onComplete()));
docsFed += unit.inputs().primary().size();
}
docCounter.record(sourceId, docsFed);
return runIds;
}
private String startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
private String startRun(
Policy policy, PolicyInputs inputs, String fileIdentity, Consumer<Boolean> onComplete) {
log.info("Running policy {} ({})", policy.id(), policy.name());
PolicyRunHandle handle =
policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP);
policyEngine.runPolicy(policy, inputs, fileIdentity, PolicyProgressListener.NOOP);
handle.completion()
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
return handle.runId();
@@ -118,8 +118,9 @@ public class FolderInputSource implements InputSource {
continue;
}
work.add(
new ResolvedInput(
ResolvedInput.forFile(
PolicyInputs.of(List.of(fileResource(file))),
identity,
success ->
completeConsumed(
ctx, identity, file, gate, contentHash, success)));
@@ -2,21 +2,40 @@ package stirling.software.proprietary.policy.input;
import java.util.function.Consumer;
import stirling.software.proprietary.policy.ledger.IdentityHasher;
import stirling.software.proprietary.policy.model.PolicyInputs;
/**
* One unit of work from an {@link InputSource}: the files to run plus a completion callback invoked
* with the run's success (e.g. a folder source routes the input to done/error). A source may return
* several of these, one per file.
*
* @param fileIdentity stable opaque reference to the document, or null when the source has none.
* Always hashed, never the source's own identity: a folder identity is a path, and a path is a
* filename. See {@link #forFile}.
*/
public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
public record ResolvedInput(
PolicyInputs inputs, String fileIdentity, Consumer<Boolean> onComplete) {
public ResolvedInput {
onComplete = onComplete == null ? success -> {} : onComplete;
}
/** No completion side effect. */
/**
* One document, referenced by the hash of the source's identity for it. Stable across sweeps,
* which is what lets the same broken file fold into one incident instead of opening a fresh one
* every time the source re-lists it.
*/
public static ResolvedInput forFile(
PolicyInputs inputs, String identity, Consumer<Boolean> onComplete) {
return new ResolvedInput(
inputs,
identity == null ? null : IdentityHasher.identityHash(identity),
onComplete);
}
/** No document reference and no completion side effect. */
public static ResolvedInput of(PolicyInputs inputs) {
return new ResolvedInput(inputs, success -> {});
return new ResolvedInput(inputs, null, success -> {});
}
}
@@ -119,8 +119,9 @@ public class S3InputSource implements InputSource {
continue;
}
work.add(
new ResolvedInput(
ResolvedInput.forFile(
PolicyInputs.of(List.of(objectResource(client, config, object))),
identity,
success ->
completeConsumed(
ctx,
@@ -100,8 +100,9 @@ public class WebhookInputSource implements InputSource {
continue;
}
work.add(
new ResolvedInput(
ResolvedInput.forFile(
PolicyInputs.of(List.of(fileResource(file))),
identity,
success -> completeConsumed(ctx, identity, file, gate, success)));
}
return work;
@@ -22,6 +22,13 @@ public class PolicyRun {
private final String policyId;
private final PipelineDefinition definition;
/**
* The source's opaque reference to the document this run is about; null for an ad-hoc run or a
* source that names no document. Hashed upstream, so never a path or a filename.
*/
private final String fileIdentity;
private final Instant createdAt = Instant.now();
private volatile PolicyRunStatus status = PolicyRunStatus.PENDING;
@@ -49,10 +56,12 @@ public class PolicyRun {
private volatile List<ResultFile> outputs = List.of();
private volatile Instant updatedAt = Instant.now();
public PolicyRun(String runId, String policyId, PipelineDefinition definition) {
public PolicyRun(
String runId, String policyId, PipelineDefinition definition, String fileIdentity) {
this.runId = runId;
this.policyId = policyId;
this.definition = definition;
this.fileIdentity = fileIdentity;
}
public int stepCount() {
@@ -103,8 +103,9 @@ public class NetworkInputSource implements InputSource {
continue;
}
work.add(
new ResolvedInput(
ResolvedInput.forFile(
PolicyInputs.of(List.of(resource(config, file))),
identity,
success ->
completeConsumed(ctx, config, file, identity, gate, success)));
}
@@ -38,7 +38,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.repository",
"stirling.software.proprietary.integration.repository"
"stirling.software.proprietary.integration.repository",
"stirling.software.proprietary.failure"
})
@EntityScan({
"stirling.software.proprietary.security.model",
@@ -51,7 +52,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.model",
"stirling.software.proprietary.integration.model"
"stirling.software.proprietary.integration.model",
"stirling.software.proprietary.failure"
})
public class DatabaseConfig {
@@ -0,0 +1,216 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpClientErrorException;
import jakarta.annotation.PostConstruct;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.util.ExceptionUtils;
import tools.jackson.databind.json.JsonMapper;
/**
* Tests for {@link FailureClassifier}. The fixtures matter as much as the assertions: a tool's 400
* arrives as a {@link org.springframework.web.client.RestClientResponseException} carrying the
* Problem Details body, so these build that exact shape rather than a convenient stand-in.
*/
class FailureClassifierTest {
private final FailureClassifier classifier =
new FailureClassifier(JsonMapper.builder().build());
/** The Problem Details body {@code GlobalExceptionHandler} produces for a coded exception. */
private static HttpClientErrorException problemDetail(HttpStatus status, String errorCode) {
String body =
"""
{"type":"/errors/pdf-password","title":"PDF password required",\
"status":%d,"detail":"The PDF Document is passworded",\
"errorCode":"%s","timestamp":"2026-01-01T00:00:00Z"}"""
.formatted(status.value(), errorCode);
return HttpClientErrorException.create(
status,
status.getReasonPhrase(),
new HttpHeaders(),
body.getBytes(StandardCharsets.UTF_8),
StandardCharsets.UTF_8);
}
private static HttpClientErrorException rawBody(String body) {
return HttpClientErrorException.create(
HttpStatus.BAD_REQUEST,
"Bad Request",
new HttpHeaders(),
body == null ? new byte[0] : body.getBytes(StandardCharsets.UTF_8),
StandardCharsets.UTF_8);
}
@Nested
@DisplayName("a downstream tool error")
class DownstreamErrors {
@Test
void withAClaimedErrorCodeClassifiesToThatKind() {
assertThat(classifier.classify(problemDetail(HttpStatus.BAD_REQUEST, "E004")))
.isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
}
@Test
void withAnUnclaimedErrorCodeFallsBackToUnknown() {
// E001 (corrupted PDF) is a real code that no kind has adopted yet. It must land in
// UNKNOWN rather than being force-fitted to the nearest kind.
assertThat(classifier.classify(problemDetail(HttpStatus.BAD_REQUEST, "E001")))
.isEqualTo(FailureKind.UNKNOWN);
}
@Test
void withANonJsonBodyFallsBackToUnknownWithoutThrowing() {
assertThat(classifier.classify(rawBody("Internal Server Error")))
.isEqualTo(FailureKind.UNKNOWN);
}
@Test
void withAnEmptyBodyFallsBackToUnknown() {
assertThat(classifier.classify(rawBody(""))).isEqualTo(FailureKind.UNKNOWN);
assertThat(classifier.classify(rawBody(null))).isEqualTo(FailureKind.UNKNOWN);
}
@Test
void withJsonButNoErrorCodePropertyFallsBackToUnknown() {
assertThat(classifier.classify(rawBody("{\"detail\":\"nope\"}")))
.isEqualTo(FailureKind.UNKNOWN);
}
@Test
void entitlementSentinelIsNotMistakenForACodedFailure() {
// An entitlement 402 carries `error`, not `errorCode`. It must not be misclassified,
// and specifically must not be attributed to a file problem.
FailureKind kind =
classifier.classify(
rawBody("{\"error\":\"PAYG_LIMIT_REACHED\",\"subscribed\":false}"));
assertThat(kind).isEqualTo(FailureKind.UNKNOWN);
assertThat(kind).isNotEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
}
}
@Nested
@DisplayName("an exception thrown in process")
class InProcess {
@Test
void carryingItsOwnErrorCodeIsClassifiedByThatCode() {
// Not every path goes over loopback; a coded exception thrown directly must work too.
ExceptionUtils.PdfPasswordException e =
ExceptionUtils.createPdfPasswordException(new IOException("password"));
assertThat(classifier.classify(e)).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
}
@Test
void isClassifiedWhenWrappedInsideAnotherException() {
// The engine wraps and rethrows, so the code is often not on the outermost throwable.
Throwable wrapped =
new IllegalStateException(
"step failed",
ExceptionUtils.createPdfPasswordException(new IOException("pw")));
assertThat(classifier.classify(wrapped))
.isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
}
}
@Nested
@DisplayName("anything unrecognised")
class Fallback {
@Test
void toolTimeoutIsUnknownUntilAKindClaimsIt() {
// Deliberate: a timeout has its own catch site in the engine but no kind yet. This
// proves the fallback holds even where specific handling already exists.
InternalApiTimeoutException e =
new InternalApiTimeoutException(
"/api/v1/misc/ocr-pdf", Duration.ofSeconds(30), new IOException("t"));
assertThat(classifier.classify(e)).isEqualTo(FailureKind.UNKNOWN);
}
@Test
void bareRuntimeExceptionIsUnknown() {
assertThat(classifier.classify(new RuntimeException("boom")))
.isEqualTo(FailureKind.UNKNOWN);
}
@Test
void nullMessageDoesNotBreakClassification() {
assertThat(classifier.classify(new RuntimeException((String) null)))
.isEqualTo(FailureKind.UNKNOWN);
}
@Test
void nullThrowableIsUnknown() {
assertThat(classifier.classify(null)).isEqualTo(FailureKind.UNKNOWN);
}
@Test
void cyclicCauseChainTerminates() {
// The JDK blocks self-causation but not a two-node cycle, and an unbounded walk would
// spin on it forever, taking the run's error handling down with it.
RuntimeException outer = new RuntimeException("outer");
RuntimeException inner = new RuntimeException("inner", outer);
outer.initCause(inner);
assertThat(classifier.classify(outer)).isEqualTo(FailureKind.UNKNOWN);
}
@Test
void aCodeBuriedBeyondTheDepthBoundIsNotFound() {
// Documents the trade: the bound is what guarantees termination, so a pathologically
// deep chain classifies as UNKNOWN rather than hanging. UNKNOWN is always recoverable.
Throwable deepest =
ExceptionUtils.createPdfPasswordException(new IOException("password"));
Throwable chain = deepest;
for (int i = 0; i < 20; i++) {
chain = new IllegalStateException("wrap " + i, chain);
}
assertThat(classifier.classify(chain)).isEqualTo(FailureKind.UNKNOWN);
}
}
@Nested
@DisplayName("the boot guard on an ambiguous registry")
class BootGuard {
@Test
void isWiredToRunAtStartupRatherThanOnlyBeingCallable() throws Exception {
// The check moved out of FailureKind's class initialiser to get a readable message.
// That only buys anything if Spring actually invokes it.
assertThat(
FailureClassifier.class
.getDeclaredMethod("verifyNoErrorCodeIsClaimedTwice")
.isAnnotationPresent(PostConstruct.class))
.isTrue();
}
@Test
void acceptsTheRealRegistry() {
assertThatCode(classifier::verifyNoErrorCodeIsClaimedTwice).doesNotThrowAnyException();
}
@Test
void wouldRejectADuplicate() {
// FailureKind is a closed enum, so the guard cannot be handed a bad registry. What can
// be shown is that the detection it depends on finds a duplicate when there is one.
assertThat(FailureKind.duplicatesIn(Stream.of("E004", "E001", "E004")))
.containsExactly("E004");
}
}
}
@@ -0,0 +1,263 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import stirling.software.common.util.ExceptionUtils;
/**
* Tests for {@link FailureKind}. Mostly invariants over the whole enum rather than assertions about
* individual members, so a kind added later cannot be malformed in a way that only shows up as a
* button that fails at runtime.
*/
class FailureKindTest {
@Nested
@DisplayName("every kind is well formed")
class Invariants {
@ParameterizedTest
@EnumSource(FailureKind.class)
void hasAllFacetsAndAtLeastOneAction(FailureKind kind) {
assertThat(kind.getStage()).isNotNull();
assertThat(kind.getSeverity()).isNotNull();
assertThat(kind.getRemedy()).isNotNull();
assertThat(kind.getScope()).isNotNull();
assertThat(kind.getActions())
.as("a kind with no actions cannot be triaged at all")
.isNotEmpty();
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void hasCopyKeysAndAnEnglishFallback(FailureKind kind) {
assertThat(kind.getTitleKey()).isNotBlank();
assertThat(kind.getDescriptionKey()).isNotBlank();
// The fallback is what lets a client render a kind it was never built with.
assertThat(kind.getDefaultTitle()).isNotBlank();
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void idIsScreamingSnakeCase(FailureKind kind) {
assertThat(kind.getId()).matches("^[A-Z][A-Z0-9_]*$");
}
@Test
void idsAreUnique() {
Set<String> ids = new HashSet<>();
for (FailureKind kind : FailureKind.values()) {
assertThat(ids.add(kind.getId())).as("duplicate id %s", kind.getId()).isTrue();
}
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void everyDeclaredErrorCodeIsARealErrorCode(FailureKind kind) {
Set<String> known =
Arrays.stream(ExceptionUtils.ErrorCode.values())
.map(ExceptionUtils.ErrorCode::getCode)
.collect(Collectors.toSet());
assertThat(known).containsAll(kind.getErrorCodes());
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void everyDeclaredActionResolvesToALabelKey(FailureKind kind) {
// Offers are one ordered list, so a label with no matching action is unrepresentable;
// all that is left to assert is that each action gets a key.
for (FailureActionId action : kind.getActions()) {
assertThat(kind.labelKeyFor(action)).startsWith("portal.failures.action.");
}
}
@Test
void noTwoKindsClaimTheSameErrorCode() {
// Computed independently of duplicateErrorCodes(), then checked against it: the boot
// guard reads that method, so a version of it that always returned empty would leave
// the guard decorative and every other test still passing.
assertThat(FailureKind.duplicateErrorCodes()).isEmpty();
Set<String> claimed = new HashSet<>();
Stream.of(FailureKind.values())
.flatMap(kind -> kind.getErrorCodes().stream())
.forEach(
code ->
assertThat(claimed.add(code))
.as("error code %s claimed twice", code)
.isTrue());
}
}
@Nested
@DisplayName("every derived key resolves to English copy")
class Copy {
/**
* The enum builds its i18n keys from the constant name, so renaming a kind or shipping a
* new one sends keys the client has no copy for, and the UI renders the raw key. Nothing
* else checks that: the portal reads these keys at runtime, and the unused-translation
* audit only looks the other way, for copy no source file mentions.
*/
private static final Set<String> KEYS = englishKeys();
@ParameterizedTest
@EnumSource(FailureKind.class)
void titleAndDescriptionAreTranslated(FailureKind kind) {
assertThat(KEYS).contains(kind.getTitleKey(), kind.getDescriptionKey());
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void everyOfferedActionIsLabelled(FailureKind kind) {
for (FailureActionId action : kind.getActions()) {
assertThat(KEYS)
.as("%s offers %s, but nothing labels it", kind.getId(), action)
.contains(kind.labelKeyFor(action));
}
}
/**
* Every dotted key in the English file, as {@code [section]} plus the name before {@code
* =}.
*/
private static Set<String> englishKeys() {
Set<String> keys = new HashSet<>();
String section = "";
for (String raw : readTranslations()) {
String line = raw.strip();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
section = line.substring(1, line.length() - 1).strip() + ".";
continue;
}
int equals = line.indexOf('=');
if (equals > 0) {
keys.add(section + line.substring(0, equals).strip());
}
}
return keys;
}
private static List<String> readTranslations() {
// Located by walking up, so the test does not depend on the directory Gradle runs it
// in.
Path relative = Path.of("frontend/editor/public/locales/en-US/translation.toml");
for (Path dir = Path.of("").toAbsolutePath(); dir != null; dir = dir.getParent()) {
Path candidate = dir.resolve(relative);
if (Files.isRegularFile(candidate)) {
try {
return Files.readAllLines(candidate, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
throw new IllegalStateException(
"No " + relative + " above " + Path.of("").toAbsolutePath());
}
}
@Nested
@DisplayName("UNKNOWN is the catch-all")
class Unknown {
@Test
void existsAndCanBeTriaged() {
assertThat(FailureKind.UNKNOWN.getActions())
.containsExactlyInAnyOrder(
FailureActionId.ACKNOWLEDGE, FailureActionId.DISMISS);
}
@Test
void claimsNoErrorCodeSoItNeverWinsALookup() {
assertThat(FailureKind.UNKNOWN.getErrorCodes()).isEmpty();
}
}
@Nested
@DisplayName("lookup")
class Lookup {
@Test
void byIdRoundTripsEveryKind() {
for (FailureKind kind : FailureKind.values()) {
assertThat(FailureKind.byId(kind.getId())).contains(kind);
}
}
@Test
void byIdIsEmptyForAnUnknownId() {
// Ids arrive from persisted rows and from clients, so this must not throw.
assertThat(FailureKind.byId("NO_SUCH_KIND")).isEmpty();
assertThat(FailureKind.byId(null)).isEmpty();
assertThat(FailureKind.byId(" ")).isEmpty();
}
@Test
void byErrorCodeResolvesTheClaimingKind() {
assertThat(FailureKind.byErrorCode("E004"))
.contains(FailureKind.INPUT_PASSWORD_PROTECTED);
}
@Test
void byErrorCodeIsEmptyForACodeNoKindHasAdoptedYet() {
// E001 is PDF_CORRUPTED: a real error code, deliberately not yet a kind.
assertThat(FailureKind.byErrorCode("E001")).isEmpty();
assertThat(FailureKind.byErrorCode(null)).isEmpty();
}
}
@Nested
@DisplayName("action declaration and labels")
class Actions {
@Test
void declaresOnlyWhatItLists() {
assertThat(FailureKind.UNKNOWN.declares(FailureActionId.ACKNOWLEDGE)).isTrue();
assertThat(FailureKind.UNKNOWN.declares(FailureActionId.DISMISS)).isTrue();
}
@Test
void overriddenLabelWinsOverTheGenericOne() {
String label =
FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(FailureActionId.DISMISS);
assertThat(label).isEqualTo("portal.failures.action.dismissSkipFile");
assertThat(label).isNotEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS));
}
@Test
void genericLabelIsUsedWhenAKindDeclaresNoOverride() {
assertThat(FailureKind.UNKNOWN.labelKeyFor(FailureActionId.DISMISS))
.isEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS))
.isEqualTo("portal.failures.action.dismiss");
}
@Test
void copyKeysAreDerivedFromTheIdInLowerCamel() {
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getTitleKey())
.isEqualTo("portal.failures.kind.inputPasswordProtected.title");
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getDescriptionKey())
.isEqualTo("portal.failures.kind.inputPasswordProtected.description");
}
}
}
@@ -0,0 +1,403 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Tests for {@link FileRunEventController}: the wire shape, the status mapping for each refusal
* reason, and that the team is taken from the principal rather than the request.
*/
@ExtendWith(MockitoExtension.class)
class FileRunEventControllerTest {
private static final Long TEAM = 4L;
@Mock private PolicyManagementAuthority authority;
@Mock private UserServiceInterface userService;
private FileRunEventStore store;
private FileRunEventController controller;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(true);
store = new FileRunEventStore(new InMemoryFileRunEventRepository());
FailureActionRegistry registry =
new FailureActionRegistry(
List.of(new AcknowledgeAction(store), new DismissAction(store)));
controller =
new FileRunEventController(
new FileRunEventService(store, registry, authority, userService, props),
authority,
props);
lenient().when(authority.canEditPolicies()).thenReturn(true);
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
lenient().when(userService.getCurrentUsername()).thenReturn("reviewer@example.com");
}
private FileRunEvent given(FailureKind kind, Long teamId, String fileId) {
return store.record(
new RecordFailure(
kind,
FailureOrigin.POLICY,
teamId,
"author@example.com",
"policy-1",
"run-1",
null,
fileId,
"the raw failure message"));
}
@Nested
@DisplayName("listing")
class Listing {
@Test
void returnsOnlyTheCallersTeamsRows() {
given(FailureKind.UNKNOWN, TEAM, "mine");
given(FailureKind.UNKNOWN, 99L, "theirs");
assertThat(controller.list(null, null, null).events())
.extracting(FileRunEventView::fileId)
.containsExactly("mine");
}
@Test
void carriesTheCopyKeysAndTheEnglishFallback() {
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
FileRunEventView view = controller.list(null, null, null).events().getFirst();
assertThat(view.titleKey())
.isEqualTo("portal.failures.kind.inputPasswordProtected.title");
assertThat(view.descriptionKey())
.isEqualTo("portal.failures.kind.inputPasswordProtected.description");
assertThat(view.defaultTitle()).isNotBlank();
assertThat(view.detail()).isEqualTo("the raw failure message");
}
@Test
void carriesActionsAlreadyResolvedForTheRow() {
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
List<FileRunEventView.ActionView> actions =
controller.list(null, null, null).events().getFirst().actions();
assertThat(actions)
.extracting(FileRunEventView.ActionView::id)
.containsExactlyInAnyOrder("ACKNOWLEDGE", "DISMISS");
assertThat(actions).allMatch(FileRunEventView.ActionView::enabled);
}
@Test
void showsAClosedRowsActionsDisabledWithAReasonRatherThanHidingThem() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
controller.act(event.id(), "DISMISS", null);
List<FileRunEventView.ActionView> actions =
controller
.list(FileRunEventStatus.DISMISSED, null, null)
.events()
.getFirst()
.actions();
assertThat(actions).noneMatch(FileRunEventView.ActionView::enabled);
assertThat(actions)
.allMatch(a -> "portal.failures.disabled.closed".equals(a.disabledReasonKey()));
}
@Test
void filtersByStatusAndByKind() {
FileRunEvent open = given(FailureKind.UNKNOWN, TEAM, "open");
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked");
controller.act(open.id(), "ACKNOWLEDGE", null);
assertThat(controller.list(FileRunEventStatus.ACKNOWLEDGED, null, null).events())
.hasSize(1);
assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events())
.extracting(FileRunEventView::fileId)
.containsExactly("locked");
assertThat(controller.list(null, "NO_SUCH_KIND", null).events()).isEmpty();
}
@Test
void capsTheRequestedLimitSoAClientCannotAskForTheWholeTable() {
// Distinct files, so five genuinely distinct rows exist. The earlier version of this
// test used a RUN-scoped kind with one hardcoded run id, so all five folded into one
// row and the assertion measured the rollup rather than the cap.
for (int i = 0; i < 5; i++) {
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f" + i);
}
// Over-large and non-positive limits are both coerced rather than rejected.
assertThat(controller.list(null, null, 100_000).events()).hasSize(5);
assertThat(controller.list(null, null, 2).events()).hasSize(2);
assertThat(controller.list(null, null, 0).events()).hasSize(1);
}
@Test
void kindFilterAppliesBeforeTheLimitNotAfter() {
// A post-limit filter would take the newest N rows and then discard non-matches,
// returning nothing while matching rows exist. The filter lives in the query.
given(FailureKind.UNKNOWN, TEAM, "old-unknown");
for (int i = 0; i < 3; i++) {
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i);
}
assertThat(controller.list(null, "UNKNOWN", 1).events())
.extracting(FileRunEventView::fileId)
.containsExactly("old-unknown");
}
}
@Nested
@DisplayName("acting")
class Acting {
@Test
void appliesADeclaredActionAndReturnsTheUpdatedRow() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
FileRunEventView updated = controller.act(event.id(), "ACKNOWLEDGE", null);
assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
assertThat(updated.statusActor()).isEqualTo("reviewer@example.com");
}
@Test
void acceptsAnAbsentBodyBecauseTheseActionsNeedNoInput() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
assertThat(controller.act(event.id(), "DISMISS", null).status())
.isEqualTo(FileRunEventStatus.DISMISSED);
}
@Test
void anUnknownActionIsABadRequest() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
assertThat(statusOf(() -> controller.act(event.id(), "APPROVE", null)))
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void anotherTeamsRowIsNotFoundRatherThanForbidden() {
// 404 rather than 403, so the response does not confirm the row exists.
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
assertThat(statusOf(() -> controller.act(theirs.id(), "ACKNOWLEDGE", null)))
.isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void anAlreadyClosedRowIsAConflict() {
// The request was well formed and would have been valid a moment earlier.
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
controller.act(event.id(), "DISMISS", null);
assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null)))
.isEqualTo(HttpStatus.CONFLICT);
}
private HttpStatus statusOf(Runnable call) {
try {
call.run();
} catch (ResponseStatusException e) {
return HttpStatus.valueOf(e.getStatusCode().value());
}
throw new AssertionError("expected the call to be refused");
}
}
@Nested
@DisplayName("the kinds catalogue")
class Kinds {
@Test
void servesEveryRegisteredKindWithItsFacetsAndDeclaredActions() {
List<FailureKindView> kinds = controller.kinds();
assertThat(kinds).hasSameSizeAs(FailureKind.values());
assertThat(kinds).extracting(FailureKindView::id).contains("UNKNOWN");
assertThat(kinds).allMatch(k -> k.stage() != null && !k.actions().isEmpty());
}
@Test
void carriesTheLabelKeyForEachDeclaredAction() {
FailureKindView locked =
controller.kinds().stream()
.filter(k -> "INPUT_PASSWORD_PROTECTED".equals(k.id()))
.findFirst()
.orElseThrow();
assertThat(locked.actions())
.extracting(FailureKindView.ActionDeclaration::labelKey)
.contains("portal.failures.action.dismissSkipFile");
}
@Test
void reportsTheErrorCodesEachKindClaims() {
FailureKindView locked =
controller.kinds().stream()
.filter(k -> "INPUT_PASSWORD_PROTECTED".equals(k.id()))
.findFirst()
.orElseThrow();
assertThat(locked.errorCodes()).containsExactly("E004");
}
}
@Nested
@DisplayName("only a team leader may review failures")
class Authorization {
@Test
void aMemberCannotListThem() {
when(authority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.list(null, null, null))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN));
}
@Test
void aMemberCannotDispatchAnAction() {
// The read being refused is not enough on its own: an id learned any other way must
// not let a member close another user's failure.
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f-1");
when(authority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.act(event.id(), "DISMISS", null))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN));
}
@Test
void theRegistryIsAlsoLeaderOnly() {
when(authority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.kinds())
.isInstanceOf(ResponseStatusException.class);
}
@Test
void loginDisabledTrustsTheLocalOperator() {
// A single-user deployment has no roles to distinguish, so the role gate must not lock
// the only user out of their own failures.
ApplicationProperties unsecured = new ApplicationProperties();
unsecured.getSecurity().setEnableLogin(false);
FileRunEventController noLogin =
new FileRunEventController(
new FileRunEventService(
store,
new FailureActionRegistry(
List.of(
new AcknowledgeAction(store),
new DismissAction(store))),
authority,
userService,
unsecured),
authority,
unsecured);
assertThatCode(() -> noLogin.list(null, null, null)).doesNotThrowAnyException();
// Not merely permitted: the role is never consulted at all, which is what makes the
// carve-out independent of however the authority answers with no users configured.
verify(authority, never()).canEditPolicies();
}
}
@Nested
@DisplayName("team scoping cannot be influenced by the caller")
class Scoping {
@Test
void thereIsNoWayToAskForAnotherTeam() {
// Stated as a test because the absence of the parameter is the property. Adding a
// teamId parameter to list() breaks this at compile time.
given(FailureKind.UNKNOWN, 99L, "theirs");
FileRunEventController.FileRunEventsResponse response =
controller.list(null, null, null);
assertThat(response.events()).isEmpty();
assertThat(
Arrays.stream(FileRunEventController.class.getDeclaredMethods())
.filter(m -> "list".equals(m.getName()))
.flatMap(m -> Arrays.stream(m.getParameterTypes()))
.toList())
.doesNotContain(Long.class);
}
@Test
void loginDisabledReadsTheUnteamedRows() {
ApplicationProperties unsecured = new ApplicationProperties();
unsecured.getSecurity().setEnableLogin(false);
FileRunEventController noLogin =
new FileRunEventController(
new FileRunEventService(
store,
new FailureActionRegistry(
List.of(
new AcknowledgeAction(store),
new DismissAction(store))),
authority,
userService,
unsecured),
authority,
unsecured);
given(FailureKind.UNKNOWN, null, "unteamed");
given(FailureKind.UNKNOWN, TEAM, "teamed");
assertThat(noLogin.list(null, null, null).events())
.extracting(FileRunEventView::fileId)
.containsExactly("unteamed");
}
}
@Nested
@DisplayName("action request body")
class RequestBody {
@Test
void nullInputsAreTreatedAsEmpty() {
assertThat(new FileRunEventController.ActionRequest(null).safeInputs()).isEmpty();
assertThat(
new FileRunEventController.ActionRequest(Map.of("password", "x"))
.safeInputs())
.containsEntry("password", "x");
}
}
}
@@ -0,0 +1,379 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration;
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration;
import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration;
import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/**
* End-to-end test of the read path and action dispatch over real HTTP, against live Jetty on a
* random port.
*
* <p>The other tests here call controller methods directly, which skips response serialisation,
* body binding, query coercion and status mapping. A double-encoded request body already slipped
* through that gap once, so these use a real {@link HttpClient} against a real socket.
*/
@SpringBootTest(
classes = FileRunEventHttpIntegrationTest.TestApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class FileRunEventHttpIntegrationTest {
private static final Long TEAM = 42L;
private static final String ACTOR = "reviewer@example.com";
@LocalServerPort private int port;
private final HttpClient http = HttpClient.newHttpClient();
private final JsonMapper mapper = JsonMapper.builder().build();
/** Shared so a test can seed a row and then read it back over the wire. */
private static InMemoryFileRunEventRepository repository;
@BeforeEach
void resetRows() {
repository.rows.clear();
}
private String seed(FailureKind kind, Long teamId, String fileId, String detail) {
FileRunEventStore store = new FileRunEventStore(repository);
return store.record(
new RecordFailure(
kind,
FailureOrigin.POLICY,
teamId,
"author@example.com",
"policy-1",
"run-1",
null,
fileId,
detail))
.id();
}
private HttpResponse<String> get(String path) throws Exception {
return http.send(
HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).GET().build(),
HttpResponse.BodyHandlers.ofString());
}
private HttpResponse<String> post(String path, String jsonBody) throws Exception {
HttpRequest.Builder builder =
HttpRequest.newBuilder(URI.create("http://localhost:" + port + path))
.header("Content-Type", "application/json");
builder =
jsonBody == null
? builder.POST(HttpRequest.BodyPublishers.noBody())
: builder.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
@Nested
@DisplayName("the read path")
class ReadPath {
@Test
void serialisesAnEventWithItsFacetsCopyKeysAndResolvedActions() throws Exception {
seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "acme-msa", "locked tight");
HttpResponse<String> response = get("/api/v1/file-run-events");
assertThat(response.statusCode()).isEqualTo(200);
JsonNode row = mapper.readTree(response.body()).get("events").get(0);
assertThat(row.get("kindId").asString()).isEqualTo("INPUT_PASSWORD_PROTECTED");
assertThat(row.get("stage").asString()).isEqualTo("INPUT");
assertThat(row.get("severity").asString()).isEqualTo("ERROR");
assertThat(row.get("scope").asString()).isEqualTo("FILE");
assertThat(row.get("origin").asString()).isEqualTo("POLICY");
assertThat(row.get("remedy").asString()).isEqualTo("NEEDS_USER_INPUT");
assertThat(row.get("titleKey").asString())
.isEqualTo("portal.failures.kind.inputPasswordProtected.title");
assertThat(row.get("defaultTitle").asString()).isNotBlank();
assertThat(row.get("detail").asString()).isEqualTo("locked tight");
assertThat(row.get("status").asString()).isEqualTo("NEW");
assertThat(row.get("occurrences").asInt()).isEqualTo(1);
// Epoch millis, not an ISO string: the client renders relative times from a number.
assertThat(row.get("lastSeenAt").isNumber()).isTrue();
JsonNode actions = row.get("actions");
assertThat(actions).hasSize(2);
assertThat(actions.get(0).get("id").asString()).isEqualTo("ACKNOWLEDGE");
assertThat(actions.get(0).get("labelKey").asString())
.isEqualTo("portal.failures.action.acknowledge");
assertThat(actions.get(0).get("enabled").asBoolean()).isTrue();
assertThat(actions.get(0).get("disabledReasonKey").isNull()).isTrue();
}
@Test
void doesNotReturnAnotherTeamsRows() throws Exception {
seed(FailureKind.UNKNOWN, 999L, "theirs", "not yours");
JsonNode events = mapper.readTree(get("/api/v1/file-run-events").body()).get("events");
assertThat(events).isEmpty();
}
@Test
void ignoresAClientSuppliedTeamId() throws Exception {
// The team comes from the principal, so a teamId query param is ignored rather than
// honoured.
seed(FailureKind.UNKNOWN, 999L, "theirs", "not yours");
JsonNode events =
mapper.readTree(get("/api/v1/file-run-events?teamId=999").body()).get("events");
assertThat(events).isEmpty();
}
@Test
void coercesQueryParametersAndFiltersOnThem() throws Exception {
String open = seed(FailureKind.UNKNOWN, TEAM, "open", "a");
seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked", "b");
post("/api/v1/file-run-events/" + open + "/actions/ACKNOWLEDGE", "{\"inputs\":{}}");
JsonNode acknowledged =
mapper.readTree(get("/api/v1/file-run-events?status=ACKNOWLEDGED").body())
.get("events");
assertThat(acknowledged).hasSize(1);
JsonNode byKind =
mapper.readTree(
get("/api/v1/file-run-events?kindId=INPUT_PASSWORD_PROTECTED")
.body())
.get("events");
assertThat(byKind).hasSize(1);
assertThat(byKind.get(0).get("fileId").asString()).isEqualTo("locked");
// `limit` binds as an Integer, so a non-numeric value is a bad request, not a 500.
assertThat(get("/api/v1/file-run-events?limit=abc").statusCode()).isEqualTo(400);
}
@Test
void rejectsAnUnknownStatusFilter() throws Exception {
assertThat(get("/api/v1/file-run-events?status=BANANA").statusCode()).isEqualTo(400);
}
@Test
void servesTheKindsCatalogue() throws Exception {
HttpResponse<String> response = get("/api/v1/file-run-events/kinds");
assertThat(response.statusCode()).isEqualTo(200);
JsonNode kinds = mapper.readTree(response.body());
assertThat(kinds).hasSize(FailureKind.values().length);
assertThat(kinds.get(0).get("actions").isArray()).isTrue();
}
}
@Nested
@DisplayName("action dispatch")
class Dispatch {
@Test
void bindsTheRequestBodyAndReturnsTheUpdatedRow() throws Exception {
// The regression guard: an object body, sent as real JSON over the wire, binding into
// ActionRequest. A double-encoded string would fail here.
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
HttpResponse<String> response =
post(
"/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE",
"{\"inputs\":{}}");
assertThat(response.statusCode()).isEqualTo(200);
JsonNode row = mapper.readTree(response.body());
assertThat(row.get("status").asString()).isEqualTo("ACKNOWLEDGED");
assertThat(row.get("statusActor").asString()).isEqualTo(ACTOR);
}
@Test
void acceptsAPopulatedInputsMap() throws Exception {
// Nothing consumes inputs yet, but the shape must bind so the first action that needs
// one (a password) does not discover a broken contract.
String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "locked");
HttpResponse<String> response =
post(
"/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE",
"{\"inputs\":{\"password\":\"hunter2\"}}");
assertThat(response.statusCode()).isEqualTo(200);
}
@Test
void acceptsAnAbsentBody() throws Exception {
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
assertThat(
post("/api/v1/file-run-events/" + id + "/actions/DISMISS", null)
.statusCode())
.isEqualTo(200);
}
@Test
void mapsAnUnknownActionToBadRequest() throws Exception {
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
assertThat(
post(
"/api/v1/file-run-events/" + id + "/actions/APPROVE",
"{\"inputs\":{}}")
.statusCode())
.isEqualTo(400);
}
@Test
void mapsAnotherTeamsRowToNotFound() throws Exception {
String id = seed(FailureKind.UNKNOWN, 999L, "theirs", "boom");
assertThat(
post(
"/api/v1/file-run-events/"
+ id
+ "/actions/ACKNOWLEDGE",
"{\"inputs\":{}}")
.statusCode())
.isEqualTo(404);
}
@Test
void mapsAnAlreadyClosedRowToConflict() throws Exception {
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
post("/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{}}");
assertThat(
post(
"/api/v1/file-run-events/"
+ id
+ "/actions/ACKNOWLEDGE",
"{\"inputs\":{}}")
.statusCode())
.isEqualTo(409);
}
@Test
void aDismissedRowComesBackWithItsActionsDisabled() throws Exception {
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
JsonNode row =
mapper.readTree(
post("/api/v1/file-run-events/" + id + "/actions/DISMISS", null)
.body());
assertThat(row.get("status").asString()).isEqualTo("DISMISSED");
for (JsonNode action : row.get("actions")) {
assertThat(action.get("enabled").asBoolean()).isFalse();
assertThat(action.get("disabledReasonKey").asString())
.isEqualTo("portal.failures.disabled.closed");
}
}
}
/**
* Spring Security is excluded rather than configured to permit everything: the auto-configured
* chain would answer 401 before the handler runs, and this test is about routing, body binding,
* serialisation and status mapping. Authorisation and team scoping have their own tests.
*/
@SpringBootConfiguration
@EnableAutoConfiguration(
exclude = {
SecurityAutoConfiguration.class,
ServletWebSecurityAutoConfiguration.class,
ManagementWebSecurityAutoConfiguration.class,
SecurityFilterAutoConfiguration.class,
UserDetailsServiceAutoConfiguration.class
})
static class TestApp {
/**
* Backed by the shared in-memory repository, since this test is about the HTTP layer and
* persistence has its own tests.
*/
@Bean
FileRunEventStore fileRunEventStore() {
repository = new InMemoryFileRunEventRepository();
return new FileRunEventStore(repository);
}
@Bean
FailureActionRegistry failureActionRegistry(List<FailureAction> actions) {
return new FailureActionRegistry(actions);
}
@Bean
AcknowledgeAction acknowledgeAction(FileRunEventStore store) {
return new AcknowledgeAction(store);
}
@Bean
DismissAction dismissAction(FileRunEventStore store) {
return new DismissAction(store);
}
@Bean
ApplicationProperties applicationProperties() {
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(true);
props.getAutomaticallyGenerated().setAppVersion("test");
return props;
}
@Bean
PolicyManagementAuthority policyManagementAuthority() {
PolicyManagementAuthority authority =
org.mockito.Mockito.mock(PolicyManagementAuthority.class);
org.mockito.Mockito.when(authority.currentUserTeamId()).thenReturn(TEAM);
org.mockito.Mockito.when(authority.canEditPolicies()).thenReturn(true);
return authority;
}
@Bean
UserServiceInterface userService() {
UserServiceInterface users = org.mockito.Mockito.mock(UserServiceInterface.class);
org.mockito.Mockito.when(users.getCurrentUsername()).thenReturn(ACTOR);
return users;
}
@Bean
FileRunEventService fileRunEventService(
FileRunEventStore store,
FailureActionRegistry registry,
PolicyManagementAuthority authority,
UserServiceInterface users,
ApplicationProperties props) {
return new FileRunEventService(store, registry, authority, users, props);
}
@Bean
FileRunEventController fileRunEventController(
FileRunEventService service,
PolicyManagementAuthority authority,
ApplicationProperties props) {
return new FileRunEventController(service, authority, props);
}
}
}
@@ -0,0 +1,393 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Tests for {@link FileRunEventService}: team scoping, the declaration guard, transition legality,
* and that triaging an incident leaves the document alone.
*/
@ExtendWith(MockitoExtension.class)
class FileRunEventServiceTest {
private static final Long TEAM = 3L;
private static final String ACTOR = "reviewer@example.com";
@Mock private PolicyManagementAuthority authority;
@Mock private UserServiceInterface userService;
private FileRunEventStore store;
private FileRunEventService service;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(true);
store = new FileRunEventStore(new InMemoryFileRunEventRepository());
FailureActionRegistry registry =
new FailureActionRegistry(
List.of(new AcknowledgeAction(store), new DismissAction(store)));
registry.verifyEveryDeclaredActionHasAHandler();
service = new FileRunEventService(store, registry, authority, userService, props);
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR);
}
private FileRunEvent given(FailureKind kind, Long teamId, String fileId) {
return store.record(
new RecordFailure(
kind,
FailureOrigin.POLICY,
teamId,
"author@example.com",
"policy-1",
"run-1",
null,
fileId,
"detail"));
}
@Nested
@DisplayName("acknowledge")
class Acknowledge {
@Test
void movesANewEventToAcknowledgedAndStampsTheActor() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
FileRunEvent updated = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
assertThat(updated.statusActor()).isEqualTo(ACTOR);
assertThat(updated.statusAt()).isNotNull();
}
@Test
void isANoOpWhenAlreadyAcknowledgedSoOwnershipIsNotStolen() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
FileRunEvent first = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
Instant originalAt = first.statusAt();
when(userService.getCurrentUsername()).thenReturn("someone-else@example.com");
FileRunEvent second = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
assertThat(second.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
assertThat(second.statusActor()).isEqualTo(ACTOR);
assertThat(second.statusAt()).isEqualTo(originalAt);
}
}
@Nested
@DisplayName("dismiss")
class Dismiss {
@Test
void closesANewEvent() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status())
.isEqualTo(FileRunEventStatus.DISMISSED);
}
@Test
void closesAnAcknowledgedEvent() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status())
.isEqualTo(FileRunEventStatus.DISMISSED);
}
}
@Nested
@DisplayName("triage never touches the document")
class NeverTouchesTheDocument {
/**
* What makes both actions valid for every kind, UNKNOWN included: they are incident
* dispositions, not document ones. Asserted structurally — an action whose only dependency
* is the event store <em>cannot</em> reach the ledger, file storage, or an output sink. A
* mocked-collaborator version of this test passed vacuously, because nothing ever handed
* the mocks to the actions.
*/
@Test
void actionsDependOnNothingButTheEventStore() {
for (FailureAction action :
List.of(new AcknowledgeAction(store), new DismissAction(store))) {
String name = action.getClass().getSimpleName();
List<Class<?>> dependencies =
Arrays.stream(action.getClass().getDeclaredFields())
// Coverage instrumentation adds its own field; only ours count.
.filter(field -> !field.isSynthetic())
.map(Field::getType)
.toList();
// Asserted first, so an action that lost its fields fails here rather than
// satisfying the check below by holding nothing at all.
assertThat(dependencies).as("%s declares no dependencies", name).isNotEmpty();
assertThat(dependencies)
.as(
"%s: an incident disposition reaches the event store and nothing"
+ " else. Adding to this is how one starts touching"
+ " documents.",
name)
.containsOnly(FileRunEventStore.class);
}
}
@Test
void dismissLeavesTheFileReferenceIntactRatherThanClearingIt() {
// Nothing is deleted, so the row must still say which file it was about.
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
FileRunEvent dismissed = service.dispatch(event.id(), "DISMISS", Map.of());
assertThat(dismissed.fileId()).isEqualTo("f1");
}
}
@Nested
@DisplayName("guards")
class Guards {
@Test
void anotherTeamsEventIsNotFound() {
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
assertThatThrownBy(() -> service.dispatch(theirs.id(), "ACKNOWLEDGE", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
}
@Test
void anUnknownEventIdIsNotFound() {
assertThatThrownBy(() -> service.dispatch("nope", "ACKNOWLEDGE", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
}
@Test
void anUnknownActionIdIsRejected() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
assertThatThrownBy(() -> service.dispatch(event.id(), "APPROVE", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ACTION_NOT_RECOGNISED);
}
@Test
void aDeclaredActionWithNoRegisteredHandlerIsRejectedWithoutAnyStateChange() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(true);
FileRunEventService missingHandler =
new FileRunEventService(
store,
new FailureActionRegistry(List.of(new AcknowledgeAction(store))),
authority,
userService,
props);
assertThatThrownBy(() -> missingHandler.dispatch(event.id(), "DISMISS", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ACTION_NOT_RECOGNISED);
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
.isEqualTo(FileRunEventStatus.NEW);
}
@Test
void aClosedEventCannotBeActedOnAgain() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
service.dispatch(event.id(), "DISMISS", Map.of());
assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
assertThatThrownBy(() -> service.dispatch(event.id(), "DISMISS", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
}
}
@Nested
@DisplayName("available actions are resolved per row")
class Availability {
@Test
void openRowOffersEveryDeclaredActionEnabled() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
List<FileRunEventService.AvailableAction> actions = service.availableActions(event);
assertThat(actions).hasSize(2);
assertThat(actions).allMatch(FileRunEventService.AvailableAction::enabled);
assertThat(actions).allMatch(action -> action.disabledReasonKey() == null);
}
@Test
void closedRowOffersThemDisabledWithAReasonRatherThanHidingThem() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
FileRunEvent dismissed = service.dispatch(event.id(), "DISMISS", Map.of());
List<FileRunEventService.AvailableAction> actions = service.availableActions(dismissed);
assertThat(actions).isNotEmpty();
assertThat(actions).noneMatch(FileRunEventService.AvailableAction::enabled);
assertThat(actions)
.allMatch(
action ->
"portal.failures.disabled.closed"
.equals(action.disabledReasonKey()));
}
@Test
void carriesTheKindsOverriddenLabelWhereItHasOne() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(service.availableActions(event))
.extracting(FileRunEventService.AvailableAction::labelKey)
.contains("portal.failures.action.dismissSkipFile");
}
@Test
void fallsBackToTheGenericLabelWhereTheKindDeclaresNoOverride() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
assertThat(service.availableActions(event))
.extracting(FileRunEventService.AvailableAction::labelKey)
.containsExactlyInAnyOrder(
"portal.failures.action.acknowledge", "portal.failures.action.dismiss");
}
}
@Nested
@DisplayName("team scoping")
class Scoping {
@Test
void listReturnsOnlyTheCallersTeam() {
given(FailureKind.UNKNOWN, TEAM, "mine");
given(FailureKind.UNKNOWN, 99L, "theirs");
assertThat(service.list(null, null, 50))
.extracting(FileRunEvent::fileId)
.containsExactly("mine");
}
@Test
void aCallerWhoseTeamCannotBeResolvedReadsNothing() {
// A run with no stored policy is recorded unteamed, and those rows are shared by every
// team, so a caller whose team does not resolve reads nothing instead.
given(FailureKind.UNKNOWN, null, "someone-elses-adhoc-run");
given(FailureKind.UNKNOWN, TEAM, "mine");
when(authority.currentUserTeamId()).thenReturn(null);
assertThat(service.list(null, null, 50)).isEmpty();
}
@Test
void aCallerWhoseTeamCannotBeResolvedCannotActOnAnUnteamedRow() {
FileRunEvent unteamed = given(FailureKind.UNKNOWN, null, "someone-elses-adhoc-run");
when(authority.currentUserTeamId()).thenReturn(null);
assertThatThrownBy(() -> service.dispatch(unteamed.id(), "ACKNOWLEDGE", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
// Not a distinct "no team" reason: that would confirm the id exists.
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
assertThat(store.find(unteamed.id(), null).orElseThrow().status())
.isEqualTo(FileRunEventStatus.NEW);
}
@Test
void loginDisabledFallsBackToTheUnteamedRowsWithoutConsultingTheAuthority() {
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(false);
FailureActionRegistry registry =
new FailureActionRegistry(
List.of(new AcknowledgeAction(store), new DismissAction(store)));
FileRunEventService unsecured =
new FileRunEventService(store, registry, authority, userService, props);
given(FailureKind.UNKNOWN, null, "unteamed");
given(FailureKind.UNKNOWN, TEAM, "teamed");
assertThat(unsecured.list(null, null, 50))
.extracting(FileRunEvent::fileId)
.containsExactly("unteamed");
}
}
@Nested
@DisplayName("the action registry")
class Registry {
@Test
void refusesToStartWhenAKindDeclaresAnActionWithNoHandler() {
// A missing handler would otherwise surface as a button that 400s in production.
FailureActionRegistry incomplete =
new FailureActionRegistry(List.of(new AcknowledgeAction(store)));
assertThatThrownBy(incomplete::verifyEveryDeclaredActionHasAHandler)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("DISMISS");
}
@Test
void refusesTwoHandlersForTheSameAction() {
assertThatThrownBy(
() ->
new FailureActionRegistry(
List.of(
new AcknowledgeAction(store),
new AcknowledgeAction(store))))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("ACKNOWLEDGE");
}
@Test
void acceptsACompleteSetOfHandlers() {
FailureActionRegistry complete =
new FailureActionRegistry(
List.of(new AcknowledgeAction(store), new DismissAction(store)));
complete.verifyEveryDeclaredActionHasAHandler();
for (FailureActionId id : FailureActionId.values()) {
assertThat(complete.find(id)).isPresent();
}
}
}
}
@@ -0,0 +1,303 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* {@link FileRunEventStore} against the real JPQL on a real (H2) database. Every other test in this
* package substitutes {@link InMemoryFileRunEventRepository}, whose Java filters would keep passing
* if the queries themselves regressed — the team-isolation clause could be deleted from the JPQL
* and no in-memory test would notice.
*
* <p>{@code NOT_SUPPORTED} suspends {@code @DataJpaTest}'s per-test transaction, so every store
* call commits in its own transaction as at runtime. That is what makes the stale-snapshot test
* meaningful: inside one shared persistence context, both reads would hand back the same managed
* instance and the bug could not show.
*/
@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class FileRunEventStoreDbTest {
private static final Long TEAM = 7L;
private static final Long OTHER_TEAM = 9L;
@Autowired private FileRunEventRepository repository;
private FileRunEventStore store;
@BeforeEach
void setUp() {
store = new FileRunEventStore(repository);
}
@AfterEach
void wipe() {
repository.deleteAllInBatch();
}
private RecordFailure failure(FailureKind kind, Long teamId, String fileId) {
return new RecordFailure(
kind,
FailureOrigin.POLICY,
teamId,
"author@example.com",
"policy-1",
"run-1",
null,
fileId,
"detail");
}
@Test
@DisplayName("team isolation is enforced by the query, including the unteamed rows")
void teamIsolationIsEnforcedBySql() {
store.record(failure(FailureKind.UNKNOWN, TEAM, "ours"));
store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs"));
store.record(failure(FailureKind.UNKNOWN, null, "unteamed"));
assertThat(store.list(TEAM, null, null, 10))
.extracting(FileRunEvent::fileId)
.containsExactly("ours");
// A plain `e.teamId = :teamId` would return nothing here: SQL equality against NULL is
// never true, which is what the explicit null branch in the JPQL exists for.
assertThat(store.list(null, null, null, 10))
.extracting(FileRunEvent::fileId)
.containsExactly("unteamed");
}
@Test
@DisplayName("a fold lands on the row's current state, not the caller's snapshot")
void foldTargetsTheCurrentRowNotACallersSnapshot() {
// The lost-update shape: record reads the row, a reviewer dismisses it, then the fold
// applies. A save() of the stale snapshot would merge status = NEW back over the dismiss.
FileRunEvent event = store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer@example.com");
FileRunEvent folded =
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
assertThat(folded.status()).isEqualTo(FileRunEventStatus.DISMISSED);
assertThat(folded.statusActor()).isEqualTo("reviewer@example.com");
assertThat(folded.occurrences()).isEqualTo(2);
}
@Test
@DisplayName("a dismiss landing mid-fold is not reverted by the folder's stale snapshot")
void aDismissLandingMidFoldIsNotReverted() {
// The reviewer's repro, made deterministic: the fold's read happens, then a dismiss lands,
// then the fold applies. The old save()-based absorb merged the pre-dismiss snapshot back
// over the row, reverting status to NEW and erasing the reviewer's action.
FileRunEvent event = store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
AtomicBoolean interleaved = new AtomicBoolean();
FileRunEventRepository interleaving =
(FileRunEventRepository)
Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[] {FileRunEventRepository.class},
(proxy, method, args) -> {
Object result;
try {
result = method.invoke(repository, args);
} catch (InvocationTargetException e) {
throw e.getCause();
}
if ("findByTeamAndDedupKey".equals(method.getName())
&& interleaved.compareAndSet(false, true)) {
store.applyStatus(
event.id(),
TEAM,
FileRunEventStatus.DISMISSED,
"reviewer@example.com");
}
return result;
});
FileRunEvent folded =
new FileRunEventStore(interleaving)
.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
assertThat(folded.status()).isEqualTo(FileRunEventStatus.DISMISSED);
assertThat(folded.statusActor()).isEqualTo("reviewer@example.com");
assertThat(folded.occurrences()).isEqualTo(2);
}
@Test
@DisplayName("a row deleted between the dedup read and the fold is re-inserted, not lost")
void aVanishedRowIsReinsertedRatherThanLost() {
// Retention or a manual purge can delete the row a fold was about to land on. The fold
// then matches nothing, and treating that as an error would drop the incident entirely:
// the recorder swallows what record() throws, leaving the failure as only a log line.
FileRunEvent first = store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
AtomicBoolean deleted = new AtomicBoolean();
FileRunEventRepository vanishing =
(FileRunEventRepository)
Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[] {FileRunEventRepository.class},
(proxy, method, args) -> {
if ("fold".equals(method.getName())
&& deleted.compareAndSet(false, true)) {
repository.deleteById(first.id());
}
try {
return method.invoke(repository, args);
} catch (InvocationTargetException e) {
throw e.getCause();
}
});
FileRunEvent replacement =
new FileRunEventStore(vanishing).record(failure(FailureKind.UNKNOWN, TEAM, "f"));
assertThat(replacement.id()).isNotEqualTo(first.id());
assertThat(replacement.occurrences()).isEqualTo(1);
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
}
@Test
@DisplayName("the same broken file folds across runs, and distinct files do not")
void repeatsFoldOnTheDocumentNotTheRun() {
// The point of the rollup. Each sweep starts a new run, so keying on the run id alone
// meant a file that failed every sweep opened a new incident every sweep, and several
// files failing in one run collapsed into one. Both are wrong; the document is the key.
RecordFailure firstSweep = locked("run-1", "file-hash-a");
RecordFailure secondSweep = locked("run-2", "file-hash-a");
RecordFailure otherFile = locked("run-1", "file-hash-b");
store.record(firstSweep);
store.record(otherFile);
FileRunEvent folded = store.record(secondSweep);
assertThat(folded.occurrences()).isEqualTo(2);
assertThat(store.list(TEAM, null, null, 10))
.as("one incident per document, however many runs it failed in")
.extracting(FileRunEvent::fileId)
.containsExactlyInAnyOrder("file-hash-a", "file-hash-b");
}
private RecordFailure locked(String runId, String fileId) {
return RecordFailure.forRun(
FailureKind.INPUT_PASSWORD_PROTECTED,
TEAM,
null,
"policy-1",
runId,
fileId,
"locked");
}
@Test
@DisplayName("the kind filter applies before the limit, not after")
void kindFilterAppliesBeforeTheLimit() {
store.record(failure(FailureKind.UNKNOWN, TEAM, "old-unknown"));
for (int i = 0; i < 3; i++) {
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i));
}
assertThat(store.list(TEAM, null, "UNKNOWN", 1))
.extracting(FileRunEvent::fileId)
.containsExactly("old-unknown");
}
@Test
@DisplayName("two racing closes resolve to one winner")
void twoClosesResolveToOneWinner() {
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "first@example.com");
// The second close passed the service's terminal() pre-check in its own request; the
// guarded UPDATE is what refuses it here.
assertThatThrownBy(
() ->
store.applyStatus(
event.id(),
TEAM,
FileRunEventStatus.DISMISSED,
"second@example.com"))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
assertThat(store.find(event.id(), TEAM).orElseThrow().statusActor())
.isEqualTo("first@example.com");
}
@Test
@DisplayName("losing a delete race reports the row missing, not closed")
void aDeletedRowIsReportedMissingRatherThanClosed() {
// Both refusals come from the same guarded UPDATE returning zero rows, so the store has to
// tell them apart afterwards. Getting it wrong answers 400 for a row that is simply gone,
// where every other "no such event" answers 404.
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
repository.deleteById(event.id());
assertThatThrownBy(
() ->
store.applyStatus(
event.id(),
TEAM,
FileRunEventStatus.DISMISSED,
"reviewer@example.com"))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
}
@Test
@DisplayName("a reopened incident only flips from RESOLVED, never from DISMISSED")
void reopenFlipsOnlyResolved() {
FileRunEvent resolved = store.record(failure(FailureKind.UNKNOWN, TEAM, "resolved-file"));
store.applyStatus(resolved.id(), TEAM, FileRunEventStatus.RESOLVED, "system");
FileRunEvent reopened = store.record(failure(FailureKind.UNKNOWN, TEAM, "resolved-file"));
assertThat(reopened.status()).isEqualTo(FileRunEventStatus.NEW);
assertThat(reopened.statusActor()).isNull();
}
@Test
@DisplayName("the unique constraint exists in the DDL, not only in the entity annotation")
void duplicateIncidentInsertTripsTheUniqueConstraint() {
store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
String dedupKey = failure(FailureKind.UNKNOWN, TEAM, "f").dedupKey();
FileRunEventEntity duplicate = new FileRunEventEntity();
duplicate.setId(UUID.randomUUID().toString());
duplicate.setTeamId(TEAM);
duplicate.setKindId(FailureKind.UNKNOWN.getId());
duplicate.setStage(FailureStage.INTERNAL);
duplicate.setSeverity(FailureSeverity.ERROR);
duplicate.setScope(FailureScope.RUN);
duplicate.setOrigin(FailureOrigin.POLICY);
duplicate.setDedupKey(dedupKey);
duplicate.setOccurrences(1);
duplicate.setStatus(FileRunEventStatus.NEW);
duplicate.setCreatedAt(Instant.now());
duplicate.setLastSeenAt(Instant.now());
assertThatThrownBy(() -> repository.saveAndFlush(duplicate))
.isInstanceOf(DataIntegrityViolationException.class);
}
@SpringBootConfiguration
@AutoConfigurationPackage
static class TestApp {}
}
@@ -0,0 +1,351 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link FileRunEventStore}, driven through a hand-written in-memory repository so the
* rollup rules are tested directly and fast. The rollup is the load-bearing part: without it,
* Dismiss means nothing for a source that re-lists the same failing file on every sweep.
*/
class FileRunEventStoreTest {
private static final Long TEAM = 7L;
private static final Long OTHER_TEAM = 9L;
private InMemoryFileRunEventRepository repository;
private FileRunEventStore store;
@BeforeEach
void setUp() {
repository = new InMemoryFileRunEventRepository();
store = new FileRunEventStore(repository);
}
private RecordFailure failure(FailureKind kind, Long teamId, String fileId, String detail) {
return new RecordFailure(
kind,
FailureOrigin.POLICY,
teamId,
"ethan@example.com",
"policy-1",
"run-1",
null,
fileId,
detail);
}
@Nested
@DisplayName("recording a new failure")
class Insert {
@Test
void persistsEveryFieldAndSnapshotsTheRegistryFacets() {
FileRunEvent event =
store.record(
failure(
FailureKind.INPUT_PASSWORD_PROTECTED,
TEAM,
"file-1",
"locked"));
assertThat(event.id()).isNotBlank();
assertThat(event.teamId()).isEqualTo(TEAM);
assertThat(event.actor()).isEqualTo("ethan@example.com");
assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
assertThat(event.policyId()).isEqualTo("policy-1");
assertThat(event.runId()).isEqualTo("run-1");
assertThat(event.fileId()).isEqualTo("file-1");
assertThat(event.origin()).isEqualTo(FailureOrigin.POLICY);
assertThat(event.status()).isEqualTo(FileRunEventStatus.NEW);
assertThat(event.occurrences()).isEqualTo(1);
assertThat(event.createdAt()).isNotNull();
assertThat(event.lastSeenAt()).isNotNull();
// Snapshotted, not derived: re-classifying a kind later must not rewrite this row.
assertThat(event.stage()).isEqualTo(FailureStage.INPUT);
assertThat(event.severity()).isEqualTo(FailureSeverity.ERROR);
assertThat(event.scope()).isEqualTo(FailureScope.FILE);
}
@Test
void keepsTheRawDetailVerbatimForAnUnknownKind() {
// For UNKNOWN this string is the only diagnostic there is, so it has to reach the row
// unaltered. Redaction itself has its own tests.
String raw = "Policy run failed: java.lang.IllegalStateException: pool exhausted";
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, null, raw));
assertThat(event.detail()).isEqualTo(raw);
}
}
@Nested
@DisplayName("the rollup")
class Rollup {
@Test
void foldsARepeatIntoTheOpenIncidentInsteadOfInsertingAgain() {
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "first"));
FileRunEvent second =
store.record(
failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "again"));
assertThat(second.occurrences()).isEqualTo(2);
assertThat(repository.rows).hasSize(1);
}
@Test
void refreshesTheDetailSoTheNewestDiagnosticIsTheOneOnShow() {
store.record(failure(FailureKind.UNKNOWN, TEAM, "file-1", "old message"));
FileRunEvent second =
store.record(failure(FailureKind.UNKNOWN, TEAM, "file-1", "new message"));
assertThat(second.detail()).isEqualTo("new message");
}
@Test
void treatsADifferentFileAsItsOwnIncident() {
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "a"));
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-2", "b"));
assertThat(repository.rows).hasSize(2);
}
@Test
void treatsADifferentKindAsItsOwnIncident() {
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "a"));
store.record(failure(FailureKind.UNKNOWN, TEAM, "file-1", "b"));
assertThat(repository.rows).hasSize(2);
}
@Test
void neverFoldsAcrossTeams() {
// Two tenants hitting the same problem are two incidents, each owned by its own team.
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "a"));
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, OTHER_TEAM, "file-1", "b"));
assertThat(repository.rows).hasSize(2);
}
@Test
void absorbsARepeatOntoADismissedRowWithoutReopeningIt() {
// A snapshot-mode source re-lists every file each sweep, so without absorption a
// dismissed failure would reappear as a new row every time.
FileRunEvent first =
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a"));
store.applyStatus(
first.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer@example.com");
FileRunEvent repeat =
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "b"));
assertThat(repository.rows).hasSize(1);
assertThat(repeat.status()).isEqualTo(FileRunEventStatus.DISMISSED);
assertThat(repeat.occurrences()).isEqualTo(2);
assertThat(repeat.statusActor()).isEqualTo("reviewer@example.com");
}
@Test
void reopensAResolvedRowBecauseTheProblemHasComeBack() {
// The counterweight to absorption, so a recurrence still surfaces.
FileRunEvent first =
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a"));
store.applyStatus(first.id(), TEAM, FileRunEventStatus.RESOLVED, "system");
FileRunEvent repeat =
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "b"));
assertThat(repository.rows).hasSize(1);
assertThat(repeat.status()).isEqualTo(FileRunEventStatus.NEW);
assertThat(repeat.occurrences()).isEqualTo(2);
// The previous closer no longer owns a reopened incident.
assertThat(repeat.statusActor()).isNull();
assertThat(repeat.statusAt()).isNull();
}
@Test
void keepsAnAcknowledgedRowAcknowledgedOnRepeat() {
FileRunEvent first =
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a"));
store.applyStatus(first.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "owner");
FileRunEvent repeat =
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "b"));
assertThat(repeat.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
assertThat(repeat.statusActor()).isEqualTo("owner");
}
@Test
void aSweepWhereEveryFileFailsProducesARowPerFile() {
// Each resolved input gets its own run, so a 400-file sweep is 400 incidents rather
// than one with a counter of 400. Grouping for display is a read-time concern.
for (int i = 0; i < 50; i++) {
store.record(
new RecordFailure(
FailureKind.UNKNOWN,
FailureOrigin.POLICY,
TEAM,
"ethan@example.com",
"policy-1",
"run-" + i,
null,
"file-" + i,
"OCR unavailable"));
}
assertThat(repository.rows).hasSize(50);
assertThat(repository.rows.values()).allMatch(row -> row.getOccurrences() == 1);
}
@Test
void treatsEachFileAsItsOwnIncidentEvenWhenNoFileIdWasCaptured() {
// The producer path cannot always name a file, and a FILE-scoped kind then falls back
// to
// the run. Without that, every locked file under one policy would fold into one row.
store.record(withoutFileId("run-a"));
store.record(withoutFileId("run-b"));
assertThat(repository.rows).hasSize(2);
assertThat(repository.rows.values()).allMatch(row -> row.getOccurrences() == 1);
}
@Test
void aRetryOfTheSameRunStillFoldsWhenNoFileIdWasCaptured() {
// The flip side: the fallback must not turn a genuine repeat into a second row.
store.record(withoutFileId("run-a"));
store.record(withoutFileId("run-a"));
assertThat(repository.rows).hasSize(1);
assertThat(repository.rows.values().iterator().next().getOccurrences()).isEqualTo(2);
}
@Test
void foldsIntoTheWinnersRowWhenAConcurrentWriterInsertedFirst() {
// Two runs failing identically at once: both read, find nothing, and try to insert. The
// loser's insert trips the unique constraint and has to fold rather than fail.
RecordFailure command = failure(FailureKind.UNKNOWN, TEAM, "file-1", "OCR unavailable");
FileRunEventEntity winner = rowFor(command);
repository.beforeNextSave = () -> repository.rows.put(winner.getId(), winner);
FileRunEvent result = store.record(command);
assertThat(repository.rows).hasSize(1);
assertThat(result.id()).isEqualTo(winner.getId());
assertThat(result.occurrences()).isEqualTo(2);
}
/** A row as the winning writer would have inserted it, for the race above. */
private FileRunEventEntity rowFor(RecordFailure command) {
FileRunEventEntity entity = new FileRunEventEntity();
entity.setId("winner");
entity.setTeamId(command.teamId());
entity.setKindId(command.kind().getId());
entity.setStage(command.kind().getStage());
entity.setSeverity(command.kind().getSeverity());
entity.setScope(command.kind().getScope());
entity.setOrigin(command.origin());
entity.setDedupKey(command.dedupKey());
entity.setOccurrences(1);
entity.setStatus(FileRunEventStatus.NEW);
entity.setCreatedAt(java.time.Instant.now());
entity.setLastSeenAt(java.time.Instant.now());
return entity;
}
private RecordFailure withoutFileId(String runId) {
return new RecordFailure(
FailureKind.INPUT_PASSWORD_PROTECTED,
FailureOrigin.POLICY,
TEAM,
"ethan@example.com",
"policy-1",
runId,
null,
null,
"The PDF Document is passworded");
}
}
@Nested
@DisplayName("reading")
class Reading {
@Test
void neverReturnsAnotherTeamsRows() {
store.record(failure(FailureKind.UNKNOWN, TEAM, "mine", "a"));
store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs", "b"));
assertThat(store.list(TEAM, null, null, 50))
.extracting(FileRunEvent::fileId)
.containsExactly("mine");
}
@Test
void treatsANullTeamAsTheUnteamedRows() {
// Login-disabled deployments have no team; a plain `= null` comparison would return
// nothing, which is why the query special-cases it.
store.record(failure(FailureKind.UNKNOWN, null, "unteamed", "a"));
store.record(failure(FailureKind.UNKNOWN, TEAM, "teamed", "b"));
assertThat(store.list(null, null, null, 50))
.extracting(FileRunEvent::fileId)
.containsExactly("unteamed");
}
@Test
void filtersByStatus() {
FileRunEvent open = store.record(failure(FailureKind.UNKNOWN, TEAM, "open", "a"));
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "closed", "b"));
store.applyStatus(open.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "me");
assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, 50))
.extracting(FileRunEvent::fileId)
.containsExactly("open");
}
@Test
void findScopesByTeamSoAnIdFromAnotherTeamIsInvisible() {
FileRunEvent theirs = store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "x", "a"));
assertThat(store.find(theirs.id(), TEAM)).isEmpty();
assertThat(store.find(theirs.id(), OTHER_TEAM)).isPresent();
}
}
@Nested
@DisplayName("dedup keys")
class Keys {
@Test
void areDeterministicForTheSameFailure() {
RecordFailure command = failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a");
assertThat(command.dedupKey()).isEqualTo(command.dedupKey());
}
@Test
void differByKindAndByScopeReference() {
RecordFailure a = failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "x");
RecordFailure b = failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f2", "x");
RecordFailure c = failure(FailureKind.UNKNOWN, TEAM, "f1", "x");
assertThat(a.dedupKey()).isNotEqualTo(b.dedupKey());
assertThat(a.dedupKey()).isNotEqualTo(c.dedupKey());
}
@Test
void areFixedWidthSoAnyLengthOfScopeReferenceFitsTheIndex() {
String longKey = "s3://bucket/" + "nested/".repeat(500) + "file.pdf";
RecordFailure command =
failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, longKey, "x");
assertThat(command.dedupKey()).hasSize(64);
}
}
}
@@ -0,0 +1,316 @@
package stirling.software.proprietary.failure;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import org.springframework.data.domain.Pageable;
/**
* Minimal stand-in for the JPA repository: enough to exercise the store's branching without a
* database. Ordering mirrors the real queries (newest first) since the rollup relies on it, and
* {@link #save} enforces the {@code (team_id, dedup_key)} unique constraint the real table
* declares.
*/
class InMemoryFileRunEventRepository implements FileRunEventRepository {
final Map<String, FileRunEventEntity> rows = new HashMap<>();
/**
* Runs once at the start of the next {@link #save}, so a test can interleave a competing writer
* between the store's read and its insert.
*/
Runnable beforeNextSave;
private static boolean sameTeam(FileRunEventEntity entity, Long teamId) {
return teamId == null ? entity.getTeamId() == null : teamId.equals(entity.getTeamId());
}
private List<FileRunEventEntity> newestFirst(List<FileRunEventEntity> matches) {
List<FileRunEventEntity> sorted = new ArrayList<>(matches);
sorted.sort(
(a, b) -> {
int byTime = b.getLastSeenAt().compareTo(a.getLastSeenAt());
// Stable tiebreak: identical timestamps are common in fast tests.
return byTime != 0 ? byTime : a.getId().compareTo(b.getId());
});
return sorted;
}
/** The real queries page in SQL, so the fake must page too or limit tests test nothing. */
private static List<FileRunEventEntity> page(List<FileRunEventEntity> rows, Pageable pageable) {
return pageable == null || pageable.isUnpaged() || rows.size() <= pageable.getPageSize()
? rows
: rows.subList(0, pageable.getPageSize());
}
private static boolean sameKind(FileRunEventEntity entity, String kindId) {
return kindId == null || kindId.equals(entity.getKindId());
}
@Override
public List<FileRunEventEntity> findByTeam(Long teamId, String kindId, Pageable pageable) {
return page(
newestFirst(
rows.values().stream()
.filter(e -> sameTeam(e, teamId) && sameKind(e, kindId))
.toList()),
pageable);
}
@Override
public List<FileRunEventEntity> findByTeamAndStatus(
Long teamId, FileRunEventStatus status, String kindId, Pageable pageable) {
return page(
newestFirst(
rows.values().stream()
.filter(
e ->
sameTeam(e, teamId)
&& e.getStatus() == status
&& sameKind(e, kindId))
.toList()),
pageable);
}
@Override
public int fold(String id, Instant now, String detail) {
FileRunEventEntity entity = rows.get(id);
if (entity == null) {
return 0;
}
entity.setOccurrences(entity.getOccurrences() + 1);
entity.setLastSeenAt(now);
if (detail != null) {
entity.setDetail(detail);
}
return 1;
}
@Override
public int reopenIfResolved(String id) {
FileRunEventEntity entity = rows.get(id);
if (entity == null || entity.getStatus() != FileRunEventStatus.RESOLVED) {
return 0;
}
entity.setStatus(FileRunEventStatus.NEW);
entity.setStatusActor(null);
entity.setStatusAt(null);
return 1;
}
@Override
public int applyStatusIf(
String id,
Long teamId,
FileRunEventStatus target,
String actor,
Instant now,
Collection<FileRunEventStatus> allowedFrom) {
FileRunEventEntity entity = rows.get(id);
if (entity == null
|| !sameTeam(entity, teamId)
|| !allowedFrom.contains(entity.getStatus())) {
return 0;
}
entity.setStatus(target);
entity.setStatusActor(actor);
entity.setStatusAt(now);
return 1;
}
@Override
public List<FileRunEventEntity> findByTeamAndDedupKey(
Long teamId, String dedupKey, Pageable pageable) {
return newestFirst(
rows.values().stream()
.filter(e -> sameTeam(e, teamId) && dedupKey.equals(e.getDedupKey()))
.toList());
}
@Override
public Optional<FileRunEventEntity> findByIdAndTeam(String id, Long teamId) {
return Optional.ofNullable(rows.get(id)).filter(e -> sameTeam(e, teamId));
}
@Override
public <S extends FileRunEventEntity> S save(S entity) {
if (beforeNextSave != null) {
Runnable hook = beforeNextSave;
beforeNextSave = null;
hook.run();
}
boolean isInsert = !rows.containsKey(entity.getId());
if (isInsert && clashesOnDedupKey(entity)) {
throw new org.springframework.dao.DataIntegrityViolationException(
"uk_file_run_events_dedup violated for " + entity.getDedupKey());
}
rows.put(entity.getId(), entity);
return entity;
}
/** Mirrors the unique constraint. SQL treats NULL teams as distinct, and so does this. */
private boolean clashesOnDedupKey(FileRunEventEntity candidate) {
if (candidate.getTeamId() == null) {
return false;
}
return rows.values().stream()
.anyMatch(
row ->
candidate.getTeamId().equals(row.getTeamId())
&& candidate.getDedupKey().equals(row.getDedupKey()));
}
// ── unused JpaRepository surface ────────────────────────────────────────
@Override
public void flush() {}
@Override
public <S extends FileRunEventEntity> S saveAndFlush(S entity) {
return save(entity);
}
@Override
public <S extends FileRunEventEntity> List<S> saveAllAndFlush(Iterable<S> entities) {
throw new UnsupportedOperationException();
}
@Override
public void deleteAllInBatch(Iterable<FileRunEventEntity> entities) {}
@Override
public void deleteAllByIdInBatch(Iterable<String> ids) {}
@Override
public void deleteAllInBatch() {}
@Override
public FileRunEventEntity getOne(String id) {
throw new UnsupportedOperationException();
}
@Override
public FileRunEventEntity getById(String id) {
throw new UnsupportedOperationException();
}
@Override
public FileRunEventEntity getReferenceById(String id) {
throw new UnsupportedOperationException();
}
@Override
public <S extends FileRunEventEntity> List<S> findAll(
org.springframework.data.domain.Example<S> example) {
throw new UnsupportedOperationException();
}
@Override
public <S extends FileRunEventEntity> List<S> findAll(
org.springframework.data.domain.Example<S> example,
org.springframework.data.domain.Sort sort) {
throw new UnsupportedOperationException();
}
@Override
public <S extends FileRunEventEntity> List<S> saveAll(Iterable<S> entities) {
throw new UnsupportedOperationException();
}
@Override
public List<FileRunEventEntity> findAll() {
return List.copyOf(rows.values());
}
@Override
public List<FileRunEventEntity> findAllById(Iterable<String> ids) {
throw new UnsupportedOperationException();
}
@Override
public Optional<FileRunEventEntity> findById(String id) {
return Optional.ofNullable(rows.get(id));
}
@Override
public boolean existsById(String id) {
return rows.containsKey(id);
}
@Override
public long count() {
return rows.size();
}
@Override
public void deleteById(String id) {
rows.remove(id);
}
@Override
public void delete(FileRunEventEntity entity) {
rows.remove(entity.getId());
}
@Override
public void deleteAllById(Iterable<? extends String> ids) {}
@Override
public void deleteAll(Iterable<? extends FileRunEventEntity> entities) {}
@Override
public void deleteAll() {
rows.clear();
}
@Override
public List<FileRunEventEntity> findAll(org.springframework.data.domain.Sort sort) {
throw new UnsupportedOperationException();
}
@Override
public org.springframework.data.domain.Page<FileRunEventEntity> findAll(Pageable pageable) {
throw new UnsupportedOperationException();
}
@Override
public <S extends FileRunEventEntity> Optional<S> findOne(
org.springframework.data.domain.Example<S> example) {
throw new UnsupportedOperationException();
}
@Override
public <S extends FileRunEventEntity> org.springframework.data.domain.Page<S> findAll(
org.springframework.data.domain.Example<S> example, Pageable pageable) {
throw new UnsupportedOperationException();
}
@Override
public <S extends FileRunEventEntity> long count(
org.springframework.data.domain.Example<S> example) {
throw new UnsupportedOperationException();
}
@Override
public <S extends FileRunEventEntity> boolean exists(
org.springframework.data.domain.Example<S> example) {
throw new UnsupportedOperationException();
}
@Override
public <S extends FileRunEventEntity, R> R findBy(
org.springframework.data.domain.Example<S> example,
Function<
org.springframework.data.repository.query.FluentQuery
.FetchableFluentQuery<
S>,
R>
queryFunction) {
throw new UnsupportedOperationException();
}
}
@@ -0,0 +1,311 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpClientErrorException;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
import tools.jackson.databind.json.JsonMapper;
/**
* Tests for {@link PolicyFailureRecorder}. Two behaviours beyond "it writes a row": the team comes
* from the originating policy, and a store problem is swallowed rather than replacing the failure
* being recorded.
*/
@ExtendWith(MockitoExtension.class)
class PolicyFailureRecorderTest {
private static final Long TEAM = 11L;
@Mock private PolicyStore policyStore;
private InMemoryFileRunEventRepository repository;
private FileRunEventStore store;
private PolicyFailureRecorder recorder;
@BeforeEach
void setUp() {
repository = new InMemoryFileRunEventRepository();
store = new FileRunEventStore(repository);
recorder =
new PolicyFailureRecorder(
new FailureClassifier(JsonMapper.builder().build()), store, policyStore);
}
private static Policy policy(String id, Long teamId) {
return new Policy(
id,
"Contract redaction",
"owner@example.com",
true,
List.of(),
List.of(),
OutputSpec.inline(),
List.of(),
teamId);
}
private static HttpClientErrorException passwordFailure() {
String body = "{\"type\":\"/errors/pdf-password\",\"errorCode\":\"E004\"}";
return HttpClientErrorException.create(
HttpStatus.BAD_REQUEST,
"Bad Request",
new HttpHeaders(),
body.getBytes(StandardCharsets.UTF_8),
StandardCharsets.UTF_8);
}
@Nested
@DisplayName("classifying and recording")
class Recording {
@Test
void classifiesTheCauseAndStoresTheRunsOwnMessage() {
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-1",
"policy-1",
"dana@example.com",
null,
"Policy run failed: locked",
passwordFailure());
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
assertThat(event.runId()).isEqualTo("run-1");
assertThat(event.policyId()).isEqualTo("policy-1");
assertThat(event.actor()).isEqualTo("dana@example.com");
assertThat(event.origin()).isEqualTo(FailureOrigin.POLICY);
// The run's message, not the exception's: that is what the operator saw. Stored
// verbatim, including for a kind we recognise, so the row says what actually happened.
assertThat(event.detail()).isEqualTo("Policy run failed: locked");
}
@Test
void keepsTheMessageForAFailureItCouldNotClassify() {
// The opposite case, and the reason the message is stored at all: with no kind to
// describe it, this text is the only thing telling a reviewer what went wrong.
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-1",
"policy-1",
"dana@example.com",
null,
"Policy run failed: something we do not recognise",
new RuntimeException("boom"));
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN);
assertThat(event.detail()).contains("something we do not recognise");
}
@Test
void recordsAnUnclassifiableFailureAsUnknownRatherThanDroppingIt() {
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-2",
"policy-1",
null,
null,
"Policy run failed: java.lang.NullPointerException",
new RuntimeException("npe"));
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN);
assertThat(event.detail()).contains("NullPointerException");
}
@Test
void acceptsAPreDecidedKindForPathsWithNothingToClassify() {
// A run rejected at admission threw nothing; the outcome is still known.
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailureAs(
FailureKind.UNKNOWN, "run-3", "policy-1", null, "could not be queued");
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
}
@Test
void twoFilesFailingTheSameWayUnderOnePolicyAreTwoIncidents() {
// The producer records per run and cannot name a file, so a FILE-scoped kind has only
// the
// run to tell two documents apart. Tested here rather than against a hand-built
// RecordFailure, because that is what let three locked files collapse into one row.
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure());
recorder.recordRunFailure(
"run-2", "policy-1", "dana@example.com", null, "locked", passwordFailure());
List<FileRunEvent> events = store.list(TEAM, null, null, 10);
assertThat(events).hasSize(2);
assertThat(events).allMatch(event -> event.occurrences() == 1);
assertThat(events)
.extracting(FileRunEvent::runId)
.containsExactlyInAnyOrder("run-1", "run-2");
}
@Test
void thatSameRunFailingTwiceStaysOneIncident() {
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure());
recorder.recordRunFailure(
"run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure());
assertThat(store.list(TEAM, null, null, 10))
.singleElement()
.extracting(FileRunEvent::occurrences)
.isEqualTo(2);
}
}
@Nested
@DisplayName("team resolution")
class TeamResolution {
@Test
void takesTheTeamFromTheOriginatingPolicy() {
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-1", "policy-1", null, null, "boom", new RuntimeException());
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
}
@Test
void leavesAnAdHocRunUnteamedRatherThanGuessing() {
// No stored policy means no team to attribute it to. Recorded unteamed rather than
// attributed to whichever team happened to be nearby.
recorder.recordRunFailure("run-1", null, null, null, "boom", new RuntimeException());
assertThat(store.list(null, null, null, 10)).hasSize(1);
assertThat(store.list(TEAM, null, null, 10)).isEmpty();
}
@Test
void survivesAPolicyLookupFailure() {
when(policyStore.get(anyString())).thenThrow(new RuntimeException("db down"));
assertThatCode(
() ->
recorder.recordRunFailure(
"run-1",
"policy-1",
null,
null,
"boom",
new RuntimeException()))
.doesNotThrowAnyException();
// Still recorded, just unteamed: a lookup problem must not lose the incident.
assertThat(store.list(null, null, null, 10)).hasSize(1);
}
}
@Nested
@DisplayName("recording is best effort")
class BestEffort {
@Test
void aStoreFailureIsSwallowedSoItCannotReplaceTheRealFailure() {
// If this threw, the engine's catch block would report a store error instead of the
// password problem that actually stopped the run.
FileRunEventStore broken = mock(FileRunEventStore.class);
when(broken.record(any())).thenThrow(new RuntimeException("event store unavailable"));
lenient()
.when(policyStore.get("policy-1"))
.thenReturn(Optional.of(policy("policy-1", TEAM)));
PolicyFailureRecorder fragile =
new PolicyFailureRecorder(
new FailureClassifier(JsonMapper.builder().build()),
broken,
policyStore);
assertThatCode(
() ->
fragile.recordRunFailure(
"run-1",
"policy-1",
null,
null,
"Policy run failed: locked",
passwordFailure()))
.doesNotThrowAnyException();
}
@Test
void aNullCauseDoesNotBreakRecording() {
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
assertThatCode(
() ->
recorder.recordRunFailure(
"run-1", "policy-1", null, null, "no cause", null))
.doesNotThrowAnyException();
assertThat(store.list(TEAM, null, null, 10).getFirst().kind())
.isEqualTo(FailureKind.UNKNOWN);
}
}
@Nested
@DisplayName("repeats")
class Repeats {
@Test
void twoIdenticalRunFailuresFoldIntoOneIncident() {
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-1", "policy-1", null, null, "boom", new IOException("x"));
recorder.recordRunFailure(
"run-1", "policy-1", null, null, "boom", new IOException("x"));
List<FileRunEvent> events = store.list(TEAM, null, null, 10);
assertThat(events).hasSize(1);
assertThat(events.getFirst().occurrences()).isEqualTo(2);
}
@Test
void twoDifferentRunsAreSeparateIncidents() {
// UNKNOWN is RUN-scoped, so the run id is what distinguishes them.
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-1", "policy-1", null, null, "boom", new IOException("x"));
recorder.recordRunFailure(
"run-2", "policy-1", null, null, "boom", new IOException("x"));
assertThat(store.list(TEAM, null, null, 10)).hasSize(2);
}
}
}
@@ -0,0 +1,110 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.Field;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* The privacy contract: a recorded failure carries no document identity of its own. There is no
* name column and the dedup key is built only from opaque ids, so nothing here derives from what a
* document is called.
*
* <p>The message itself is stored verbatim. It is the user's own error about their own file, and
* scrubbing it made rows harder to act on without making them meaningfully safer.
*/
@DisplayName("a recorded failure carries no document identity of its own")
class RecordFailurePrivacyTest {
private static RecordFailure withDetail(String detail) {
return RecordFailure.forRun(
FailureKind.UNKNOWN, 1L, "dana@example.com", "policy-1", "run-1", null, detail);
}
@Test
void toleratesAnAbsentMessage() {
assertThat(withDetail(null).detail()).isNull();
}
@Test
void hasNoFileNameFieldAtAll() {
// Structural, not behavioural: if a fileName component is ever added back, this fails.
assertThat(List.of(RecordFailure.class.getRecordComponents()))
.extracting(RecordComponent::getName)
.doesNotContain("fileName");
assertThat(List.of(FileRunEventEntity.class.getDeclaredFields()))
.extracting(Field::getName)
.doesNotContain("fileName");
assertThat(List.of(FileRunEventView.class.getRecordComponents()))
.extracting(RecordComponent::getName)
.doesNotContain("fileName");
}
@Test
void dedupKeyIsBuiltOnlyFromOpaqueIdentifiers() {
// Two files under the same policy hash differently (so they stay separate incidents), but
// the inputs are ids, so the hash is not reversible to a document name.
RecordFailure a =
new RecordFailure(
FailureKind.INPUT_PASSWORD_PROTECTED,
FailureOrigin.POLICY,
1L,
null,
"policy-1",
"run-1",
null,
"file-aaa",
"x");
RecordFailure b =
new RecordFailure(
FailureKind.INPUT_PASSWORD_PROTECTED,
FailureOrigin.POLICY,
1L,
null,
"policy-1",
"run-1",
null,
"file-bbb",
"x");
assertThat(a.dedupKey()).isNotEqualTo(b.dedupKey());
assertThat(a.dedupKey()).hasSize(64).isEqualTo(a.dedupKey());
assertThat(a.scopeRef()).doesNotContain(".pdf");
}
@Nested
@DisplayName("the detail cap")
class DetailCap {
@Test
void capsAnOversizedMessageAtTheLimitIncludingTheEllipsis() {
String stored = withDetail("x".repeat(5_000)).detail();
assertThat(stored).hasSize(2_000).endsWith("");
}
@Test
void leavesAMessageAtTheLimitAlone() {
String atLimit = "x".repeat(2_000);
assertThat(withDetail(atLimit).detail()).isEqualTo(atLimit);
}
@Test
void neverCutsBetweenTheHalvesOfASurrogatePair() {
// A string of astral-plane characters: every char is half of a surrogate pair, so a
// blind substring at the cap has a 50% chance of storing invalid UTF-16.
String stored = withDetail("\uD835\uDC9C".repeat(3_000)).detail();
assertThat(new String(stored.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8))
.as("a round-trip through UTF-8 mangles an unpaired surrogate")
.isEqualTo(stored);
}
}
}
@@ -213,7 +213,7 @@ class PolicyControllerTest {
}
private static PolicyRunHandle handle(String runId) {
PolicyRun run = new PolicyRun(runId, null, definitionWithStep());
PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null);
return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run));
}
@@ -314,7 +314,7 @@ class PolicyControllerTest {
@Test
@DisplayName("returns the run view when present")
void found() {
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep());
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null);
when(runRegistry.get("run-3")).thenReturn(run);
ResponseEntity<PolicyRunView> response = controller.status("run-3");
@@ -343,9 +343,9 @@ class PolicyControllerTest {
@Test
@DisplayName("excludes ad-hoc runs and runs owned by others")
void filtersRuns() {
PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep());
PolicyRun ownedStored = new PolicyRun("owned", "policy-A", definitionWithStep());
PolicyRun otherStored = new PolicyRun("other", "policy-B", definitionWithStep());
PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null);
PolicyRun ownedStored = new PolicyRun("owned", "policy-A", definitionWithStep(), null);
PolicyRun otherStored = new PolicyRun("other", "policy-B", definitionWithStep(), null);
when(runRegistry.all()).thenReturn(List.of(adHoc, ownedStored, otherStored));
// ownedByCurrentUser: strip then re-apply scope reproduces the key only for the owned
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -10,9 +11,11 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
@@ -22,6 +25,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
@@ -50,6 +54,7 @@ import stirling.software.common.service.TaskManager;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
import stirling.software.proprietary.failure.PolicyFailureRecorder;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PipelineStep;
@@ -85,6 +90,7 @@ class PolicyEngineTest {
@Mock private JobOwnershipService jobOwnershipService;
@Mock private ResourceMonitor resourceMonitor;
@Mock private JobQueue jobQueue;
@Mock private PolicyFailureRecorder failureRecorder;
@TempDir Path tempDir;
@@ -112,6 +118,7 @@ class PolicyEngineTest {
executor,
taskManager,
registry,
failureRecorder,
fileStorage,
jobOwnershipService,
List.of(sink, recordingSink),
@@ -212,6 +219,52 @@ class PolicyEngineTest {
assertEquals(PolicyRunStatus.FAILED, run.getStatus());
verify(taskManager).setError(eq(runId), anyString());
verify(taskManager, never()).setComplete(runId);
// A failed run is recorded durably, so an admin can see it after the in-memory run expires.
verify(failureRecorder)
.recordRunFailure(
eq(runId), any(), any(), any(), anyString(), any(Throwable.class));
}
@Test
void recordingAFailureNeverChangesTheRunsOutcome() throws Exception {
// Recording is best-effort: losing the incident row is bad, but turning a classified
// failure
// into a different, confusing failure is worse.
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
doThrow(new RuntimeException("event store unavailable"))
.when(failureRecorder)
.recordRunFailure(
anyString(), any(), any(), any(), anyString(), any(Throwable.class));
PolicyRunHandle handle =
engine.submit(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP);
PolicyRun run = handle.completion().get(10, TimeUnit.SECONDS);
assertEquals(PolicyRunStatus.FAILED, run.getStatus());
// The original failure message survives, rather than being replaced by the store's.
assertTrue(run.getError().contains("boom"));
}
@Test
void successfulRunRecordsNoFailureEvent() throws Exception {
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
.thenReturn(new StoredFile("file-1", 7L));
engine.submit(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP)
.completion()
.get(10, TimeUnit.SECONDS);
verifyNoInteractions(failureRecorder);
}
@Test
@@ -407,6 +460,43 @@ class PolicyEngineTest {
assertFalse(engine.cancel("does-not-exist"));
}
@Test
void cancellingARunRecordsNoFailureEvent() throws Exception {
// A cancellation is an intended outcome, not an incident. Recording one would put a row in
// front of an admin describing something a user deliberately did.
//
// The cancel happens while the tool call is in flight, held on a latch: an earlier version
// of this test awaited completion first, and cancel() on a finished run is a documented
// no-op, so it asserted nothing.
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
CountDownLatch toolEntered = new CountDownLatch(1);
CountDownLatch releaseTool = new CountDownLatch(1);
when(internalApiClient.post(eq(ROTATE), any()))
.thenAnswer(
invocation -> {
toolEntered.countDown();
assertTrue(
releaseTool.await(10, TimeUnit.SECONDS),
"test never released the tool call");
return ResponseEntity.ok(pdf("rotated", "rotated.pdf"));
});
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
.thenReturn(new StoredFile("file-1", 7L));
PolicyRunHandle handle =
engine.submit(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP);
assertTrue(toolEntered.await(10, TimeUnit.SECONDS), "run never reached the tool call");
assertTrue(engine.cancel(handle.runId()), "cancel was a no-op, so this asserts nothing");
releaseTool.countDown();
handle.completion().get(10, TimeUnit.SECONDS);
verifyNoInteractions(failureRecorder);
}
// --- helpers ---
private static PipelineDefinition definition(PipelineStep... steps) {
@@ -1,6 +1,7 @@
package stirling.software.proprietary.policy.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -299,7 +300,11 @@ class PolicyExecutorTest {
definition(new PipelineStep(compress, Map.of())),
PolicyInputs.of(List.of(pdf("img", "image.png"))),
PolicyProgressListener.NOOP));
assertTrue(ex.getMessage().contains("image.png"));
// Names the rejected extension, never the document. This message becomes the run's error
// and
// is persisted on the failure record, which holds no document name.
assertTrue(ex.getMessage().contains("png"));
assertFalse(ex.getMessage().contains("image.png"));
// Type check happens before any dispatch.
verify(internalApiClient, never()).post(anyString(), any());
}
@@ -96,7 +96,8 @@ class PolicyRunRegistryTest {
private PolicyRun register(String runId) {
PolicyRun run =
new PolicyRun(runId, null, new PipelineDefinition(runId, List.of(), List.of()));
new PolicyRun(
runId, null, new PipelineDefinition(runId, List.of(), List.of()), null);
registry.register(run);
return run;
}
@@ -78,13 +78,13 @@ class PolicyRunnerTest {
@Test
void runsOnceWithNoFilesWhenThePolicyHasNoSources() {
Policy policy = policy(List.of());
when(policyEngine.runPolicy(eq(policy), any(), any()))
when(policyEngine.runPolicy(eq(policy), any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.run(policy);
ArgumentCaptor<PolicyInputs> inputs = ArgumentCaptor.forClass(PolicyInputs.class);
verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any());
verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any(), any());
assertTrue(inputs.getValue().primary().isEmpty());
// Ledger hygiene still runs: rows recorded for a generator policy's folder outputs
// are pruned by its own sweeps rather than accumulating until the policy is deleted.
@@ -137,12 +137,12 @@ class PolicyRunnerTest {
List.of(
ResolvedInput.of(PolicyInputs.of(List.of())),
ResolvedInput.of(PolicyInputs.of(List.of()))));
when(policyEngine.runPolicy(any(), any(), any()))
when(policyEngine.runPolicy(any(), any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.run(policy);
verify(policyEngine, times(2)).runPolicy(eq(policy), any(), any());
verify(policyEngine, times(2)).runPolicy(eq(policy), any(), any(), any());
}
@Test
@@ -150,11 +150,11 @@ class PolicyRunnerTest {
InputSpec spec = InputSpec.folder("/in");
Policy policy = policy(List.of(spec));
AtomicBoolean outcome = new AtomicBoolean(false);
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set);
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), null, outcome::set);
when(folderSource.supports(spec)).thenReturn(true);
when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit));
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
when(policyEngine.runPolicy(any(), any(), any()))
when(policyEngine.runPolicy(any(), any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", completion));
runner.run(policy);
@@ -171,11 +171,11 @@ class PolicyRunnerTest {
InputSpec spec = InputSpec.folder("/in");
Policy policy = policy(List.of(spec));
AtomicBoolean outcome = new AtomicBoolean(true);
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set);
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), null, outcome::set);
when(folderSource.supports(spec)).thenReturn(true);
when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit));
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
when(policyEngine.runPolicy(any(), any(), any()))
when(policyEngine.runPolicy(any(), any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", completion));
runner.run(policy);
@@ -224,12 +224,12 @@ class PolicyRunnerTest {
when(folderSource.supports(spec)).thenReturn(true);
when(folderSource.resolve(eq(spec), any()))
.thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of()))));
when(policyEngine.runPolicy(any(), any(), any()))
when(policyEngine.runPolicy(any(), any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.run(policy, SweepKind.LIGHT);
verify(policyEngine).runPolicy(eq(policy), any(), any());
verify(policyEngine).runPolicy(eq(policy), any(), any(), any());
verify(processedLedger, never()).markSeen(any(), any());
verify(processedLedger, never()).deleteUnseen(any(), anyLong());
}
@@ -244,12 +244,12 @@ class PolicyRunnerTest {
when(folderSource.resolve(eq(broken), any())).thenThrow(new IOException("mount gone"));
when(folderSource.resolve(eq(healthy), any()))
.thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of()))));
when(policyEngine.runPolicy(any(), any(), any()))
when(policyEngine.runPolicy(any(), any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.run(policy);
verify(policyEngine).runPolicy(eq(policy), any(), any()); // healthy source still ran
verify(policyEngine).runPolicy(eq(policy), any(), any(), any()); // healthy source still ran
verify(processedLedger, never()).deleteUnseen(any(), anyLong()); // history preserved
}
+4 -6
View File
@@ -12,14 +12,12 @@ RUN apt-get update \
# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`.
WORKDIR /app/engine
COPY pyproject.toml uv.lock .env ./
COPY scripts/ ./scripts/
COPY engine/pyproject.toml engine/uv.lock engine/.env ./
COPY engine/scripts/ ./scripts/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
COPY src/ ./src/
uv sync --frozen --no-dev --group engine
COPY engine/src/ ./src/
WORKDIR /app
COPY Taskfile.yml ./
COPY .taskfiles/ ./.taskfiles/
+4 -4
View File
@@ -2,13 +2,13 @@
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY engine/pyproject.toml engine/uv.lock ./engine/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen
cd engine && uv sync --frozen --group engine --group engine-dev
ENV PATH="/app/.venv/bin:$PATH"
ENV PATH="/app/engine/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
EXPOSE 5001
CMD ["uv", "run", "uvicorn", "stirling.api.app:app", "--host", "0.0.0.0", "--port", "5001", "--reload"]
CMD ["uv", "run", "--project", "/app/engine", "--frozen", "--group", "engine", "uvicorn", "stirling.api.app:app", "--host", "0.0.0.0", "--port", "5001", "--reload"]
+74 -32
View File
@@ -1,33 +1,72 @@
[project]
name = "engine"
version = "0.1.0"
description = "AI Document Engine"
requires-python = ">=3.13"
dependencies = [
"cryptography>=50.0.0",
"fastapi>=0.116.0",
"pgvector>=0.3.6",
"psycopg[binary,pool]>=3.2",
"pydantic>=2.0.0",
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration
"pydantic-ai>=1.99.0,<2.0.0",
"pydantic-ai-slim[voyageai]>=1.99.0,<2.0.0",
"pydantic-settings>=2.0.0",
"python-dotenv>=1.2.1",
"sqlite-vec>=0.1.6",
"uvicorn>=0.35.0",
"opentelemetry-sdk>=1.39.0",
"posthog>=3.0.0",
]
description = "Stirling PDF Python projects"
requires-python = ">=3.13,<3.14"
dependencies = []
[dependency-groups]
dev = [
"anyio>=4.0.0",
"datamodel-code-generator[ruff]>=0.26.0",
"pytest>=8.0.0",
"pyright>=1.1.408",
"referencing>=0.35.0",
"ruff>=0.14.10",
# Runtime dependencies for the AI document engine service.
engine = [
"cryptography>=50.0.0",
"fastapi>=0.141.1",
"opentelemetry-sdk>=1.39.1",
"pgvector>=0.5.0",
"posthog>=7.38.3",
"psycopg[binary,pool]>=3.3.4",
"pydantic>=2.13.4",
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration.
"pydantic-ai>=1.107.2,<2.0.0",
"pydantic-ai-slim[voyageai]>=1.107.2,<2.0.0",
"pydantic-settings>=2.15.0",
"python-dotenv>=1.2.2",
"sqlite-vec>=0.1.9",
"uvicorn>=0.52.1",
]
# Type checking, testing, model generation, and formatting tools for the engine.
engine-dev = [
"anyio>=4.14.2",
"datamodel-code-generator[ruff]==0.64.0",
"pyright>=1.1.411",
"pytest>=9.1.1",
"referencing>=0.37.0",
"ruff==0.15.5",
]
# Dependencies for the Cucumber/Python integration test suite.
cucumber = [
"behave>=1.3.3",
"behave-html-formatter>=0.9.10",
"opencv-python-headless>=5.0.0.93",
"pdf2image>=1.17.0",
"pillow>=12.3.0",
"pypdf[crypto]>=6.15.0",
"qrcode[pil]>=8.2",
"reportlab>=5.0.0",
"requests>=2.34.2",
]
# Shared Python utilities used by repository scripts and CI workflows.
tools = [
"deep-translator>=1.11.4",
"defusedxml>=0.7.1",
"fonttools>=4.63.0",
"fpdf2>=2.8.7",
"openai>=2.53.0",
"requests>=2.34.2",
"tomli-w>=1.2.0",
"tomlkit>=0.15.1",
"urllib3>=2.7.0",
"unoserver>=3.7",
"weasyprint>=69.0",
]
# Release-signature verification dependencies.
updater-signatures = [
"cryptography>=50.0.0",
]
# Pinned repository-wide pre-commit tooling.
pre-commit = [
"codespell==2.4.2",
"ruff==0.15.5",
"tomli-w==1.2.0",
]
[build-system]
@@ -36,9 +75,10 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src"]
exclude = [
"tests",
]
exclude = ["tests"]
[tool.uv]
default-groups = []
[tool.ruff]
line-length = 120
@@ -53,14 +93,16 @@ select = [
"W",
"RUF100",
"UP",
"PYI", # flake8-pyi: flags deprecated typing constructs
"FA", # flake8-future-annotations: flags missing future annotations imports
"BLE", # flake8-blind-except: flags bare `except Exception`
"PYI", # flake8-pyi: flags deprecated typing constructs
"FA", # flake8-future-annotations: flags missing future annotations imports
"BLE", # flake8-blind-except: flags bare `except Exception`
]
[tool.ruff.lint.isort]
known-first-party = ["stirling", "tests"]
[tool.pyright]
pythonVersion = "3.13"
reportImportCycles = "warning"
reportUnnecessaryCast = "warning"
reportUnnecessaryTypeIgnoreComment = "warning"
+6 -1
View File
@@ -65,6 +65,8 @@ class ToolIOWhen(ApiModel):
param: str
matches: list[str]
# The value the endpoint uses when this parameter is absent; None when it has none.
default: str | None = None
class ToolIOCase(ApiModel):
@@ -378,7 +380,10 @@ def collect_tool_io(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
def _render_when(condition: dict[str, Any]) -> str:
return f"ToolIOWhen(param={json.dumps(condition['param'])}, matches={json.dumps(condition['matches'])})"
parts = [f"param={json.dumps(condition['param'])}", f"matches={json.dumps(condition['matches'])}"]
if "default" in condition:
parts.append(f"default={json.dumps(condition['default'])}")
return f"ToolIOWhen({', '.join(parts)})"
def _render_case(case: dict[str, Any]) -> str:
+21 -8
View File
@@ -58,6 +58,8 @@ class ToolIOWhen(ApiModel):
param: str
matches: list[str]
# The value the endpoint uses when this parameter is absent; None when it has none.
default: str | None = None
class ToolIOCase(ApiModel):
@@ -101,7 +103,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SIMO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="singleOrMultiple", matches=["single"])],
when=[ToolIOWhen(param="singleOrMultiple", matches=["single"], default="multiple")],
produces=ToolFormat.IMAGE,
arity=ToolArity.SISO,
)
@@ -130,15 +132,19 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SISO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="outputFormat", matches=["ps"])],
when=[ToolIOWhen(param="outputFormat", matches=["ps"], default="eps")],
produces=ToolFormat.POSTSCRIPT,
arity=ToolArity.SISO,
),
ToolIOCase(
when=[ToolIOWhen(param="outputFormat", matches=["pcl"])], produces=ToolFormat.PCL, arity=ToolArity.SISO
when=[ToolIOWhen(param="outputFormat", matches=["pcl"], default="eps")],
produces=ToolFormat.PCL,
arity=ToolArity.SISO,
),
ToolIOCase(
when=[ToolIOWhen(param="outputFormat", matches=["xps"])], produces=ToolFormat.XPS, arity=ToolArity.SISO
when=[ToolIOWhen(param="outputFormat", matches=["xps"], default="eps")],
produces=ToolFormat.XPS,
arity=ToolArity.SISO,
),
],
),
@@ -151,7 +157,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.MIMO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"])],
when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"], default="false")],
produces=ToolFormat.PDF,
arity=ToolArity.MISO,
)
@@ -202,7 +208,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SISO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="dryRun", matches=["true"])], produces=ToolFormat.JSON, arity=ToolArity.SISO
when=[ToolIOWhen(param="dryRun", matches=["true"], default="false")],
produces=ToolFormat.JSON,
arity=ToolArity.SISO,
)
],
),
@@ -223,7 +231,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SISO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="sidecar", matches=["true"])], produces=ToolFormat.ZIP, arity=ToolArity.SISO
when=[ToolIOWhen(param="sidecar", matches=["true"], default="false")],
produces=ToolFormat.ZIP,
arity=ToolArity.SISO,
)
],
),
@@ -242,7 +252,10 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SISO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="password", matches=[""]), ToolIOWhen(param="ownerPassword", matches=[""])],
when=[
ToolIOWhen(param="password", matches=[""], default=""),
ToolIOWhen(param="ownerPassword", matches=[""], default=""),
],
produces=ToolFormat.PDF,
arity=ToolArity.SISO,
)
+2 -2
View File
@@ -4,7 +4,7 @@ import asyncio
import json
import logging
import os
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, assert_never
@@ -133,7 +133,7 @@ class ConcurrencyLimitedModel(WrapperModel):
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
run_context: RunContext[Any] | None = None,
) -> AsyncIterator[StreamedResponse]:
) -> AsyncGenerator[StreamedResponse]:
async with self._semaphore:
async with super().request_stream(
messages, model_settings, model_request_parameters, run_context
@@ -89,11 +89,16 @@ def resolve_output(spec: ToolIOSpec, parameters: dict[str, object] | None) -> Re
for rule in spec.cases:
all_hold = True
for condition in rule.when:
if parameters is None or condition.param not in parameters:
if parameters is not None and condition.param in parameters:
raw: object = parameters[condition.param]
elif condition.default is not None:
# The caller omitted it, so it takes the endpoint's default.
raw = condition.default
else:
saw_unknown_param = True
all_hold = False
continue
normalised = _normalise(parameters[condition.param])
normalised = _normalise(raw)
all_hold = all_hold and any(_normalise(m) == normalised for m in condition.matches)
if all_hold:
return ResolvedOutput(format=rule.produces, arity=rule.arity, certain=True)
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import threading
from collections.abc import Callable, Iterator
from collections.abc import Callable, Generator
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
@@ -31,7 +31,7 @@ def _client(
settings_factory: Callable[[], AppSettings],
*,
client_addr: tuple[str, int] = ("127.0.0.1", 12345),
) -> Iterator[TestClient]:
) -> Generator[TestClient]:
"""Enter a TestClient whose lifespan builds app.state from ``settings_factory``."""
previous = app.dependency_overrides.get(load_settings)
app.dependency_overrides[load_settings] = settings_factory
+1099 -1348
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54 -41
View File
@@ -1,4 +1,7 @@
{
"editor/src/core/assets/Brand.stories.tsx :: Logos": [
"scrollable-region-focusable"
],
"editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [
"aria-progressbar-name",
"color-contrast"
@@ -886,9 +889,6 @@
"editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [
"button-name"
],
"editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [
"color-contrast"
],
@@ -1377,8 +1377,17 @@
"editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [
"button-name"
],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": ["color-contrast"],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": ["color-contrast"],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [
"scrollable-region-focusable"
],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"],
"editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"],
"editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [
@@ -1395,10 +1404,15 @@
"color-contrast"
],
"editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [
"color-contrast",
"scrollable-region-focusable"
"color-contrast"
],
"editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"],
"editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [
"scrollable-region-focusable"
],
"editor/src/core/ui/Collapsible.stories.tsx :: Default": [
"scrollable-region-focusable"
],
"editor/src/core/ui/Drawer.stories.tsx :: Playground": [
"aria-allowed-role",
"color-contrast"
@@ -1565,11 +1579,16 @@
"editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"],
"editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"],
"editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [
"color-contrast"
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/Tabs.stories.tsx :: Playground": [
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/Tabs.stories.tsx :: Playground": ["color-contrast"],
"editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [
"color-contrast"
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"],
"editor/src/portal/components/AppShell.stories.tsx :: Mobile": [
@@ -1951,24 +1970,34 @@
"editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [
"color-contrast"
],
"editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [
"aria-progressbar-name",
"color-contrast"
],
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [
"color-contrast"
],
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [
"color-contrast"
],
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [
"aria-progressbar-name",
"editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/portal/components/infrastructure/SecurityTab.stories.tsx :: Default": [
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Editing": [
"color-contrast"
],
"editor/src/portal/components/infrastructure/StorageTab.stories.tsx :: Default": [
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Paused": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Testing": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Failed Run": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Run Result": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [
@@ -2267,17 +2296,9 @@
"landmark-unique"
],
"editor/src/portal/views/Documents.stories.tsx :: Default": [
"aria-prohibited-attr",
"color-contrast",
"empty-table-header",
"nested-interactive"
],
"editor/src/portal/views/Documents.stories.tsx :: Empty": [
"aria-prohibited-attr",
"color-contrast",
"empty-table-header",
"nested-interactive"
"color-contrast"
],
"editor/src/portal/views/Documents.stories.tsx :: Empty": ["color-contrast"],
"editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [
"color-contrast",
"landmark-no-duplicate-banner",
@@ -2300,9 +2321,6 @@
"editor/src/portal/views/Integrations.stories.tsx :: No Connections": [
"color-contrast"
],
"editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Default": [
"color-contrast",
"empty-table-header"
@@ -2311,16 +2329,8 @@
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"],
"editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"],
"editor/src/portal/views/Sources.stories.tsx :: Default": [
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Sources.stories.tsx :: Empty": [
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"],
"editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"],
"editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [
"aria-hidden-focus"
],
@@ -2354,6 +2364,9 @@
"aria-dialog-name",
"color-contrast"
],
"editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [
"button-name",
"color-contrast"
+29 -1
View File
@@ -29,7 +29,16 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/theme/index.css";
// The editor's semantic token layer (--bg-surface, --onboarding-title, …).
// The app reaches it through its style entry; without it here, components
// styled on those variables render unthemed (e.g. transparent modal surfaces)
// and axe measures contrast against colours the app never shows.
import "@core/styles/theme.css";
import "@core/tokens/base.css";
// Portal element reset + typography. Scoped to .portal-scope in the app so it
// can't leak into the editor; the decorator below adds that class around
// portal stories only, mirroring how PortalApp mounts.
import "@portal/theme/base.css";
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
// glob, so the toolbar language switcher can flip between all languages with no
@@ -201,6 +210,13 @@ const withProviders: Decorator = (Story, context) => {
// anything that isn't "dark" as light — matching the addon's own
// `selected || defaultTheme` fallback where defaultTheme is light.
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
// PortalApp mounts its views inside a .portal-scope wrapper, which is what
// the portal's base.css keys its reset/typography on. Give portal stories
// the same wrapper (and only them — the scoping exists precisely so portal
// styles never apply to editor components).
const isPortalStory = (context.parameters.fileName ?? "").includes(
"/portal/",
);
return (
<MemoryRouter initialEntries={["/"]}>
<QueryClientProvider client={queryClient}>
@@ -214,7 +230,13 @@ const withProviders: Decorator = (Story, context) => {
<TierKey tier={tier}>
<UIProvider>
<Suspense fallback={null}>
<Story />
{isPortalStory ? (
<div className="portal-scope">
<Story />
</div>
) : (
<Story />
)}
</Suspense>
</UIProvider>
</TierKey>
@@ -229,6 +251,12 @@ const withProviders: Decorator = (Story, context) => {
const preview: Preview = {
loaders: [mswLoader],
// The scan runs once per theme (SCAN_THEME=light|dark, forwarded by
// .storybook/vitest.config.ts); pinning the global here themes every story in
// the run. Unset — the Storybook UI — falls back to the toolbar default.
initialGlobals: {
theme: import.meta.env.VITE_SCAN_THEME === "dark" ? "dark" : "light",
},
parameters: {
layout: "padded",
controls: {
+8
View File
@@ -14,6 +14,14 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
* Run with: npx vitest run --config .storybook/vitest.config.ts
*/
export default defineConfig({
// Forwards the SCAN_THEME env var into the browser bundle, where preview.tsx
// uses it to pin the theme global for the whole run. The Storybook dev/build
// pipeline never sets it, so the toolbar default stays "light" there.
define: {
"import.meta.env.VITE_SCAN_THEME": JSON.stringify(
process.env.SCAN_THEME ?? "",
),
},
optimizeDeps: {
// Pre-scan every story + the preview so Vite discovers the story set's large
// dep surface (embedpdf plugins, @mui icons, …) in one pass up front.
@@ -4869,12 +4869,10 @@ enterUsername = "أدخل اسم المستخدم"
failedToSignIn = "فشل تسجيل الدخول بواسطة {{provider}}: {{message}}"
forgotPassword = "هل نسيت كلمة المرور؟"
loggingIn = "جارٍ تسجيل الدخول..."
logIn = "تسجيل الدخول"
login = "تسجيل الدخول"
magicLinkSent = "تم إرسال الرابط السحري إلى {{email}}! تفقد بريدك واضغط الرابط لتسجيل الدخول."
mfaCode = "رمز المصادقة"
mfaRequired = "رمز المصادقة ذات العاملين مطلوب"
noAccount = "ليس لديك حساب؟"
or = "أو"
password = "كلمة المرور"
passwordChangedSuccess = "تم تغيير كلمة المرور بنجاح! يرجى تسجيل الدخول بكلمة المرور الجديدة."
@@ -4869,12 +4869,10 @@ enterUsername = "İstifadəçi adını daxil edin"
failedToSignIn = "{{provider}} ilə daxil olmaq alınmadı: {{message}}"
forgotPassword = "Parolunuzu unutmusunuz?"
loggingIn = "Daxil olunur..."
logIn = "Daxil ol"
login = "Daxil ol"
magicLinkSent = "Sehrli link {{email}} ünvanına göndərildi! Zəhmət olmasa e-poçtunuzu yoxlayın və daxil olmaq üçün linkə klikləyin."
mfaCode = "Doğrulama kodu"
mfaRequired = "İki faktorlu doğrulama kodu tələb olunur"
noAccount = "Hesabınız yoxdur?"
or = "Və ya"
password = "Parol"
passwordChangedSuccess = "Parol uğurla dəyişdirildi! Zəhmət olmasa yeni parolunuzla daxil olun."

Some files were not shown because too many files have changed in this diff Show More