diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 2a3adffeb..3bf650446 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -18,6 +18,15 @@ version: '3' tasks: dev: desc: "Start backend dev server" + cmds: + - task: dev:proprietary + vars: + PORT: '{{.PORT}}' + AIENGINE_URL: '{{.AIENGINE_URL}}' + AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + + dev:proprietary: + desc: "Start backend dev server in proprietary mode" ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' @@ -50,9 +59,14 @@ tasks: PORT: '{{.PORT | default "8080"}}' # Override to "" to run the pure `saas` profile against your own SAAS_DB_*. PROFILES: '{{.PROFILES | default "dev"}}' + AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' + AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' env: SERVER_PORT: '{{.PORT}}' STIRLING_FLAVOR: saas + AIENGINE_URL: '{{.AIENGINE_URL}}' + AIENGINE_ENABLED: '{{if .AIENGINE_URL}}true{{else}}false{{end}}' + AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' cmds: - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}" platforms: [windows] diff --git a/Taskfile.yml b/Taskfile.yml index dc4d5130b..8315829ba 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -60,24 +60,20 @@ tasks: dev:saas: desc: "Start SaaS backend + frontend concurrently on free ports" - vars: - PORTS: - sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}' - BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' - FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' - deps: - - task: backend:dev:saas - vars: - PORT: '{{.BACKEND_PORT}}' - - task: frontend:dev:saas - vars: - PORT: '{{.FRONTEND_PORT}}' - BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - OPEN: "true" + cmds: + - task: dev:_all + vars: { FRONTEND: saas, BACKEND: saas } dev:all: desc: "Start backend + frontend + engine concurrently on free ports" + cmds: + - task: dev:_all + + dev:_all: + internal: true vars: + FRONTEND: '{{.FRONTEND | default "proprietary"}}' + BACKEND: '{{.BACKEND | default "proprietary"}}' PORTS: sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}' BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' @@ -87,11 +83,11 @@ tasks: - task: engine:dev vars: PORT: '{{.ENGINE_PORT}}' - - task: backend:dev + - task: 'backend:dev:{{.BACKEND}}' vars: PORT: '{{.BACKEND_PORT}}' AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}' - - task: frontend:dev + - task: 'frontend:dev:{{.FRONTEND}}' vars: PORT: '{{.FRONTEND_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java index 3abf919f1..85d918d1e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java @@ -13,26 +13,18 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.Policy; /** - * The single authority on which filesystem locations a policy may read from or write to. Folder - * sources and sinks take a configured directory, so without this a user who can save a policy could - * point one at Stirling's own config/secrets directory and exfiltrate (or overwrite) it. Every - * folder source and sink runs its directory through {@link #requirePermitted(Path)} at save time - * and again at run time. + * Authority on which filesystem locations a policy may read/write. Checked at save time and again + * at run time, fail-closed in order: * - *
Enforced fail-closed, in order: + *
Paths are compared after normalisation, so {@code ..} segments cannot walk out of an allowed - * root. (Symlink escape is not defended here; an operator who configures an allowed root containing - * a symlink to a sensitive location is trusted.) + *
Compared after normalisation so {@code ..} cannot escape a root. Symlink escape is not
+ * defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
public class FolderAccessGuard {
@@ -50,13 +42,7 @@ public class FolderAccessGuard {
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
}
- /**
- * Check that {@code dir} is a permitted folder location, returning its normalised absolute
- * form.
- *
- * @throws IllegalArgumentException if folder access is disabled (SaaS or no roots configured),
- * the path is inside a protected directory, or it falls outside every allowed root
- */
+ /** Returns the normalised absolute path; throws if not permitted. */
public Path requirePermitted(Path dir) {
if (saasActive) {
throw new IllegalArgumentException(
@@ -81,7 +67,7 @@ public class FolderAccessGuard {
return normalized;
}
- /** Whether this policy reads from or writes to a folder, and so is subject to these rules. */
+ /** Whether this policy touches a folder source/sink, and so is subject to these rules. */
public boolean usesFolderAccess(Policy policy) {
boolean readsFolder =
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java
new file mode 100644
index 000000000..934379614
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java
@@ -0,0 +1,56 @@
+package stirling.software.proprietary.policy.config;
+
+import java.util.List;
+
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+
+import stirling.software.common.model.ApplicationProperties;
+import stirling.software.common.service.UserServiceInterface;
+import stirling.software.proprietary.policy.model.Policy;
+
+/**
+ * Decides who may act on a stored {@link Policy}: its owner and global admins only, with no
+ * separate view/edit/run capability. Enforced only when login is enabled; single-user deployments
+ * pass every check. The owner is assigned server-side, never from client input.
+ */
+@Component
+@RequiredArgsConstructor
+public class PolicyAccessGuard {
+
+ private final UserServiceInterface userService;
+ private final ApplicationProperties applicationProperties;
+
+ /** Owner for a new policy: the current user, or {@code null} when login is disabled. */
+ public String ownerForNewPolicy() {
+ return enforced() ? userService.getCurrentUsername() : null;
+ }
+
+ /** Whether the current user may view, edit, delete, or run the given stored policy. */
+ public boolean canAccess(Policy policy) {
+ if (!enforced() || userService.isCurrentUserAdmin()) {
+ return true;
+ }
+ String current = userService.getCurrentUsername();
+ return current != null && current.equals(policy.owner());
+ }
+
+ /**
+ * The subset of {@code policies} the current user may see (their own; everything for an admin).
+ */
+ public List Runs execute asynchronously and return a run id immediately. Poll {@code GET /run/{runId}} for
- * status, and download outputs via the existing {@code GET /api/v1/general/files/{fileId}} using
- * the file ids in the run view.
+ * Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
+ * GET /run/{runId}} for status, download outputs via {@code GET /api/v1/general/files/{fileId}}.
*/
@Slf4j
@RestController
@@ -79,9 +73,9 @@ public class PolicyController {
private final PolicyStore policyStore;
private final PolicyValidator policyValidator;
private final FolderAccessGuard folderAccessGuard;
+ private final PolicyAccessGuard policyAccessGuard;
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
- private final ObjectMapper objectMapper;
private final TempFileManager tempFileManager;
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -89,15 +83,16 @@ public class PolicyController {
summary = "Run a tool pipeline",
description =
"Accepts the documents to process (multipart field 'fileInput'), any supporting"
- + " files (each under a multipart field named as its asset key, e.g."
- + " 'company-logo'), and a JSON pipeline definition ('json'). Runs the"
- + " steps in order asynchronously and returns a run id. Poll the run"
- + " status endpoint and download outputs via /api/v1/general/files/{id}.")
+ + " files (under 'assets[i].key' / 'assets[i].file'), and the pipeline"
+ + " definition as an application/json part named 'json'. Runs the steps"
+ + " in order asynchronously and returns a run id. Poll the run status"
+ + " endpoint and download outputs via /api/v1/general/files/{id}.")
public ResponseEntity Wire form: {@code fileInput} (repeated) for primaries, and {@code assets[i].key} / {@code
+ * assets[i].file} for each supporting asset.
+ */
+@Data
+@Schema(description = "Files for a policy run: primary documents plus keyed supporting assets")
+public class PolicyRunFiles {
+
+ @Schema(description = "Primary input documents", format = "binary")
+ private List Each run is the unit of async work: {@link #submit} returns a run id immediately and the
- * pipeline executes on a virtual thread, so a step blocking on a slow tool does not tie up a
- * platform thread. The run drives {@link PolicyExecutor} for the actual step loop, registers its
- * outputs and progress with {@link TaskManager} (so the existing job status/download endpoints work
- * unchanged), and keeps rich state in {@link PolicyRunRegistry}.
- *
- * The engine deliberately manages its own virtual-thread execution rather than routing through
- * {@code JobExecutorService}: that path force-completes a job once its work returns, which is
- * incompatible with a run that suspends in {@code WAITING_FOR_INPUT}. It still applies the shared
- * {@link ResourceMonitor}/{@link JobQueue} admission control, so heavy runs queue under load
- * instead of oversubscribing.
+ * Manages its own virtual-thread execution rather than {@code JobExecutorService}, which
+ * force-completes a job once its work returns: incompatible with a run that suspends in {@code
+ * WAITING_FOR_INPUT}. Still applies the shared {@link ResourceMonitor}/{@link JobQueue} admission
+ * control so heavy runs queue under load.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyEngine {
- /**
- * Resource weight of a pipeline run for admission control. A run chains many tools and holds
- * intermediate files, so it is weighted as heavy work: the shared {@link ResourceMonitor}
- * should let it start while the system is healthy but hold it back under memory/CPU pressure.
- * See {@link ResourceMonitor#shouldQueueJob(int)} for how a weight maps to that decision.
- */
+ // Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
+ // files. See ResourceMonitor#shouldQueueJob(int).
private static final int RUN_RESOURCE_WEIGHT = 50;
private final PolicyExecutor stepExecutor;
@@ -72,16 +65,14 @@ public class PolicyEngine {
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
/**
- * Submit a pipeline to run asynchronously. The returned handle's run id scopes a job in {@link
- * TaskManager}, so progress (notes), status, and result files are observable via the existing
- * job endpoints as well as via {@link #getRun(String)}; its completion future resolves when the
- * run reaches a terminal or paused state.
+ * Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job
+ * (status/notes/results observable via the job endpoints); its future resolves when the run
+ * reaches a terminal or paused state.
*/
public PolicyRunHandle submit(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
- // Scope the run id to the current user (on this request thread) so the file-download
- // ownership check passes; NoOpJobOwnershipService returns the id unchanged when security
- // is off.
+ // Scope the run id to the current user (this request thread) so the file-download
+ // ownership check passes. No-op when security is off.
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
taskManager.createTask(runId);
PolicyRun run = new PolicyRun(runId, definition);
@@ -90,9 +81,8 @@ public class PolicyEngine {
PolicyProgressListener tracking = trackingListener(runId, run, listener);
Runnable task = () -> runToCompletion(run, inputs, tracking, completion);
- // Each run is one admission unit; steps run synchronously within it, so this gates heavy
- // work under load without the pool-within-pool risk of queueing each tool call. Under
- // resource pressure the run waits in the shared JobQueue; otherwise it starts immediately.
+ // One admission unit per run; steps run synchronously within it, so this gates heavy work
+ // without the pool-within-pool risk of queueing each tool call.
if (resourceMonitor.shouldQueueJob(RUN_RESOURCE_WEIGHT)) {
log.debug("Queueing policy run {} under resource pressure", runId);
jobQueue.queueJob(
@@ -110,10 +100,7 @@ public class PolicyEngine {
return new PolicyRunHandle(runId, completion);
}
- /**
- * Run a stored policy on demand. Builds the policy's pipeline and submits it. {@code enabled}
- * gates automatic triggering, not explicit runs, so this runs regardless of that flag.
- */
+ /** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
public PolicyRunHandle runPolicy(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return submit(policy.toDefinition(), inputs, listener);
@@ -124,8 +111,7 @@ public class PolicyEngine {
}
/**
- * Request cancellation of a run. Stage 1 marks the run cancelled in the registry if it has not
- * already finished; interrupting an in-flight tool call lands in a later stage.
+ * Mark a run cancelled if not already finished. Does not yet interrupt an in-flight tool call.
*/
public boolean cancel(String runId) {
PolicyRun run = registry.get(runId);
@@ -139,10 +125,7 @@ public class PolicyEngine {
return cancelled;
}
- /**
- * Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented; the run shape and
- * {@link WaitState} snapshot are in place so this can be added without reworking the engine.
- */
+ /** Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented. */
public String resume(String runId, List {@code files} are the final output resources (temp files, not yet stored to {@code
- * FileStorage}). {@code report} is the structured metadata payload captured from the last step that
- * produced one (a JSON body, or an {@code X-Stirling-Tool-Report} header), with {@code reportTool}
- * naming the step it came from; both are null when no step produced a report.
+ * Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored).
+ * {@code report}/{@code reportTool} carry the last step's structured report and its operation, or
+ * null if no step produced one.
*/
public record PolicyExecutionResult(List This is the single execution loop for the proprietary surface (AI plans now;
- * manually-triggered runs and watched folders later). Each step is dispatched synchronously via
- * {@link InternalApiClient} loopback HTTP: the tool runs in its own handler and returns its file
- * inline. The caller decides how to run the executor itself (the AI turn loop calls it directly;
- * the engine runs it on a virtual thread for async runs). Files cross step boundaries as {@link
- * Resource} temp files; they are only persisted to durable storage at the run boundaries by the
+ * Steps dispatch synchronously via {@link InternalApiClient} loopback HTTP (each tool runs in
+ * its own handler, returns its file inline). The caller controls threading. Files cross step
+ * boundaries as {@link Resource} temp files and are only persisted at the run boundaries by the
* caller.
*/
@Slf4j
@@ -58,25 +54,16 @@ public class PolicyExecutor {
private final TempFileManager tempFileManager;
private final ObjectMapper objectMapper;
- /**
- * 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.
- */
+ // files: result files (one, or many for ZIP-response tools). report: optional structured
+ // payload the tool surfaced alongside or instead of a file.
private record ToolResult(List 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.
+ * Multi-input endpoints get all files in one call; others are called once per file. ZIP
+ * responses are unpacked so each inner file is its own result (e.g. split). For per-file
+ * dispatch the first non-null report wins.
*/
private ToolResult executeStep(
PipelineStep step,
@@ -146,17 +129,10 @@ public class PolicyExecutor {
}
/**
- * Call an endpoint and return its result files and optional report.
- *
- * Carries everything needed to resume: a human-readable reason, the 0-based index of the step to
- * resume from, and the intermediate files produced so far. The engine persists those files and
- * suspends the run.
- *
- * Defined now to fix the run shape; no step throws it yet, and the resume handshake is
- * implemented in a later stage.
+ * Thrown by a step that needs further user input, pausing the run in {@code WAITING_FOR_INPUT}
+ * instead of failing. Carries the resume reason, 0-based resume step index, and intermediate files;
+ * the engine persists those and suspends. Not yet thrown by any step.
*/
@Getter
public class PolicyInputRequiredException extends RuntimeException {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunHandle.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunHandle.java
index eeda863cc..09deaa7b5 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunHandle.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunHandle.java
@@ -5,12 +5,9 @@ import java.util.concurrent.CompletableFuture;
import stirling.software.proprietary.policy.model.PolicyRun;
/**
- * Returned by {@link PolicyEngine#submit}: the run id (for status polling and result download) plus
- * a future that resolves when the run reaches a terminal or paused state.
- *
- * The completion future lets callers react to the end of a run (e.g. an SSE endpoint sending a
- * final event and closing the stream) without polling. It carries the {@link PolicyRun} whose
- * status describes the outcome (completed, failed, cancelled, or waiting for input); it does not
- * complete exceptionally for ordinary run failures.
+ * Returned by {@link PolicyEngine#submit}: the run id (status polling, result download) plus a
+ * future that resolves when the run reaches a terminal or paused state. The future carries the
+ * {@link PolicyRun} whose status describes the outcome; it does not complete exceptionally for
+ * ordinary run failures.
*/
public record PolicyRunHandle(String runId, CompletableFuture Finished runs are evicted on a fixed interval once they age past {@code
- * policies.runExpiryMinutes}, mirroring the job-result expiry in {@code TaskManager} so a run's
- * rich in-memory state does not outlive the process. Only terminal runs are evicted; active and
- * paused ({@code WAITING_FOR_INPUT}) runs are retained regardless of age. Result files are not
- * touched here: a run shares its runId with a {@code TaskManager} job, which owns file-lifecycle
- * cleanup, so eviction only frees this map's entry.
+ * A scheduled sweep evicts only terminal runs aged past {@code policies.runExpiryMinutes};
+ * active and paused runs are kept regardless of age. Eviction frees only this map's entry: the
+ * shared {@code TaskManager} job owns file-lifecycle cleanup.
*/
@Slf4j
@Service
@@ -61,7 +57,7 @@ public class PolicyRunRegistry {
return runs.values();
}
- /** Scheduled hook: evict terminal runs that finished before the expiry window. */
+ /** Scheduled sweep entry point. */
private void evictExpiredRuns() {
try {
evictExpired(Instant.now().minus(runExpiry));
@@ -71,9 +67,8 @@ public class PolicyRunRegistry {
}
/**
- * Remove every terminal run last updated before {@code cutoff}; active and paused runs are kept
- * regardless of age. Returns the number evicted. Package-visible so the scheduled sweep and
- * tests exercise the same path with an explicit cutoff.
+ * Evict terminal runs last updated before {@code cutoff}, returning the count. Package-visible
+ * so the sweep and tests share one path with an explicit cutoff.
*/
int evictExpired(Instant cutoff) {
int removed = 0;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java
index 8685bf020..2a59e91bb 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java
@@ -20,14 +20,8 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
/**
- * Runs policies, and is the one place that knows how to turn a policy's configured {@link InputSpec
- * sources} into actual runs. Triggers (schedule, and future webhook/folder-watch) decide
- * when to run and call {@link #run(Policy)}; they never touch sources themselves. The
- * controller uses the supplied-input and ad-hoc entry points for on-demand work.
- *
- * This is the seam that keeps triggers and sources independent: a trigger depends on the runner,
- * the runner depends on the {@link InputSource} beans, and a source depends on neither - it just
- * yields {@link ResolvedInput units of work}, each carrying its own completion hook.
+ * Turns a policy's configured {@link InputSpec sources} into runs. Triggers decide when
+ * and call {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points.
*/
@Slf4j
@Service
@@ -38,10 +32,9 @@ public class PolicyRunner {
private final List The trigger is optional (a {@code null} trigger is a manual-only policy and needs no
- * validation); every configured source is validated.
+ * Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean
+ * that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger
+ * is a manual-only policy and skips trigger validation.
*/
@Service
@RequiredArgsConstructor
@@ -31,8 +28,7 @@ public class PolicyValidator {
private final List Two modes via the {@code mode} option:
- *
- * Mode option: "consume" (default) claims each file by moving it into {@code
+ * .stirling/processing} then routes it to {@code .stirling/done} or {@code .stirling/error}, so
+ * each file runs once; "snapshot" reads without moving, so every run sees the full set. Readiness
+ * is checked first so files mid-write are skipped.
*/
@Slf4j
@Service
@@ -44,7 +36,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
- // Bookkeeping lives under one hidden namespace dir so the watched folder stays tidy.
+ // Bookkeeping lives under one hidden dir so the watched folder stays tidy.
private static final String WORK_SUBDIR = ".stirling";
private static final String PROCESSING_SUBDIR = "processing";
private static final String DONE_SUBDIR = "done";
@@ -107,6 +99,7 @@ public class FolderInputSource implements InputSource {
return work;
}
+ // Atomic move into processing/: only one sweep can win the claim, the rest see the file gone.
private Path claim(Path inputDir, Path file) {
try {
Path processingDir = workDir(inputDir, PROCESSING_SUBDIR);
@@ -135,7 +128,6 @@ public class FolderInputSource implements InputSource {
}
}
- /** A bookkeeping subdirectory under the watched folder's {@code .stirling} namespace. */
private static Path workDir(Path inputDir, String subdir) {
return inputDir.resolve(WORK_SUBDIR).resolve(subdir);
}
@@ -166,7 +158,6 @@ public class FolderInputSource implements InputSource {
}
}
- /** The typed, validated form of a folder source's options: the directory and dedup mode. */
record FolderConfig(Path directory, boolean snapshot) {
private static final String DIRECTORY_OPTION = "directory";
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java
index a6f116d02..436f6c526 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java
@@ -7,14 +7,9 @@ import java.util.List;
import stirling.software.proprietary.policy.model.InputSpec;
/**
- * Resolves one of a policy's {@link InputSpec sources} into the files to run on - answering
- * where a run's files come from, independent of when it runs. The counterpart of
- * {@code PolicyOutputSink}: implementations are beans selected by {@link #supports(InputSpec)}, so
- * a new source kind (folder, S3) is just a new bean.
- *
- * Driven by the {@code PolicyRunner}, which a trigger calls when a policy is due; a source is
- * passive and knows nothing about what triggered the run. A manual run may instead supply files
- * directly and bypass sources entirely.
+ * Resolves a policy {@link InputSpec} into the files to run on. Implementations are beans selected
+ * by {@link #supports(InputSpec)}, so a new source kind (folder, S3) is just a new bean. A manual
+ * run may supply files directly and bypass sources entirely.
*/
public interface InputSource {
@@ -24,24 +19,18 @@ public interface InputSource {
/** Whether this source can handle the given spec. */
boolean supports(InputSpec spec);
- /**
- * Check that an input spec is usable, throwing {@link IllegalArgumentException} if not. Called
- * when a policy is saved so misconfiguration fails fast rather than at run time.
- */
+ /** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
default void validate(InputSpec spec) {}
/**
- * Resolve the spec into zero or more units of work, each carrying the files for one run and a
- * completion hook. Returning an empty list means there is nothing to run right now.
+ * Resolve the spec into zero or more units of work, each carrying one run's files and a
+ * completion hook. Empty list means nothing to run right now.
*/
List A policy holds a list of these; a run pulls from every one. An empty list means the policy
- * runs with no input files (a generator pipeline, or files supplied directly to a manual run).
- *
- * Data-driven and parallel to {@link OutputSpec}/{@link TriggerConfig}: a new source kind is a
- * new {@code type} handled by a new {@code InputSource} bean.
+ * One input source for a policy. {@code type} keys an {@code InputSource} bean; a run pulls from
+ * every source.
*/
public record InputSpec(String type, Map New destinations (folder, S3) are added as new sink beans keyed on a new {@code type} without
- * changing this shape or the engine.
- */
+/** Where a run's outputs are delivered. {@code type} keys a {@code PolicyOutputSink} bean. */
public record OutputSpec(String type, Map This is the single shape executed by the policy engine, shared by AI plans, manually-triggered
- * runs, and (later) watched folders. {@code output} may be null for callers that handle result
- * files themselves (e.g. the AI workflow, which builds its own response payload).
+ * {@code output} may be null for callers that handle result files themselves (e.g. the AI
+ * workflow, which builds its own response payload).
*/
public record PipelineDefinition(String name, List {@code operation} is a Stirling tool endpoint path (e.g. {@code /api/v1/misc/compress-pdf}),
- * matching the dispatch convention used by {@code InternalApiClient}. {@code parameters} are the
- * tool-specific scalar form fields.
- *
- * {@code fileParameters} binds a tool's named file fields (beyond the primary {@code fileInput}
- * stream) to supporting files supplied with the run: it maps the form field name (e.g. {@code
- * stampImage}, {@code overlayFiles}) to an asset key in the run's supporting-file store. This keeps
- * supporting inputs (a stamp image, a certificate, an overlay) out of the document stream that
- * flows step to step.
+ * {@code fileParameters} maps a tool's named file field (e.g. {@code stampImage}, beyond the
+ * primary {@code fileInput} stream) to an asset key in the run's supporting-file store, keeping
+ * supporting inputs out of the document stream that flows step to step.
*/
public record PipelineStep(
String operation, Map Every policy can always be run on demand (manually). It may additionally carry one automatic
- * {@link TriggerConfig} - usually a schedule - that fires it without a person asking; a {@code
- * null} trigger means manual-only. A trigger decides when a run happens and a {@link
- * InputSpec source} decides where its files come from; the two are independent, and a run
- * pulls from every configured source.
- *
- * This is the feature's central configuration object - what a user defines and the engine runs.
+ * Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code
+ * null} trigger means manual-only. Trigger decides when, {@link InputSpec sources} decide where
+ * files come from; a run pulls from every source.
*/
public record Policy(
String id,
@@ -42,7 +37,7 @@ public record Policy(
this(id, name, owner, enabled, trigger, List.of(), steps, output);
}
- /** The engine-level, trigger-agnostic view of this policy's pipeline. */
+ /** This policy's pipeline as the engine sees it. */
public PipelineDefinition toDefinition() {
return new PipelineDefinition(name, steps, output);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java
index 4e6ddae83..7088cd441 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java
@@ -6,17 +6,9 @@ import java.util.Map;
import org.springframework.core.io.Resource;
/**
- * The files a run operates on, split into two roles:
- *
- * This carries the rich execution state (status, step cursor, wait state) that the job system's
- * {@code JobResult} does not model. The run is also projected into {@code TaskManager} for
- * cluster-visible status, progress notes, and file download; this object is the authoritative
- * source of the state machine.
+ * Live, mutable state of one pipeline run, held in memory by {@code PolicyRunRegistry} and the
+ * authoritative source of the state machine. Carries execution state ({@code JobResult} does not
+ * model status/step cursor/wait state); also projected into {@code TaskManager} for cluster-visible
+ * status and download.
*/
@Getter
public class PolicyRun {
@@ -69,9 +67,7 @@ public class PolicyRun {
touch();
}
- /**
- * Mark cancelled if the run has not already reached a terminal state. Returns whether it did.
- */
+ /** Cancels unless already terminal; returns whether it transitioned. */
public synchronized boolean cancel() {
if (status.isTerminal()) {
return false;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunStatus.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunStatus.java
index ee75f5e50..646703bf5 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunStatus.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunStatus.java
@@ -1,11 +1,8 @@
package stirling.software.proprietary.policy.model;
/**
- * Lifecycle states of a {@link PolicyRun}.
- *
- * {@code WAITING_FOR_INPUT} is modelled now so the engine and run shape support pausing a run
- * (e.g. a step that blocks for a human decision) without holding a thread; the resume handshake is
- * implemented in a later stage.
+ * Lifecycle states of a {@link PolicyRun}. {@code WAITING_FOR_INPUT} models a thread-free pause;
+ * the resume handshake lands in a later stage.
*/
public enum PolicyRunStatus {
PENDING,
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java
index a3427835c..bba12a3c5 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java
@@ -5,8 +5,8 @@ import java.util.List;
import stirling.software.common.model.job.ResultFile;
/**
- * Read-only view of a {@link PolicyRun} returned by the status endpoint. Output files are surfaced
- * as {@link ResultFile} so the caller can download each via {@code GET /api/v1/general/files/{id}}.
+ * Read-only view of a {@link PolicyRun} for the status endpoint. Outputs are {@link ResultFile}s,
+ * downloadable via {@code GET /api/v1/general/files/{id}}.
*/
public record PolicyRunView(
String runId,
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Schedule.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Schedule.java
index 148534636..7a684942f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Schedule.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Schedule.java
@@ -11,16 +11,9 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
- * When a scheduled policy should fire, expressed as intent ("every day at 02:00") rather than a
- * cron string. A frontend builds one of these from a friendly picker; the schedule trigger asks it
- * for the next firing after a given moment.
- *
- * Sealed so every cadence is an explicit, self-validating shape and adding a new one forces a
- * new subtype rather than another string convention to learn. The {@code type} discriminator stays
- * on the wire so the frontend can switch on it without knowing the Java hierarchy.
- *
- * Wall-clock kinds ({@link Daily}, {@link Weekly}, {@link Monthly}) are evaluated in the zone of
- * the {@code after} argument; {@link Every} is a fixed offset and ignores wall-clock time.
+ * A scheduled policy's firing cadence; {@code type} is the JSON discriminator. Wall-clock kinds
+ * ({@link Daily}, {@link Weekly}, {@link Monthly}) evaluate in the {@code after} argument's zone;
+ * {@link Every} is a fixed offset and ignores wall-clock time.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@@ -42,10 +35,7 @@ public sealed interface Schedule {
DAYS
}
- /**
- * A fixed cadence repeating from when the policy was last seen: "every 15 minutes", "every 6
- * hours". Time of day is irrelevant.
- */
+ /** A fixed offset from {@code after}: "every 15 minutes", "every 6 hours". No time of day. */
record Every(long count, Unit unit) implements Schedule {
public Every {
if (count <= 0) {
@@ -91,8 +81,7 @@ public sealed interface Schedule {
@Override
public ZonedDateTime nextAfter(ZonedDateTime after) {
- // The soonest of the next 7 days that lands on a chosen weekday, at the configured
- // time.
+ // Soonest of the next 7 days landing on a chosen weekday, at the configured time.
for (int i = 0; i <= 7; i++) {
ZonedDateTime candidate = after.plusDays(i).with(at);
if (candidate.isAfter(after) && days.contains(candidate.getDayOfWeek())) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/TriggerConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/TriggerConfig.java
index e18347dc3..477de122e 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/TriggerConfig.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/TriggerConfig.java
@@ -3,17 +3,9 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
- * A {@link Policy}'s optional automatic trigger - what fires it without a person asking. {@code
- * type} selects a trigger kind ("schedule", and in future "webhook", "folder-watch", ...); {@code
- * options} carries type-specific configuration (a {@link Schedule}, a webhook secret, ...).
- *
- * Manual running is not a trigger kind: every policy can always be run on demand, so a
- * policy with no automatic trigger simply has a {@code null} {@code TriggerConfig}. A trigger
- * answers only "when"; where a run's files come from is a separate concern owned by the policy's
- * {@link InputSpec sources}.
- *
- * Data-driven and parallel to {@link OutputSpec}: new trigger kinds are new {@code type} values
- * handled by a new trigger bean, with no change to the model.
+ * A {@link Policy}'s automatic trigger; {@code type} keys a trigger bean (e.g. "schedule"). Manual
+ * running is not a trigger kind: a manual-only policy carries a {@code null} {@code TriggerConfig}.
+ * Answers only "when"; file sources are the policy's {@link InputSpec}s.
*/
public record TriggerConfig(String type, Map Stored as fileIds rather than in-memory resources by design: a paused run must be resumable
- * long after its worker thread has gone.
+ * Resumable snapshot captured when a run pauses ({@link PolicyRunStatus#WAITING_FOR_INPUT}). {@code
+ * resumeStepIndex} is the 0-based step to continue from; {@code pendingFileIds} are intermediate
+ * files held in {@code FileStorage} (not in-memory resources) so a pause survives the worker thread
+ * ending or a node restart.
*/
public record WaitState(String reason, int resumeStepIndex, List Files are streamed to disk (so large outputs are not buffered) and given unique names to avoid
- * clobbering existing files. The returned {@link ResultFile}s describe what was written (path +
- * size); they carry a synthetic id because the deliverable is the file on disk, not a {@code
- * FileStorage} entry, so folder outputs are not downloadable via {@code /files/{id}}.
+ * Writes a run's outputs to the {@code directory} given in the {@link OutputSpec}. Files are
+ * streamed (not buffered) and uniquely named to avoid clobbering. Returned {@link ResultFile}s
+ * carry a synthetic id since the deliverable is the file on disk, not a {@code FileStorage} entry,
+ * so folder outputs are not downloadable via {@code /files/{id}}.
*/
@Slf4j
@Service
@@ -95,11 +92,7 @@ public class FolderOutputSink implements PolicyOutputSink {
return Path.of(directory.toString());
}
- /**
- * The resource's filename reduced to a bare, traversal-free name: any directory component or
- * "../" is stripped so a crafted output name cannot escape {@code targetDir}. Falls back to a
- * synthetic name when the filename is absent or reduces to nothing usable.
- */
+ // Strip any directory component / "../" so a crafted output name cannot escape targetDir.
private static String safeName(String filename, int index) {
if (filename == null || filename.isBlank()) {
return "output-" + index;
@@ -111,7 +104,7 @@ public class FolderOutputSink implements PolicyOutputSink {
return name;
}
- /** Resolve a non-colliding path in {@code dir}, appending " (n)" before the extension. */
+ // Non-colliding path, appending " (n)" before the extension.
private static Path uniqueTarget(Path dir, String filename) {
Path candidate = dir.resolve(filename);
if (!Files.exists(candidate)) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java
index bef5d46b3..cb5848f52 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java
@@ -17,9 +17,8 @@ import stirling.software.common.service.FileStorage;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
- * Default output sink: stores each output file in {@code FileStorage} so it is downloadable via
- * {@code GET /api/v1/general/files/{fileId}}. This is the destination for manually-triggered runs
- * whose results are returned to the caller.
+ * Default sink: stores each output in {@code FileStorage} so it is downloadable via {@code GET
+ * /api/v1/general/files/{fileId}}. Used for manual runs whose results return to the caller.
*/
@Service
@RequiredArgsConstructor
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java
index c98b55ad6..6e6c80ba4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java
@@ -9,11 +9,9 @@ import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
- * Delivers a finished run's output files to a destination, returning durable {@link ResultFile}
- * descriptors (fileId + metadata) for the run record.
- *
- * Implementations are Spring beans selected by {@link #supports(OutputSpec)}. New destinations
- * (folder, S3) are added as new beans without changing the engine.
+ * Delivers a finished run's outputs to a destination, returning {@link ResultFile} descriptors for
+ * the run record. Implementations are beans selected by {@link #supports(OutputSpec)}, so a new
+ * destination (folder, S3) is just a new bean.
*/
public interface PolicyOutputSink {
@@ -23,19 +21,10 @@ public interface PolicyOutputSink {
/** Whether this sink can handle the given output spec. */
boolean supports(OutputSpec spec);
- /**
- * Check that an output spec is usable, throwing {@link IllegalArgumentException} if not. Called
- * when a policy is saved so misconfiguration fails fast rather than at run time.
- */
+ /** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
default void validate(OutputSpec spec) {}
- /**
- * Persist/deliver the output files and return their descriptors.
- *
- * @param runId the run these outputs belong to
- * @param outputs the final pipeline output resources
- * @param spec the requested destination
- */
+ /** Persist/deliver the output files and return their descriptors. */
List All methods default to no-ops so callers implement only what they surface.
+ * Receives live progress as a pipeline run executes (SSE stream, job notes, or both). Step indices
+ * are 1-based. All methods default to no-ops.
*/
public interface PolicyProgressListener {
- /** A listener that ignores all progress. */
PolicyProgressListener NOOP = new PolicyProgressListener() {};
- /** Called immediately before step {@code stepIndex} of {@code stepCount} begins. */
default void onStepStart(int stepIndex, int stepCount, String operation) {}
- /** Called immediately after step {@code stepIndex} of {@code stepCount} completes. */
default void onStepComplete(int stepIndex, int stepCount, String operation) {}
- /** Called on a keep-alive tick so downstream connections can detect disconnects promptly. */
+ /** Keep-alive tick so downstream connections can detect disconnects promptly. */
default void onHeartbeat() {}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java
index 2ec94d1eb..21445d838 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java
@@ -9,9 +9,8 @@ import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.policy.model.Policy;
/**
- * In-memory {@link PolicyStore}. Not the runtime bean - {@link JpaPolicyStore} is the durable
- * store. Kept as a lightweight, dependency-free implementation for tests and for any future no-
- * database mode.
+ * In-memory {@link PolicyStore} for tests and any future no-database mode. {@link JpaPolicyStore}
+ * is the runtime bean.
*/
public class InProcessPolicyStore implements PolicyStore {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java
index e4e05ea4c..6a4c0428f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java
@@ -13,9 +13,8 @@ import stirling.software.proprietary.policy.model.Policy;
import tools.jackson.databind.ObjectMapper;
/**
- * Durable {@link PolicyStore} backed by JPA. The runtime store whenever the proprietary module runs
- * (a datasource is always present). Policies are persisted as JSON via {@link PolicyEntity}; the
- * scalar columns are kept in sync for querying.
+ * Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via
+ * {@link PolicyEntity}, with scalar columns kept in sync for querying.
*/
@Service
@RequiredArgsConstructor
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java
index d5aad6130..944b49461 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java
@@ -12,13 +12,11 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
- * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}.
- *
- * The whole policy is stored as JSON in {@code policyJson} (authoritative on read, and the same
- * serialization the API uses); the scalar columns are denormalized copies for querying - notably
- * {@code triggerType} + {@code enabled} so background triggers can fetch their policies. Ownership
- * is a plain {@code owner} string rather than a foreign key, to stay decoupled from the security
- * entities; richer team scoping can be layered on later.
+ * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
+ * as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies
+ * for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch
+ * their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the
+ * security entities.
*/
@Entity
@Table(name = "policies")
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java
index 16b2ae861..c9a2a0ecf 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java
@@ -5,24 +5,19 @@ import java.util.Optional;
import stirling.software.proprietary.policy.model.Policy;
-/**
- * Stores {@link Policy} definitions. The in-memory implementation backs simple deployments now; a
- * durable (JPA) implementation can replace it behind this interface without touching callers.
- */
+/** Stores {@link Policy} definitions. */
public interface PolicyStore {
- /** Create or update a policy. A blank/absent id is assigned; returns the stored policy. */
+ /** Create or update; a blank/absent id is assigned. Returns the stored policy. */
Policy save(Policy policy);
Optional The watcher is a latency optimisation, not a source of truth, so this pairs an event watch
- * with a low-frequency reconcile sweep ({@code watchReconcileSeconds}). The reconcile both
- * (a) re-syncs which directories are watched as policies are created/edited/deleted and folders
- * appear on disk, and (b) runs every folder-watch policy once, catching files that pre-dated the
- * watch, events lost to inotify-queue overflow, and changes on filesystems that do not deliver
- * events at all (NFS, many container bind mounts). Both paths just call {@link PolicyRunner#run};
- * the {@link InputSource} does the claiming, so a redundant run finds nothing to claim and is
- * harmless.
+ * The watch is a latency optimisation, not a source of truth: a periodic reconcile sweep ({@code
+ * watchReconcileSeconds}) re-syncs watched dirs and re-runs every policy, covering files that
+ * pre-dated the watch, dropped events, and filesystems that emit none (NFS, bind mounts). Redundant
+ * runs are harmless since {@link InputSource} does the claiming.
*
- * Like {@link ScheduleTrigger}, watch state is per-node and in memory; this assumes a single
- * node and rebuilds its registrations on restart from the {@link PolicyStore}.
+ * Watch state is in memory, so this assumes a single node and rebuilds registrations on restart.
*/
@Slf4j
@Service
@@ -65,7 +59,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
private volatile boolean running;
- // Package-visible (not private) so tests can drive syncRegistrations() against a real service.
+ // Package-visible so tests can drive syncRegistrations() against a real service.
volatile WatchService watchService;
private volatile ScheduledExecutorService reconciler;
@@ -125,8 +119,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
private void watchLoop() {
- // Capture the service once: stop() may null the field, and a local avoids racing that to an
- // NPE (close() still wakes take()/poll() on this same instance).
+ // Capture once: stop() may null the field; close() still wakes take()/poll() on this local.
WatchService watcher = watchService;
if (watcher == null) {
return;
@@ -146,9 +139,8 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
/**
- * Collect the directories touched by {@code first} and any further events that arrive within
- * the quiet period, so a burst of file-system events becomes a single set of affected
- * directories. The event kinds are irrelevant: any event on a watched dir just means "go look".
+ * Coalesce a burst of file-system events into one set of affected directories: drain everything
+ * arriving within the quiet period. Event kinds are irrelevant; any event means "go look".
*/
private Set Triggers are background, configuration-driven beans (schedule, and in future webhook or
- * folder-watch): on {@link #start()} they begin watching/scheduling for the policies returned by
- * {@code PolicyStore.findByTriggerType(type())}, and stop on {@link #stop()}. New trigger kinds are
- * new beans of this type; the runner and the {@code Policy} model do not change.
- *
- * Manual running is not a trigger - every policy can always be run on demand via the {@code
- * PolicyRunner} regardless of whether it has a trigger.
+ * Decides when a policy runs. On firing it hands the policy to {@code PolicyRunner}; it
+ * never resolves sources itself. New trigger kinds are just new beans of this type.
*/
public interface PolicyTrigger {
- /** Stable identifier for this trigger kind, matching {@code TriggerConfig.type()}. */
+ /** Matches {@code TriggerConfig.type()}. */
String type();
/**
- * Check that this trigger is usable for the given policy, throwing {@link
- * IllegalArgumentException} if not. Called when a policy is saved so misconfiguration fails
- * fast rather than at fire time. Receives the whole {@link Policy} (not just its {@code
- * TriggerConfig}) so a trigger whose firing depends on the policy's sources (folder-watch) can
- * assert that relationship; most triggers only inspect {@code policy.trigger()}.
+ * Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole
+ * {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that.
*/
default void validate(Policy policy) {}
- /** Begin activating policies of this type (e.g. start the schedule sweep). */
default void start() {}
- /** Stop activating and release any resources. */
default void stop() {}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java
index 1e87dcd4d..7d71138df 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java
@@ -8,14 +8,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-/**
- * Starts and stops every {@link PolicyTrigger} with the application lifecycle. Background triggers
- * (schedule, and future folder/S3) begin watching on {@link #start()} and release resources on
- * {@link #stop()}; request-driven triggers (manual) are no-ops.
- *
- * This is the single activation point for triggers - a new background trigger only has to be a
- * {@link PolicyTrigger} bean.
- */
+/** Starts and stops every {@link PolicyTrigger} with the application lifecycle. */
@Slf4j
@Service
@RequiredArgsConstructor
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java
index f138710d7..65391e978 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java
@@ -24,16 +24,9 @@ import stirling.software.proprietary.policy.store.PolicyStore;
import tools.jackson.databind.ObjectMapper;
/**
- * Fires policies on a {@link Schedule}. On {@link #start()} it sweeps on a fixed interval; each
- * sweep finds the enabled "schedule" policies and runs any whose next firing has come due since it
- * last fired.
+ * Fires policies on a {@link Schedule}: a fixed-interval sweep runs each due "schedule" policy.
*
- * The trigger only decides when: once a policy is due it hands it to the {@link
- * PolicyRunner}, which pulls from the policy's configured sources and starts the runs. The trigger
- * knows nothing about folders, buckets, or how many runs a sweep produces.
- *
- * Caveat: last-fire times are tracked in memory, so this assumes a single node and resets
- * on restart; cluster-wide coordination (leader election) is a follow-up.
+ * Last-fire times are in memory, so this assumes a single node and resets on restart.
*/
@Slf4j
@Service
@@ -101,8 +94,7 @@ public class ScheduleTrigger implements PolicyTrigger {
continue;
}
- // First time we see a policy, baseline its last-fire to now so it does not fire
- // immediately; subsequent sweeps fire it once its next firing has passed.
+ // Baseline a newly-seen policy to now so it does not fire immediately.
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
if (!next.toInstant().isAfter(now)) {
@@ -114,9 +106,8 @@ public class ScheduleTrigger implements PolicyTrigger {
}
/**
- * The typed, validated form of a schedule trigger's options: the {@link Schedule} and the zone
- * its wall-clock kinds are evaluated in (default UTC). Construction fails for a missing/invalid
- * schedule or zone.
+ * Validated schedule-trigger options: the {@link Schedule} and the zone it runs in (UTC by
+ * default).
*/
record ScheduleConfig(Schedule schedule, ZoneId zone) {
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java
new file mode 100644
index 000000000..93720bc7d
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java
@@ -0,0 +1,87 @@
+package stirling.software.proprietary.policy.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import stirling.software.common.model.ApplicationProperties;
+import stirling.software.common.service.UserServiceInterface;
+import stirling.software.proprietary.policy.model.OutputSpec;
+import stirling.software.proprietary.policy.model.Policy;
+
+/**
+ * Tests for {@link PolicyAccessGuard}: owner-or-admin access, no-op when login is disabled, and
+ * server-side owner assignment.
+ */
+@ExtendWith(MockitoExtension.class)
+class PolicyAccessGuardTest {
+
+ @Mock private UserServiceInterface userService;
+
+ private PolicyAccessGuard guard(boolean loginEnabled) {
+ ApplicationProperties properties = new ApplicationProperties();
+ properties.getSecurity().setEnableLogin(loginEnabled);
+ return new PolicyAccessGuard(userService, properties);
+ }
+
+ @Test
+ void loginDisabledAllowsEverythingAndAssignsNoOwner() {
+ PolicyAccessGuard guard = guard(false);
+ List
- *
+ * Call an endpoint, returning result files and optional report. Response handling: JSON body is
+ * the report with no file; a file body returns the file plus any {@link
+ * AiToolResponseHeaders#TOOL_REPORT} header report; ZIP responses (per tool metadata) are
+ * unpacked to a flat file list.
*/
private ToolResult callEndpoint(
PipelineStep step, List
- *
- *
- * Readiness is checked first (via {@link FileReadinessChecker}) so files mid-write are skipped.
+ *
- *
- *
- * Asset values are lists so a single key can carry multi-file fields (e.g. attachments).
+ * A run's files. {@code primary} documents flow step to step; {@code supportingFiles} are auxiliary
+ * assets bound by key via {@link PipelineStep#fileParameters()} and never enter the document
+ * stream. Asset values are lists so one key can carry a multi-file field (e.g. attachments).
*/
public record PolicyInputs(List