Compare commits

...
Author SHA1 Message Date
Anthony Stirling f3b170f96a i18n 2026-05-15 12:44:33 +01:00
Anthony Stirling d93c2798e8 fixes 2026-05-15 12:42:57 +01:00
Anthony Stirling a3dd9a39ff improve merge 2026-05-15 12:20:34 +01:00
James Brunton 270af912d4 Fix frontend download links pointing to split Mac binaries (#6325) 2026-05-07 20:25:01 +01:00
Anthony Stirling efd2ed754c Enhance automate to have import and export buttons (#6326) 2026-05-07 20:23:11 +01:00
ConnorYohandJames Brunton 6730ad7cbb Desktop: persist auth token to disk when Credential Manager is restricted (#6303)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-05-07 16:02:48 +01:00
James Brunton 8fb9fced35 Convert to building a universal Mac app (#6302) 2026-05-07 14:26:28 +01:00
Reece BrowneandEthanHealy01 ed56490f93 Fix move button on multitool (#6291)
Hitting move button twice on multitool didn't work

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-05-05 20:46:47 +00:00
dependabot[bot]andAnthony Stirling c892b985e2 build(deps): bump springSecuritySamlVersion from 7.0.4 to 7.0.5 (#6255)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 19:01:06 +01:00
James Brunton 95c39b4648 Fix missing desktop save indicator on files (#6310)
# Description of Changes
Save indicator stopped showing up after #6050, which fixed the missing
truncation on filenames, but accidentally bypassed the save indicator
component at the same time. This PR puts the component back in and makes
it support truncation so we can have both.

<img width="586" height="166" alt="image"
src="https://github.com/user-attachments/assets/529c3dcb-ee00-4a6d-ae53-ef8657204369"
/>
2026-05-05 11:19:11 +00:00
dependabot[bot] 44f2d0d679 build(deps): bump the embedpdf group across 1 directory with 23 updates (#6299)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 12:15:01 +01:00
dependabot[bot]andAnthony Stirling 1a63aa045e build(deps): bump the tauri group across 1 directory with 7 updates (#6305)
Bumps the tauri group with 5 updates in the /frontend/src-tauri
directory:

| Package | From | To |
| --- | --- | --- |
| [tauri](https://github.com/tauri-apps/tauri) | `2.10.2` | `2.10.3` |
| [tauri-plugin-fs](https://github.com/tauri-apps/plugins-workspace) |
`2.4.5` | `2.5.0` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace)
| `2.6.0` | `2.7.0` |
| [tauri-plugin-http](https://github.com/tauri-apps/plugins-workspace) |
`2.5.7` | `2.5.8` |
|
[tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace)
| `2.4.0` | `2.4.1` |


Updates `tauri` from 2.10.2 to 2.10.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/tauri/releases">tauri's
releases</a>.</em></p>
<blockquote>
<h2>tauri v2.10.3</h2>
<!-- raw HTML omitted -->
<pre><code>Fetching advisory database from
`https://github.com/RustSec/advisory-db.git`
Loaded 941 security advisories (from /home/runner/.cargo/advisory-db)
    Updating crates.io index
    Scanning Cargo.lock for vulnerabilities (1052 crate dependencies)
Crate:     atk
Version:   0.18.2
Warning:   unmaintained
Title:     gtk-rs GTK3 bindings - no longer maintained
Date:      2024-03-04
ID:        RUSTSEC-2024-0413
URL:       https://rustsec.org/advisories/RUSTSEC-2024-0413
Dependency tree:
atk 0.18.2
└── gtk 0.18.2
    ├── wry 0.54.0
    │   └── tauri-runtime-wry 2.10.1
    │       └── tauri 2.10.3
    │           ├── tauri-utils 2.8.3
    │           │   ├── tauri-schema-generator 0.0.0
    │           │   ├── tauri-runtime-wry 2.10.1
    │           │   ├── tauri-runtime 2.10.1
    │           │   │   ├── tauri-runtime-wry 2.10.1
    │           │   │   └── tauri 2.10.3
    │           │   ├── tauri-plugin 2.5.4
    │           │   │   ├── tauri-plugin-sample 0.1.0
    │           │   │   │   └── api 0.1.0
    │           │   │   └── tauri-plugin-log 2.6.0
    │           │   │       └── api 0.1.0
    │           │   ├── tauri-macros 2.5.5
    │           │   │   └── tauri 2.10.3
    │           │   ├── tauri-codegen 2.5.5
    │           │   │   ├── tauri-macros 2.5.5
    │           │   │   └── tauri-build 2.5.6
    │           │   │       ├── tauri-file-associations-demo 0.1.0
    │           │   │       ├── tauri 2.10.3
    │           │   │       ├── resources 0.1.0
    │           │   │       ├── bench_helloworld 0.1.0
    │           │   │       ├── bench_files_transfer 0.1.0
    │           │   │       ├── bench_cpu_intensive 0.1.0
    │           │   │       └── api 0.1.0
    │           │   ├── tauri-cli 2.10.1
    │           │   │   └── tauri-cli-node 0.0.0
    │           │   ├── tauri-bundler 2.8.1
    │           │   │   └── tauri-cli 2.10.1
    │           │   ├── tauri-build 2.5.6
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/tauri/commit/9b17a7aeae9a83222ffe829aa4e2d8a5ba6bed8c"><code>9b17a7a</code></a>
fix(ci): bump rustsec/audit-check to v2 and ignore time audit (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15030">#15030</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/d86827980d45826bb30749a3dbc11c9326ed8bd9"><code>d868279</code></a>
apply version updates (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14897">#14897</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/3a65cc6885ea61e35dc5be23b229043ab6e92372"><code>3a65cc6</code></a>
fix(test): disable <code>resolve_resource_dir</code> on Android (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15026">#15026</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/52cf195b78e0b78f02d8aae997116c620355b095"><code>52cf195</code></a>
refactor(cli): reduce some nesting code (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14844">#14844</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/c3cbff3f7430161715f80f82128b345a6f7140c9"><code>c3cbff3</code></a>
fix: resource path handles <code>./</code> path differently (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14662">#14662</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/33754ae5e3740d022483b6164511c5c001a3c24b"><code>33754ae</code></a>
fix(cli): unusable empty password private keys (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15022">#15022</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/3935dee121e1dcb4f377c9933233c0ace186b0bb"><code>3935dee</code></a>
Add AI tool policy to contributing guide (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15002">#15002</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/33932a72b27c970dde242da5241f7a340277507d"><code>33932a7</code></a>
chore(deps-dev): bump svelte from 5.51.5 to 5.53.5 (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15015">#15015</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/7d3c7593a9d41ac1cc7f72e959263f22d32bc61d"><code>7d3c759</code></a>
chore(deps): update dependency rollup to v4.59.0 (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15001">#15001</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/f20256bca542dfd6ba4b5e2741a6a9439cc57be8"><code>f20256b</code></a>
chore: fix clippy warnings (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14999">#14999</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/tauri/compare/tauri-v2.10.2...tauri-v2.10.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-fs` from 2.4.5 to 2.5.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-fs's
releases</a>.</em></p>
<blockquote>
<h2>dialog-js v2.5.0</h2>
<h2>[2.5.0]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/dff6fa986a9a05ba98b6ca660fea78ae97251fc2"><code>dff6fa98</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3034">#3034</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../onehumandev"><code>@​onehumandev</code></a>)
Add <code>pickerMode</code> option to file picker (currently only used
on iOS)</li>
</ul>
<h3>Dependencies</h3>
<ul>
<li>Upgraded to <code>fs-js@2.4.5</code></li>
</ul>
<h3>feat</h3>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/c23fa03f07d5c1c220bcf0bca482364513e3f754"><code>c23fa03f</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3098">#3098</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Lepidopteran"><code>@​Lepidopteran</code></a>)
Add <code>xdg-portal</code> as an optional feature for
<code>rfd</code></li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn Unknown user config &quot;always-auth&quot;. This
will stop working in the next major version of npm.
npm warn publish npm auto-corrected some errors in your package.json
when publishing. Please run &quot;npm pkg fix&quot; to address these
errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-dialog@2.5.0
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 3.5kB README.md
npm notice 6.4kB dist-js/index.cjs
npm notice 12.2kB dist-js/index.d.ts
npm notice 6.3kB dist-js/index.js
npm notice 11B dist-js/init.d.ts
npm notice 656B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-dialog
npm notice version: 2.5.0
npm notice filename: tauri-apps-plugin-dialog-2.5.0.tgz
npm notice package size: 5.8 kB
npm notice unpacked size: 30.0 kB
npm notice shasum: 52057077b52cc51643ac9829d48c2c590e5e1a54
npm notice integrity: sha512-I0R0ygwRd9AN8[...]lxPHD5vDcFjiA==
npm notice total files: 7
npm notice
npm notice Security Notice: Classic tokens have been revoked. Granular
tokens are now limited to 90 days and require 2FA by default. Update
your CI/CD workflows to avoid disruption. Learn more
https://gh.io/all-npm-classic-tokens-revoked
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=804721691
+ @tauri-apps/plugin-dialog@2.5.0
</code></pre>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/d6a3898001a4bcc659e045f9501498751b77dbe6"><code>d6a3898</code></a>
Publish New Versions (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3268">#3268</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2e5bcdf202f98ec0fb0481725dd32f0626c766c8"><code>2e5bcdf</code></a>
chore(deps): fix audits (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3373">#3373</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/4374b4fc1a9a7c0506034d86707fd3288d347f9b"><code>4374b4f</code></a>
chore(notification): remove unused dev-deps (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3372">#3372</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/f75d21db3351d6f12adf585c2c797c20ece94f7f"><code>f75d21d</code></a>
chore(deps): remove used of tauri-utils build feature (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3360">#3360</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/4b95f5e079593d5b2b98220c332f420dc70d18d1"><code>4b95f5e</code></a>
chore(deps): update dependency eslint to v10.1.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3357">#3357</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/99c3e37b54c115e8712f67c2b53ed1f2f0b4aae6"><code>99c3e37</code></a>
chore(deps): bump tar in /plugins/updater/tests/updater-migration/v1-app
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3352">#3352</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/eaac19a5b7e325a06d4c1c6d740e38d86c55cd89"><code>eaac19a</code></a>
chore(deps): update rust crate tar to v0.4.45 [security] (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3353">#3353</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/5183e314cbabc179228e232992ad67025f30272d"><code>5183e31</code></a>
chore(deps): update dependency typescript-eslint to v8.57.1 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3344">#3344</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2c0883e64e9a53ea18102ed350cd5e65ba7c8f4b"><code>2c0883e</code></a>
chore(deps): update dependency vite to v8 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3346">#3346</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/024ec0c29c20cf94579dab9b79d6be0da61a8daa"><code>024ec0c</code></a>
fix(deep-link): ChromeOS deep link calls filtered and ignored by plugin
(fix ...</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.5...fs-v2.5.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-dialog` from 2.6.0 to 2.7.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-dialog's
releases</a>.</em></p>
<blockquote>
<h2>dialog-js v2.7.0</h2>
<h2>[2.7.0]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/24154472a6710a690173df0a121125d1f1b871e8"><code>24154472</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3287">#3287</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Legend-Master"><code>@​Legend-Master</code></a>)
Re-use <code>message</code> command in Rust side for <code>ask</code>
and <code>confirm</code> commands, <code>allow-ask</code> and
<code>allow-confirm</code> permissions are now aliases to
<code>allow-message</code></li>
</ul>
<h3>Dependencies</h3>
<ul>
<li>Upgraded to <code>fs-js@2.5.0</code></li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn Unknown user config &quot;always-auth&quot;. This
will stop working in the next major version of npm. See `npm help npmrc`
for supported config options.
npm warn publish npm auto-corrected some errors in your package.json
when publishing. Please run &quot;npm pkg fix&quot; to address these
errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-dialog@2.7.0
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 3.5kB README.md
npm notice 6.9kB dist-js/index.cjs
npm notice 14.6kB dist-js/index.d.ts
npm notice 6.8kB dist-js/index.js
npm notice 11B dist-js/init.d.ts
npm notice 657B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-dialog
npm notice version: 2.7.0
npm notice filename: tauri-apps-plugin-dialog-2.7.0.tgz
npm notice package size: 6.7 kB
npm notice unpacked size: 33.3 kB
npm notice shasum: b510ecd42d9900725eaf51f42ec98523c40d29b4
npm notice integrity: sha512-4nS/hfGMGCXiA[...]RtrKXkANKDHvw==
npm notice total files: 7
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=1235993203
+ @tauri-apps/plugin-dialog@2.7.0
</code></pre>
<!-- raw HTML omitted -->
<h2>dialog v2.7.0</h2>
<h2>[2.7.0]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/24154472a6710a690173df0a121125d1f1b871e8"><code>24154472</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3287">#3287</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Legend-Master"><code>@​Legend-Master</code></a>)
Re-use <code>message</code> command in Rust side for <code>ask</code>
and <code>confirm</code> commands, <code>allow-ask</code> and
<code>allow-confirm</code> permissions are now aliases to
<code>allow-message</code></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/51b430be986ed98635c02ba504f6f30a35771c01"><code>51b430b</code></a>
ci: delete .changes/updater-new-bundle-support.md</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/fd439b143e265fc3ab4de435d6d4b641cffc66f1"><code>fd439b1</code></a>
Publish New Versions (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2964">#2964</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2522b71f6bcae65c03b24415eb9295c9e7c84ffc"><code>2522b71</code></a>
fix(deep-link): revert the breaking change introduced by <a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2928">#2928</a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2970">#2970</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/9021a732474322073769c1b21f6eee2e4b0dd15e"><code>9021a73</code></a>
chore(deps): update dependency rollup to v4.50.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2966">#2966</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/625bb1c0965394b88522643731f78ccbcca84add"><code>625bb1c</code></a>
feat(log): re-export the log crate (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2965">#2965</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/6215afe0239f815740a2c28150614ca4fef0b486"><code>6215afe</code></a>
chore(deps): update dependency rollup to v4.49.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2962">#2962</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/8cf8eeab02efddd4e44e3bba92b33902a8c00956"><code>8cf8eea</code></a>
feat(updater): inject bundle_type into endpoint url (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2960">#2960</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/509eba8d441c4f6ecf0af77b572cb2afd69a752d"><code>509eba8</code></a>
feat: support message dialogs with 3 buttons (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2641">#2641</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/9ac5fe84e704ef20c437456cb1017b54b101b333"><code>9ac5fe8</code></a>
feat(updater): support bundle-specific targets (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2624">#2624</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/c24741031993894929a9ed00972ff9f6cd9540be"><code>c247410</code></a>
chore(deps): update dependency typescript-eslint to v8.41.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2956">#2956</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/log-v2.6.0...log-v2.7.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-http` from 2.5.7 to 2.5.8
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-http's
releases</a>.</em></p>
<blockquote>
<h2>http-js v2.5.8</h2>
<h2>[2.5.8]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/29712892526cfc2d35c9002e0a56925084ae1b73"><code>29712892</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3252">#3252</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../NVolcz"><code>@​NVolcz</code></a>)
Correct Response header initialization to support cloning and ensure
Set-Cookie visibility.</li>
</ul>
<h3>Dependencies</h3>
<ul>
<li>Upgraded to <code>fs-js@2.5.0</code></li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn Unknown user config &quot;always-auth&quot;. This
will stop working in the next major version of npm. See `npm help npmrc`
for supported config options.
npm warn publish npm auto-corrected some errors in your package.json
when publishing. Please run &quot;npm pkg fix&quot; to address these
errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-http@2.5.8
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 2.6kB README.md
npm notice 7.1kB dist-js/index.cjs
npm notice 2.4kB dist-js/index.d.ts
npm notice 7.0kB dist-js/index.js
npm notice 655B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-http
npm notice version: 2.5.8
npm notice filename: tauri-apps-plugin-http-2.5.8.tgz
npm notice package size: 5.1 kB
npm notice unpacked size: 20.5 kB
npm notice shasum: a6e638fd0d1b17563ef0c53745373c0b1eb50260
npm notice integrity: sha512-oxd7oypzQeu8k[...]hKUbhuKWcunRw==
npm notice total files: 6
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=1235993387
+ @tauri-apps/plugin-http@2.5.8
</code></pre>
<!-- raw HTML omitted -->
<h2>http v2.5.8</h2>
<h2>[2.5.8]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/29712892526cfc2d35c9002e0a56925084ae1b73"><code>29712892</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3252">#3252</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../NVolcz"><code>@​NVolcz</code></a>)
Correct Response header initialization to support cloning and ensure
Set-Cookie visibility.</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/d6a3898001a4bcc659e045f9501498751b77dbe6"><code>d6a3898</code></a>
Publish New Versions (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3268">#3268</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2e5bcdf202f98ec0fb0481725dd32f0626c766c8"><code>2e5bcdf</code></a>
chore(deps): fix audits (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3373">#3373</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/4374b4fc1a9a7c0506034d86707fd3288d347f9b"><code>4374b4f</code></a>
chore(notification): remove unused dev-deps (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3372">#3372</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/f75d21db3351d6f12adf585c2c797c20ece94f7f"><code>f75d21d</code></a>
chore(deps): remove used of tauri-utils build feature (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3360">#3360</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/4b95f5e079593d5b2b98220c332f420dc70d18d1"><code>4b95f5e</code></a>
chore(deps): update dependency eslint to v10.1.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3357">#3357</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/99c3e37b54c115e8712f67c2b53ed1f2f0b4aae6"><code>99c3e37</code></a>
chore(deps): bump tar in /plugins/updater/tests/updater-migration/v1-app
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3352">#3352</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/eaac19a5b7e325a06d4c1c6d740e38d86c55cd89"><code>eaac19a</code></a>
chore(deps): update rust crate tar to v0.4.45 [security] (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3353">#3353</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/5183e314cbabc179228e232992ad67025f30272d"><code>5183e31</code></a>
chore(deps): update dependency typescript-eslint to v8.57.1 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3344">#3344</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2c0883e64e9a53ea18102ed350cd5e65ba7c8f4b"><code>2c0883e</code></a>
chore(deps): update dependency vite to v8 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3346">#3346</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/024ec0c29c20cf94579dab9b79d6be0da61a8daa"><code>024ec0c</code></a>
fix(deep-link): ChromeOS deep link calls filtered and ignored by plugin
(fix ...</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/http-v2.5.7...http-v2.5.8">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-single-instance` from 2.4.0 to 2.4.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-single-instance's
releases</a>.</em></p>
<blockquote>
<h2>store-js v2.4.1</h2>
<h2>[2.4.1]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9"><code>93426f85</code></a>
Fixed an issue that caused docs.rs builds to fail. No user facing
changes.</li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn publish npm auto-corrected some errors in your
package.json when publishing. Please run &quot;npm pkg fix&quot; to
address these errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-store@2.4.1
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 4.4kB README.md
npm notice 6.6kB dist-js/index.cjs
npm notice 8.5kB dist-js/index.d.ts
npm notice 6.4kB dist-js/index.js
npm notice 711B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-store
npm notice version: 2.4.1
npm notice filename: tauri-apps-plugin-store-2.4.1.tgz
npm notice package size: 5.5 kB
npm notice unpacked size: 27.5 kB
npm notice shasum: 5e2d3362e41861d2fa79a3f1a78c091e12963236
npm notice integrity: sha512-ckGSEzZ5Ii4Hf[...]ugpGRDOFOunkA==
npm notice total files: 6
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=644610830
+ @tauri-apps/plugin-store@2.4.1
</code></pre>
<!-- raw HTML omitted -->
<h2>store v2.4.1</h2>
<h2>[2.4.1]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9"><code>93426f85</code></a>
Fixed an issue that caused docs.rs builds to fail. No user facing
changes.</li>
</ul>
<!-- raw HTML omitted -->
<pre><code>Updating crates.io index
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/d66aa6ff78cbbeaef1542a1fa930c33399578a25"><code>d66aa6f</code></a>
publish new versions (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2822">#2822</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/6f345870df4e7b187deb869df03b79858e03b4fe"><code>6f34587</code></a>
fix(single-instance): disable dbus name replacement (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2860">#2860</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/708fa4e2b77e847ce554115e80f7eb685f4a322e"><code>708fa4e</code></a>
chore(deps): update dependency eslint-config-prettier to v10.1.8 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2858">#2858</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/b7292030594daa04e78979214478031241b6e38e"><code>b729203</code></a>
fix(upload): fix download() locks main thread on Android (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2838">#2838</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2f9c71aae77cddd4ca2ffe9d13e9e1f23ee4f478"><code>2f9c71a</code></a>
chore(deps): update dependency rollup to v4.45.1 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2850">#2850</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/80d4d8e128922b022e76049189a657b71ce95a97"><code>80d4d8e</code></a>
chore(deps): update eslint monorepo to v9.31.0 (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2839">#2839</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/e7a98b0d2e60463ff86a0f031ce3c84c83a21274"><code>e7a98b0</code></a>
chore(deps): update dependency typescript-eslint to v8.37.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2848">#2848</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/44a1f659125a341191420e650608b0b6ff316a0e"><code>44a1f65</code></a>
fix(fs): <code>writeFile</code> create file by default (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2846">#2846</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/6210cd31df00bb3c55642013c74dc1da81fa00f9"><code>6210cd3</code></a>
chore(deps): update dependency rollup to v4.45.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2841">#2841</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/467f07b7de4103a653e40fec8badb6f24abcef39"><code>467f07b</code></a>
chore(deps): update dependency vite to v7 (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2800">#2800</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.0...fs-v2.4.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-deep-link` from 2.4.7 to 2.4.9
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-deep-link's
releases</a>.</em></p>
<blockquote>
<h2>deep-link-js v2.4.9</h2>
<h2>[2.4.9]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/e6cdc9f52e2cd975b11b8e4c12879d597f1f76c3"><code>e6cdc9f5</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3396">#3396</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Legend-Master"><code>@​Legend-Master</code></a>)
Fix broken iOS custom URL schemes</li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn Unknown user config &quot;always-auth&quot;. This
will stop working in the next major version of npm. See `npm help npmrc`
for supported config options.
npm warn publish npm auto-corrected some errors in your package.json
when publishing. Please run &quot;npm pkg fix&quot; to address these
errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-deep-link@2.4.9
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 6.2kB README.md
npm notice 3.5kB dist-js/index.cjs
npm notice 2.9kB dist-js/index.d.ts
npm notice 3.4kB dist-js/index.js
npm notice 801B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-deep-link
npm notice version: 2.4.9
npm notice filename: tauri-apps-plugin-deep-link-2.4.9.tgz
npm notice package size: 4.4 kB
npm notice unpacked size: 17.7 kB
npm notice shasum: ae56d59130380f806b533b3107c3f16654e66a8d
npm notice integrity: sha512-u0SKOUHnJ1wqe[...]hIvqLBRpgHJlA==
npm notice total files: 6
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=1429011657
+ @tauri-apps/plugin-deep-link@2.4.9
</code></pre>
<!-- raw HTML omitted -->
<h2>deep-link v2.4.9</h2>
<h2>[2.4.9]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/e6cdc9f52e2cd975b11b8e4c12879d597f1f76c3"><code>e6cdc9f5</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3396">#3396</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Legend-Master"><code>@​Legend-Master</code></a>)
Fix broken iOS custom URL schemes</li>
</ul>
<!-- raw HTML omitted -->
<pre><code>&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/5c7668b6bb7c9a509f394d584568b3a922161e50"><code>5c7668b</code></a>
publish new versions (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3397">#3397</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/ec0540138bece081e9a87982091947360e61987a"><code>ec05401</code></a>
chore(deps): update rust crate toml to v1 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3323">#3323</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/b86e999beb9fb48ef54adf3d0af631f94c06a5d0"><code>b86e999</code></a>
chore(deps): update tauri packages to 2.11 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3407">#3407</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/c463d8ab1422a4cf44f627a7b4e6ba9d3553f334"><code>c463d8a</code></a>
chore(deps): update rustls-webpki in lockfile, ignore core2 in audit (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3405">#3405</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/1bb7beb3076a8bf76b084223d0e4225bb2e53bc9"><code>1bb7beb</code></a>
chore(deps): bump openssl (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3402">#3402</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/3412fa2741b3b6e7a251e3548a43b21f2c26c635"><code>3412fa2</code></a>
docs(readme): fix platform support matrix (opener supports mobile)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/af81fdac9881d4ed52fbc18b9511bf10f7540ebb"><code>af81fda</code></a>
docs(readme): fix platform support matrix (mobile is supported)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/c1fd33b3a2735f2e25c1d026dc524af932db3315"><code>c1fd33b</code></a>
fix(opener): allow open network share locations (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3343">#3343</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/250857b7b72cd59825a14caa046160dc2ba1bb12"><code>250857b</code></a>
chore(deps): update dependency typescript to v6 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3363">#3363</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/964e13f124ad1feeb93c10168b265dc4936f738c"><code>964e13f</code></a>
fix(store): dead lock trying to set while exiting (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3395">#3395</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/deep-link-v2.4.7...deep-link-v2.4.9">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-build` from 2.5.5 to 2.6.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/tauri/releases">tauri-build's
releases</a>.</em></p>
<blockquote>
<h2>tauri-build v2.6.0</h2>
<!-- raw HTML omitted -->
<pre><code>Fetching advisory database from
`https://github.com/RustSec/advisory-db.git`
Loaded 1060 security advisories (from /home/runner/.cargo/advisory-db)
    Updating crates.io index
    Scanning Cargo.lock for vulnerabilities (1086 crate dependencies)
Crate:     atk
Version:   0.18.2
Warning:   unmaintained
Title:     gtk-rs GTK3 bindings - no longer maintained
Date:      2024-03-04
ID:        RUSTSEC-2024-0413
URL:       https://rustsec.org/advisories/RUSTSEC-2024-0413
Dependency tree:
atk 0.18.2
└── gtk 0.18.2
    ├── wry 0.55.0
    │   └── tauri-runtime-wry 2.11.0
    │       └── tauri 2.11.0
    │           ├── tauri-utils 2.9.0
    │           │   ├── tauri-schema-generator 0.0.0
    │           │   ├── tauri-runtime-wry 2.11.0
    │           │   ├── tauri-runtime 2.11.0
    │           │   │   ├── tauri-runtime-wry 2.11.0
    │           │   │   └── tauri 2.11.0
    │           │   ├── tauri-plugin 2.6.0
    │           │   │   ├── tauri-plugin-sample 0.1.0
    │           │   │   │   └── api 0.1.0
    │           │   │   └── tauri-plugin-log 2.6.0
    │           │   │       └── api 0.1.0
    │           │   ├── tauri-macros 2.6.0
    │           │   │   └── tauri 2.11.0
    │           │   ├── tauri-codegen 2.6.0
    │           │   │   ├── tauri-macros 2.6.0
    │           │   │   └── tauri-build 2.6.0
    │           │   │       ├── tauri-file-associations-demo 0.1.0
    │           │   │       ├── tauri 2.11.0
    │           │   │       ├── resources 0.1.0
    │           │   │       ├── bench_helloworld 0.1.0
    │           │   │       ├── bench_files_transfer 0.1.0
    │           │   │       ├── bench_cpu_intensive 0.1.0
    │           │   │       └── api 0.1.0
    │           │   ├── tauri-cli 2.11.0
    │           │   │   └── tauri-cli-node 0.0.0
    │           │   ├── tauri-bundler 2.9.0
    │           │   │   └── tauri-cli 2.11.0
    │           │   ├── tauri-build 2.6.0
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/tauri/commit/e60834fc67d87c10e2f44b2568052295cb61c325"><code>e60834f</code></a>
Apply Version Updates From Current Changes (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15041">#15041</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/df05c00563a91fc936bd15c6b10dd2825472f96b"><code>df05c00</code></a>
chore: minor bump for codegen crate</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/13bea1777fed60c75e37fe67d7ded5dde0b2997d"><code>13bea17</code></a>
chore: fmt</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/9808236ebf7755d498d674b614f3fc75eeac1ec4"><code>9808236</code></a>
fix(macOS): correct value for work_area.position.y (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14655">#14655</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/eb0312ea9e493954298ac0b3fdaae7eafb52750e"><code>eb0312e</code></a>
feat(mobile): Propagate tao::Event::Suspended and tao::Event::Resumed to
the ...</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/4ef5797f0fb27fa2df3f39f4a54e48ef319560ec"><code>4ef5797</code></a>
feat(ios): add --no-sign and --archive-only flags to ios build (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15061">#15061</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/110336c88a8c0a04476619db0a5c8f7694d969a5"><code>110336c</code></a>
fix(macOS): fix incorrect window position on multi-monitor setups (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15250">#15250</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/c00a3dbffccd6e051d3b7332f706b6c63759865d"><code>c00a3db</code></a>
feat(macros): add support for rename command macro in tauri-macros <a
href="https://redirect.github.com/tauri-apps/tauri/issues/14173">#14173</a>
(<a
href="https://redirect.github.com/tauri-apps/tauri/issues/1">#1</a>...</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/764b9139a32de149d8a914a6b5ec6cd1937c64eb"><code>764b913</code></a>
feat(cli): restart Android emulator if it is disconnected from adb (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14313">#14313</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/1035f12eeb8b23d9780881606d442d11c786e39e"><code>1035f12</code></a>
fix(windows): tauri-bundler detect arm system (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14923">#14923</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/tauri/compare/tauri-build-v2.5.5...tauri-build-v2.6.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 12:05:54 +01:00
dependabot[bot] 1ae24e18b5 build(deps): bump eclipse-temurin from a051234 to b27ca47 in /docker/base (#6292)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 11:42:36 +01:00
dependabot[bot]andAnthony Stirling 4764f1931b build(deps): bump sha2 from 0.10.9 to 0.11.0 in /frontend/src-tauri (#6180)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 11:40:58 +01:00
dependabot[bot]andAnthony Stirling 7f00441aab build(deps): bump windows from 0.58.0 to 0.61.3 in /frontend/src-tauri (#6185)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 11:40:46 +01:00
dependabot[bot]andAnthony Stirling 561d07b8b1 build(deps): bump globals from 17.4.0 to 17.5.0 in /frontend (#6285)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 11:40:13 +01:00
dependabot[bot]andAnthony Stirling f39db01919 build(deps): bump step-security/harden-runner from 2.15.1 to 2.19.0 (#6228)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 11:39:59 +01:00
dependabot[bot]andAnthony Stirling 4ab7d3b3ae build(deps): bump the mui group across 1 directory with 2 updates (#6301)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:53:16 +01:00
dependabot[bot]andAnthony Stirling d8519a6517 build(deps): bump com.google.guava:guava from 33.5.0-jre to 33.6.0-jre (#6283)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:39:53 +01:00
dependabot[bot]andAnthony Stirling 34c9e9bdc5 build(deps): bump actions/setup-node from 6.3.0 to 6.4.0 (#6258)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:38:42 +01:00
dependabot[bot]andAnthony Stirling 4663a9a194 build(deps): bump org.springdoc:springdoc-openapi-starter-webmvc-ui from 3.0.2 to 3.0.3 in /app/common (#6286)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:35:19 +01:00
dependabot[bot]andAnthony Stirling f89f7d99ba build(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#6297)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:34:45 +01:00
dependabot[bot] 84e30cd008 build(deps): bump actions/github-script from 7.1.0 to 9.0.0 (#6298)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 10:33:13 +01:00
dependabot[bot] 69236a89c8 build(deps): bump gradle/actions from 5.0.1 to 6.1.0 (#6294)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 10:32:53 +01:00
dependabot[bot] b66e12d15d build(deps): bump eclipse-temurin from a051234 to b27ca47 in /docker/embedded (#6293)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 10:32:29 +01:00
James BruntonandEthanHealy01 3fe8adc5cb Switch key areas to lazily import to improve Vite chunk size (#6278)
# Description of Changes
Vite currently warns that when it's bundling our code that the chunk
size is way too high because most of the imports are static so it can't
split them into smaller chunks. This PR changes a few key areas to use
lazy imports to try and make the chunks as small as possible with
minimal code changes.

Vite's warnings kick in at minified chunks being >500kB, and we've got a
little way to go still to reach that, but we can keep chipping away at
this and I'd rather get the biggest wins done now. I've also included
Lighthouse scores because there's been discussion about improving ours
recently. It's not the aim of this PR to improve it, but it's nice that
it makes it a little better.

## Current main chunks

Build split into 12 chunks. Largest chunk in build is:

```
[frontend:build] dist/assets/index-B6JiWDxZ.js               5,175.51 kB │ gzip: 1,495.85 kB
```

<img width="1442" height="775" alt="image"
src="https://github.com/user-attachments/assets/b0e8a3fa-4ef3-4ccd-8c1d-bfed2d99bd27"
/>

Lighthouse score:

<img width="423" height="146" alt="before"
src="https://github.com/user-attachments/assets/c62056e8-2e77-49a6-a1ae-f08ec8021fb3"
/>

## This PR's chunks

Build split into 176 chunks. Largest chunk in build is:

```
[frontend:build] dist/assets/index-qCgeCY4B.js                              2,878.54 kB │ gzip:   861.03 kB
```

<img width="1447" height="776" alt="image"
src="https://github.com/user-attachments/assets/8d0c3cf0-cc25-41c3-b114-4940d3e99349"
/>

Lighthouse score:

<img width="402" height="145" alt="after"
src="https://github.com/user-attachments/assets/99a26eb3-bd15-4b92-bf22-82b58b458f52"
/>

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-05-01 15:21:06 +00:00
James BruntonandEthanHealy01 51f5345151 Inform AI engine which endpoints are disabled on the backend (#6251)
# Description of Changes
Have the Java send a list of enabled endpoints to the AI engine so it
can intelligently respond to the user that the tool does exist but is
disabled on the server so it can't acutally run the operation, instead
of the current behaviour where it sends the API call back and then 503
errors because the execution fails when the URL is disabled.

<img width="380" height="208" alt="image"
src="https://github.com/user-attachments/assets/5842fb2e-2e55-45a5-8205-25515636daae"
/>

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-05-01 14:59:53 +00:00
James Brunton 5541dd666c Flesh out RAG system (#6197)
# Description of Changes
Flesh out the RAG system and connect it to the PDF Question Agent so it
can respond to questions about PDFs of an extremely large size.

I'd expect lots more work will need to be done to finish off the RAG
system to really be what we need, but this should be a reasonable start
which will let us connect it to tools and have the ingestion mostly
handled automatically. I'm leaving file deletion and proper file ID
management to be done in a future PR. We also need to consider whether
all tools should retrieve content exclusively via RAG, or whether it's
beneficial to have tools sometimes fetch the direct content and other
times fetch it from RAG.

A diagram of the expected interaction is as follows:

```mermaid
sequenceDiagram
    autonumber
    actor U as User
    participant FE as Frontend<br/>(ChatPanel)
    participant J as Java<br/>(AiWorkflowService)
    participant O as Engine:<br/>OrchestratorAgent
    participant QA as Engine:<br/>PdfQuestionAgent
    participant RAG as Engine:<br/>RagService + SqliteVecStore
    participant V as VoyageAI<br/>(embeddings)
    participant L as LLM<br/>(Claude / etc.)

    U->>FE: types "Summarise this PDF"<br/>(PDF already uploaded)
    FE->>J: POST /api/v1/ai/orchestrate/stream<br/>multipart: fileInputs[], userMessage
    Note over J: ByteHashFileIdStrategy<br/>id = sha256(bytes)[:16]
    J->>O: POST /api/v1/orchestrator<br/>{ files:[{id,name}], userMessage }

    O->>L: route via fast model
    L-->>O: delegate_pdf_question
    O->>QA: PdfQuestionRequest

    loop for each file
        QA->>RAG: has_collection(file.id)
        RAG-->>QA: false
    end
    QA-->>O: NeedIngestResponse(files_to_ingest)
    O-->>J: { outcome:"need_ingest", filesToIngest:[...] }

    Note over J: onNeedIngest
    loop per file
        J->>J: PDFBox: extract page text
        J->>O: POST /api/v1/rag/documents<br/>(long-running timeout)
        O->>RAG: chunk + stage documents
        O->>V: embed_documents (batches of 256)
        V-->>O: embeddings
        O->>RAG: add_documents
        O-->>J: { chunks_indexed: N }
    end

    Note over J: retry with resumeWith=pdf_question
    J->>O: POST /api/v1/orchestrator
    Note over O: fast-path to PdfQuestionAgent

    O->>QA: PdfQuestionRequest
    Note over QA: build RagCapability<br/>pinned to file IDs
    QA->>L: run(prompt) with search_knowledge tool

    loop up to max_searches
        L->>QA: search_knowledge(query)
        QA->>V: embed_query
        V-->>QA: query vector
        QA->>RAG: search(vector, collections=[file.id])
        RAG-->>QA: top-k chunks
        QA-->>L: formatted chunks
    end

    Note over QA: once budget spent,<br/>prepare() hides the tool
    L-->>QA: PdfQuestionAnswerResponse
    QA-->>O: answer
    O-->>J: { outcome:"answer", answer, evidence }
    J-->>FE: SSE "result"
    FE->>U: assistant bubble
```
2026-05-01 14:11:54 +01:00
Anthony Stirling 5605062153 Add Dependabot groups for frontend npm + cargo deps (#6287) 2026-05-01 11:37:07 +01:00
ConnorYohandJames Brunton 86774d556e Pdf comment agent (#6196)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-05-01 10:19:38 +01:00
212 changed files with 10224 additions and 1853 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.10.0
pkgver=2.10.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.10.0
pkgver=2.10.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+93
View File
@@ -48,6 +48,81 @@ updates:
cooldown:
default-days: 7
rebase-strategy: "auto"
groups:
embedpdf:
patterns:
- "@embedpdf/*"
mantine:
patterns:
- "@mantine/*"
- "postcss-preset-mantine"
mui:
patterns:
- "@mui/*"
tauri-js:
patterns:
- "@tauri-apps/*"
emotion:
patterns:
- "@emotion/*"
react:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
typescript-eslint:
patterns:
- "@typescript-eslint/*"
- "typescript-eslint"
eslint:
patterns:
- "eslint"
- "@eslint/*"
vite:
patterns:
- "vite"
- "vite-*"
- "@vitejs/*"
vitest:
patterns:
- "vitest"
- "@vitest/*"
testing-library:
patterns:
- "@testing-library/*"
i18next:
patterns:
- "i18next"
- "i18next-*"
- "react-i18next"
iconify:
patterns:
- "@iconify/*"
- "@iconify-json/*"
stripe:
patterns:
- "@stripe/*"
posthog:
patterns:
- "@posthog/*"
- "posthog-js"
supabase:
patterns:
- "@supabase/*"
dnd-kit:
patterns:
- "@dnd-kit/*"
tailwind:
patterns:
- "tailwindcss"
- "@tailwindcss/*"
postcss:
patterns:
- "postcss"
- "postcss-*"
exclude-patterns:
- "postcss-preset-mantine"
- package-ecosystem: cargo
directories:
@@ -59,6 +134,24 @@ updates:
cooldown:
default-days: 7
rebase-strategy: "auto"
groups:
tauri:
patterns:
- "tauri"
- "tauri-build"
- "tauri-plugin-*"
serde:
patterns:
- "serde"
- "serde_*"
tracing:
patterns:
- "tracing"
- "tracing-*"
tokio:
patterns:
- "tokio"
- "tokio-*"
- package-ecosystem: pip
directory: /testing/cucumber
+7 -7
View File
@@ -35,13 +35,13 @@ jobs:
pr_ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
- name: Resolve PR info
id: resolve
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
@@ -111,7 +111,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -132,7 +132,7 @@ jobs:
- name: Add deployment started comment
id: deployment-started
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -309,7 +309,7 @@ jobs:
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -359,7 +359,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -376,7 +376,7 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clean up V2 deployment comments
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -70,7 +70,7 @@ jobs:
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -88,7 +88,7 @@ jobs:
- name: Get PR data
id: get-pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const prNumber = context.eventName === 'workflow_dispatch'
@@ -154,7 +154,7 @@ jobs:
- name: Add 'in_progress' reaction to comment
if: github.event_name == 'issue_comment'
id: add-eyes-reaction
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -183,7 +183,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -212,7 +212,7 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
@@ -383,7 +383,7 @@ jobs:
- name: Add success reaction to comment
if: success() && github.event_name == 'issue_comment'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -418,7 +418,7 @@ jobs:
- name: Add failure reaction to comment
if: failure() && github.event_name == 'issue_comment'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -438,7 +438,7 @@ jobs:
- name: Post deployment URL to PR
if: success()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -474,7 +474,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -489,7 +489,7 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Apply label commands
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -39,7 +39,7 @@ jobs:
- name: Remove 'pr-deployed' label if present
id: remove-label-comment
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
@@ -71,7 +71,7 @@ jobs:
- name: Comment on fixer failures
if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- engine-check -->';
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -158,7 +158,7 @@ jobs:
- name: Post comment on PR if needed
if: steps.actor.outputs.is_repo_dev == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
continue-on-error: true
with:
github-token: ${{ steps.setup-bot.outputs.token }}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+4 -4
View File
@@ -22,7 +22,7 @@ jobs:
spring-security: [true, false]
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
- name: Checkout repository
@@ -44,7 +44,7 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
cache-disabled: true
@@ -66,7 +66,7 @@ jobs:
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.spotless-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- java-formatting-check -->';
@@ -148,7 +148,7 @@ jobs:
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
+3 -3
View File
@@ -44,7 +44,7 @@ jobs:
SYSTEM_ENABLEANALYTICS: "false"
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
- name: Checkout repository
@@ -55,7 +55,7 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
@@ -257,7 +257,7 @@ jobs:
stop_backend
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/playwright-report/
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
- name: Checkout repository
+3 -3
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -36,7 +36,7 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
cache-disabled: true
@@ -52,7 +52,7 @@ jobs:
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -37,7 +37,7 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
cache-disabled: true
@@ -53,7 +53,7 @@ jobs:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openapi-docs
path: ./SwaggerDoc.json
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -43,7 +43,7 @@ jobs:
- name: Get PR data
id: get-pr-data
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -87,7 +87,7 @@ jobs:
- name: Determine reference file
id: determine-file
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -240,7 +240,7 @@ jobs:
- name: Post comment on PR
if: env.SCRIPT_OUTPUT != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+4 -4
View File
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -48,7 +48,7 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
cache-disabled: true
@@ -90,7 +90,7 @@ jobs:
- name: Upload Cucumber Report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report
path: testing/cucumber/report.html
@@ -99,7 +99,7 @@ jobs:
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docker-compose-test-reports
path: testing/reports/
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
- name: Checkout repository
@@ -26,7 +26,7 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
@@ -82,14 +82,14 @@ jobs:
fi
- name: Upload backend log
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: backend-log-live-${{ github.run_id }}
path: /tmp/backend.log
retention-days: 7
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-live-${{ github.run_id }}
path: frontend/playwright-report/
+3 -3
View File
@@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
@@ -33,7 +33,7 @@ jobs:
run: task frontend:test:e2e -- --project=stubbed --workers=3
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
path: frontend/playwright-report/
@@ -28,7 +28,7 @@ jobs:
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -52,7 +52,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -80,7 +80,7 @@ jobs:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
@@ -139,7 +139,7 @@ jobs:
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -192,7 +192,7 @@ jobs:
- name: Comment on PR - License Check Results
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -316,7 +316,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -341,7 +341,7 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
@@ -369,7 +369,7 @@ jobs:
- name: Upload artifact on license issues
if: env.LICENSE_WARNINGS_EXIST == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: backend-dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
@@ -382,7 +382,7 @@ jobs:
- name: Delete previous backend license check comments
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -412,7 +412,7 @@ jobs:
- name: Comment on PR - Backend License Check Results
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
+4 -4
View File
@@ -15,13 +15,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
@@ -37,7 +37,7 @@ jobs:
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.frontend-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- frontend-check -->';
@@ -86,7 +86,7 @@ jobs:
echo "============================================"
exit 1
- name: Upload frontend build artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-build
path: frontend/dist/
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+42 -18
View File
@@ -44,7 +44,7 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -67,7 +67,7 @@ jobs:
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
@@ -93,18 +93,18 @@ jobs:
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For push/release events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
build-jars:
@@ -127,7 +127,7 @@ jobs:
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -140,13 +140,13 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "npm"
@@ -173,7 +173,7 @@ jobs:
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
- name: Upload JAR artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar
@@ -191,7 +191,7 @@ jobs:
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
allowed-endpoints: >
@@ -208,7 +208,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "npm"
@@ -218,7 +218,23 @@ jobs:
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
# x86_64 JDK is set up first so the aarch64 step below can leave its
# JAVA_HOME as the active one. The macOS universal JRE build needs
# jmods from both arches; the x64 path is captured into the env
# before the second setup-java overwrites JAVA_HOME.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
architecture: "x64"
- name: Capture x86_64 JAVA_HOME
if: matrix.platform == 'macos-15'
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
@@ -227,13 +243,21 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
env:
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
run: task desktop:jlink:universal-mac
- name: Prepare desktop build
run: task desktop:prepare
env:
@@ -321,7 +345,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -342,7 +366,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -557,7 +581,7 @@ jobs:
# Only ship the MSI installer on Windows. The loose exe and WiX toolset exes
# are not the user-facing installer - the MSI contains the signed inner exe.
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
@@ -568,7 +592,7 @@ jobs:
- name: Upload build artifacts
if: always() && steps.digicert-setup.conclusion != 'failure'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
@@ -582,7 +606,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
@@ -42,7 +42,7 @@ jobs:
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-nightly-${{ github.run_id }}
path: frontend/playwright-report/
+18 -22
View File
@@ -22,8 +22,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.info.outputs.version }}
dmg_arm64_sha256: ${{ steps.hashes.outputs.dmg_arm64_sha256 }}
dmg_x86_64_sha256: ${{ steps.hashes.outputs.dmg_x86_64_sha256 }}
dmg_sha256: ${{ steps.hashes.outputs.dmg_sha256 }}
msi_sha256: ${{ steps.hashes.outputs.msi_sha256 }}
deb_sha256: ${{ steps.hashes.outputs.deb_sha256 }}
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
@@ -63,17 +62,15 @@ jobs:
sha256sum "$file" | awk '{print $1}'
}
DMG_ARM64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-aarch64.dmg")
DMG_X64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-x86_64.dmg")
DMG_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-universal.dmg")
MSI_SHA=$(download_sha256 "${BASE}/Stirling-PDF-windows-x86_64.msi")
DEB_SHA=$(download_sha256 "${BASE}/Stirling-PDF-linux-x86_64.deb")
JAR_SHA=$(download_sha256 "${BASE}/Stirling-PDF-with-login.jar")
echo "dmg_arm64_sha256=$DMG_ARM64_SHA" >> "$GITHUB_OUTPUT"
echo "dmg_x86_64_sha256=$DMG_X64_SHA" >> "$GITHUB_OUTPUT"
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
echo "dmg_sha256=$DMG_SHA" >> "$GITHUB_OUTPUT"
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
update-homebrew-and-scoop:
needs: get-release-info
@@ -93,22 +90,21 @@ jobs:
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap
# Stirling-PDF now ships a single universal DMG, so the Cask should
# have one sha256 line rather than separate on_arm/on_intel blocks.
# We rewrite every `sha256 "..."` in the Cask to the same value so
# the workflow keeps working through the Cask migration: pre-migration
# both arch blocks get the universal SHA (still correct, since both
# would resolve to the same DMG), post-migration the lone sha256 line
# is updated.
- name: Update Homebrew cask (Casks/stirling-pdf.rb)
env:
VERSION: ${{ needs.get-release-info.outputs.version }}
ARM64_SHA: ${{ needs.get-release-info.outputs.dmg_arm64_sha256 }}
X64_SHA: ${{ needs.get-release-info.outputs.dmg_x86_64_sha256 }}
DMG_SHA: ${{ needs.get-release-info.outputs.dmg_sha256 }}
run: |
CASK="tap/Casks/stirling-pdf.rb"
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$CASK"
awk -v arm="$ARM64_SHA" -v x64="$X64_SHA" '
/on_arm/ { in_arm=1 }
/on_intel/ { in_arm=0; in_intel=1 }
/end/ { in_arm=0; in_intel=0 }
in_arm && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" arm "\"") }
in_intel && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" x64 "\"") }
{ print }
' "$CASK" > tmp && mv tmp "$CASK"
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$CASK"
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${DMG_SHA}\"/g" "$CASK"
- name: Update Homebrew formula (Formula/stirling-pdf-server.rb)
env:
@@ -116,8 +112,8 @@ jobs:
JAR_SHA: ${{ needs.get-release-info.outputs.jar_sha256 }}
run: |
FORMULA="tap/Formula/stirling-pdf-server.rb"
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$FORMULA"
sed -i "s/sha256 \".*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$FORMULA"
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
- name: Update Scoop stirling-pdf.json
env:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -56,7 +56,7 @@ jobs:
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -67,7 +67,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: SARIF file
path: results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -40,7 +40,7 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+45 -22
View File
@@ -1,6 +1,6 @@
name: Build Tauri Applications
# Multi-OS Tauri desktop bundle build matrix (Windows / macOS arm+intel /
# Multi-OS Tauri desktop bundle build matrix (Windows / macOS universal /
# Linux). Called from build.yml on PRs that touch desktop sources (gated
# via the `tauri` filter in .github/config/.files.yaml). Also runnable
# on demand via workflow_dispatch with a per-platform selector.
@@ -37,7 +37,7 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -48,17 +48,16 @@ jobs:
PLATFORM: ${{ inputs.platform }}
run: |
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
# Resolve requested platform — populated by either workflow_dispatch
# or workflow_call inputs; both paths default to "all".
case "$PLATFORM" in
windows) ENTRIES=("$WINDOWS") ;;
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
macos) ENTRIES=("$MACOS") ;;
linux) ENTRIES=("$LINUX") ;;
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
esac
# Drop macOS entries when Apple certificate secret is unavailable
@@ -87,7 +86,7 @@ jobs:
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -101,7 +100,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "npm"
@@ -111,7 +110,23 @@ jobs:
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
# x86_64 JDK is set up first so the aarch64 step below can leave its
# JAVA_HOME as the active one. The macOS universal JRE build needs
# jmods from both arches; the x64 path is captured into the env
# before the second setup-java overwrites JAVA_HOME.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
architecture: "x64"
- name: Capture x86_64 JAVA_HOME
if: matrix.platform == 'macos-15'
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
@@ -120,12 +135,21 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
- name: Setup Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
env:
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
run: task desktop:jlink:universal-mac
- name: Prepare desktop build
run: task desktop:prepare
env:
@@ -213,7 +237,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -234,7 +258,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -246,7 +270,7 @@ jobs:
echo "Certificate imported successfully."
- name: Check DMG creation dependencies (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
if: matrix.platform == 'macos-15'
run: |
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
@@ -373,7 +397,7 @@ jobs:
fi
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
if: matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/src-tauri/target
@@ -398,7 +422,7 @@ jobs:
# Only ship the MSI installer. The loose exe and WiX toolset exes
# are not the user-facing installer - the MSI contains the signed inner exe.
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
@@ -472,7 +496,7 @@ jobs:
}
- name: Upload artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
@@ -491,7 +515,7 @@ jobs:
echo "❌ No Windows executable found"
exit 1
fi
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
echo "Checking for macOS artifacts..."
find . -name "*.dmg" | head -5
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
@@ -533,12 +557,12 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
- name: Post/Update PR Comment with Download Links
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;
@@ -556,8 +580,7 @@ jobs:
// Map of expected artifact names to display info
const artifactMap = {
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' },
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' }
};
@@ -625,7 +648,7 @@ jobs:
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+3 -3
View File
@@ -48,7 +48,7 @@ jobs:
cache-scope: stirling-pdf-fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -89,7 +89,7 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
cache-disabled: true
@@ -151,7 +151,7 @@ jobs:
- name: Upload Reports
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: reports-docker-${{ matrix.artifact-suffix }}
path: |
+6 -6
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -39,7 +39,7 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
@@ -131,7 +131,7 @@ jobs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
@@ -150,14 +150,14 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
@@ -186,7 +186,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
+4
View File
@@ -266,3 +266,7 @@ docs/type3/signatures/
# Claude
.claude/
# Playwright MCP screenshots / traces
.playwright-mcp/
*.playwright-mcp.png
+22 -2
View File
@@ -1,7 +1,12 @@
version: '3'
vars:
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
# Single source of truth for the jlink module list (also read by
# frontend/scripts/build-universal-mac-jre.sh during CI). The desktop
# taskfile runs with dir: frontend (see Taskfile.yml), so this path
# is relative to that directory.
JLINK_MODULES:
sh: tr -d '\n' < src-tauri/jlink-modules.txt
tasks:
prepare:
@@ -96,7 +101,7 @@ tasks:
jlink
--add-modules {{.JLINK_MODULES}}
--strip-debug
--compress=2
--compress=zip-6
--no-header-files
--no-man-pages
--output src-tauri/runtime/jre
@@ -107,3 +112,18 @@ tasks:
desc: "Remove JLink runtime and bundled JARs"
cmds:
- rm -rf src-tauri/libs src-tauri/runtime
# macOS-only. Replaces jlink:runtime's single-arch JRE with a universal
# (arm64 + x86_64) one for the universal Tauri shell. Runs the x86_64
# jlink under Rosetta on Apple Silicon, so it is opt-in and not part of
# the default desktop:build flow. Requires AARCH64_JAVA_HOME and
# X64_JAVA_HOME to point at matching JDK installations with jmods/.
jlink:universal-mac:
desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build"
deps: [jlink:jar]
platforms: [darwin]
env:
JLINK_MODULES: "{{.JLINK_MODULES}}"
OUTPUT_DIR: src-tauri/runtime/jre
cmds:
- scripts/build-universal-mac-jre.sh
+1
View File
@@ -239,6 +239,7 @@ tasks:
- task: typecheck:saas
- task: typecheck:desktop
- task: typecheck:scripts
- task: typecheck:prototypes
# ============================================================
# Quality Gate
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies {
api 'com.github.junrar:junrar:7.5.10' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2"
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
@@ -68,6 +68,36 @@ public class EndpointConfiguration {
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
}
/**
* Translate a full request URI like {@code /api/v1/general/remove-pages} into the endpoint key
* used by this configuration ({@code remove-pages}). Convert endpoints are a special case -
* {@code /api/v1/convert/pdf/img} is registered as {@code pdf-to-img}. Returns {@code null} if
* the URI is not an {@code /api/v1/<group>/<endpoint>} path.
*/
public static String endpointKeyForUri(String uri) {
if (uri == null || !uri.contains("/api/v1")) {
return null;
}
String[] parts = uri.split("/");
if (parts.length <= 4) {
return null;
}
if ("convert".equals(parts[3]) && parts.length > 5) {
return parts[4] + "-to-" + parts[5];
}
return parts[4];
}
/**
* Convenience wrapper around {@link #isEndpointEnabled(String)} that accepts a full request URI
* and translates it to the endpoint key. Falls back to treating the URI itself as a key for
* non-{@code /api/v1/...} paths so callers can pass arbitrary URIs.
*/
public boolean isEndpointEnabledForUri(String uri) {
String key = endpointKeyForUri(uri);
return isEndpointEnabled(key != null ? key : uri);
}
public void enableEndpoint(String endpoint) {
String normalized = normalizeEndpoint(endpoint);
endpointStatuses.put(normalized, true);
@@ -237,6 +237,13 @@ public class ApplicationProperties {
private boolean enabled = false;
private String url = "http://localhost:5001";
private int timeoutSeconds = 120;
/**
* Longer timeout for heavy operations like RAG ingestion, which embeds the whole document
* and can take multiple minutes for large books. Applied per-call when the caller
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
*/
private int longRunningTimeoutSeconds = 600;
}
@Data
@@ -0,0 +1,15 @@
package stirling.software.common.model.api.comments;
/**
* Absolute position of a PDF annotation in the document.
*
* <p>Coordinates are in PDF user-space with the origin at the page's bottom-left, consistent with
* PDFBox's {@code PDRectangle} convention.
*
* @param pageIndex 0-indexed page number the annotation lives on.
* @param x bottom-left x coordinate of the annotation rectangle.
* @param y bottom-left y coordinate of the annotation rectangle.
* @param width width of the annotation rectangle, in user-space units.
* @param height height of the annotation rectangle, in user-space units.
*/
public record AnnotationLocation(int pageIndex, float x, float y, float width, float height) {}
@@ -0,0 +1,15 @@
package stirling.software.common.model.api.comments;
/**
* Description of a single sticky-note (PDF Text) annotation to place on a document.
*
* <p>{@code author} and {@code subject} are optional — callers that pass {@code null} get a default
* author/subject from {@code PdfAnnotationService}.
*
* @param location where to anchor the annotation icon, in PDF user-space.
* @param text the comment body shown in the popup (required, non-blank).
* @param author optional author label shown in the popup; {@code null} → service default.
* @param subject optional subject line shown in the popup; {@code null} → service default.
*/
public record StickyNoteSpec(
AnnotationLocation location, String text, String author, String subject) {}
@@ -34,11 +34,17 @@ import stirling.software.common.util.TempFileManager;
@Slf4j
public class InternalApiClient {
// Allowlist for internal dispatch. Matches a fixed namespace prefix,
// Allowlist for internal dispatch. Matches fixed namespace prefixes,
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
// character that could alter the resolved endpoint on the local Spring server.
//
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
// intentionally NOT permitted to avoid plan steps re-entering the orchestrator.
private static final Pattern ALLOWED_ENDPOINT_PATH =
Pattern.compile("^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$");
Pattern.compile(
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
private final ServletContext servletContext;
private final UserServiceInterface userService;
@@ -0,0 +1,157 @@
package stirling.software.common.service;
import java.util.Calendar;
import java.util.List;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.comments.AnnotationLocation;
import stirling.software.common.model.api.comments.StickyNoteSpec;
/**
* Shared primitive for adding sticky-note (PDF Text) annotations to a document.
*
* <p>Used by:
*
* <ul>
* <li>{@code /api/v1/misc/add-comments} — a deterministic, reusable tool.
* <li>AI-agent flows that generate comment specs (e.g. PDF review agent, math auditor review
* mode) and hand them off to this service for deterministic placement.
* </ul>
*/
@Slf4j
@Service
public class PdfAnnotationService {
/** Yellow sticky-note fill colour (R, G, B in 0..1 range). */
private static final float[] STICKY_NOTE_COLOR_RGB = {1f, 0.95f, 0.4f};
/** Opacity for the sticky-note icon. */
private static final float ANNOTATION_OPACITY = 0.9f;
/** PDF Text-annotation icon name — {@code "Comment"} is one of the standard icons. */
private static final String ANNOTATION_ICON_NAME = "Comment";
/** Default subject shown in the annotation popup when a spec does not supply one. */
private static final String DEFAULT_SUBJECT = "Stirling AI Comment";
/** Default author label shown in the annotation popup when a spec does not supply one. */
private static final String DEFAULT_AUTHOR = "Stirling AI";
/**
* Cap on sticky-note text length. PDF annotation bodies can technically be much longer, but
* anything beyond this is almost certainly pathological (accidental document-dump or malicious
* payload) and would bloat the output file.
*/
private static final int MAX_COMMENT_TEXT_LENGTH = 100_000;
/**
* Add a list of sticky notes to {@code doc}. Specs that reference an out-of-range page or
* contain blank text are logged and skipped; this method never throws for a single bad spec.
*
* @return the number of annotations actually applied
*/
public int addStickyNotes(PDDocument doc, List<StickyNoteSpec> specs) {
if (specs == null || specs.isEmpty()) {
return 0;
}
int totalPages = doc.getNumberOfPages();
Calendar now = Calendar.getInstance();
int applied = 0;
for (int i = 0; i < specs.size(); i++) {
StickyNoteSpec spec = specs.get(i);
if (!isValid(spec, totalPages, i)) {
continue;
}
apply(doc, spec, now);
applied++;
}
if (applied < specs.size()) {
log.warn(
"Applied {}/{} sticky notes; {} skipped due to invalid specs.",
applied,
specs.size(),
specs.size() - applied);
}
return applied;
}
/**
* Add a single sticky note. Convenience wrapper; prefer {@link #addStickyNotes(PDDocument,
* List)} when placing multiple annotations so log output is batched.
*/
public void addStickyNote(PDDocument doc, StickyNoteSpec spec) {
addStickyNotes(doc, List.of(spec));
}
private boolean isValid(StickyNoteSpec spec, int totalPages, int index) {
if (spec == null || spec.location() == null) {
log.warn("Skipping sticky-note[{}]: spec or location is null.", index);
return false;
}
if (spec.text() == null || spec.text().isBlank()) {
log.warn("Skipping sticky-note[{}]: text is blank.", index);
return false;
}
if (spec.text().length() > MAX_COMMENT_TEXT_LENGTH) {
log.warn(
"Skipping sticky-note[{}]: text length {} exceeds limit {}.",
index,
spec.text().length(),
MAX_COMMENT_TEXT_LENGTH);
return false;
}
AnnotationLocation loc = spec.location();
if (loc.width() <= 0f || loc.height() <= 0f) {
log.warn(
"Skipping sticky-note[{}]: non-positive dimensions width={} height={}.",
index,
loc.width(),
loc.height());
return false;
}
int page = loc.pageIndex();
if (page < 0 || page >= totalPages) {
log.warn(
"Skipping sticky-note[{}]: pageIndex={} out of range [0, {}).",
index,
page,
totalPages);
return false;
}
return true;
}
private void apply(PDDocument doc, StickyNoteSpec spec, Calendar now) {
AnnotationLocation loc = spec.location();
PDAnnotationText annot = new PDAnnotationText();
annot.setContents(spec.text());
annot.setRectangle(new PDRectangle(loc.x(), loc.y(), loc.width(), loc.height()));
annot.setSubject(nonBlankOr(spec.subject(), DEFAULT_SUBJECT));
annot.setTitlePopup(nonBlankOr(spec.author(), DEFAULT_AUTHOR));
annot.setColor(new PDColor(STICKY_NOTE_COLOR_RGB, PDDeviceRGB.INSTANCE));
annot.setCreationDate(now);
annot.setConstantOpacity(ANNOTATION_OPACITY);
annot.getCOSObject().setName(COSName.NAME, ANNOTATION_ICON_NAME);
try {
doc.getPage(loc.pageIndex()).getAnnotations().add(annot);
} catch (java.io.IOException e) {
log.warn(
"Failed to attach sticky note to page {}: {}", loc.pageIndex(), e.getMessage());
}
}
private static String nonBlankOr(String value, String fallback) {
return value != null && !value.isBlank() ? value : fallback;
}
}
@@ -0,0 +1,139 @@
package stirling.software.common.util;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
/**
* Locate text on a specific PDF page and return its bounding box in PDF user-space (bottom-left
* origin). Used by tools that receive "anchor by text" hints — e.g. {@code
* /api/v1/misc/add-comments} when callers supply an {@code anchorText} instead of explicit
* coordinates.
*
* <p>Matching is tolerant: case-insensitive with punctuation/whitespace stripped on both sides, so
* a caller-supplied needle of {@code "215000"} matches page text {@code "$215,000"}, and {@code
* "Total Revenue"} matches {@code "Total Revenue."}.
*/
@Slf4j
@Component
public class PdfTextLocator {
/** One found line of text with its user-space bounding box. */
public record MatchedBox(float x, float y, float width, float height) {}
/**
* Find the first line on {@code pageIndex} (0-indexed) whose text contains {@code needle} under
* the tolerant match. Returns empty when no match, when the page index is out of range, or when
* the needle is blank.
*/
public Optional<MatchedBox> findOnPage(PDDocument doc, int pageIndex, String needle) {
if (doc == null
|| needle == null
|| needle.isBlank()
|| pageIndex < 0
|| pageIndex >= doc.getNumberOfPages()) {
return Optional.empty();
}
String normalizedNeedle = normalize(needle);
if (normalizedNeedle.isEmpty()) {
return Optional.empty();
}
List<CapturedLine> lines = new ArrayList<>();
LineCapturingStripper stripper;
try {
stripper = new LineCapturingStripper(lines);
stripper.setStartPage(pageIndex + 1);
stripper.setEndPage(pageIndex + 1);
stripper.setSortByPosition(true);
// Side effect: populates `lines`. We don't need the concatenated text.
stripper.getText(doc);
} catch (IOException e) {
log.warn(
"PdfTextLocator failed to extract text on page {}: {}",
pageIndex,
e.getMessage());
return Optional.empty();
}
PDRectangle mediaBox = doc.getPage(pageIndex).getMediaBox();
float pageHeight = mediaBox.getHeight();
for (CapturedLine line : lines) {
if (normalize(line.text).contains(normalizedNeedle)) {
// PDFBox's *DirAdj coords descend from the top of the page; convert to PDF
// user-space (origin = bottom-left) so the bbox can feed a PDRectangle directly.
float userSpaceY = pageHeight - line.yTopDown - line.height;
return Optional.of(new MatchedBox(line.x, userSpaceY, line.width, line.height));
}
}
return Optional.empty();
}
/** Strip everything non-alphanumeric and lowercase for tolerant matching. */
private static String normalize(String s) {
return s.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
}
private static final class CapturedLine {
String text;
float x;
float yTopDown;
float width;
float height;
}
private static final class LineCapturingStripper extends PDFTextStripper {
private final List<CapturedLine> lines;
LineCapturingStripper(List<CapturedLine> sink) throws IOException {
super();
this.lines = sink;
}
@Override
protected void writeString(String text, List<TextPosition> textPositions)
throws IOException {
if (textPositions != null && !textPositions.isEmpty()) {
CapturedLine line = new CapturedLine();
line.text = text;
float minX = Float.MAX_VALUE;
float maxRight = 0f;
float minY = Float.MAX_VALUE;
float maxHeight = 0f;
for (TextPosition p : textPositions) {
float x = p.getXDirAdj();
float y = p.getYDirAdj();
float w = p.getWidthDirAdj();
float h = p.getHeightDir();
if (h == 0f) {
// Workaround: some fonts report 0 height via TextPosition; fall back to
// the nominal font size so downstream bboxes are never zero-height.
h = p.getFontSizeInPt();
}
if (x < minX) minX = x;
if (x + w > maxRight) maxRight = x + w;
if (y < minY) minY = y;
if (h > maxHeight) maxHeight = h;
}
line.x = minX;
line.width = maxRight - minX;
line.yTopDown = minY;
line.height = maxHeight;
lines.add(line);
}
super.writeString(text, textPositions);
}
}
}
@@ -0,0 +1,39 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.Test;
class EndpointConfigurationTest {
@Test
void endpointKeyForUriExtractsSimpleKebab() {
assertEquals(
"remove-pages",
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
assertEquals(
"compress-pdf",
EndpointConfiguration.endpointKeyForUri("/api/v1/misc/compress-pdf"));
assertEquals(
"add-watermark",
EndpointConfiguration.endpointKeyForUri("/api/v1/security/add-watermark"));
}
@Test
void endpointKeyForUriComposesConvertEndpoints() {
assertEquals(
"pdf-to-img", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
assertEquals(
"pdf-to-word", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/word"));
assertEquals(
"html-to-pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/html/pdf"));
}
@Test
void endpointKeyForUriReturnsNullForNonApiPaths() {
assertNull(EndpointConfiguration.endpointKeyForUri(null));
assertNull(EndpointConfiguration.endpointKeyForUri("/some-page"));
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
}
}
@@ -92,6 +92,49 @@ class InternalApiClientTest {
assertThrows(SecurityException.class, () -> client.post("/api/v1/admin/settings", body));
}
@Test
void postRejectsAiEndpointsOutsideToolsSubnamespace() {
// /api/v1/ai/orchestrate and other non-tool AI endpoints are not internally
// dispatchable. Only /api/v1/ai/tools/* and the general/misc/security/convert/filter
// namespaces are on the allowlist — letting a plan step re-enter /orchestrate would
// introduce recursion risk.
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
assertThrows(SecurityException.class, () -> client.post("/api/v1/ai/orchestrate", body));
}
@Test
void postAcceptsAiToolsSubnamespace() throws Exception {
// Agent tool paths like /api/v1/ai/tools/pdf-comment-agent are on the allowlist and
// should be dispatchable by the orchestrator's plan executor.
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", namedResource("input.pdf", "data"));
Path tempPath = Files.createTempFile("internal-api-ai-tools-test", ".tmp");
TempFile tempFile = mock(TempFile.class);
when(tempFile.getPath()).thenReturn(tempPath);
when(tempFile.getFile()).thenReturn(tempPath.toFile());
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
try (var ignored =
mockConstruction(
RestTemplate.class,
(rt, ctx) -> {
when(rt.httpEntityCallback(any(), eq(Resource.class)))
.thenReturn((RequestCallback) req -> {});
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
})) {
ResponseEntity<Resource> response =
client.post("/api/v1/ai/tools/pdf-comment-agent", body);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
} finally {
Files.deleteIfExists(tempPath);
}
}
@Test
void postRejectsPathTraversal() {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
@@ -0,0 +1,191 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.api.comments.AnnotationLocation;
import stirling.software.common.model.api.comments.StickyNoteSpec;
class PdfAnnotationServiceTest {
private PdfAnnotationService service;
@BeforeEach
void setUp() {
service = new PdfAnnotationService();
}
@Test
void addStickyNotesPlacesOneAnnotationPerValidSpec() throws IOException {
byte[] bytes = twoPagePdfBytes();
try (PDDocument doc = Loader.loadPDF(bytes)) {
List<StickyNoteSpec> specs =
List.of(
spec(0, 72f, 700f, "First comment", "alice", null),
spec(1, 100f, 650f, "Second comment", null, "Second"));
int applied = service.addStickyNotes(doc, specs);
assertEquals(2, applied);
byte[] saved = save(doc);
try (PDDocument reloaded = Loader.loadPDF(saved)) {
assertEquals(1, textAnnotations(reloaded.getPage(0).getAnnotations()).size());
assertEquals(1, textAnnotations(reloaded.getPage(1).getAnnotations()).size());
PDAnnotationText first =
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
assertEquals("First comment", first.getContents());
assertEquals("alice", first.getTitlePopup(), "author override propagates");
assertNotNull(first.getSubject(), "subject falls back to default when null");
}
}
}
@Test
void skipsSpecsWithBlankText() throws IOException {
byte[] bytes = twoPagePdfBytes();
try (PDDocument doc = Loader.loadPDF(bytes)) {
List<StickyNoteSpec> specs =
List.of(
spec(0, 72f, 700f, "Valid", null, null),
spec(0, 72f, 680f, " ", null, null));
int applied = service.addStickyNotes(doc, specs);
assertEquals(1, applied, "Blank-text spec must be skipped");
}
}
@Test
void skipsSpecsWithOutOfRangePageIndex() throws IOException {
byte[] bytes = twoPagePdfBytes();
try (PDDocument doc = Loader.loadPDF(bytes)) {
List<StickyNoteSpec> specs =
List.of(
spec(0, 72f, 700f, "OK", null, null),
spec(99, 72f, 700f, "Too far", null, null),
spec(-1, 72f, 700f, "Negative", null, null));
int applied = service.addStickyNotes(doc, specs);
assertEquals(1, applied, "Only the in-range spec should be applied");
}
}
@Test
void handlesNullAndEmptySpecList() throws IOException {
byte[] bytes = twoPagePdfBytes();
try (PDDocument doc = Loader.loadPDF(bytes)) {
assertEquals(0, service.addStickyNotes(doc, null));
assertEquals(0, service.addStickyNotes(doc, List.of()));
}
}
@Test
void skipsSpecsWithNonPositiveDimensions() throws IOException {
byte[] bytes = twoPagePdfBytes();
try (PDDocument doc = Loader.loadPDF(bytes)) {
StickyNoteSpec zeroWidth =
new StickyNoteSpec(
new AnnotationLocation(0, 72f, 700f, 0f, 20f),
"Zero width",
null,
null);
StickyNoteSpec negativeHeight =
new StickyNoteSpec(
new AnnotationLocation(0, 72f, 680f, 20f, -5f),
"Negative height",
null,
null);
List<StickyNoteSpec> specs =
List.of(spec(0, 72f, 660f, "OK", null, null), zeroWidth, negativeHeight);
int applied = service.addStickyNotes(doc, specs);
assertEquals(1, applied, "Only the positively-sized spec should be applied");
}
}
@Test
void skipsSpecsWithOverlongText() throws IOException {
byte[] bytes = twoPagePdfBytes();
try (PDDocument doc = Loader.loadPDF(bytes)) {
String overlong = "x".repeat(100_001);
List<StickyNoteSpec> specs =
List.of(
spec(0, 72f, 700f, "Short", null, null),
spec(0, 72f, 680f, overlong, null, null));
int applied = service.addStickyNotes(doc, specs);
assertEquals(1, applied, "Overlong-text spec must be skipped");
}
}
@Test
void appliesDefaultAuthorAndSubjectWhenAbsent() throws IOException {
byte[] bytes = twoPagePdfBytes();
try (PDDocument doc = Loader.loadPDF(bytes)) {
service.addStickyNote(doc, spec(0, 72f, 700f, "No author given", null, null));
byte[] saved = save(doc);
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAnnotationText annot =
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
assertTrue(
annot.getTitlePopup() != null && !annot.getTitlePopup().isBlank(),
"Default author should be applied");
assertTrue(
annot.getSubject() != null && !annot.getSubject().isBlank(),
"Default subject should be applied");
}
}
}
// --- helpers ---
private static StickyNoteSpec spec(
int page, float x, float y, String text, String author, String subject) {
return new StickyNoteSpec(
new AnnotationLocation(page, x, y, 20f, 20f), text, author, subject);
}
private static byte[] twoPagePdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
return save(doc);
}
}
private static byte[] save(PDDocument doc) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
List<PDAnnotationText> out = new ArrayList<>();
for (PDAnnotation a : annotations) {
if (a instanceof PDAnnotationText t) {
out.add(t);
}
}
return out;
}
}
@@ -0,0 +1,97 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.util.Optional;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.Test;
import stirling.software.common.util.PdfTextLocator.MatchedBox;
class PdfTextLocatorTest {
private final PdfTextLocator locator = new PdfTextLocator();
@Test
void findsLineContainingNeedleAndReturnsUserSpaceBox() throws Exception {
byte[] pdf = pdfWithLines(new String[] {"Revenue: $215,000", "Expenses: $120,000"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "215000");
assertThat(match).isPresent();
MatchedBox box = match.get();
// Line was drawn at y=720 in user-space (bottom-left origin); locator
// should return a bbox close to that height band with non-zero width.
assertThat(box.width()).isGreaterThan(0f);
assertThat(box.height()).isGreaterThan(0f);
assertThat(box.y()).isBetween(700f, 740f);
}
}
@Test
void matchIsCaseAndPunctuationInsensitive() throws Exception {
byte[] pdf = pdfWithLines(new String[] {"Total Revenue.", "Q4 summary"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "total revenue");
assertThat(match).isPresent();
}
}
@Test
void returnsEmptyWhenNeedleNotFound() throws Exception {
byte[] pdf = pdfWithLines(new String[] {"Nothing to see here"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "not-on-this-page");
assertThat(match).isEmpty();
}
}
@Test
void returnsEmptyForBlankNeedle() throws Exception {
byte[] pdf = pdfWithLines(new String[] {"Any text"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertThat(locator.findOnPage(doc, 0, "")).isEmpty();
assertThat(locator.findOnPage(doc, 0, " ")).isEmpty();
assertThat(locator.findOnPage(doc, 0, null)).isEmpty();
}
}
@Test
void returnsEmptyForOutOfRangePage() throws Exception {
byte[] pdf = pdfWithLines(new String[] {"Single page"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertThat(locator.findOnPage(doc, -1, "single")).isEmpty();
assertThat(locator.findOnPage(doc, 99, "single")).isEmpty();
}
}
private static byte[] pdfWithLines(String[] lines) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.setNonStrokingColor(Color.BLACK);
float y = 720f;
for (String line : lines) {
cs.beginText();
cs.newLineAtOffset(72f, y);
cs.showText(line);
cs.endText();
y -= 20f;
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
}
@@ -21,28 +21,7 @@ public class EndpointInterceptor implements HandlerInterceptor {
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String requestURI = request.getRequestURI();
boolean isEnabled;
// Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages)
if (requestURI.contains("/api/v1") && requestURI.split("/").length > 4) {
String[] requestURIParts = requestURI.split("/");
String requestEndpoint;
// Endpoint: /api/v1/convert/pdf/img becomes pdf-to-img
if ("convert".equals(requestURIParts[3]) && requestURIParts.length > 5) {
requestEndpoint = requestURIParts[4] + "-to-" + requestURIParts[5];
} else {
requestEndpoint = requestURIParts[4];
}
log.debug("Request endpoint: {}", requestEndpoint);
isEnabled = endpointConfiguration.isEndpointEnabled(requestEndpoint);
log.debug("Is endpoint enabled: {}", isEnabled);
} else {
isEnabled = endpointConfiguration.isEndpointEnabled(requestURI);
}
boolean isEnabled = endpointConfiguration.isEndpointEnabledForUri(requestURI);
if (!isEnabled) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
return false;
@@ -185,40 +185,39 @@ public class MergeController {
return new String[0];
}
// Adds a table of contents to the merged document using filenames as chapter titles
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
// Create the document outline
// Reads page counts from on-disk source files in read-only mode. A failed read falls back to
// 1 so TOC generation still produces a usable (if slightly misaligned) outline.
private int[] collectPageCounts(File[] sourceFiles) {
int[] counts = new int[sourceFiles.length];
for (int i = 0; i < sourceFiles.length; i++) {
try (PDDocument doc = pdfDocumentFactory.load(sourceFiles[i], true)) {
counts[i] = doc.getNumberOfPages();
} catch (IOException e) {
ExceptionUtils.logException("page count for TOC", e);
counts[i] = 1;
}
}
return counts;
}
// Adds a table of contents to the merged document using filenames as chapter titles.
// Page counts are passed in so we don't re-open every source PDF just to count pages.
private void addTableOfContents(
PDDocument mergedDocument, MultipartFile[] files, int[] pageCounts) {
PDDocumentOutline outline = new PDDocumentOutline();
mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
int pageIndex = 0; // Current page index in the merged document
// Iterate through the original files
for (MultipartFile file : files) {
// Get the filename without extension to use as bookmark title
String filename = file.getOriginalFilename();
String title = GeneralUtils.removeExtension(filename);
// Create an outline item for this file
int pageIndex = 0;
for (int i = 0; i < files.length; i++) {
String title = GeneralUtils.removeExtension(files[i].getOriginalFilename());
PDOutlineItem item = new PDOutlineItem();
item.setTitle(title);
// Set the destination to the first page of this file in the merged document
if (pageIndex < mergedDocument.getNumberOfPages()) {
PDPage page = mergedDocument.getPage(pageIndex);
item.setDestination(page);
item.setDestination(mergedDocument.getPage(pageIndex));
}
// Add the item to the outline
outline.addLast(item);
// Increment page index for the next file
try (PDDocument doc = pdfDocumentFactory.load(file)) {
pageIndex += doc.getNumberOfPages();
} catch (IOException e) {
ExceptionUtils.logException("document loading for TOC generation", e);
pageIndex++; // Increment by at least one if we can't determine page count
}
int count = pageCounts[i];
pageIndex += count > 0 ? count : 1;
}
}
@@ -288,6 +287,7 @@ public class MergeController {
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
boolean generateToc = request.isGenerateToc();
boolean preserveAccessibility = request.isPreserveAccessibility();
MultipartFile[] files = request.getFileInput();
if (files == null) {
@@ -306,31 +306,34 @@ public class MergeController {
request.getSortType())); // Sort files based on requested sort type
}
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
// Hold the merge output until response streaming completes. We only close it on failure;
// on success ownership transfers to the response (deleted when Spring closes the stream).
TempFile mergeOutput = new TempFile(tempFileManager, ".pdf");
boolean keepMergeOutput = false;
try {
PDFMergerUtility mergerUtility = new PDFMergerUtility();
// OPTIMIZE_RESOURCES_MODE closes source documents progressively and skips
// structure-tree copying — drops PDF/UA tags but uses much less heap.
// PDFBOX_LEGACY_MODE preserves tags at the cost of higher peak heap.
mergerUtility.setDocumentMergeMode(
preserveAccessibility
? PDFMergerUtility.DocumentMergeMode.PDFBOX_LEGACY_MODE
: PDFMergerUtility.DocumentMergeMode.OPTIMIZE_RESOURCES_MODE);
long totalSize = 0;
List<Integer> invalidIndexes = new ArrayList<>();
File[] sourceFiles = new File[files.length];
for (int index = 0; index < files.length; index++) {
MultipartFile multipartFile = files[index];
totalSize += multipartFile.getSize();
File tempFile =
tempFileManager.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
// Pre-validate each PDF so we can report which one(s) are broken
// Use the original MultipartFile to avoid deleting the tempFile during validation
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
// OK
} catch (IOException e) {
ExceptionUtils.logException("PDF pre-validate", e);
invalidIndexes.add(index);
}
mergerUtility.addSource(tempFile); // Add source file to the merger utility
File tempFile = tempFileManager.convertMultipartFileToFile(multipartFile);
filesToDelete.add(tempFile);
sourceFiles[index] = tempFile;
mergerUtility.addSource(tempFile);
}
// Pre-validation is intentionally omitted: PDFMergerUtility surfaces corrupted inputs
// via PdfErrorUtils.isCorruptedPdfError below, and a separate validation pass would
// double-allocate PDDocument graphs and re-spool every source >10 MB to disk.
mergerUtility.setDestinationFileName(mt.getFile().getAbsolutePath());
mergerUtility.setDestinationFileName(mergeOutput.getFile().getAbsolutePath());
try {
mergerUtility.mergeDocuments(
@@ -339,39 +342,59 @@ public class MergeController {
} catch (IOException e) {
ExceptionUtils.logException("PDF merge", e);
if (PdfErrorUtils.isCorruptedPdfError(e)) {
// Identify which source file(s) are corrupt for operator diagnostics.
// Only runs on the failure path so the happy path stays fast.
List<String> badFiles = new ArrayList<>();
for (int i = 0; i < sourceFiles.length; i++) {
try (PDDocument ignored =
pdfDocumentFactory.load(sourceFiles[i], true)) {
// OK
} catch (IOException corruptInput) {
String name = files[i].getOriginalFilename();
badFiles.add(name != null ? name : ("index " + i));
}
}
if (!badFiles.isEmpty()) {
log.warn("Corrupted PDFs in merge input: {}", badFiles);
}
throw ExceptionUtils.createMultiplePdfCorruptedException(e);
}
throw e;
}
// Load the merged PDF document and operate on it inside try-with-resources
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
// Remove signatures if removeCertSign is true
if (removeCertSign) {
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(PDSignatureField.class::isInstance)
.toList();
// Common case: caller wants neither cert-sign removal nor a TOC. Skip the
// load-and-resave round-trip entirely — the merged file on disk is the response.
// For 4000+ page jobs this avoids materialising the merged PDDocument in heap.
if (!removeCertSign && !generateToc) {
outputTempFile = mergeOutput;
keepMergeOutput = true;
} else {
// Page counts are needed only when generating a TOC. Read them from the already-
// on-disk source files in read-only mode (no metadata mutation, no extra spool).
int[] pageCounts = generateToc ? collectPageCounts(sourceFiles) : null;
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(
fieldsToRemove,
false); // Flatten the fields, effectively removing them
outputTempFile = new TempFile(tempFileManager, ".pdf");
try (PDDocument mergedDocument = pdfDocumentFactory.load(mergeOutput.getFile())) {
// Resource cache off for the modify pass — we never call getImage() here,
// and disabling it prevents PDFBox from caching XObjects when the page tree
// is iterated during outline insertion or AcroForm flattening.
mergedDocument.setResourceCache(null);
if (removeCertSign) {
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(PDSignatureField.class::isInstance)
.toList();
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(fieldsToRemove, false);
}
}
}
}
// Add table of contents if generateToc is true
if (generateToc && files.length > 0) {
addTableOfContents(mergedDocument, files);
}
// Save the modified document to a temporary file
outputTempFile = new TempFile(tempFileManager, ".pdf");
try {
if (generateToc && files.length > 0) {
addTableOfContents(mergedDocument, files, pageCounts);
}
mergedDocument.save(outputTempFile.getFile());
} catch (Exception e) {
outputTempFile.close();
@@ -380,7 +403,7 @@ public class MergeController {
}
}
} catch (Exception ex) {
if (outputTempFile != null) {
if (outputTempFile != null && outputTempFile != mergeOutput) {
outputTempFile.close();
}
if (ex instanceof IOException && PdfErrorUtils.isCorruptedPdfError((IOException) ex)) {
@@ -390,6 +413,9 @@ public class MergeController {
}
throw ex;
} finally {
if (!keepMergeOutput && outputTempFile != mergeOutput) {
mergeOutput.close();
}
for (File file : filesToDelete) {
tempFileManager.deleteTempFile(file); // Delete temporary files
}
@@ -0,0 +1,174 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.model.api.comments.AnnotationLocation;
import stirling.software.common.model.api.comments.StickyNoteSpec;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfAnnotationService;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfTextLocator;
import stirling.software.common.util.PdfTextLocator.MatchedBox;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* Deterministic Java tool: add sticky-note comments to a PDF at caller-supplied positions.
* Composable primitive used by AI agents (that generate comment specs) and by any other caller —
* Automate workflows, scripts, unit tests — that has comment positions and text in hand.
*
* <p>Each {@code CommentSpec} element accepts either absolute coordinates ({@code x, y, width,
* height}) or an {@code anchorText} hint. When {@code anchorText} is present, the tool scans the
* target page, finds the first line whose text contains the needle (tolerant match — case and
* punctuation insensitive), and anchors the sticky-note icon at that line's bounding box. Falls
* back to the supplied coordinates when no match is found.
*
* <p>Pairs with {@link PdfAnnotationService} (annotation creation) and {@link PdfTextLocator}
* (anchor resolution).
*/
@Slf4j
@MiscApi
@RequiredArgsConstructor
public class AddCommentsController {
/** Sticky-note icon size in PDF user-space units. Matches the agents' default. */
private static final float ANCHOR_ICON_SIZE = 20f;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final PdfAnnotationService pdfAnnotationService;
private final PdfTextLocator pdfTextLocator;
private final ObjectMapper objectMapper;
@AutoJobPostMapping(value = "/add-comments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@Operation(
summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
description =
"Attaches PDF Text (sticky-note) annotations to the document."
+ " Each CommentSpec can either supply absolute coordinates or an"
+ " `anchorText` hint; when provided, the tool locates the first matching"
+ " line on the target page and anchors the icon there (falling back to"
+ " the coordinates if no match). Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> addComments(@ModelAttribute AddCommentsRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fileInput is required");
}
String commentsJson = request.getComments();
if (commentsJson == null || commentsJson.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "comments JSON is required");
}
List<CommentSpecDto> dtos;
try {
dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
}
try (PDDocument document = pdfDocumentFactory.load(file)) {
List<StickyNoteSpec> specs = resolveSpecs(document, dtos);
pdfAnnotationService.addStickyNotes(document, specs);
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try {
document.save(tempOut.getFile());
} catch (IOException e) {
tempOut.close();
throw e;
}
return WebResponseUtils.pdfFileToWebResponse(
tempOut,
GeneralUtils.generateFilename(file.getOriginalFilename(), "_commented.pdf"));
}
}
/**
* Convert the wire DTOs into {@link StickyNoteSpec}s, resolving any {@code anchorText} hints
* against the PDF. Each spec is resolved independently so a miss falls back locally without
* affecting other specs.
*/
private List<StickyNoteSpec> resolveSpecs(PDDocument document, List<CommentSpecDto> dtos) {
List<StickyNoteSpec> specs = new ArrayList<>(dtos.size());
for (CommentSpecDto dto : dtos) {
specs.add(toSpec(document, dto));
}
return specs;
}
private StickyNoteSpec toSpec(PDDocument document, CommentSpecDto d) {
AnnotationLocation location = resolveLocation(document, d);
return new StickyNoteSpec(location, d.text, d.author, d.subject);
}
private AnnotationLocation resolveLocation(PDDocument document, CommentSpecDto d) {
if (d.anchorText == null || d.anchorText.isBlank()) {
return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
}
Optional<MatchedBox> match = pdfTextLocator.findOnPage(document, d.pageIndex, d.anchorText);
if (match.isEmpty()) {
log.debug(
"add-comments: no match for anchorText {!r} on page {}; using fallback coords",
d.anchorText,
d.pageIndex);
return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
}
MatchedBox box = match.get();
// Anchor the icon at the top-left of the matched line, matching the convention used by
// PdfCommentAgentOrchestrator for its chunk-based placement.
float iconX = box.x();
float iconY = box.y() + box.height() - ANCHOR_ICON_SIZE;
return new AnnotationLocation(
d.pageIndex, iconX, iconY, ANCHOR_ICON_SIZE, ANCHOR_ICON_SIZE);
}
/**
* Wire-format DTO for a single element in the {@code comments} JSON array. Flat record-like
* shape keeps the JSON simple for humans, AI-engine plan parameters, and Automate steps alike.
*
* <p>{@code anchorText} is optional. When present, the server locates the first line on {@code
* pageIndex} containing that text (tolerant match) and places the icon there; the {@code
* x/y/width/height} act as fallback when no match is found.
*/
private static final class CommentSpecDto {
public int pageIndex;
public float x;
public float y;
public float width;
public float height;
public String text;
public String author;
public String subject;
public String anchorText;
}
}
@@ -40,6 +40,16 @@ public class MergePdfsRequest extends MultiplePDFFiles {
defaultValue = "false")
private boolean generateToc = false;
@Schema(
description =
"Flag indicating whether to preserve PDF/UA accessibility tags (structure"
+ " tree) in the merged output. When false (default) the merger runs in"
+ " resource-optimised mode which drops tags but uses significantly less"
+ " heap. Set true when merging tagged PDFs intended for screen readers.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean preserveAccessibility = false;
@Schema(
description =
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
@@ -0,0 +1,33 @@
package stirling.software.SPDF.model.api.misc;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
/**
* Request body for {@code POST /api/v1/misc/add-comments}.
*
* <p>The {@code comments} field is a JSON-encoded array of {@code CommentSpec} objects rather than
* a nested multipart part so the endpoint stays compatible with {@code InternalApiClient}'s flat
* multipart form body (orchestrator plan dispatch). Jackson parses it controller-side.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class AddCommentsRequest extends PDFFile {
@Schema(
description =
"JSON array of comment specs. Each element has: {pageIndex, x, y, width,"
+ " height, text, author?, subject?}. Coordinates are PDF user-space with"
+ " origin at the page's bottom-left.",
example =
"[{\"pageIndex\":0,\"x\":72,\"y\":720,\"width\":20,\"height\":20,"
+ "\"text\":\"Check this paragraph\",\"author\":\"Reviewer\","
+ "\"subject\":\"Unclear wording\"}]",
requiredMode = RequiredMode.REQUIRED)
private String comments;
}
@@ -27,60 +27,19 @@ class EndpointInterceptorTest {
}
@Test
void preHandleAllowsEnabledApiEndpoint() throws Exception {
void preHandleAllowsEnabledEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(true);
when(endpointConfiguration.isEndpointEnabledForUri("/api/v1/general/remove-pages"))
.thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleBlocksDisabledApiEndpoint() throws Exception {
void preHandleBlocksDisabledEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(false);
when(endpointConfiguration.isEndpointEnabledForUri("/api/v1/general/remove-pages"))
.thenReturn(false);
assertFalse(interceptor.preHandle(request, response, new Object()));
verify(response).sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
}
@Test
void preHandleExtractsConvertEndpointCorrectly() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleBlocksDisabledConvertEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(false);
assertFalse(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleUsesFullUriForNonApiPaths() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleBlocksDisabledNonApiPath() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(false);
assertFalse(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleUsesFullUriForShortApiPath() throws Exception {
// URI with /api/v1 but not enough segments (split length <= 4)
when(request.getRequestURI()).thenReturn("/api/v1/general");
when(endpointConfiguration.isEndpointEnabled("/api/v1/general")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleExtractsNonConvertApiEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/security/add-watermark");
when(endpointConfiguration.isEndpointEnabled("add-watermark")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
}
@@ -75,137 +75,57 @@ class MergeControllerTest {
void testAddTableOfContents_WithMultipleFiles_Success() throws Exception {
// Given
MultipartFile[] files = {mockFile1, mockFile2, mockFile3};
int[] pageCounts = {2, 2, 2};
// Mock the merged document setup
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(6);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
when(mockMergedDocument.getPage(2)).thenReturn(mockPage2);
when(mockMergedDocument.getPage(4)).thenReturn(mockPage1);
// Mock individual document loading for page count
PDDocument doc1 = mock(PDDocument.class);
PDDocument doc2 = mock(PDDocument.class);
PDDocument doc3 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(pdfDocumentFactory.load(mockFile2)).thenReturn(doc2);
when(pdfDocumentFactory.load(mockFile3)).thenReturn(doc3);
when(doc1.getNumberOfPages()).thenReturn(2);
when(doc2.getNumberOfPages()).thenReturn(2);
when(doc3.getNumberOfPages()).thenReturn(2);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
invokeAddToc(mockMergedDocument, files, pageCounts);
// Then
ArgumentCaptor<PDDocumentOutline> outlineCaptor =
ArgumentCaptor.forClass(PDDocumentOutline.class);
verify(mockCatalog).setDocumentOutline(outlineCaptor.capture());
assertNotNull(outlineCaptor.getValue());
PDDocumentOutline capturedOutline = outlineCaptor.getValue();
assertNotNull(capturedOutline);
// Verify that documents were loaded for page count
verify(pdfDocumentFactory).load(mockFile1);
verify(pdfDocumentFactory).load(mockFile2);
verify(pdfDocumentFactory).load(mockFile3);
// Verify document closing
verify(doc1).close();
verify(doc2).close();
verify(doc3).close();
// TOC must NOT re-open source PDFs to count pages — that was the OOM hot spot.
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_WithSingleFile_Success() throws Exception {
// Given
MultipartFile[] files = {mockFile1};
int[] pageCounts = {3};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(3);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(3);
invokeAddToc(mockMergedDocument, files, pageCounts);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(pdfDocumentFactory).load(mockFile1);
verify(doc1).close();
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_WithEmptyArray_Success() throws Exception {
// Given
MultipartFile[] files = {};
int[] pageCounts = {};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
invokeAddToc(mockMergedDocument, files, pageCounts);
// Then
verify(mockMergedDocument).getDocumentCatalog();
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_WithIOException_HandlesGracefully() throws Exception {
// Given
MultipartFile[] files = {mockFile1, mockFile2};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(4);
when(mockMergedDocument.getPage(anyInt()))
.thenReturn(mockPage1); // Use anyInt() to avoid stubbing conflicts
// First document loads successfully
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(2);
// Second document throws IOException
when(pdfDocumentFactory.load(mockFile2))
.thenThrow(new IOException("Failed to load document"));
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(
() -> addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files));
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(pdfDocumentFactory).load(mockFile1);
verify(pdfDocumentFactory).load(mockFile2);
verify(doc1).close();
}
@Test
void testAddTableOfContents_FilenameWithoutExtension_UsesFullName() throws Exception {
// Given
MockMultipartFile fileWithoutExtension =
new MockMultipartFile(
"file",
@@ -213,53 +133,40 @@ class MergeControllerTest {
MediaType.APPLICATION_PDF_VALUE,
"PDF content".getBytes());
MultipartFile[] files = {fileWithoutExtension};
int[] pageCounts = {1};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(1);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
PDDocument doc = mock(PDDocument.class);
when(pdfDocumentFactory.load(fileWithoutExtension)).thenReturn(doc);
when(doc.getNumberOfPages()).thenReturn(1);
invokeAddToc(mockMergedDocument, files, pageCounts);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(doc).close();
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_PageIndexExceedsDocumentPages_HandlesGracefully() throws Exception {
// Given
MultipartFile[] files = {mockFile1};
int[] pageCounts = {3};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(0); // No pages in merged document
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(3);
assertDoesNotThrow(() -> invokeAddToc(mockMergedDocument, files, pageCounts));
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(
() -> addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files));
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(mockMergedDocument, never()).getPage(anyInt());
verify(doc1).close();
verifyNoInteractions(pdfDocumentFactory);
}
private void invokeAddToc(PDDocument merged, MultipartFile[] files, int[] pageCounts)
throws Exception {
Method m =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class, int[].class);
m.setAccessible(true);
m.invoke(mergeController, merged, files, pageCounts);
}
@Test
@@ -0,0 +1,286 @@
package stirling.software.SPDF.controller.api.misc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfAnnotationService;
import stirling.software.common.util.PdfTextLocator;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class)
class AddCommentsControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
private PdfAnnotationService pdfAnnotationService;
private PdfTextLocator pdfTextLocator;
private ObjectMapper objectMapper;
private AddCommentsController controller;
@BeforeEach
void setUp() throws Exception {
pdfAnnotationService = new PdfAnnotationService();
pdfTextLocator = new PdfTextLocator();
objectMapper = JsonMapper.builder().build();
controller =
new AddCommentsController(
pdfDocumentFactory,
tempFileManager,
pdfAnnotationService,
pdfTextLocator,
objectMapper);
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File file =
Files.createTempFile(tempDir, "addcomments", ".pdf").toFile();
TempFile tf = org.mockito.Mockito.mock(TempFile.class);
lenient().when(tf.getPath()).thenReturn(file.toPath());
lenient().when(tf.getFile()).thenReturn(file);
return tf;
});
}
@Test
void appliesEachCommentSpecAsStickyNote() throws Exception {
MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
AddCommentsRequest request = new AddCommentsRequest();
request.setFileInput(file);
request.setComments(
"""
[{"pageIndex":0,"x":72,"y":700,"width":20,"height":20,"text":"First","author":"me","subject":"S1"},
{"pageIndex":1,"x":100,"y":650,"width":20,"height":20,"text":"Second"}]
""");
ResponseEntity<Resource> response = controller.addComments(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
byte[] result = drainBody(response);
try (PDDocument reloaded = Loader.loadPDF(result)) {
List<PDAnnotationText> p0 = textAnnotations(reloaded.getPage(0).getAnnotations());
List<PDAnnotationText> p1 = textAnnotations(reloaded.getPage(1).getAnnotations());
assertThat(p0).hasSize(1);
assertThat(p1).hasSize(1);
assertThat(p0.get(0).getContents()).isEqualTo("First");
assertThat(p1.get(0).getContents()).isEqualTo("Second");
}
}
@Test
void anchorsStickyNoteAtLocatedTextWhenAnchorTextMatches() throws Exception {
byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
MockMultipartFile file = pdf("doc.pdf", pdfBytes);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
AddCommentsRequest request = new AddCommentsRequest();
request.setFileInput(file);
// Fallback coords deliberately far from the line so we can tell which path ran.
request.setComments(
"""
[{"pageIndex":0,"x":10,"y":10,"width":5,"height":5,
"text":"Check this total","author":"tester","subject":"S",
"anchorText":"215000"}]
""");
ResponseEntity<Resource> response = controller.addComments(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
List<PDAnnotationText> notes = textAnnotations(reloaded.getPage(0).getAnnotations());
assertThat(notes).hasSize(1);
PDRectangle rect = notes.get(0).getRectangle();
// Line was drawn at user-space y=720 with font size 12; icon should land in that band,
// not at the fallback y=10. Width/height fixed to 20 by the anchor path.
assertThat(rect.getWidth()).isEqualTo(20f);
assertThat(rect.getHeight()).isEqualTo(20f);
assertThat(rect.getLowerLeftY()).isBetween(700f, 740f);
assertThat(rect.getLowerLeftX()).isGreaterThan(50f);
}
}
@Test
void fallsBackToAbsoluteCoordsWhenAnchorTextMisses() throws Exception {
byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
MockMultipartFile file = pdf("doc.pdf", pdfBytes);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
AddCommentsRequest request = new AddCommentsRequest();
request.setFileInput(file);
request.setComments(
"""
[{"pageIndex":0,"x":55,"y":33,"width":7,"height":9,
"text":"No match","anchorText":"not-on-this-page"}]
""");
ResponseEntity<Resource> response = controller.addComments(request);
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
List<PDAnnotationText> notes = textAnnotations(reloaded.getPage(0).getAnnotations());
assertThat(notes).hasSize(1);
PDRectangle rect = notes.get(0).getRectangle();
assertThat(rect.getLowerLeftX()).isEqualTo(55f);
assertThat(rect.getLowerLeftY()).isEqualTo(33f);
assertThat(rect.getWidth()).isEqualTo(7f);
assertThat(rect.getHeight()).isEqualTo(9f);
}
}
@Test
void rejectsBlankCommentsJson() {
AddCommentsRequest request = new AddCommentsRequest();
request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
request.setComments("");
assertThatThrownBy(() -> controller.addComments(request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void rejectsInvalidJson() {
AddCommentsRequest request = new AddCommentsRequest();
request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
request.setComments("not-json");
assertThatThrownBy(() -> controller.addComments(request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void rejectsMissingFileInput() {
AddCommentsRequest request = new AddCommentsRequest();
request.setComments("[]");
assertThatThrownBy(() -> controller.addComments(request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void returnsSuccessForEmptyCommentsArray() throws Exception {
// An empty JSON array is a valid payload — nothing to annotate, but the caller
// should still get back the input PDF without any error so pipelines that
// produce zero comments don't have to special-case the empty result.
MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
AddCommentsRequest request = new AddCommentsRequest();
request.setFileInput(file);
request.setComments("[]");
ResponseEntity<Resource> response = controller.addComments(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
assertThat(textAnnotations(reloaded.getPage(0).getAnnotations())).isEmpty();
assertThat(textAnnotations(reloaded.getPage(1).getAnnotations())).isEmpty();
}
}
// --- helpers ---
private static MockMultipartFile pdf(String name, byte[] bytes) {
return new MockMultipartFile("fileInput", name, MediaType.APPLICATION_PDF_VALUE, bytes);
}
private static byte[] twoPagePdfBytes() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static byte[] singlePagePdfWithLine(String line) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.setNonStrokingColor(Color.BLACK);
cs.beginText();
cs.newLineAtOffset(72f, 720f);
cs.showText(line);
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (java.io.InputStream is = response.getBody().getInputStream()) {
is.transferTo(baos);
}
return baos.toByteArray();
}
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
List<PDAnnotationText> out = new ArrayList<>();
for (PDAnnotation a : annotations) {
if (a instanceof PDAnnotationText t) {
out.add(t);
}
}
return out;
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ spotless {
}
dependencies {
implementation project(':common')
api 'com.google.guava:guava:33.5.0-jre'
api 'com.google.guava:guava:33.6.0-jre'
api 'org.springframework:spring-jdbc'
api 'org.springframework:spring-webmvc'
@@ -34,11 +34,14 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiEngineEndpointResolver;
import stirling.software.proprietary.service.AiWorkflowService;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
@Slf4j
@RestController
@@ -53,6 +56,7 @@ public class AiEngineController {
private final Executor aiStreamExecutor;
private final TaskManager taskManager;
private final JobOwnershipService jobOwnershipService;
private final AiEngineEndpointResolver endpointResolver;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
@@ -68,13 +72,15 @@ public class AiEngineController {
ObjectMapper objectMapper,
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
TaskManager taskManager,
JobOwnershipService jobOwnershipService) {
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
this.objectMapper = objectMapper;
this.aiStreamExecutor = aiStreamExecutor;
this.taskManager = taskManager;
this.jobOwnershipService = jobOwnershipService;
this.endpointResolver = endpointResolver;
}
@GetMapping("/health")
@@ -197,17 +203,36 @@ public class AiEngineController {
"Sends a user message to the PDF edit agent which returns a structured plan"
+ " of tool operations to perform")
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
validateJson(requestBody);
String response = aiEngineClient.post("/api/v1/pdf/edit", requestBody);
JsonNode parsed = parseJson(requestBody);
if (!parsed.isObject()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
}
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
}
private void validateJson(String body) {
private JsonNode parseJson(String body) {
try {
objectMapper.readValue(body, JsonNode.class);
return objectMapper.readValue(body, JsonNode.class);
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Request body is not valid JSON");
}
}
/**
* Always overwrite {@code enabled_endpoints} with the server's view of which endpoints are
* usable. The engine must not trust a client-supplied list - the gate is owned by the Java
* EndpointConfiguration. Values are full URL paths (e.g. {@code /api/v1/misc/compress-pdf})
* that the engine matches against its {@code ToolEndpoint} enum, silently dropping any it
* doesn't recognise (which lets the two sides drift in either direction without breaking).
*/
private String withEnabledEndpoints(ObjectNode body) {
ArrayNode enabled = objectMapper.createArrayNode();
endpointResolver.getEnabledEndpointUrls().forEach(enabled::add);
body.set("enabled_endpoints", enabled);
return body.toString();
}
}
@@ -20,21 +20,30 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.api.ai.Verdict;
import stirling.software.proprietary.service.AiToolInputValidator;
import stirling.software.proprietary.service.MathAuditorOrchestrator;
/**
* Public entry point for the Math Auditor Agent (mathAuditorAgent).
*
* <p>Accepts a PDF from the client, hands it to the {@link MathAuditorOrchestrator} which runs the
* multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict}.
* multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict} as JSON.
*
* <p>This endpoint is a pure specialist — it produces the structured finding and nothing more.
* Presentation (rendering as a chat answer, projecting to PDF comments, etc.) is the responsibility
* of the caller (e.g. the orchestrator's {@code delegate_pdf_question} or {@code
* delegate_pdf_review} meta-agents).
*
* <p>Lives under {@code /api/v1/ai/tools/} so it is dispatchable by the AI orchestrator via the
* standard {@code InternalApiClient} allowlist — no special-case plumbing needed.
*
* <p>The raw PDF never leaves Java. Python receives only structured text and CSV data.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/ai")
@RequestMapping("/api/v1/ai/tools")
@RequiredArgsConstructor
@Tag(name = "AI Engine", description = "AI-powered document analysis endpoints.")
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class MathAuditorAgentController {
private final MathAuditorOrchestrator orchestrator;
@@ -49,11 +58,12 @@ public class MathAuditorAgentController {
The auditor checks:
- Table row and column totals (tally errors)
- Inline arithmetic expressions (e.g. "100 + 200 = 300")
- Cross-page figure consistency (same figure cited differently on different pages)
- Cross-page figure consistency
- Prose claims about percentages, growth rates, and comparisons
The PDF is processed entirely on the Java side; only extracted text and table data
are sent to the AI engine.
Returns a JSON Verdict describing every discrepancy found. How the Verdict is
presented to the end user (chat answer, PDF annotations, etc.) is up to the
caller.
Input: PDF Output: JSON Type: SISO
""")
@@ -68,11 +78,7 @@ public class MathAuditorAgentController {
@RequestParam(value = "tolerance", defaultValue = "0.01")
BigDecimal tolerance) {
String contentType = fileInput.getContentType();
if (contentType == null || !contentType.equals("application/pdf")) {
return ResponseEntity.badRequest().build();
}
AiToolInputValidator.validatePdfUpload(fileInput);
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
return ResponseEntity.badRequest().build();
}
@@ -89,9 +95,6 @@ public class MathAuditorAgentController {
} catch (IOException e) {
log.error("[math-auditor-agent] IO error during audit", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
} catch (Exception e) {
log.error("[math-auditor-agent] unexpected error during audit", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -0,0 +1,120 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
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.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.service.AiToolResponseHeaders;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Public entry point for the PDF Comment Agent (pdfCommentAgent).
*
* <p>Accepts a PDF and a natural-language prompt, delegates to {@link PdfCommentAgentOrchestrator}
* which consults the Python engine and applies {@code PDAnnotationText} sticky-note annotations,
* then streams the annotated PDF back in the response body. This shape matches the rest of the
* Stirling tool endpoints ({@code /api/v1/misc/*}, {@code /api/v1/general/*}) and is what the AI
* workflow orchestrator expects when dispatching this tool as a plan step.
*
* <p>The raw PDF never leaves Java. Python only receives positioned text chunks.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/ai/tools")
@RequiredArgsConstructor
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class PdfCommentAgentController {
private final PdfCommentAgentOrchestrator orchestrator;
private final ObjectMapper objectMapper;
@PostMapping(
value = "/pdf-comment-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_PDF_VALUE)
@Operation(
summary = "Annotate a PDF with AI-generated sticky-note comments",
description =
"""
Runs the PDF Comment Agent against the supplied PDF. Java extracts positioned
text chunks from the document, ships them (with the user's prompt) to the
AI engine, then applies the returned comments as standard PDF Text
annotations (sticky notes) anchored to the relevant chunks.
The annotated PDF is streamed back in the response body with
Content-Type: application/pdf.
Input: PDF + prompt Output: PDF Type: SISO
""")
public ResponseEntity<Resource> pdfCommentAgent(
@Parameter(description = "The PDF document to annotate", required = true)
@RequestParam("fileInput")
MultipartFile fileInput,
@Parameter(
description =
"Natural-language instructions for the AI — what to comment on",
required = true)
@RequestParam("prompt")
String prompt)
throws IOException {
String safeName =
fileInput.getOriginalFilename() != null
? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
: "<unnamed>";
log.info(
"[pdf-comment-agent] request file={} promptLen={}",
safeName,
prompt == null ? 0 : prompt.length());
// ResponseStatusException (validation errors) propagates to Spring's default handler;
// IOException is re-thrown to produce a 500. Other RuntimeExceptions likewise propagate.
AnnotatedPdf annotated = orchestrator.applyComments(fileInput, prompt);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", annotated.fileName());
headers.setContentLength(annotated.bytes().length);
headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated));
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(annotated.bytes()));
}
/**
* Build the metadata JSON surfaced in {@link AiToolResponseHeaders#TOOL_REPORT} alongside the
* annotated PDF. Kept small (fits comfortably in a header): counts and the agent's rationale so
* a chat UI can show "Added 3 comments: <rationale>" alongside the downloaded file.
*/
private String buildReportHeader(AnnotatedPdf annotated) {
ObjectNode node = objectMapper.createObjectNode();
node.put("annotationsApplied", annotated.annotationsApplied());
node.put("instructionsReceived", annotated.instructionsReceived());
if (annotated.rationale() != null) {
node.put("rationale", annotated.rationale());
}
try {
return objectMapper.writeValueAsString(node);
} catch (Exception e) {
log.warn("Failed to serialise pdf-comment-agent report header: {}", e.getMessage());
return "{\"annotationsApplied\":" + annotated.annotationsApplied() + "}";
}
}
}
@@ -0,0 +1,27 @@
package stirling.software.proprietary.model.api.ai;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* A file supplied to the AI engine, identified by a stable opaque id plus a display name.
*
* <p>Values MUST match {@code AiFile} in {@code engine/src/stirling/contracts/common.py}.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "File reference sent to the AI engine")
public class AiFile {
@Schema(
description =
"Opaque, stable identifier. Owned by Java; used as the RAG collection key.")
private String id;
@Schema(description = "Original filename, used by agents in user-facing prompts and responses.")
private String name;
}
@@ -0,0 +1,24 @@
package stirling.software.proprietary.model.api.ai;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Body for {@code POST /api/v1/rag/documents} on the AI engine. Sent by Java when the engine
* reports {@code need_ingest} and the requested document's extracted content must be stored before
* the workflow can continue.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AiRagIngestRequest {
private String documentId;
private String source;
private List<AiRagPageText> pageText;
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.model.api.ai;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/** A single page of extracted text for RAG ingest requests. */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AiRagPageText {
private int pageNumber;
private String text;
}
@@ -11,8 +11,8 @@ import lombok.Data;
@Schema(description = "Per-file content extraction request from the AI engine")
public class AiWorkflowFileRequest {
@Schema(description = "Original filename of the requested file", example = "contract.pdf")
private String fileName;
@Schema(description = "The file the engine wants content extracted for")
private AiFile file;
@Schema(description = "Specific 1-based page numbers to extract from this file")
private List<Integer> pageNumbers = new ArrayList<>();
@@ -12,6 +12,7 @@ public enum AiWorkflowOutcome {
ANSWER("answer"),
NOT_FOUND("not_found"),
NEED_CONTENT("need_content"),
NEED_INGEST("need_ingest"),
PLAN("plan"),
NEED_CLARIFICATION("need_clarification"),
CANNOT_DO("cannot_do"),
@@ -8,6 +8,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import tools.jackson.databind.JsonNode;
@Data
@Schema(description = "Structured AI workflow result")
public class AiWorkflowResponse {
@@ -71,6 +73,12 @@ public class AiWorkflowResponse {
@Schema(description = "Per-file text extraction requests from the AI engine")
private List<AiWorkflowFileRequest> files = new ArrayList<>();
@Schema(
description =
"Files the AI engine requires to be ingested into RAG before it can continue"
+ " the workflow. Populated on need_ingest outcomes.")
private List<AiFile> filesToIngest = new ArrayList<>();
@Schema(description = "Maximum number of pages the AI engine wants text extracted from")
private Integer maxPages;
@@ -79,4 +87,12 @@ public class AiWorkflowResponse {
@Schema(description = "AI engine capability to resume with on the next turn")
private String resumeWith;
@Schema(
description =
"Optional structured report from the tool (e.g. math-auditor Verdict, PDF"
+ " comment-agent summary). Tools surface this either via a JSON response"
+ " body or via the X-Stirling-Tool-Report header. May be null for tools"
+ " that produce only a file.")
private JsonNode report;
}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.model.api.ai.comments;
import java.util.List;
/**
* Request body sent from Java to the Python PDF Comment Agent at {@code POST
* /api/v1/ai/pdf-comment-agent/generate}.
*
* @param sessionId Random UUID that uniquely identifies this generate call.
* @param userMessage The user's natural-language prompt (e.g. "flag any ambiguous dates").
* @param chunks Positioned text chunks extracted from the PDF that the model may comment on.
*/
public record PdfCommentEngineRequest(
String sessionId, String userMessage, List<TextChunk> chunks) {}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.model.api.ai.comments;
import java.util.List;
/**
* Response body returned by the Python PDF Comment Agent at {@code POST
* /api/v1/ai/pdf-comment-agent/generate}.
*
* @param sessionId Echoes the session id from the request.
* @param comments The comments the agent wants to place on the document.
* @param rationale Short free-text explanation of the agent's choices.
*/
public record PdfCommentEngineResponse(
String sessionId, List<PdfCommentInstruction> comments, String rationale) {}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.model.api.ai.comments;
/**
* A single comment instruction returned by the Python PDF Comment Agent.
*
* @param chunkId The {@link TextChunk#id()} the comment should anchor to.
* @param commentText The comment body (required, non-null).
* @param author Optional author/title for the popup. May be {@code null}.
* @param subject Optional subject line for the popup. May be {@code null}.
*/
public record PdfCommentInstruction(
String chunkId, String commentText, String author, String subject) {}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.model.api.ai.comments;
/**
* One positioned text chunk extracted from a PDF, sent to the Python PDF Comment Agent so it can
* pick which chunks to annotate.
*
* <p>The bounding box is in PDF user-space coordinates (origin at the page's bottom-left).
*
* @param id Stable chunk id in the form {@code "p{pageIdx}-c{chunkIdx}"} (both 0-indexed).
* @param page 0-indexed page number.
* @param x Bottom-left x coordinate of the chunk bbox (PDF user-space).
* @param y Bottom-left y coordinate of the chunk bbox (PDF user-space).
* @param width Width of the chunk bbox.
* @param height Height of the chunk bbox.
* @param text The plain-text content of the chunk (truncated to a sane length).
*/
public record TextChunk(
String id, int page, float x, float y, float width, float height, String text) {}
@@ -5,8 +5,10 @@ import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@@ -22,32 +24,55 @@ public class AiEngineClient {
private final ApplicationProperties applicationProperties;
private final HttpClient httpClient;
@Autowired
public AiEngineClient(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
this.httpClient =
this(
applicationProperties,
HttpClient.newBuilder()
.connectTimeout(
Duration.ofSeconds(
applicationProperties.getAiEngine().getTimeoutSeconds()))
.build();
.build());
}
/** Package-private constructor that accepts an HttpClient directly; intended for tests. */
AiEngineClient(ApplicationProperties applicationProperties, HttpClient httpClient) {
this.applicationProperties = applicationProperties;
this.httpClient = httpClient;
}
public String post(String path, String jsonBody) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
return postWithTimeout(path, jsonBody, Duration.ofSeconds(config.getTimeoutSeconds()));
}
/**
* POST with an explicit per-call timeout, for heavy operations (e.g. RAG ingestion of a large
* document) that legitimately take longer than the default timeout.
*/
public String postLongRunning(String path, String jsonBody) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
return postWithTimeout(
path, jsonBody, Duration.ofSeconds(config.getLongRunningTimeoutSeconds()));
}
private String postWithTimeout(String path, String jsonBody, Duration timeout)
throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled");
}
String url = config.getUrl().stripTrailing() + path;
log.debug("Proxying AI engine request to {}", url);
log.debug("Proxying AI engine request to {} (timeout {}s)", url, timeout.toSeconds());
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
@@ -86,6 +111,14 @@ public class AiEngineClient {
private HttpResponse<String> sendRequest(HttpRequest request) throws IOException {
try {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch (HttpTimeoutException e) {
throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "AI engine timed out", e);
} catch (IOException e) {
// Connection refused, DNS failure, socket reset, etc. — surface as
// SERVICE_UNAVAILABLE so every caller of this client sees a structured
// status rather than a raw 500 from an unhandled IOException.
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI engine unreachable: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(
@@ -0,0 +1,87 @@
package stirling.software.proprietary.service;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
/**
* Discovers every {@code /api/v1/...} request mapping in the application and exposes the subset
* that {@link EndpointConfiguration} reports as currently enabled. The AI engine receives this list
* as-is and silently drops anything it doesn't recognise, so we don't try to predict what the
* engine considers a tool - we just emit what's enabled here.
*/
@Slf4j
@Service
public class AiEngineEndpointResolver {
private static final String API_PREFIX = "/api/v1/";
private final ApplicationContext applicationContext;
private final EndpointConfiguration endpointConfiguration;
// Written once on the Spring startup thread during ContextRefreshedEvent, read on HTTP
// request threads. Spring's lifecycle establishes happens-before (the servlet container
// and its worker threads are started after refresh completes), so no volatile is needed.
private Set<String> apiUrls = Set.of();
public AiEngineEndpointResolver(
ApplicationContext applicationContext, EndpointConfiguration endpointConfiguration) {
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
}
@EventListener(ContextRefreshedEvent.class)
public void discoverApiUrls() {
Set<String> discovered = new TreeSet<>();
for (RequestMappingHandlerMapping mapping :
applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) {
for (RequestMappingInfo info : mapping.getHandlerMethods().keySet()) {
for (String pattern : extractPatterns(info)) {
if (pattern.startsWith(API_PREFIX)) {
discovered.add(pattern);
}
}
}
}
apiUrls = Set.copyOf(discovered);
log.debug("Discovered {} /api/v1/ endpoint URLs for AI engine filtering", apiUrls.size());
}
public List<String> getEnabledEndpointUrls() {
return apiUrls.stream()
.filter(endpointConfiguration::isEndpointEnabledForUri)
.sorted()
.toList();
}
private static Set<String> extractPatterns(RequestMappingInfo info) {
try {
Method getDirectPaths = info.getClass().getMethod("getDirectPaths");
Object result = getDirectPaths.invoke(info);
if (result instanceof Set<?> set) {
Set<String> patterns = new HashSet<>();
for (Object value : set) {
if (value instanceof String s) {
patterns.add(s);
}
}
return patterns;
}
} catch (Exception e) {
log.trace("getDirectPaths unavailable on RequestMappingInfo", e);
}
return Set.of();
}
}
@@ -0,0 +1,48 @@
package stirling.software.proprietary.service;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
/**
* Shared input-validation for AI-backed tool endpoints.
*
* <p>Spring's {@code spring.servlet.multipart.max-file-size} is tuned for the regular PDF tools (2
* GB) — far too permissive for AI tools where upload size translates directly into token budget,
* memory, and engine cost. Every AI tool should call {@link #validatePdfUpload} on its input before
* doing any work.
*/
public final class AiToolInputValidator {
/**
* Upper bound on PDF size accepted by any AI tool. Chosen so that a realistic document fits
* (contracts, research papers, books) while capping pathological uploads that would blow the
* engine's token budget or memory.
*/
public static final long MAX_INPUT_FILE_BYTES = 50L * 1024 * 1024;
private AiToolInputValidator() {}
/**
* Validate a PDF uploaded to an AI tool endpoint. Throws {@link ResponseStatusException} with
* an appropriate HTTP status on any failure.
*/
public static void validatePdfUpload(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fileInput is required");
}
String contentType = file.getContentType();
if (contentType == null || !contentType.equals(MediaType.APPLICATION_PDF_VALUE)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Only application/pdf uploads are supported");
}
if (file.getSize() > MAX_INPUT_FILE_BYTES) {
throw new ResponseStatusException(
HttpStatus.PAYLOAD_TOO_LARGE,
"PDF exceeds maximum size of "
+ (MAX_INPUT_FILE_BYTES / (1024 * 1024))
+ " MB for AI tools");
}
}
}
@@ -0,0 +1,21 @@
package stirling.software.proprietary.service;
/**
* Custom response headers the AI tools use when returning a file body with structured metadata.
*
* <p>Kept in one place because the value is referenced both server-side (tools that produce the
* header, orchestrator code that consumes it) and client-side (HTTP response handling in the
* frontend). Changing the string requires updating every reader, so centralising avoids the "must
* stay in sync" coupling.
*/
public final class AiToolResponseHeaders {
/**
* Header tools set to surface a structured metadata report alongside a file body. Value is a
* JSON object whose shape depends on the tool (e.g. {@code annotationsApplied}, {@code
* rationale} for pdf-comment-agent). Absent when the tool has no metadata to report.
*/
public static final String TOOL_REPORT = "X-Stirling-Tool-Report";
private AiToolResponseHeaders() {}
}
@@ -11,6 +11,7 @@ import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -35,6 +36,9 @@ import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.ZipExtractionUtils;
import stirling.software.proprietary.model.api.ai.AiConversationMessage;
import stirling.software.proprietary.model.api.ai.AiFile;
import stirling.software.proprietary.model.api.ai.AiRagIngestRequest;
import stirling.software.proprietary.model.api.ai.AiRagPageText;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
@@ -47,6 +51,8 @@ import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@@ -54,6 +60,8 @@ import tools.jackson.databind.ObjectMapper;
@RequiredArgsConstructor
public class AiWorkflowService {
private static final String RAG_DOCUMENTS_ENDPOINT = "/api/v1/rag/documents";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final AiEngineClient aiEngineClient;
private final PdfContentExtractor pdfContentExtractor;
@@ -62,6 +70,8 @@ public class AiWorkflowService {
private final FileStorage fileStorage;
private final ToolMetadataService toolMetadataService;
private final TempFileManager tempFileManager;
private final FileIdStrategy fileIdStrategy;
private final AiEngineEndpointResolver endpointResolver;
@FunctionalInterface
public interface ProgressListener {
@@ -76,6 +86,16 @@ public class AiWorkflowService {
record Terminal(AiWorkflowResponse response) implements WorkflowState {}
}
/**
* Internal value-class for tool responses. {@code files} holds any result files (typically one;
* multiple for ZIP-response tools). {@code report} holds an optional structured metadata
* payload the tool chose to surface alongside (or instead of) a file.
*
* <p>Tools populate the report either by returning a JSON body (whole body → report) or by
* adding the {@link AiToolResponseHeaders#TOOL_REPORT} header alongside a file body.
*/
private record ToolResult(List<Resource> files, JsonNode report) {}
public AiWorkflowResponse orchestrate(AiWorkflowRequest request) throws IOException {
return orchestrate(request, NOOP_LISTENER);
}
@@ -84,42 +104,53 @@ public class AiWorkflowService {
throws IOException {
validateRequest(request);
Map<String, MultipartFile> filesByName = new LinkedHashMap<>();
// Key by opaque file id, not filename. Filenames aren't guaranteed unique across an
// upload (users can rotate the same 'scan.pdf' twice), and the engine identifies files
// by id in every response shape that asks Java to look a file up again.
Map<String, MultipartFile> filesById = new LinkedHashMap<>();
List<AiFile> files = new ArrayList<>();
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
filesByName.put(
fileInput.getFileInput().getOriginalFilename(), fileInput.getFileInput());
MultipartFile multipartFile = fileInput.getFileInput();
AiFile aiFile =
new AiFile(
fileIdStrategy.idFor(multipartFile),
multipartFile.getOriginalFilename());
filesById.put(aiFile.getId(), multipartFile);
files.add(aiFile);
}
WorkflowTurnRequest initialRequest = new WorkflowTurnRequest();
initialRequest.setUserMessage(request.getUserMessage().trim());
initialRequest.setFileNames(new ArrayList<>(filesByName.keySet()));
initialRequest.setFiles(files);
initialRequest.setConversationHistory(
request.getConversationHistory() == null
? new ArrayList<>()
: new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
WorkflowState state = new WorkflowState.Pending(initialRequest);
while (state instanceof WorkflowState.Pending pending) {
state = advance(pending.request(), filesByName, listener);
state = advance(pending.request(), filesById, listener);
}
return ((WorkflowState.Terminal) state).response();
}
private WorkflowState advance(
WorkflowTurnRequest request,
Map<String, MultipartFile> filesByName,
Map<String, MultipartFile> filesById,
ProgressListener listener)
throws IOException {
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.CALLING_ENGINE));
AiWorkflowResponse response = invokeOrchestrator(request);
return switch (response.getOutcome()) {
case NEED_CONTENT -> onNeedContent(response, filesByName, request, listener);
case TOOL_CALL -> onToolCall(response, filesByName, listener);
case PLAN -> onPlan(response, filesByName, listener);
case ANSWER,
NOT_FOUND,
case NEED_CONTENT -> onNeedContent(response, filesById, request, listener);
case NEED_INGEST -> onNeedIngest(response, filesById, request, listener);
case TOOL_CALL -> onToolCall(response, filesById, listener);
case PLAN -> onPlan(response, filesById, request, listener);
case ANSWER -> onAnswer(response, filesById, request, listener);
case NOT_FOUND,
NEED_CLARIFICATION,
CANNOT_DO,
DRAFT,
@@ -132,7 +163,7 @@ public class AiWorkflowService {
private WorkflowState onNeedContent(
AiWorkflowResponse response,
Map<String, MultipartFile> filesByName,
Map<String, MultipartFile> filesById,
WorkflowTurnRequest request,
ProgressListener listener)
throws IOException {
@@ -143,43 +174,42 @@ public class AiWorkflowService {
List<AiWorkflowFileRequest> requestedFiles = response.getFiles();
// Validate requested file names before loading anything
// Validate requested file ids before loading anything
if (requestedFiles != null && !requestedFiles.isEmpty()) {
for (AiWorkflowFileRequest fileReq : requestedFiles) {
if (!filesByName.containsKey(fileReq.getFileName())) {
AiFile file = fileReq.getFile();
if (file == null || !filesById.containsKey(file.getId())) {
String display = file == null ? "<missing file>" : file.getName();
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested unknown file: " + fileReq.getFileName()));
cannotContinue("AI engine requested unknown file: " + display));
}
}
}
List<String> fileNamesToLoad =
List<AiFile> filesToLoad =
(requestedFiles == null || requestedFiles.isEmpty())
? new ArrayList<>(filesByName.keySet())
: requestedFiles.stream().map(AiWorkflowFileRequest::getFileName).toList();
? new ArrayList<>(request.getFiles())
: requestedFiles.stream().map(AiWorkflowFileRequest::getFile).toList();
Map<String, AiWorkflowFileRequest> requestedByName =
Map<String, AiWorkflowFileRequest> requestedById =
requestedFiles == null || requestedFiles.isEmpty()
? Map.of()
: requestedFiles.stream()
.collect(
Collectors.toMap(
AiWorkflowFileRequest::getFileName, r -> r));
.collect(Collectors.toMap(r -> r.getFile().getId(), r -> r));
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.EXTRACTING_CONTENT));
List<LoadedFile> loadedFiles = new ArrayList<>();
try {
for (String fileName : fileNamesToLoad) {
PDDocument doc = pdfDocumentFactory.load(filesByName.get(fileName), true);
loadedFiles.add(new LoadedFile(fileName, doc));
for (AiFile file : filesToLoad) {
PDDocument doc = pdfDocumentFactory.load(filesById.get(file.getId()), true);
loadedFiles.add(new LoadedFile(file.getId(), file.getName(), doc));
}
List<PdfContentResult> contentResults =
pdfContentExtractor.extractContent(
loadedFiles,
requestedByName,
requestedById,
response.getMaxPages(),
response.getMaxCharacters());
@@ -187,10 +217,11 @@ public class AiWorkflowService {
WorkflowTurnRequest nextRequest = new WorkflowTurnRequest();
nextRequest.setUserMessage(request.getUserMessage());
nextRequest.setFileNames(request.getFileNames());
nextRequest.setFiles(request.getFiles());
nextRequest.setConversationHistory(request.getConversationHistory());
nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults));
nextRequest.setResumeWith(response.getResumeWith());
nextRequest.setEnabledEndpoints(request.getEnabledEndpoints());
return new WorkflowState.Pending(nextRequest);
} finally {
for (LoadedFile lf : loadedFiles) {
@@ -203,10 +234,74 @@ public class AiWorkflowService {
}
}
private WorkflowState onNeedIngest(
AiWorkflowResponse response,
Map<String, MultipartFile> filesById,
WorkflowTurnRequest request,
ProgressListener listener)
throws IOException {
List<AiFile> filesToIngest = response.getFilesToIngest();
if (filesToIngest == null || filesToIngest.isEmpty()) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine returned need_ingest without listing any files to ingest."));
}
// Guard against a retry loop: if we've already ingested this turn and the engine still
// asks for more, something is wrong on its side.
if (!request.getArtifacts().isEmpty() || request.getResumeWith() != null) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested ingest after the workflow had already been resumed."));
}
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.EXTRACTING_CONTENT));
for (AiFile file : filesToIngest) {
MultipartFile multipartFile = filesById.get(file.getId());
if (multipartFile == null) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested ingest for unknown file: " + file.getName()));
}
ingestFile(file, multipartFile);
}
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.PROCESSING));
WorkflowTurnRequest nextRequest = new WorkflowTurnRequest();
nextRequest.setUserMessage(request.getUserMessage());
nextRequest.setFiles(request.getFiles());
nextRequest.setConversationHistory(request.getConversationHistory());
nextRequest.setResumeWith(response.getResumeWith());
return new WorkflowState.Pending(nextRequest);
}
private void ingestFile(AiFile file, MultipartFile multipartFile) throws IOException {
List<AiRagPageText> pages = new ArrayList<>();
try (PDDocument document = pdfDocumentFactory.load(multipartFile, true)) {
int pageCount = document.getNumberOfPages();
for (int pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
String pageText = pdfContentExtractor.extractPageTextRaw(document, pageNumber);
if (pageText != null && !pageText.isBlank()) {
pages.add(new AiRagPageText(pageNumber, pageText));
}
}
}
AiRagIngestRequest ingestRequest =
new AiRagIngestRequest(file.getId(), file.getName(), pages);
String body = objectMapper.writeValueAsString(ingestRequest);
aiEngineClient.postLongRunning(RAG_DOCUMENTS_ENDPOINT, body);
log.debug(
"Ingested file into RAG: id={}, name={}, pages={}",
file.getId(),
file.getName(),
pages.size());
}
@SuppressWarnings("unchecked")
private WorkflowState onToolCall(
AiWorkflowResponse response,
Map<String, MultipartFile> filesByName,
Map<String, MultipartFile> filesById,
ProgressListener listener) {
String endpointPath = response.getTool();
Map<String, Object> parameters = response.getParameters();
@@ -219,14 +314,15 @@ public class AiWorkflowService {
}
try {
List<Resource> inputFiles = toResources(filesByName);
List<Resource> inputFiles = toResources(filesById);
listener.onProgress(AiWorkflowProgressEvent.executingTool(endpointPath, 1, 1));
List<Resource> results = executeStep(endpointPath, parameters, inputFiles);
ToolResult result = executeStep(endpointPath, parameters, inputFiles);
return new WorkflowState.Terminal(
buildCompletedResponse(
response.getRationale(),
results,
new ArrayList<>(filesByName.keySet())));
result.files(),
inputFileNames(filesById),
result.report()));
} catch (Exception e) {
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
return new WorkflowState.Terminal(
@@ -234,19 +330,46 @@ public class AiWorkflowService {
}
}
@SuppressWarnings("unchecked")
private WorkflowState onPlan(
AiWorkflowResponse response,
Map<String, MultipartFile> filesByName,
Map<String, MultipartFile> filesById,
WorkflowTurnRequest previousRequest,
ProgressListener listener) {
return runPlan(
response.getSteps(),
response.getResumeWith(),
response.getSummary(),
filesById,
previousRequest,
listener);
}
private WorkflowState onAnswer(
AiWorkflowResponse response,
Map<String, MultipartFile> filesById,
WorkflowTurnRequest previousRequest,
ProgressListener listener) {
return new WorkflowState.Terminal(response);
}
@SuppressWarnings("unchecked")
private WorkflowState runPlan(
List<Map<String, Object>> steps,
String resumeWith,
String summary,
Map<String, MultipartFile> filesById,
WorkflowTurnRequest previousRequest,
ProgressListener listener) {
List<Map<String, Object>> steps = response.getSteps();
if (steps == null || steps.isEmpty()) {
return new WorkflowState.Terminal(
cannotContinue("AI engine returned a plan with no steps."));
}
try {
List<Resource> currentFiles = toResources(filesByName);
List<Resource> currentFiles = toResources(filesById);
// Propagate the *last* non-null report — the terminal step defines the output.
JsonNode lastReport = null;
String lastReportTool = null;
for (int i = 0; i < steps.size(); i++) {
Map<String, Object> step = steps.get(i);
@@ -263,14 +386,34 @@ public class AiWorkflowService {
listener.onProgress(
AiWorkflowProgressEvent.executingTool(endpointPath, i + 1, steps.size()));
currentFiles = executeStep(endpointPath, parameters, currentFiles);
ToolResult stepResult = executeStep(endpointPath, parameters, currentFiles);
currentFiles = stepResult.files();
if (stepResult.report() != null) {
lastReport = stepResult.report();
lastReportTool = endpointPath;
}
}
// Multi-turn: if the plan was emitted with resume_with set, the delegate wants
// Java to re-invoke the orchestrator with any captured report as an artifact.
if (resumeWith != null && !resumeWith.isBlank() && lastReport != null) {
WorkflowTurnRequest resumeRequest = new WorkflowTurnRequest();
resumeRequest.setUserMessage(previousRequest.getUserMessage());
resumeRequest.setFiles(previousRequest.getFiles());
resumeRequest.setConversationHistory(previousRequest.getConversationHistory());
resumeRequest.setArtifacts(new ArrayList<>(previousRequest.getArtifacts()));
resumeRequest
.getArtifacts()
.add(
new PdfContentExtractor.ToolReportArtifact(
lastReportTool, lastReport));
resumeRequest.setResumeWith(resumeWith);
return new WorkflowState.Pending(resumeRequest);
}
return new WorkflowState.Terminal(
buildCompletedResponse(
response.getSummary(),
currentFiles,
new ArrayList<>(filesByName.keySet())));
summary, currentFiles, inputFileNames(filesById), lastReport));
} catch (Exception e) {
log.error("Failed to execute plan: {}", e.getMessage(), e);
return new WorkflowState.Terminal(
@@ -278,31 +421,54 @@ public class AiWorkflowService {
}
}
private static List<String> inputFileNames(Map<String, MultipartFile> filesById) {
return filesById.values().stream().map(MultipartFile::getOriginalFilename).toList();
}
/**
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
*
* <p>A structured {@code report} may be returned alongside (or instead of) files — see {@link
* ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first
* non-null report wins.
*/
private List<Resource> executeStep(
private ToolResult executeStep(
String endpointPath, Map<String, Object> parameters, List<Resource> inputFiles)
throws IOException {
List<Resource> results = new ArrayList<>();
List<Resource> files = new ArrayList<>();
JsonNode report = null;
if (toolMetadataService.isMultiInput(endpointPath)) {
results.addAll(callEndpoint(endpointPath, parameters, inputFiles));
ToolResult r = callEndpoint(endpointPath, parameters, inputFiles);
files.addAll(r.files());
report = r.report();
} else {
for (Resource file : inputFiles) {
results.addAll(callEndpoint(endpointPath, parameters, List.of(file)));
ToolResult r = callEndpoint(endpointPath, parameters, List.of(file));
files.addAll(r.files());
if (report == null) {
report = r.report();
}
}
}
return results;
return new ToolResult(files, report);
}
/**
* Call an endpoint and return the response body. Endpoints that are declared as ZIP-returning
* in the API spec (multi-output, or {@code Output:ZIP-*}) are unpacked into their individual
* entries so callers always see a flat list of result files.
* Call an endpoint and return its result files and optional report.
*
* <ul>
* <li>JSON body (Content-Type: application/json) → the entire body is the report, no files
* are returned.
* <li>File body (PDF etc.) → the file is returned; if an {@link
* AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is
* parsed as the report.
* <li>ZIP responses declared by the tool metadata service are unpacked so callers always see
* a flat list of result files.
* </ul>
*/
private List<Resource> callEndpoint(
private ToolResult callEndpoint(
String endpointPath, Map<String, Object> parameters, List<Resource> files)
throws IOException {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
@@ -324,15 +490,48 @@ public class AiWorkflowService {
"Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath);
}
Resource resource = response.getBody();
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
return ZipExtractionUtils.extractZip(resource, tempFileManager);
HttpHeaders headers = response.getHeaders();
MediaType contentType = headers.getContentType();
// JSON-only response — the whole body is the structured report, no result file.
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
try (java.io.InputStream is = resource.getInputStream()) {
JsonNode report = objectMapper.readTree(is);
return new ToolResult(List.of(), report);
}
}
return List.of(resource);
JsonNode report = parseReportHeader(headers, endpointPath);
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
return new ToolResult(ZipExtractionUtils.extractZip(resource, tempFileManager), report);
}
return new ToolResult(List.of(resource), report);
}
private List<Resource> toResources(Map<String, MultipartFile> filesByName) throws IOException {
/**
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
* or return null.
*/
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readTree(raw);
} catch (JacksonException e) {
log.warn(
"Ignoring malformed {} header from {}: {}",
AiToolResponseHeaders.TOOL_REPORT,
endpointPath,
e.getMessage());
return null;
}
}
private List<Resource> toResources(Map<String, MultipartFile> filesById) throws IOException {
List<Resource> resources = new ArrayList<>();
for (MultipartFile file : filesByName.values()) {
for (MultipartFile file : filesById.values()) {
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
@@ -348,7 +547,10 @@ public class AiWorkflowService {
}
private AiWorkflowResponse buildCompletedResponse(
String summary, List<Resource> resultFiles, List<String> inputFileNames)
String summary,
List<Resource> resultFiles,
List<String> inputFileNames,
JsonNode report)
throws IOException {
// Store every output file individually so each gets its own Stirling file ID and the
// frontend can add them as independent variants without going through a zip.
@@ -386,6 +588,7 @@ public class AiWorkflowService {
completed.setOutcome(AiWorkflowOutcome.COMPLETED);
completed.setSummary(summary);
completed.setResultFiles(descriptors);
completed.setReport(report);
// Mirror the first file into the legacy single-file fields so existing clients still work.
if (!descriptors.isEmpty()) {
AiWorkflowResultFile first = descriptors.getFirst();
@@ -420,9 +623,10 @@ public class AiWorkflowService {
@Data
private static class WorkflowTurnRequest {
private String userMessage;
private List<String> fileNames = new ArrayList<>();
private List<AiFile> files = new ArrayList<>();
private List<AiConversationMessage> conversationHistory = new ArrayList<>();
private List<WorkflowArtifact> artifacts = new ArrayList<>();
private String resumeWith;
private List<String> enabledEndpoints = new ArrayList<>();
}
}
@@ -0,0 +1,53 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
/**
* Content-addressable id derived from the SHA-256 hash of the uploaded bytes. Same content always
* hashes to the same id, so re-uploads dedupe naturally in RAG. Suitable for session and SaaS
* deployments; a folder-watch deployment would use a different strategy keyed by path.
*/
@Component
public class ByteHashFileIdStrategy implements FileIdStrategy {
/**
* Hex-char length of the returned id. 16 chars = 64 bits of collision space, which is plenty
* for per-user document sets and keeps RAG collection names short.
*/
private static final int ID_HEX_LENGTH = 16;
private static final int BUFFER_SIZE = 64 * 1024;
@Override
public String idFor(MultipartFile file) throws IOException {
MessageDigest digest = sha256();
try (InputStream in = file.getInputStream()) {
byte[] buffer = new byte[BUFFER_SIZE];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
byte[] hash = digest.digest();
StringBuilder hex = new StringBuilder(ID_HEX_LENGTH);
for (int i = 0; hex.length() < ID_HEX_LENGTH; i++) {
hex.append(String.format("%02x", hash[i]));
}
return hex.toString();
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
// SHA-256 is mandated by the JDK; absent only if the platform is broken.
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import org.springframework.web.multipart.MultipartFile;
/**
* Produces stable identifiers for uploaded files. The identifier is opaque to the AI engine and
* serves as the RAG collection key when content is ingested. Swapping implementations (content
* hash, filesystem path, tenant-scoped id, etc.) is how the system adapts to different deployment
* models without any engine-side change.
*/
public interface FileIdStrategy {
String idFor(MultipartFile file) throws IOException;
}
@@ -0,0 +1,249 @@
package stirling.software.proprietary.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.comments.AnnotationLocation;
import stirling.software.common.model.api.comments.StickyNoteSpec;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfAnnotationService;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineRequest;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineResponse;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentInstruction;
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
import tools.jackson.databind.ObjectMapper;
/**
* Composed AI tool for PDF comment generation.
*
* <p>Runs the full flow:
*
* <ol>
* <li>Validate inputs (PDF, non-empty prompt within length limit).
* <li>Extract positioned text chunks from the PDF.
* <li>POST the chunks + prompt to the Python agent at {@code
* /api/v1/ai/pdf-comment-agent/generate}.
* <li>Resolve each returned chunk-id reference to an absolute {@link StickyNoteSpec}.
* <li>Hand the specs to {@link PdfAnnotationService} for deterministic placement.
* <li>Save the annotated PDF, return the bytes + filename.
* </ol>
*
* <p>Annotation primitives live in {@link PdfAnnotationService} (shared with {@code
* /api/v1/misc/add-comments}). This class owns only the AI-specific bits: chunk extraction and
* engine round-trip.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PdfCommentAgentOrchestrator {
private static final String GENERATE_PATH = "/api/v1/ai/pdf-comment-agent/generate";
private static final int MAX_PROMPT_LEN = 4000;
/** Width/height of the sticky-note icon placed on the page, in PDF user-space units. */
private static final float ANNOTATION_SIZE = 20f;
/** Filename used when the uploaded PDF has no usable original filename. */
private static final String FALLBACK_OUTPUT_NAME = "document-commented.pdf";
/**
* Small value record returned to the controller: the annotated PDF bytes, the suggested
* download filename (used in the {@code Content-Disposition} header), and metadata the
* controller emits in the {@code X-Stirling-Tool-Report} header so callers (frontend,
* orchestrator) can surface a chat-style summary alongside the file.
*/
public record AnnotatedPdf(
byte[] bytes,
String fileName,
int annotationsApplied,
int instructionsReceived,
String rationale) {}
private final AiEngineClient aiEngineClient;
private final PdfTextChunkExtractor pdfTextChunkExtractor;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final PdfAnnotationService pdfAnnotationService;
/**
* Run the full PDF comment generation flow.
*
* @param pdfFile the uploaded PDF
* @param prompt the user's natural-language instructions
* @return the annotated PDF bytes and suggested filename
*/
public AnnotatedPdf applyComments(MultipartFile pdfFile, String prompt) throws IOException {
AiToolInputValidator.validatePdfUpload(pdfFile);
String trimmedPrompt = prompt == null ? "" : prompt.trim();
if (trimmedPrompt.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Prompt is required");
}
if (trimmedPrompt.length() > MAX_PROMPT_LEN) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Prompt exceeds maximum length of " + MAX_PROMPT_LEN + " characters");
}
String sessionId = UUID.randomUUID().toString();
log.info(
"[pdf-comment-agent] session={} file={} promptLen={}",
sessionId,
safeName(pdfFile.getOriginalFilename()),
trimmedPrompt.length());
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
List<TextChunk> chunks = pdfTextChunkExtractor.extract(document);
if (chunks.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "PDF has no extractable text");
}
log.info(
"[pdf-comment-agent] session={} extracted {} chunks across {} pages",
sessionId,
chunks.size(),
document.getNumberOfPages());
PdfCommentEngineResponse engineResponse =
requestComments(sessionId, trimmedPrompt, chunks);
List<PdfCommentInstruction> instructions =
engineResponse.comments() == null ? List.of() : engineResponse.comments();
// Resolve chunk-id-referenced comments to absolute sticky-note specs, then delegate
// placement to the shared service (same primitive /api/v1/misc/add-comments uses).
List<StickyNoteSpec> specs = resolveSpecs(instructions, chunks, sessionId);
int applied = pdfAnnotationService.addStickyNotes(document, specs);
log.info(
"[pdf-comment-agent] session={} placed {}/{} sticky notes",
sessionId,
applied,
instructions.size());
byte[] annotatedBytes;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
document.save(baos);
annotatedBytes = baos.toByteArray();
}
String outputName = buildOutputFileName(pdfFile.getOriginalFilename());
log.info(
"[pdf-comment-agent] session={} done fileName={} bytes={}",
sessionId,
outputName,
annotatedBytes.length);
return new AnnotatedPdf(
annotatedBytes,
outputName,
applied,
instructions.size(),
engineResponse.rationale());
}
}
// -----------------------------------------------------------------------
// Engine round-trip
// -----------------------------------------------------------------------
private PdfCommentEngineResponse requestComments(
String sessionId, String prompt, List<TextChunk> chunks) throws IOException {
PdfCommentEngineRequest engineRequest =
new PdfCommentEngineRequest(sessionId, prompt, chunks);
String requestBody = objectMapper.writeValueAsString(engineRequest);
String responseBody = aiEngineClient.post(GENERATE_PATH, requestBody);
PdfCommentEngineResponse engineResponse =
objectMapper.readValue(responseBody, PdfCommentEngineResponse.class);
List<PdfCommentInstruction> instructions =
engineResponse.comments() == null ? List.of() : engineResponse.comments();
log.info(
"[pdf-comment-agent] session={} engine returned {} comments: {}",
sessionId,
instructions.size(),
engineResponse.rationale());
return engineResponse;
}
// -----------------------------------------------------------------------
// Chunk-id → StickyNoteSpec resolution
// -----------------------------------------------------------------------
/**
* Translate each engine-returned {@link PdfCommentInstruction} (chunk-id-referenced) into an
* absolute-positioned {@link StickyNoteSpec}. Unknown or malformed ids are logged and dropped.
*/
private List<StickyNoteSpec> resolveSpecs(
List<PdfCommentInstruction> instructions, List<TextChunk> chunks, String sessionId) {
if (instructions.isEmpty()) {
return List.of();
}
Map<String, TextChunk> chunksById = new HashMap<>();
for (TextChunk chunk : chunks) {
chunksById.put(chunk.id(), chunk);
}
List<StickyNoteSpec> specs = new ArrayList<>(instructions.size());
for (PdfCommentInstruction inst : instructions) {
if (inst == null || inst.chunkId() == null || inst.commentText() == null) {
log.warn(
"[pdf-comment-agent] session={} skipping malformed instruction: {}",
sessionId,
inst);
continue;
}
TextChunk chunk = chunksById.get(inst.chunkId());
if (chunk == null) {
log.warn(
"[pdf-comment-agent] session={} unknown chunkId={} - skipping",
sessionId,
inst.chunkId());
continue;
}
// Anchor the sticky-note icon at the top-left of the chunk's bbox.
float iconX = chunk.x();
float iconY = chunk.y() + chunk.height() - ANNOTATION_SIZE;
AnnotationLocation loc =
new AnnotationLocation(
chunk.page(), iconX, iconY, ANNOTATION_SIZE, ANNOTATION_SIZE);
specs.add(new StickyNoteSpec(loc, inst.commentText(), inst.author(), inst.subject()));
}
return specs;
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static String buildOutputFileName(String originalFilename) {
String safe = safeName(originalFilename);
if (safe == null || safe.isBlank() || "<unnamed>".equals(safe)) {
return FALLBACK_OUTPUT_NAME;
}
String base = FilenameUtils.getBaseName(safe);
if (base == null || base.isBlank()) {
base = "document";
}
return base + "-commented.pdf";
}
private static String safeName(String originalFilename) {
return originalFilename != null
? originalFilename.replaceAll("[\\r\\n]", "_")
: "<unnamed>";
}
}
@@ -44,7 +44,11 @@ public class PdfContentExtractor {
private static final int TEXT_PRESENCE_THRESHOLD = 20;
record LoadedFile(String fileName, PDDocument document) {}
/**
* A loaded PDF alongside the opaque file id used by the AI engine as its RAG collection key.
* Keyed by id (not name) because filenames aren't unique across an upload.
*/
record LoadedFile(String id, String fileName, PDDocument document) {}
// -----------------------------------------------------------------------
// Low-level extraction methods (usable by any agent)
@@ -126,7 +130,7 @@ public class PdfContentExtractor {
*/
List<PdfContentResult> extractContent(
List<LoadedFile> loadedFiles,
Map<String, AiWorkflowFileRequest> requestedByName,
Map<String, AiWorkflowFileRequest> requestedById,
int maxPages,
int maxCharacters)
throws IOException {
@@ -136,7 +140,7 @@ public class PdfContentExtractor {
for (LoadedFile lf : loadedFiles) {
if (remainingPages <= 0 || remainingCharacters <= 0) break;
AiWorkflowFileRequest fileReq = requestedByName.get(lf.fileName());
AiWorkflowFileRequest fileReq = requestedById.get(lf.id());
List<AiPdfContentType> contentTypes =
fileReq != null && !fileReq.getContentTypes().isEmpty()
? fileReq.getContentTypes()
@@ -210,6 +214,12 @@ public class PdfContentExtractor {
artifact.setFiles(results.stream().map(ExtractedFileText.class::cast).toList());
yield artifact;
}
case TOOL_REPORT ->
// TOOL_REPORT artifacts don't come from PDF content extraction — they're
// built by AiWorkflowService from tool-response metadata. Never reached
// from this code path; presence in the enum is to satisfy the switch.
throw new IllegalArgumentException(
"TOOL_REPORT artifacts are not produced by PdfContentExtractor");
};
}
@@ -320,7 +330,8 @@ public class PdfContentExtractor {
* Values MUST match {@code ArtifactKind} in {@code engine/src/stirling/contracts/common.py}.
*/
enum ArtifactKind {
EXTRACTED_TEXT("extracted_text");
EXTRACTED_TEXT("extracted_text"),
TOOL_REPORT("tool_report");
private final String value;
@@ -364,4 +375,23 @@ public class PdfContentExtractor {
private final ArtifactKind kind = ArtifactKind.EXTRACTED_TEXT;
private List<ExtractedFileText> files = new ArrayList<>();
}
/**
* Carries a structured report produced by a specialist tool back to the orchestrator on a
* resume turn. Shape matches {@code engine/src/stirling/contracts/common.py ToolReportArtifact}
* — {@code sourceTool} must be a valid endpoint path string.
*/
@Data
static final class ToolReportArtifact implements WorkflowArtifact {
private final ArtifactKind kind = ArtifactKind.TOOL_REPORT;
private String sourceTool;
private tools.jackson.databind.JsonNode report;
ToolReportArtifact() {}
ToolReportArtifact(String sourceTool, tools.jackson.databind.JsonNode report) {
this.sourceTool = sourceTool;
this.report = report;
}
}
}
@@ -0,0 +1,176 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.io.Writer;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
/**
* Extracts positioned, line-level text chunks from a PDF so the PDF Comment Agent can decide where
* to anchor annotations. One chunk per text line, with the bounding box converted to PDF user-space
* coordinates (origin = bottom-left).
*/
@Slf4j
@Service
public class PdfTextChunkExtractor {
/** Hard cap on total chunks emitted per document. */
private static final int MAX_CHUNKS_PER_DOC = 2000;
/** Truncate each chunk's text to at most this many characters. */
private static final int MAX_CHUNK_TEXT_LENGTH = 500;
/**
* Extract line-level text chunks with bounding boxes from the given document.
*
* <p>Each chunk's coordinates are in PDF user-space (origin = bottom-left of the page). Chunks
* that are whitespace-only after trimming are skipped.
*
* @param document the open PDF
* @return positioned chunks (never {@code null})
* @throws IOException on PDF parse errors
*/
public List<TextChunk> extract(PDDocument document) throws IOException {
List<TextChunk> chunks = new ArrayList<>();
ChunkStripper stripper = new ChunkStripper(document, chunks);
stripper.setSortByPosition(true);
stripper.getText(document);
return chunks;
}
/**
* PDFTextStripper subclass that emits one {@link TextChunk} per call to {@code writeString}.
* PDFBox invokes {@code writeString} once per visual line when {@link
* PDFTextStripper#setSortByPosition(boolean)} is true, which gives us exactly the granularity
* we want.
*/
private static final class ChunkStripper extends PDFTextStripper {
private final PDDocument document;
private final List<TextChunk> chunks;
private int currentPageIdx = 0; // 0-indexed, tracked via startPage
private int chunkIdxOnPage = 0;
private boolean capWarningLogged = false;
ChunkStripper(PDDocument document, List<TextChunk> chunks) throws IOException {
super();
this.document = document;
this.chunks = chunks;
}
@Override
protected void startPage(org.apache.pdfbox.pdmodel.PDPage page) throws IOException {
super.startPage(page);
// getCurrentPageNo() is 1-based; convert to 0-based.
currentPageIdx = getCurrentPageNo() - 1;
chunkIdxOnPage = 0;
}
@Override
protected void writeString(String text, List<TextPosition> textPositions)
throws IOException {
if (chunks.size() >= MAX_CHUNKS_PER_DOC) {
if (!capWarningLogged) {
log.warn(
"[pdf-comment-agent] chunk cap of {} reached; remaining text will not"
+ " be extracted",
MAX_CHUNKS_PER_DOC);
capWarningLogged = true;
}
return;
}
if (textPositions == null || textPositions.isEmpty()) {
return;
}
String trimmed = text == null ? "" : text.trim();
if (trimmed.isEmpty()) {
return;
}
// Compute the bounding box from the min/max of TextPosition adjusted coordinates.
// getXDirAdj / getYDirAdj / getHeightDir / getWidthDirAdj already account for the
// page's rotation so we can treat them as axis-aligned in the page's display frame.
float minX = Float.POSITIVE_INFINITY;
float maxRight = Float.NEGATIVE_INFINITY;
float minYTopDown = Float.POSITIVE_INFINITY; // smallest y in top-down coords
float maxHeight = 0f;
for (TextPosition pos : textPositions) {
float x = pos.getXDirAdj();
float right = x + pos.getWidthDirAdj();
float yTop = pos.getYDirAdj();
float h = pos.getHeightDir();
if (h <= 0f) {
h = pos.getFontSizeInPt();
}
if (x < minX) minX = x;
if (right > maxRight) maxRight = right;
if (yTop < minYTopDown) minYTopDown = yTop;
if (h > maxHeight) maxHeight = h;
}
if (maxHeight <= 0f) {
// Fallback if everything was zero — small but non-zero so the rect is valid.
maxHeight = 10f;
}
float width = maxRight - minX;
if (width <= 0f) {
return;
}
// Convert y to PDF user-space (origin at bottom-left of the page).
// getYDirAdj reports the top of each glyph, measured from the top of the page.
PDRectangle mediaBox = document.getPage(currentPageIdx).getMediaBox();
float pageHeight = mediaBox.getHeight();
float bottomY = pageHeight - minYTopDown - maxHeight;
String id = "p" + currentPageIdx + "-c" + chunkIdxOnPage;
chunkIdxOnPage++;
String storedText = trimmed;
if (storedText.length() > MAX_CHUNK_TEXT_LENGTH) {
storedText = storedText.substring(0, MAX_CHUNK_TEXT_LENGTH);
}
chunks.add(
new TextChunk(id, currentPageIdx, minX, bottomY, width, maxHeight, storedText));
}
@Override
protected void writeCharacters(TextPosition text) {
// no-op: we only emit chunks at writeString granularity
}
@Override
public String getText(PDDocument doc) throws IOException {
// We don't actually need the concatenated text — just the side-effects. Return early
// to avoid building a (potentially massive) StringBuilder of the whole document.
try (Writer discard =
new Writer() {
@Override
public void write(char[] cbuf, int off, int len) {
// discard
}
@Override
public void flush() {}
@Override
public void close() {}
}) {
writeText(doc, discard);
}
return "";
}
}
}
@@ -0,0 +1,134 @@
package stirling.software.proprietary.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.BeforeEach;
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.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver;
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
import tools.jackson.databind.json.JsonMapper;
/**
* Controller tests for {@link PdfCommentAgentController}. The orchestrator is mocked so the test
* never hits the engine or real filesystem.
*/
@ExtendWith(MockitoExtension.class)
class PdfCommentAgentControllerTest {
@Mock private PdfCommentAgentOrchestrator orchestrator;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
PdfCommentAgentController controller =
new PdfCommentAgentController(orchestrator, JsonMapper.builder().build());
mockMvc =
MockMvcBuilders.standaloneSetup(controller)
// standaloneSetup's defaults don't handle ResponseStatusException; wire up
// both the ResponseStatusException resolver (for orchestrator 400s) and
// DefaultHandlerExceptionResolver (so missing @RequestParam still 400s).
.setHandlerExceptionResolvers(
new ResponseStatusExceptionResolver(),
new DefaultHandlerExceptionResolver())
.build();
}
@Test
void acceptsValidPdfAndReturnsAnnotatedBytes() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput",
"input.pdf",
MediaType.APPLICATION_PDF_VALUE,
"%PDF-1.4\n%%EOF".getBytes());
byte[] annotatedBytes = "%PDF-1.4\n<annotated>\n%%EOF".getBytes();
AnnotatedPdf stub = new AnnotatedPdf(annotatedBytes, "input-commented.pdf", 2, 2, "ok");
when(orchestrator.applyComments(any(MultipartFile.class), eq("flag dates")))
.thenReturn(stub);
mockMvc.perform(
multipart("/api/v1/ai/tools/pdf-comment-agent")
.file(pdfFile)
.param("prompt", "flag dates"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(
header().string(
"Content-Disposition",
org.hamcrest.Matchers.containsString(
"input-commented.pdf")))
.andExpect(content().bytes(annotatedBytes));
verify(orchestrator).applyComments(any(MultipartFile.class), eq("flag dates"));
}
@Test
void propagatesOrchestratorBadRequestForNonPdfUpload() throws Exception {
// The controller delegates validation to the orchestrator; a ResponseStatusException
// thrown by the orchestrator should propagate to Spring as a 400.
MockMultipartFile notPdf =
new MockMultipartFile(
"fileInput", "input.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
when(orchestrator.applyComments(any(MultipartFile.class), eq("whatever")))
.thenThrow(
new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Only application/pdf uploads are supported"));
mockMvc.perform(
multipart("/api/v1/ai/tools/pdf-comment-agent")
.file(notPdf)
.param("prompt", "whatever"))
.andExpect(status().isBadRequest());
verify(orchestrator).applyComments(any(MultipartFile.class), eq("whatever"));
}
@Test
void rejectsMissingFileInput() throws Exception {
mockMvc.perform(multipart("/api/v1/ai/tools/pdf-comment-agent").param("prompt", "test"))
.andExpect(status().is4xxClientError());
verify(orchestrator, never()).applyComments(any(), anyString());
}
@Test
void rejectsMissingPromptParameter() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput",
"input.pdf",
MediaType.APPLICATION_PDF_VALUE,
"%PDF-1.4\n%%EOF".getBytes());
mockMvc.perform(multipart("/api/v1/ai/tools/pdf-comment-agent").file(pdfFile))
.andExpect(status().is4xxClientError());
verify(orchestrator, never()).applyComments(any(), anyString());
}
}
@@ -0,0 +1,87 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.net.ConnectException;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
/**
* Verifies that AiEngineClient surfaces network-layer failures as structured HTTP statuses so every
* AI tool caller sees a consistent, meaningful error rather than a raw 500.
*/
class AiEngineClientTest {
private ApplicationProperties applicationProperties;
private HttpClient httpClient;
private AiEngineClient client;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getAiEngine().setEnabled(true);
applicationProperties.getAiEngine().setUrl("http://localhost:5001");
applicationProperties.getAiEngine().setTimeoutSeconds(5);
httpClient = mock(HttpClient.class);
client = new AiEngineClient(applicationProperties, httpClient);
}
@Test
void postWrapsConnectIOExceptionAsServiceUnavailable() throws Exception {
ConnectException cause = new ConnectException("Connection refused");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
assertSame(cause, ex.getCause(), "Original cause should be preserved for diagnostics");
}
@Test
void postWrapsTimeoutAsGatewayTimeout() throws Exception {
HttpTimeoutException cause = new HttpTimeoutException("request timed out");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertEquals(HttpStatus.GATEWAY_TIMEOUT, ex.getStatusCode());
assertSame(cause, ex.getCause());
}
@Test
void getWrapsGenericIOExceptionAsServiceUnavailable() throws Exception {
IOException cause = new IOException("socket reset");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.get("/x"));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@Test
void postShortCircuitsWhenEngineDisabled() {
applicationProperties.getAiEngine().setEnabled(false);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
}
@@ -0,0 +1,80 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
class AiToolInputValidatorTest {
@Test
void acceptsValidPdfUpload() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "a.pdf", "application/pdf", new byte[] {1, 2});
assertDoesNotThrow(() -> AiToolInputValidator.validatePdfUpload(file));
}
@Test
void rejectsNullFile() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(null));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
void rejectsEmptyFile() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "a.pdf", "application/pdf", new byte[0]);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(file));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
void rejectsNonPdfContentType() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "a.txt", "text/plain", new byte[] {1, 2});
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(file));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
void rejectsMissingContentType() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "a.pdf", null, new byte[] {1, 2});
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(file));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
void rejectsOversizedFile() {
// Mock getSize() to avoid allocating a 50 MB test payload.
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getContentType()).thenReturn("application/pdf");
when(file.getSize()).thenReturn(AiToolInputValidator.MAX_INPUT_FILE_BYTES + 1);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(file));
assertEquals(HttpStatus.PAYLOAD_TOO_LARGE, ex.getStatusCode());
}
}
@@ -3,8 +3,11 @@ package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -21,6 +24,8 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,6 +37,7 @@ import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -72,6 +78,8 @@ class AiWorkflowServiceTest {
@Mock private InternalApiClient internalApiClient;
@Mock private FileStorage fileStorage;
@Mock private ToolMetadataService toolMetadataService;
@Mock private FileIdStrategy fileIdStrategy;
@Mock private AiEngineEndpointResolver endpointResolver;
@TempDir Path tempDir;
@@ -80,13 +88,19 @@ class AiWorkflowServiceTest {
private AiWorkflowService service;
@BeforeEach
void setUp() {
void setUp() throws IOException {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("ai-test-");
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
objectMapper = JsonMapper.builder().build();
// Mock strategy yields the filename as id so each MockMultipartFile in a test gets a
// distinct collection key. Real strategy (ByteHashFileIdStrategy) hashes bytes.
lenient()
.when(fileIdStrategy.idFor(any(MultipartFile.class)))
.thenAnswer(inv -> ((MultipartFile) inv.getArgument(0)).getOriginalFilename());
service =
new AiWorkflowService(
pdfDocumentFactory,
@@ -96,7 +110,10 @@ class AiWorkflowServiceTest {
internalApiClient,
fileStorage,
toolMetadataService,
tempFileManager);
tempFileManager,
fileIdStrategy,
endpointResolver);
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
}
@Test
@@ -246,6 +263,46 @@ class AiWorkflowServiceTest {
verify(internalApiClient, never()).post(anyString(), any());
}
@Test
void needIngestExtractsPageTextAndPostsToRagThenRetries() throws IOException {
MockMultipartFile input = pdf("report.pdf", "bytes");
when(fileIdStrategy.idFor(any())).thenReturn("report-id");
PDDocument document = new PDDocument();
document.addPage(new PDPage());
document.addPage(new PDPage());
when(pdfDocumentFactory.load(any(MultipartFile.class), anyBoolean())).thenReturn(document);
when(pdfContentExtractor.extractPageTextRaw(eq(document), anyInt()))
.thenReturn("page content");
int[] orchestratorCalls = {0};
when(aiEngineClient.post(eq("/api/v1/orchestrator"), anyString()))
.thenAnswer(
inv -> {
orchestratorCalls[0]++;
if (orchestratorCalls[0] == 1) {
return """
{
"outcome":"need_ingest",
"resumeWith":"pdf_question",
"reason":"ingest first",
"filesToIngest":[{"id":"report-id","name":"report.pdf"}],
"contentTypes":["page_text"]
}
""";
}
return """
{"outcome":"answer","answer":"done","evidence":[]}
""";
});
AiWorkflowResponse result = service.orchestrate(requestFor(input, "summarise this"));
assertEquals(AiWorkflowOutcome.ANSWER, result.getOutcome());
verify(aiEngineClient, times(1)).postLongRunning(eq("/api/v1/rag/documents"), anyString());
verify(aiEngineClient, times(2)).post(eq("/api/v1/orchestrator"), anyString());
}
// --- helpers ---
private void stubOrchestrator(String responseJson) throws IOException {
@@ -0,0 +1,255 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
import org.junit.jupiter.api.BeforeEach;
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.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfAnnotationService;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineResponse;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentInstruction;
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Smoke tests for {@link PdfCommentAgentOrchestrator}. Collaborators (engine client, PDF factory,
* chunk extractor) are mocked; the orchestrator is exercised end-to-end on an in-memory PDF so the
* returned bytes can be re-loaded and inspected.
*/
@ExtendWith(MockitoExtension.class)
class PdfCommentAgentOrchestratorTest {
@Mock private AiEngineClient aiEngineClient;
@Mock private PdfTextChunkExtractor pdfTextChunkExtractor;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
private ObjectMapper objectMapper;
private PdfAnnotationService pdfAnnotationService;
private PdfCommentAgentOrchestrator orchestrator;
@BeforeEach
void setUp() {
objectMapper = JsonMapper.builder().build();
// Real (not mocked) — it's a pure primitive; exercising it in the test gives us stronger
// assertions (the annotated PDF actually has the expected sticky notes).
pdfAnnotationService = new PdfAnnotationService();
orchestrator =
new PdfCommentAgentOrchestrator(
aiEngineClient,
pdfTextChunkExtractor,
pdfDocumentFactory,
objectMapper,
pdfAnnotationService);
}
@Test
void happyPathAppliesValidInstructionsOnCorrectPagesAndReturnsBytes() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
byte[] pdfBytes = twoPagePdfBytes();
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk zero");
TextChunk c1 = new TextChunk("p0-c1", 0, 72f, 680f, 100f, 12f, "Chunk one");
TextChunk c2 = new TextChunk("p1-c0", 1, 72f, 700f, 100f, 12f, "Chunk two");
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0, c1, c2));
PdfCommentEngineResponse engineResponse =
new PdfCommentEngineResponse(
"session-1",
List.of(
new PdfCommentInstruction(
"p0-c0", "Comment on page 0", "alice", "Heads up"),
new PdfCommentInstruction(
"p1-c0", "Comment on page 1", null, null)),
"reviewed");
when(aiEngineClient.post(anyString(), anyString()))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "please comment");
assertEquals("doc-commented.pdf", result.fileName());
assertNotNull(result.bytes(), "Returned bytes must not be null");
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
List<PDAnnotationText> page0Texts = textAnnotations(saved.getPage(0).getAnnotations());
List<PDAnnotationText> page1Texts = textAnnotations(saved.getPage(1).getAnnotations());
assertEquals(1, page0Texts.size(), "Exactly one annotation on page 0");
assertEquals(1, page1Texts.size(), "Exactly one annotation on page 1");
assertEquals("Comment on page 0", page0Texts.get(0).getContents());
assertEquals("Comment on page 1", page1Texts.get(0).getContents());
}
}
@Test
void unknownChunkIdsAreSkippedButValidOnesApplied() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
byte[] pdfBytes = twoPagePdfBytes();
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk zero");
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0));
PdfCommentEngineResponse engineResponse =
new PdfCommentEngineResponse(
"session-2",
List.of(
new PdfCommentInstruction("p0-c0", "Valid", null, null),
new PdfCommentInstruction("p999-c999", "Bogus", null, null)),
"mixed");
when(aiEngineClient.post(anyString(), anyString()))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "test");
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
int totalAnnotations = 0;
for (int i = 0; i < saved.getNumberOfPages(); i++) {
totalAnnotations += textAnnotations(saved.getPage(i).getAnnotations()).size();
}
assertEquals(1, totalAnnotations, "Only the valid chunk annotation should be applied");
}
}
@Test
void emptyCommentsListReturnsDocumentWithoutAnnotations() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
byte[] pdfBytes = twoPagePdfBytes();
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk");
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0));
PdfCommentEngineResponse engineResponse =
new PdfCommentEngineResponse("s", List.of(), "no comments worth making");
when(aiEngineClient.post(anyString(), anyString()))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "test");
assertEquals("doc-commented.pdf", result.fileName());
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
for (int i = 0; i < saved.getNumberOfPages(); i++) {
assertTrue(
textAnnotations(saved.getPage(i).getAnnotations()).isEmpty(),
"Page " + i + " should have no text annotations");
}
}
}
@Test
void emptyChunksListThrowsBadRequestAndDoesNotCallEngine() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
byte[] pdfBytes = twoPagePdfBytes();
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of());
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> orchestrator.applyComments(input, "whatever"));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString());
}
@Test
void promptTooLongThrowsBadRequestAndDoesNotCallEngine() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
String tooLong = "x".repeat(4001);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> orchestrator.applyComments(input, tooLong));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString());
}
@Test
void blankPromptThrowsBadRequestAndDoesNotCallEngine() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> orchestrator.applyComments(input, " "));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString());
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private static MockMultipartFile pdf(String filename) {
return new MockMultipartFile(
"fileInput",
filename,
MediaType.APPLICATION_PDF_VALUE,
"%PDF-1.4\n%%EOF".getBytes());
}
private static byte[] twoPagePdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 2; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 700);
cs.showText("Page " + i + " content");
cs.endText();
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
List<PDAnnotationText> out = new ArrayList<>();
for (PDAnnotation a : annotations) {
if (a instanceof PDAnnotationText t) {
out.add(t);
}
}
return out;
}
}
@@ -0,0 +1,157 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
/**
* Unit tests for {@link PdfTextChunkExtractor}. Exercises chunk id format, bounding-box validity,
* multi-page extraction, the empty-PDF path, and the 2000-chunk cap.
*/
class PdfTextChunkExtractorTest {
private static final Pattern CHUNK_ID_PATTERN = Pattern.compile("^p\\d+-c\\d+$");
private final PdfTextChunkExtractor extractor = new PdfTextChunkExtractor();
@Test
void extractsOneChunkPerVisualLineWithValidBoundingBoxes() throws IOException {
byte[] pdf = buildTwoPagePdf("Line A on page one", "Line B on page two");
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TextChunk> chunks = extractor.extract(doc);
assertFalse(chunks.isEmpty(), "Extractor should produce at least one chunk");
for (TextChunk chunk : chunks) {
assertTrue(
CHUNK_ID_PATTERN.matcher(chunk.id()).matches(),
"Chunk id should match p{page}-c{idx}, got: " + chunk.id());
assertTrue(chunk.width() > 0f, "width > 0, chunk=" + chunk);
assertTrue(chunk.height() > 0f, "height > 0, chunk=" + chunk);
assertFalse(chunk.text() == null || chunk.text().isBlank(), "text non-blank");
PDRectangle box = doc.getPage(chunk.page()).getMediaBox();
assertTrue(chunk.x() >= 0f, "x >= 0, chunk=" + chunk);
assertTrue(chunk.y() >= 0f, "y >= 0, chunk=" + chunk);
assertTrue(
chunk.x() + chunk.width() <= box.getWidth() + 0.01f,
"x + width fits within page width, chunk=" + chunk);
assertTrue(
chunk.y() + chunk.height() <= box.getHeight() + 0.01f,
"y + height fits within page height, chunk=" + chunk);
}
assertTrue(
chunks.stream().anyMatch(c -> c.page() == 0 && c.text().contains("Line A")),
"Expected a page-0 chunk containing 'Line A'; chunks=" + chunks);
assertTrue(
chunks.stream().anyMatch(c -> c.page() == 1 && c.text().contains("Line B")),
"Expected a page-1 chunk containing 'Line B'; chunks=" + chunks);
}
}
@Test
void returnsEmptyListForPdfWithNoExtractableText() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
byte[] pdf = baos.toByteArray();
try (PDDocument loaded = Loader.loadPDF(pdf)) {
List<TextChunk> chunks = extractor.extract(loaded);
assertTrue(chunks.isEmpty(), "Expected no chunks, got=" + chunks);
}
}
}
@Test
void enforcesHardCapOf2000Chunks() throws IOException {
byte[] pdf = buildPdfWithManyLines(2500);
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TextChunk> chunks = extractor.extract(doc);
assertEquals(
2000,
chunks.size(),
"Extractor should cap at MAX_CHUNKS_PER_DOC (2000); got=" + chunks.size());
}
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private static byte[] buildTwoPagePdf(String page1Text, String page2Text) throws IOException {
try (PDDocument doc = new PDDocument()) {
addPageWithLine(doc, page1Text);
addPageWithLine(doc, page2Text);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static void addPageWithLine(PDDocument doc, String text) throws IOException {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 700);
cs.showText(text);
cs.endText();
}
}
/**
* Build a PDF with {@code totalLines} short lines of text spread across pages so the extractor
* has to produce one chunk per line.
*/
private static byte[] buildPdfWithManyLines(int totalLines) throws IOException {
int linesPerPage = 50;
try (PDDocument doc = new PDDocument()) {
int remaining = totalLines;
int lineCounter = 0;
while (remaining > 0) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
cs.beginText();
cs.newLineAtOffset(72, 780);
int toWrite = Math.min(linesPerPage, remaining);
for (int i = 0; i < toWrite; i++) {
cs.showText("line-" + lineCounter++);
if (i < toWrite - 1) {
cs.newLineAtOffset(0, -14);
}
}
cs.endText();
}
remaining -= linesPerPage;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
}
+2 -2
View File
@@ -25,7 +25,7 @@ ext {
imageioVersion = "3.13.1"
lombokVersion = "1.18.44"
bouncycastleVersion = "1.83"
springSecuritySamlVersion = "7.0.4"
springSecuritySamlVersion = "7.0.5"
openSamlVersion = "5.2.1"
commonmarkVersion = "0.28.0"
googleJavaFormatVersion = "1.28.0"
@@ -78,7 +78,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.10.0'
version = '2.10.1'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
+1 -1
View File
@@ -364,7 +364,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
# Final runtime image - the actual base image
FROM eclipse-temurin:25-jre-noble@sha256:a051234f864d7ab78bf0188c3c540ac06c711a3b566f00f246be37073cc99dce AS runtime
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS runtime
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
+1 -1
View File
@@ -48,7 +48,7 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble@sha256:a051234f864d7ab78bf0188c3c540ac06c711a3b566f00f246be37073cc99dce AS jar-extract
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
+6 -1
View File
@@ -30,7 +30,12 @@ STIRLING_RAG_PGVECTOR_DSN=
STIRLING_RAG_CHUNK_SIZE=512
STIRLING_RAG_CHUNK_OVERLAP=64
STIRLING_RAG_TOP_K=5
STIRLING_RAG_TOP_K=20
# Per-run cap on ``search_knowledge`` calls. After this many calls the tool is
# removed from the agent's toolset so it must answer from what it already retrieved
# rather than chain more searches.
STIRLING_RAG_MAX_SEARCHES=5
# Upper bounds on PDF page text the engine will request per extraction round.
STIRLING_MAX_PAGES=200
+6 -1
View File
@@ -68,4 +68,9 @@ reportDeprecated = "warning"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
# ``tests`` is on the path so test modules can import shared helpers (e.g.
# ``from conftest import build_app_settings``) without packaging the tests dir.
pythonpath = ["src", "tests"]
# Use importlib import mode so test directories don't need __init__.py files
# and duplicate basenames (e.g. multiple test_routes.py) collect cleanly.
addopts = "--import-mode=importlib"
+27 -3
View File
@@ -73,7 +73,11 @@ class ToolDiscovery:
for path, path_item in sorted(self.spec.get("paths", {}).items()):
if "{" in path or not any(path.startswith(p) for p in self.ALLOWED_PATH_PREFIXES):
continue
properties = self._get_request_properties(path_item)
body_props = self._get_request_properties(path_item) or {}
query_props = self._get_query_parameters(path_item)
# Body properties win on name collision — body is the canonical param source
# for the existing tools; query params are additive.
properties = {**query_props, **body_props}
if not properties:
continue
clean_props = self._filter_properties(properties)
@@ -127,6 +131,26 @@ class ToolDiscovery:
return self._resolve_ref(schema).get("properties")
return None
def _get_query_parameters(self, path_item: dict[str, Any]) -> dict[str, Any]:
"""Extract query parameters as a property map — AI tools expose their main
inputs (e.g. ``prompt``, ``tolerance``) here rather than in the request body,
and a handful of converters use query strings alongside multipart files.
"""
post = path_item.get("post") or {}
props: dict[str, Any] = {}
for param in post.get("parameters") or []:
if param.get("in") != "query":
continue
name = param.get("name")
schema = param.get("schema")
if not name or not schema:
continue
resolved = dict(self._resolve_ref(schema))
if "description" not in resolved and param.get("description"):
resolved["description"] = param["description"]
props[name] = resolved
return props
def _filter_properties(self, properties: dict[str, Any]) -> dict[str, Any]:
"""Remove base-class fields and binary upload fields, resolving any $refs."""
clean: dict[str, Any] = {}
@@ -246,7 +270,7 @@ def main() -> None:
raise SystemExit(f"OpenAPI spec not found at {spec_path}\nRun 'task engine:tool-models' to generate it.")
output_path = Path(args.output)
with open(spec_path) as f:
with open(spec_path, encoding="utf-8") as f:
spec = json.load(f)
result = ToolDiscovery(spec).discover()
@@ -255,7 +279,7 @@ def main() -> None:
print(f"Generated {len(result.tools)} tool models from {spec_path.name}")
for tool in result.tools:
print(f" {tool.enum_name}: {tool.path} {tool.class_name}")
print(f" {tool.enum_name}: {tool.path} -> {tool.class_name}")
if __name__ == "__main__":
+2
View File
@@ -4,6 +4,7 @@ from .execution import ExecutionPlanningAgent
from .orchestrator import OrchestratorAgent
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from .pdf_questions import PdfQuestionAgent
from .pdf_review import PdfReviewAgent
from .user_spec import UserSpecAgent
__all__ = [
@@ -13,5 +14,6 @@ __all__ = [
"PdfEditParameterSelector",
"PdfEditPlanSelection",
"PdfQuestionAgent",
"PdfReviewAgent",
"UserSpecAgent",
]
+8 -1
View File
@@ -1,6 +1,13 @@
from __future__ import annotations
from stirling.contracts import ExtractedFileText
from stirling.contracts import ExtractedFileText, ExtractedTextArtifact, OrchestratorRequest
def get_extracted_text_artifact(request: OrchestratorRequest) -> ExtractedTextArtifact | None:
for artifact in request.artifacts:
if isinstance(artifact, ExtractedTextArtifact):
return artifact
return None
def has_page_text(page_text: list[ExtractedFileText]) -> bool:
@@ -0,0 +1,79 @@
"""
Math-auditor presentation helpers.
Used by ``PdfQuestionAgent`` and ``PdfReviewAgent`` to (a) decide whether
a request needs the math auditor at all, and (b) pull a Verdict back out
of the resume-turn artifacts.
Intent classification is language-agnostic a small LLM call rather than
an English regex so a request like "vérifiez les totaux" routes to the
math path the same as "check the totals".
"""
from __future__ import annotations
from pydantic import Field
from pydantic_ai import Agent
from stirling.contracts import (
MathAuditorToolReportArtifact,
OrchestratorRequest,
Verdict,
)
from stirling.models import ApiModel
from stirling.services import AppRuntime
def extract_math_verdict(request: OrchestratorRequest) -> Verdict | None:
"""Find a math-auditor Verdict in the request's artifacts, if any.
Meta-agents call this on resume to detect whether the specialist has
already run. The Verdict is already type-validated by the time it lands
in :class:`MathAuditorToolReportArtifact` pydantic rejected the whole
request earlier if the payload was malformed.
"""
for artifact in request.artifacts:
if isinstance(artifact, MathAuditorToolReportArtifact):
return artifact.report
return None
_MATH_INTENT_SYSTEM_PROMPT = (
"Decide whether the user's prompt is asking for verification of "
"numerical content — math correctness, audit, recalculation, totals, "
"sums, percentages, balances, arithmetic, or financial figures. "
"Set is_math=true if so, otherwise false. Decide from the meaning of "
"the prompt, not specific keywords; the prompt may be in any language."
)
class _MathIntentDecision(ApiModel):
is_math: bool = Field(
description=(
"True if the prompt is about verifying numerical content "
"(math, audit, calculations, totals, percentages, etc.)."
),
)
class MathIntentClassifier:
"""Tiny LLM classifier that returns whether a prompt needs the math auditor.
Shared between ``PdfQuestionAgent`` and ``PdfReviewAgent`` so both delegates
use the same decision shape and prompt. One agent instance per consumer
(cheap; matches the existing pattern of per-request agent construction).
"""
def __init__(self, runtime: AppRuntime) -> None:
self._agent: Agent[None, _MathIntentDecision] = Agent(
model=runtime.fast_model,
output_type=_MathIntentDecision,
system_prompt=_MATH_INTENT_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
async def classify(self, user_message: str) -> bool:
if not user_message:
return False
result = await self._agent.run(user_message)
return result.output.is_math
+36 -60
View File
@@ -10,24 +10,21 @@ from pydantic_ai.tools import RunContext
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.agents.pdf_review import PdfReviewAgent
from stirling.agents.user_spec import UserSpecAgent
from stirling.contracts import (
AgentDraftRequest,
AgentDraftWorkflowResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
PdfEditRequest,
PdfEditResponse,
PdfQuestionRequest,
PdfQuestionResponse,
PdfQuestionOrchestrateResponse,
SupportedCapability,
ToolOperationStep,
UnsupportedCapabilityResponse,
format_conversation_history,
format_file_names,
)
from stirling.contracts.pdf_edit import EditPlanResponse
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@@ -61,11 +58,14 @@ class OrchestratorAgent:
description="Delegate requests to create or revise a user agent spec and return the draft result.",
),
ToolOutput(
self.math_auditor_agent,
name="math_auditor_agent",
self.delegate_pdf_review,
name="delegate_pdf_review",
description=(
"Delegate requests to check arithmetic, validate table totals, "
"audit financial calculations, or verify mathematical accuracy in PDFs."
"Delegate requests to review a PDF and leave review comments, notes, or"
" sticky-note annotations on the document itself. Use this when the user"
" wants the PDF returned with comments attached (e.g. 'review this',"
" 'add review comments', 'flag unclear sentences', 'annotate with"
" feedback')."
),
),
ToolOutput(
@@ -79,11 +79,13 @@ class OrchestratorAgent:
"You are the top-level orchestrator. "
"Choose exactly one output function that best handles the request. "
"Use delegate_pdf_edit for requested modifications of single or multiple PDFs. "
"Use delegate_pdf_question for questions about PDF contents. "
"Use delegate_pdf_question for questions about the contents of the attached PDFs. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use math_auditor_agent for requests to check arithmetic, validate "
"table totals, audit financial calculations, or verify math in PDFs. "
"Use unsupported_capability only when none of the other outputs fit."
"Use delegate_pdf_review when the user wants the PDF returned with review"
" comments attached — anything like 'review this', 'annotate with comments',"
" 'leave feedback on the PDF'. "
"Use unsupported_capability when the user asks about the assistant itself "
"or when none of the other outputs fit; supply a helpful message."
),
model_settings=runtime.fast_model_settings,
)
@@ -91,7 +93,7 @@ class OrchestratorAgent:
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
logger.info(
"[orchestrator] handle: files=%s resume_with=%s artifacts=%s msg=%r",
request.file_names,
[file.name for file in request.files],
request.resume_with,
[type(a).__name__ for a in request.artifacts],
request.user_message,
@@ -106,10 +108,17 @@ class OrchestratorAgent:
return result.output
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
"""Fast-path to get back to the correct endpoint without having to call AI."""
"""Fast-path to get back to the correct endpoint without having to call AI.
Also the entry point for the *multi-turn* flow where a delegate emits a plan with
``resume_with`` set Java runs the plan, captures any tool reports as artifacts, and
re-enters via this method so the delegate can digest the reports.
"""
match capability:
case SupportedCapability.PDF_QUESTION:
return await self._run_pdf_question(request)
case SupportedCapability.PDF_REVIEW:
return await self._run_pdf_review(request)
case SupportedCapability.PDF_EDIT:
return await self._run_pdf_edit(request)
case SupportedCapability.AGENT_DRAFT:
@@ -128,51 +137,25 @@ class OrchestratorAgent:
return await self._run_pdf_edit(ctx.deps.request)
async def _run_pdf_edit(self, request: OrchestratorRequest) -> PdfEditResponse:
extracted_text = self._get_extracted_text_artifact(request)
return await PdfEditAgent(self.runtime).handle(
PdfEditRequest(
user_message=request.user_message,
file_names=request.file_names,
conversation_history=request.conversation_history,
page_text=extracted_text.files if extracted_text is not None else [],
)
)
return await PdfEditAgent(self.runtime).orchestrate(request)
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionResponse:
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionOrchestrateResponse:
return await self._run_pdf_question(ctx.deps.request)
async def _run_pdf_question(self, request: OrchestratorRequest) -> PdfQuestionResponse:
extracted_text = self._get_extracted_text_artifact(request)
return await PdfQuestionAgent(self.runtime).handle(
PdfQuestionRequest(
question=request.user_message,
file_names=request.file_names,
page_text=extracted_text.files if extracted_text is not None else [],
conversation_history=request.conversation_history,
)
)
async def _run_pdf_question(self, request: OrchestratorRequest) -> PdfQuestionOrchestrateResponse:
return await PdfQuestionAgent(self.runtime).orchestrate(request)
async def delegate_user_spec(self, ctx: RunContext[OrchestratorDeps]) -> AgentDraftWorkflowResponse:
return await self._run_agent_draft(ctx.deps.request)
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).draft(
AgentDraftRequest(
user_message=request.user_message,
conversation_history=request.conversation_history,
)
)
return await UserSpecAgent(self.runtime).orchestrate(request)
async def math_auditor_agent(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse:
return EditPlanResponse(
summary="Validate mathematical calculations in the document.",
steps=[
ToolOperationStep(
tool=AgentToolId.MATH_AUDITOR_AGENT,
parameters=MathAuditorAgentParams(),
)
],
)
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse:
return await self._run_pdf_review(ctx.deps.request)
async def _run_pdf_review(self, request: OrchestratorRequest) -> EditPlanResponse:
return await PdfReviewAgent(self.runtime).orchestrate(request)
async def unsupported_capability(
self,
@@ -182,20 +165,13 @@ class OrchestratorAgent:
) -> UnsupportedCapabilityResponse:
return UnsupportedCapabilityResponse(capability=capability, message=message)
def _get_extracted_text_artifact(self, request: OrchestratorRequest) -> ExtractedTextArtifact | None:
for artifact in request.artifacts:
if isinstance(artifact, ExtractedTextArtifact):
return artifact
return None
def _build_prompt(self, request: OrchestratorRequest) -> str:
artifact_summary = self._describe_artifacts(request)
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
history = format_conversation_history(request.conversation_history)
return (
f"Conversation history:\n{history}\n"
f"User message: {request.user_message}\n"
f"Files: {file_names}\n"
f"Files: {format_file_names(request.files)}\n"
f"Available artifacts:\n{artifact_summary}"
)

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