Skip to content

Commit 51478e5

Browse files
authored
Policies backend (Stirling-Tools#6527)
# Description of Changes Add a backend for running any multi-step PDF operations. This is designed to be used for the upcoming Policies feature, along with anything else that will require automated running of PDF operations, like the Automate tool or Processing Folders. The implementation is not complete. I've tried to get all the infrastructure in there so that we can add in whichever triggers we need in the future (like cron triggers or watching folders on disk) but currently it just supports manual triggering of the policy. The basis of this work was the operation running from the Stirling Engine, which this PR removes in favour of this new system. The only currently accessible frontend way to test this work is to ask the AI chat to execute multiple operations on a PDF, but I've also extensively tested with direct API calls to make sure that the policies work and persist properly.
1 parent 69e62d8 commit 51478e5

37 files changed

Lines changed: 2819 additions & 176 deletions

app/common/src/main/java/stirling/software/common/service/ToolMetadataService.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
package stirling.software.common.service;
22

3+
import java.util.List;
4+
35
/** Provides metadata about tool endpoints for internal dispatch. */
46
public interface ToolMetadataService {
57

68
/** Returns true if the given operation path accepts multiple input files. */
79
boolean isMultiInput(String operationPath);
810

11+
/**
12+
* Returns the file extensions (lowercase, no leading dot, e.g. {@code "pdf"}) that the
13+
* operation accepts as input ({@code output=false}) or produces as output ({@code
14+
* output=true}), derived from the endpoint's declared type. Returns {@code null} when the
15+
* endpoint declares no specific type, which callers should treat as "any type accepted".
16+
*/
17+
List<String> getExtensionTypes(boolean output, String operationPath);
18+
919
/**
1020
* Returns true when the endpoint's ZIP response is a transport for multiple typed results and
1121
* should be unpacked: multi-output endpoints (Type:SIMO / Type:MIMO) and wrapper declarations

app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ private String getApiDocsUrl() {
6363
return "http://localhost:" + port + contextPath + "/v1/api-docs";
6464
}
6565

66+
@Override
6667
public List<String> getExtensionTypes(boolean output, String operationName) {
6768
if (outputToFileTypes.isEmpty()) {
6869
outputToFileTypes.put("PDF", List.of("pdf"));
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
package stirling.software.proprietary.policy.controller;
2+
3+
import java.io.IOException;
4+
import java.util.ArrayList;
5+
import java.util.LinkedHashMap;
6+
import java.util.List;
7+
import java.util.Map;
8+
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.core.io.FileSystemResource;
11+
import org.springframework.core.io.Resource;
12+
import org.springframework.http.HttpStatus;
13+
import org.springframework.http.MediaType;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.util.MultiValueMap;
16+
import org.springframework.web.bind.annotation.DeleteMapping;
17+
import org.springframework.web.bind.annotation.GetMapping;
18+
import org.springframework.web.bind.annotation.PathVariable;
19+
import org.springframework.web.bind.annotation.PostMapping;
20+
import org.springframework.web.bind.annotation.RequestBody;
21+
import org.springframework.web.bind.annotation.RequestMapping;
22+
import org.springframework.web.bind.annotation.RequestParam;
23+
import org.springframework.web.bind.annotation.RestController;
24+
import org.springframework.web.multipart.MultipartFile;
25+
import org.springframework.web.multipart.MultipartHttpServletRequest;
26+
import org.springframework.web.server.ResponseStatusException;
27+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
28+
29+
import io.github.pixee.security.Filenames;
30+
import io.swagger.v3.oas.annotations.Hidden;
31+
import io.swagger.v3.oas.annotations.Operation;
32+
import io.swagger.v3.oas.annotations.tags.Tag;
33+
34+
import lombok.RequiredArgsConstructor;
35+
import lombok.extern.slf4j.Slf4j;
36+
37+
import stirling.software.common.model.job.JobResponse;
38+
import stirling.software.common.util.TempFile;
39+
import stirling.software.common.util.TempFileManager;
40+
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
41+
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
42+
import stirling.software.proprietary.policy.model.PipelineDefinition;
43+
import stirling.software.proprietary.policy.model.Policy;
44+
import stirling.software.proprietary.policy.model.PolicyInputs;
45+
import stirling.software.proprietary.policy.model.PolicyRun;
46+
import stirling.software.proprietary.policy.model.PolicyRunStatus;
47+
import stirling.software.proprietary.policy.model.PolicyRunView;
48+
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
49+
import stirling.software.proprietary.policy.store.PolicyStore;
50+
import stirling.software.proprietary.policy.trigger.ManualTrigger;
51+
import stirling.software.proprietary.security.config.PremiumEndpoint;
52+
53+
import tools.jackson.core.JacksonException;
54+
import tools.jackson.databind.ObjectMapper;
55+
56+
/**
57+
* Manages policies and runs pipelines. The premium backend entry point: CRUD for stored {@code
58+
* Policy} objects, running a stored policy by id, and running an ad-hoc pipeline (for AI/Automate
59+
* one-offs).
60+
*
61+
* <p>Runs execute asynchronously and return a run id immediately. Poll {@code GET /run/{runId}} for
62+
* status, and download outputs via the existing {@code GET /api/v1/general/files/{fileId}} using
63+
* the file ids in the run view.
64+
*/
65+
@Slf4j
66+
@RestController
67+
@RequestMapping("/api/v1/policies")
68+
@Hidden
69+
@PremiumEndpoint
70+
@RequiredArgsConstructor
71+
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
72+
public class PolicyController {
73+
74+
private final ManualTrigger manualTrigger;
75+
private final PolicyRunRegistry runRegistry;
76+
private final PolicyStore policyStore;
77+
private final ObjectMapper objectMapper;
78+
private final TempFileManager tempFileManager;
79+
80+
/** SSE emitter timeout, generous enough for long multi-step runs on large files. */
81+
@Value("${stirling.policies.streamTimeoutMs:1800000}")
82+
private long streamTimeoutMs;
83+
84+
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
85+
@Operation(
86+
summary = "Run a tool pipeline",
87+
description =
88+
"Accepts the documents to process (multipart field 'fileInput'), any supporting"
89+
+ " files (each under a multipart field named as its asset key, e.g."
90+
+ " 'company-logo'), and a JSON pipeline definition ('json'). Runs the"
91+
+ " steps in order asynchronously and returns a run id. Poll the run"
92+
+ " status endpoint and download outputs via /api/v1/general/files/{id}.")
93+
public ResponseEntity<JobResponse<Void>> run(
94+
@RequestParam("json") String json, MultipartHttpServletRequest request)
95+
throws IOException {
96+
PipelineDefinition definition = parseDefinition(json);
97+
PolicyInputs inputs = collectInputs(request);
98+
String runId = manualTrigger.fire(definition, inputs, PolicyProgressListener.NOOP).runId();
99+
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
100+
}
101+
102+
@PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
103+
@Operation(
104+
summary = "Run a tool pipeline with live progress",
105+
description =
106+
"Same as /run, but returns Server-Sent Events: a 'step' event as each step"
107+
+ " starts and completes, then a terminal 'completed', 'failed',"
108+
+ " 'cancelled', or 'waiting' event carrying the final run view.")
109+
public SseEmitter runStream(
110+
@RequestParam("json") String json, MultipartHttpServletRequest request)
111+
throws IOException {
112+
PipelineDefinition definition = parseDefinition(json);
113+
PolicyInputs inputs = collectInputs(request);
114+
115+
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
116+
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
117+
118+
PolicyRunHandle handle = manualTrigger.fire(definition, inputs, streamListener(emitter));
119+
// Close the stream with a terminal event once the run finishes. whenComplete runs on the
120+
// engine's worker thread after the run is done, so this never races the step events.
121+
handle.completion()
122+
.whenComplete(
123+
(run, throwable) -> {
124+
if (throwable != null) {
125+
sendEvent(
126+
emitter,
127+
"failed",
128+
Map.of("message", throwable.getMessage()));
129+
} else {
130+
sendEvent(emitter, terminalEventName(run), PolicyRunView.of(run));
131+
}
132+
emitter.complete();
133+
});
134+
return emitter;
135+
}
136+
137+
@GetMapping("/run/{runId}")
138+
@Operation(
139+
summary = "Get pipeline run status",
140+
description = "Returns the current status, step cursor, and output files of a run.")
141+
public ResponseEntity<PolicyRunView> status(@PathVariable String runId) {
142+
PolicyRun run = runRegistry.get(runId);
143+
if (run == null) {
144+
return ResponseEntity.notFound().build();
145+
}
146+
return ResponseEntity.ok(PolicyRunView.of(run));
147+
}
148+
149+
// --- Policy management ---
150+
151+
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
152+
@Operation(
153+
summary = "Create or update a policy",
154+
description =
155+
"Stores a policy (trigger config + steps + output + metadata). A blank id is"
156+
+ " assigned; returns the stored policy with its id.")
157+
public ResponseEntity<Policy> savePolicy(@RequestBody String json) {
158+
return ResponseEntity.ok(policyStore.save(parsePolicy(json)));
159+
}
160+
161+
@GetMapping
162+
@Operation(summary = "List policies")
163+
public List<Policy> listPolicies() {
164+
return policyStore.all();
165+
}
166+
167+
@GetMapping("/{policyId}")
168+
@Operation(summary = "Get a policy by id")
169+
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
170+
return policyStore
171+
.get(policyId)
172+
.map(ResponseEntity::ok)
173+
.orElseGet(() -> ResponseEntity.notFound().build());
174+
}
175+
176+
@DeleteMapping("/{policyId}")
177+
@Operation(summary = "Delete a policy by id")
178+
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
179+
return policyStore.delete(policyId)
180+
? ResponseEntity.noContent().build()
181+
: ResponseEntity.notFound().build();
182+
}
183+
184+
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
185+
@Operation(
186+
summary = "Run a stored policy",
187+
description =
188+
"Runs the stored policy's pipeline on the supplied files (primary documents"
189+
+ " under 'fileInput', supporting files under their asset-key fields)."
190+
+ " Runs regardless of the policy's enabled flag, which only gates"
191+
+ " automatic triggering. Returns a run id.")
192+
public ResponseEntity<JobResponse<Void>> runStoredPolicy(
193+
@PathVariable String policyId, MultipartHttpServletRequest request) throws IOException {
194+
Policy policy =
195+
policyStore
196+
.get(policyId)
197+
.orElseThrow(
198+
() ->
199+
new ResponseStatusException(
200+
HttpStatus.NOT_FOUND, "No policy: " + policyId));
201+
PolicyInputs inputs = collectInputs(request);
202+
String runId = manualTrigger.run(policy, inputs, PolicyProgressListener.NOOP).runId();
203+
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
204+
}
205+
206+
private Policy parsePolicy(String json) {
207+
try {
208+
return objectMapper.readValue(json, Policy.class);
209+
} catch (JacksonException e) {
210+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON");
211+
}
212+
}
213+
214+
private PipelineDefinition parseDefinition(String json) {
215+
PipelineDefinition definition;
216+
try {
217+
definition = objectMapper.readValue(json, PipelineDefinition.class);
218+
} catch (JacksonException e) {
219+
throw new ResponseStatusException(
220+
HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON");
221+
}
222+
if (definition.steps().isEmpty()) {
223+
throw new ResponseStatusException(
224+
HttpStatus.BAD_REQUEST, "Pipeline definition has no steps");
225+
}
226+
return definition;
227+
}
228+
229+
/**
230+
* Split the multipart file parts into the primary document stream ("fileInput") and the named
231+
* supporting-file store: every other file field becomes an asset keyed by its field name, which
232+
* a step references from {@code fileParameters}.
233+
*/
234+
private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException {
235+
MultiValueMap<String, MultipartFile> fileMap = request.getMultiFileMap();
236+
List<Resource> primary = toResources(fileMap.get("fileInput"));
237+
Map<String, List<Resource>> supportingFiles = new LinkedHashMap<>();
238+
for (Map.Entry<String, List<MultipartFile>> entry : fileMap.entrySet()) {
239+
if ("fileInput".equals(entry.getKey())) {
240+
continue;
241+
}
242+
List<Resource> assets = toResources(entry.getValue());
243+
if (!assets.isEmpty()) {
244+
supportingFiles.put(entry.getKey(), assets);
245+
}
246+
}
247+
return new PolicyInputs(primary, supportingFiles);
248+
}
249+
250+
/**
251+
* A progress listener that forwards each step transition to the SSE stream as a "step" event.
252+
*/
253+
private PolicyProgressListener streamListener(SseEmitter emitter) {
254+
return new PolicyProgressListener() {
255+
@Override
256+
public void onStepStart(int stepIndex, int stepCount, String operation) {
257+
sendEvent(emitter, "step", stepEvent("started", stepIndex, stepCount, operation));
258+
}
259+
260+
@Override
261+
public void onStepComplete(int stepIndex, int stepCount, String operation) {
262+
sendEvent(emitter, "step", stepEvent("completed", stepIndex, stepCount, operation));
263+
}
264+
};
265+
}
266+
267+
private static Map<String, Object> stepEvent(
268+
String phase, int stepIndex, int stepCount, String operation) {
269+
return Map.of(
270+
"phase", phase,
271+
"stepIndex", stepIndex,
272+
"stepCount", stepCount,
273+
"operation", operation);
274+
}
275+
276+
private static String terminalEventName(PolicyRun run) {
277+
PolicyRunStatus status = run.getStatus();
278+
return switch (status) {
279+
case COMPLETED -> "completed";
280+
case FAILED -> "failed";
281+
case CANCELLED -> "cancelled";
282+
case WAITING_FOR_INPUT -> "waiting";
283+
default -> "ended";
284+
};
285+
}
286+
287+
private void sendEvent(SseEmitter emitter, String name, Object data) {
288+
try {
289+
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
290+
} catch (IOException | IllegalStateException e) {
291+
// Client disconnected or the emitter already closed. The run continues and its results
292+
// remain downloadable via the job endpoints; nothing useful left to stream.
293+
log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage());
294+
}
295+
}
296+
297+
private List<Resource> toResources(List<MultipartFile> files) throws IOException {
298+
List<Resource> resources = new ArrayList<>();
299+
if (files == null) {
300+
return resources;
301+
}
302+
for (MultipartFile file : files) {
303+
if (file == null || file.isEmpty()) {
304+
continue;
305+
}
306+
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
307+
file.transferTo(tempFile.getPath());
308+
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
309+
resources.add(
310+
new FileSystemResource(tempFile.getFile()) {
311+
@Override
312+
public String getFilename() {
313+
return originalName;
314+
}
315+
});
316+
}
317+
return resources;
318+
}
319+
}

0 commit comments

Comments
 (0)