Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
interval (`a2at.server.heartbeat-interval-seconds`, default 15 seconds, `0` disables). Keeps intermediaries
from idle-timing-out long-running SSE connections during agent execution; the heartbeat integrates with the
emitter lifecycle and stops on stream completion, error, timeout, or client disconnect.
- Make collection fields on model types defensively copied and expose them through unmodifiable
views (`Workflow`, `WorkflowStep`, `WorkflowSearchResult`, `ExecutionResult`, `SendMessageResult`,
`TaskResult`, `BusinessFailure`, and the message records). Source- and binary-compatible; callers
that mutate the collections returned by these getters now receive `UnsupportedOperationException`
instead of silently corrupting engine-owned snapshots.

## [0.0.10] — 2026-09-16

Expand Down
44 changes: 40 additions & 4 deletions config/spotbugs/spotbugs-exclude.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,43 @@
for this codebase's patterns. Clean up over time; new findings still block verify.
-->
<FindBugsFilter>
<!-- Returning/storing mutable collections in builders, records and config accessors is
the intended API surface; defensive copies are made at trust boundaries. -->
<!-- Lombok-generated builder fluent setters store the caller's reference into the transient
builder; build() routes through constructors that perform the defensive copies. -->
<Match>
<Class name="~.*\$.*Builder"/>
<Or>
<Bug pattern="EI_EXPOSE_REP"/>
<Bug pattern="EI_EXPOSE_REP2"/>
<Bug pattern="EI_EXPOSE_REP2_EI_EXPOSE_REP2"/>
</Or>
</Match>

<!-- Engine-internal mutable-exposure acceptances (review on next touch):
- NotificationSubscription: the live completion/acknowledgement futures ARE the API
- RemoteA2AErrorException: error envelope headers, exception is terminal
- WireLog.Entry: internal observability record, hot path
- WorkflowEngineClientConfig: credentialsConfig already deep-copied/unmodifiable
- WorkflowExecutor: stores collaborator references during assembly
- A2AController: stores collaborator references during assembly -->
<Match>
<Or>
<Class name="~.*NotificationSubscription"/>
<Class name="~.*RemoteA2AErrorException"/>
<Class name="~.*WireLog\$Entry"/>
<Class name="~.*WorkflowEngineClientConfig"/>
<Class name="~.*WorkflowExecutor"/>
<Class name="~.*A2AController"/>
</Or>
<Or>
<Bug pattern="EI_EXPOSE_REP"/>
<Bug pattern="EI_EXPOSE_REP2"/>
</Or>
</Match>

<!-- Lombok-generated equals/hashCode re-check fields the compiler knows are non-null. -->
<Match>
<Bug pattern="RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE"/>
</Match>

<!-- Java serialization is not used by this project. -->
<Match>
<Or>
Expand Down Expand Up @@ -69,13 +96,19 @@
- NegotiationStrategy intentionally shadows the control interface name (sample mirror)
- Boolean-returning booleanParam helper may return null to signal "unset"
- handleLogin catches Exception broadly as the demo login boundary
- doPrivileged warning: SecurityManager is not used by this project -->
- doPrivileged warning: SecurityManager is not used by this project
- OmcAgentLauncher.servers(): demo server registry, iterated by the demo only
- SpringWorkbenchExecutor/SpringWorkbenchExtensionLifecycle: storing the Spring
properties bean reference is the standard Spring configuration pattern -->
<Match>
<Or>
<Class name="~.*NegotiationBaseAgentExecutor.*"/>
<Class name="~.*NotificationPolicy.*"/>
<Class name="~.*examples.negotiation.NegotiationStrategy"/>
<Class name="~.*JdkHttpA2AServer.*"/>
<Class name="~.*OmcAgentLauncher.*"/>
<Class name="~.*SpringWorkbenchExecutor.*"/>
<Class name="~.*SpringWorkbenchExtensionLifecycle.*"/>
<Class name="~.*A2ATInitialization.*"/>
</Or>
<Bug pattern="DP_CREATE_CLASSLOADER_INSIDE_DO_PRIVILEGED"/>
Expand All @@ -86,6 +119,9 @@
<Class name="~.*NotificationPolicy.*"/>
<Class name="~.*examples.negotiation.NegotiationStrategy"/>
<Class name="~.*JdkHttpA2AServer.*"/>
<Class name="~.*OmcAgentLauncher.*"/>
<Class name="~.*SpringWorkbenchExecutor.*"/>
<Class name="~.*SpringWorkbenchExtensionLifecycle.*"/>
</Or>
</Match>
</FindBugsFilter>
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ public String code() {
return code;
}

/** Safe business facts selected by the host. */
/** Safe business facts selected by the host; unmodifiable. */
public Map<String, Object> details() {
return details;
return java.util.Collections.unmodifiableMap(details);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the License); you may
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
Expand All @@ -19,30 +19,62 @@

package dev.openan.workflow.engine.model;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* Outcome of one workflow run: overall success, per-step execution history, aggregated step
* outputs, and a terminal error description when the run failed.
*
* <p>Collection fields are copied on write and exposed unmodifiable, so callers cannot mutate the
* result after the engine has produced it.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ExecutionResult {
private boolean success;

/** Per-step records in execution order. */
/** Per-step records in execution order. Stored defensively; exposed unmodifiable. */
private List<Map<String, Object>> history;

/** Step name to business outputs; nested structure preserved. */
private Map<String, Map<String, Object>> stepOutputs;

/** Terminal failure description; raw error text, not redacted. Null on success. */
private String error;

/** All-args constructor with defensive copies; Lombok's builder routes through it. */
public ExecutionResult(
boolean success,
List<Map<String, Object>> history,
Map<String, Map<String, Object>> stepOutputs,
String error) {
this.success = success;
this.history = history == null ? null : new ArrayList<>(history);
this.stepOutputs = stepOutputs == null ? null : new HashMap<>(stepOutputs);
this.error = error;
}

public List<Map<String, Object>> getHistory() {
return history == null ? null : Collections.unmodifiableList(history);
}

public void setHistory(List<Map<String, Object>> history) {
this.history = history == null ? null : new ArrayList<>(history);
}

public Map<String, Map<String, Object>> getStepOutputs() {
return stepOutputs == null ? null : Collections.unmodifiableMap(stepOutputs);
}

public void setStepOutputs(Map<String, Map<String, Object>> stepOutputs) {
this.stepOutputs = stepOutputs == null ? null : new HashMap<>(stepOutputs);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,24 @@ public record MessageContent(
extensions = Collections.unmodifiableSet(active);
}

/** Unmodifiable view; the underlying snapshot is immutable by construction. */
@Override
public List<Part<?>> parts() {
return Collections.unmodifiableList(parts);
}

/** Unmodifiable view; the underlying snapshot is immutable by construction. */
@Override
public Map<String, Object> metadata() {
return Collections.unmodifiableMap(metadata);
}

/** Unmodifiable view; the underlying set is immutable by construction. */
@Override
public Set<String> extensions() {
return Collections.unmodifiableSet(extensions);
}

/** Plain A2A text, without implicit extension activation or generation. */
public static MessageContent text(String text) {
return parts(List.of(new TextPart(Objects.requireNonNull(text, "text"))));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ public record ReceivedMessage(
.toList();
}

/** Unmodifiable view; artifacts are defensive snapshots. */
@Override
public Map<String, Object> taskMetadata() {
return java.util.Collections.unmodifiableMap(taskMetadata);
}

/** Unmodifiable view; artifacts are defensive snapshots. */
@Override
public List<Artifact> artifacts() {
return java.util.Collections.unmodifiableList(artifacts);
}

private static void project(List<Part<?>> parts, List<Object> values) {
for (Part<?> part : parts) {
if (part instanceof TextPart text) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,23 @@ public List<Object> getOutputs() {
.toList();
}

/** Unmodifiable view; stored via a defensive JSON snapshot. */
public Map<String, Object> getMetadata() {
return metadata == null ? null : java.util.Collections.unmodifiableMap(metadata);
}

/** Stores a defensive JSON snapshot of the supplied metadata. */
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? Map.of() : BusinessValues.map(metadata);
}

/** Unmodifiable view of the structured response projection. */
public List<ReceivedMessage> getReceivedMessages() {
return java.util.Collections.unmodifiableList(receivedMessages);
}

/** Snapshots the list supplied by transport adapters. */
public void setReceivedMessages(List<ReceivedMessage> messages) {
this.receivedMessages = List.copyOf(messages);
this.receivedMessages = messages == null ? List.of() : List.copyOf(messages);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,16 @@ public record TaskExecutionResult(
: receivedMessages.stream().flatMap(message -> message.outputs().stream()).toList();
errorDetails = errorDetails == null ? java.util.Map.of() : BusinessValues.map(errorDetails);
}

/** Unmodifiable view; outputs are defensive snapshots. */
@Override
public List<Object> outputs() {
return java.util.Collections.unmodifiableList(outputs);
}

/** Unmodifiable view; error details are defensive snapshots. */
@Override
public java.util.Map<String, Object> errorDetails() {
return java.util.Collections.unmodifiableMap(errorDetails);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,14 @@ public static TaskResult success(List<Object> outputs) {
public static TaskResult failure(String code, String message) {
return builder().success(false).errorCode(code).error(message).build();
}

/** Unmodifiable view; outputs are defensive snapshots. */
public List<Object> getOutputs() {
return java.util.Collections.unmodifiableList(outputs);
}

/** Unmodifiable view; error details are defensive snapshots. */
public java.util.Map<String, Object> getErrorDetails() {
return java.util.Collections.unmodifiableMap(errorDetails);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
package dev.openan.workflow.engine.model;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
Expand All @@ -35,14 +35,31 @@
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Workflow {
@Builder.Default private String id = "";
@Builder.Default private String name = "";
@Builder.Default private String description = "";
@Builder.Default private List<WorkflowStep> steps = List.of();

/** All-args constructor with defensive copies; Lombok's builder routes through it. */
public Workflow(String id, String name, String description, List<WorkflowStep> steps) {
this.id = id == null ? "" : id;
this.name = name == null ? "" : name;
this.description = description == null ? "" : description;
this.steps = steps == null ? List.of() : new ArrayList<>(steps);
}

/** Unmodifiable view; steps are snapshots of the loaded definition. */
public List<WorkflowStep> getSteps() {
return steps == null ? null : Collections.unmodifiableList(steps);
}

/** Stores a defensive copy of the supplied step list. */
public void setSteps(List<WorkflowStep> steps) {
this.steps = steps == null ? List.of() : new ArrayList<>(steps);
}

/**
* Builds a workflow from a PSOP-style map (as returned by the orchestration center).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
package dev.openan.workflow.engine.model;

import dev.openan.workflow.engine.registry.LoadPsop;
import java.util.Collections;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
Expand All @@ -35,7 +35,6 @@
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class WorkflowSearchResult {
private String workflowId;
Expand All @@ -48,4 +47,38 @@ public class WorkflowSearchResult {
private String userIntent;
private String relatedPreflow;
private String tasksSummary;

/** All-args constructor with defensive copies; Lombok's builder routes through it. */
public WorkflowSearchResult(
String workflowId,
String workflowType,
String name,
String description,
List<String> tags,
String createdAt,
double score,
String userIntent,
String relatedPreflow,
String tasksSummary) {
this.workflowId = workflowId;
this.workflowType = workflowType;
this.name = name;
this.description = description;
this.tags = tags == null ? List.of() : List.copyOf(tags);
this.createdAt = createdAt;
this.score = score;
this.userIntent = userIntent;
this.relatedPreflow = relatedPreflow;
this.tasksSummary = tasksSummary;
}

/** Unmodifiable view; tags are snapshots. */
public List<String> getTags() {
return tags == null ? null : Collections.unmodifiableList(tags);
}

/** Stores a defensive copy of the supplied tag list. */
public void setTags(List<String> tags) {
this.tags = tags == null ? List.of() : List.copyOf(tags);
}
}
Loading