WorkflowHistoryCommandHttpAdapter.java
package org.hammer.audio.workflow.editor.http;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import org.hammer.audio.workflow.editor.WorkflowProjection;
import org.hammer.audio.workflow.history.CreateWorkflowBranchCommand;
import org.hammer.audio.workflow.history.RestoreWorkflowVersionCommand;
import org.hammer.audio.workflow.history.WorkflowBranchCreationResult;
import org.hammer.audio.workflow.history.WorkflowChange;
import org.hammer.audio.workflow.history.WorkflowHistoryCommandService;
import org.hammer.audio.workflow.history.WorkflowHistoryComparison;
import org.hammer.audio.workflow.history.WorkflowRestoreResult;
import org.hammer.audio.workflow.store.CommitId;
import org.hammer.audio.workflow.store.CommitMetadata;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** HTTP boundary for explicit branch-scoped workflow comparison, branching and restore. */
@RestController
@RequestMapping("/workflow/history")
@ConditionalOnProperty(name = "workbench.persistence.mode", havingValue = "hibernate")
public final class WorkflowHistoryCommandHttpAdapter {
private final WorkflowHistoryCommandService commandService;
/**
* Creates the history-command controller.
*
* @param commandService compare, branch and restore application service
*/
public WorkflowHistoryCommandHttpAdapter(WorkflowHistoryCommandService commandService) {
this.commandService = Objects.requireNonNull(commandService, "commandService");
}
/** Creates a new branch from one exact source-branch-reachable commit. */
@PostMapping("/branches")
public BranchResponse createBranch(@Valid @RequestBody BranchRequest request) {
return BranchResponse.from(
commandService.createBranch(
new CreateWorkflowBranchCommand(
request.sourceBranch(),
request.newBranch(),
new CommitId(request.fromCommitId()))));
}
/** Compares two exact commits reachable from one branch. */
@PostMapping("/compare")
public ComparisonResponse compare(@Valid @RequestBody CompareRequest request) {
return ComparisonResponse.from(
commandService.compare(
request.branch(),
new CommitId(request.beforeCommitId()),
new CommitId(request.afterCommitId())));
}
/** Restores a historical snapshot as a new audit commit on the expected current HEAD. */
@PostMapping("/restore")
public RestoreResponse restore(@Valid @RequestBody RestoreRequest request) {
WorkflowRestoreResult result =
commandService.restore(
new RestoreWorkflowVersionCommand(
request.branch(),
new CommitId(request.targetCommitId()),
new CommitId(request.expectedHeadCommitId()),
new CommitMetadata(request.author(), request.message(), request.timestamp())));
return RestoreResponse.from(result);
}
/**
* Exact new-branch creation request.
*
* @param sourceBranch source branch proving commit reachability
* @param newBranch branch name that must not already exist
* @param fromCommitId exact initial branch HEAD
*/
public record BranchRequest(
@NotBlank String sourceBranch, @NotBlank String newBranch, @NotBlank String fromCommitId) {
public BranchRequest {
// Bean validation owns request-contract checks at the HTTP boundary.
}
}
/**
* Newly created branch identity and exact initial workflow checkpoint.
*
* @param sourceBranch source branch from which the exact commit was selected
* @param branch newly created branch name
* @param headCommitId exact initial HEAD commit of the new branch
* @param workflowId workflow identity stored at the initial branch checkpoint
*/
public record BranchResponse(
String sourceBranch, String branch, String headCommitId, String workflowId) {
static BranchResponse from(WorkflowBranchCreationResult result) {
return new BranchResponse(
result.sourceBranch(), result.branch(), result.head().value(), result.workflowId());
}
}
/**
* Branch-scoped comparison request.
*
* @param branch branch from which both commits must be reachable
* @param beforeCommitId exact earlier commit
* @param afterCommitId exact later commit
*/
public record CompareRequest(
@NotBlank String branch, @NotBlank String beforeCommitId, @NotBlank String afterCommitId) {
public CompareRequest {
// Bean validation owns request-contract checks at the HTTP boundary.
}
}
/**
* Non-destructive restore request.
*
* @param branch branch receiving the new restore commit
* @param targetCommitId reachable historical commit whose snapshot is restored
* @param expectedHeadCommitId optimistic-concurrency branch HEAD
* @param author restore commit author
* @param message restore commit message
* @param timestamp restore commit timestamp
*/
public record RestoreRequest(
@NotBlank String branch,
@NotBlank String targetCommitId,
@NotBlank String expectedHeadCommitId,
@NotBlank String author,
@NotBlank String message,
@NotNull Instant timestamp) {
public RestoreRequest {
// Bean validation owns request-contract checks at the HTTP boundary.
}
}
/**
* Transport-safe comparison including both graph states and semantic change atoms.
*
* @param beforeCommitId exact before commit
* @param afterCommitId exact after commit
* @param before graph projection before the change
* @param after graph projection after the change
* @param changes ordered semantic changes
*/
public record ComparisonResponse(
String beforeCommitId,
String afterCommitId,
WorkflowProjection before,
WorkflowProjection after,
List<ChangeResponse> changes) {
public ComparisonResponse {
beforeCommitId = Objects.requireNonNull(beforeCommitId, "beforeCommitId");
afterCommitId = Objects.requireNonNull(afterCommitId, "afterCommitId");
before = Objects.requireNonNull(before, "before");
after = Objects.requireNonNull(after, "after");
changes = List.copyOf(Objects.requireNonNull(changes, "changes"));
}
static ComparisonResponse from(WorkflowHistoryComparison comparison) {
return new ComparisonResponse(
comparison.beforeCommit().value(),
comparison.afterCommit().value(),
WorkflowProjection.fromWorkflow(comparison.beforeWorkflow()),
WorkflowProjection.fromWorkflow(comparison.afterWorkflow()),
comparison.diff().changes().stream().map(ChangeResponse::from).toList());
}
}
/**
* Transport-safe semantic change atom.
*
* @param kind stable change kind
* @param targetId affected workflow, node or edge identifier
* @param propertyKey metadata key or stable semantic field path
* @param oldValue previous canonical value
* @param newValue new canonical value
*/
public record ChangeResponse(
String kind, String targetId, String propertyKey, String oldValue, String newValue) {
static ChangeResponse from(WorkflowChange change) {
return switch (change) {
case WorkflowChange.NodeAdded added ->
new ChangeResponse("NODE_ADDED", added.node().id(), null, null, added.node().label());
case WorkflowChange.NodeRemoved removed ->
new ChangeResponse(
"NODE_REMOVED", removed.node().id(), null, removed.node().label(), null);
case WorkflowChange.EdgeAdded added ->
new ChangeResponse("EDGE_ADDED", added.edge().id(), null, null, null);
case WorkflowChange.EdgeRemoved removed ->
new ChangeResponse("EDGE_REMOVED", removed.edge().id(), null, null, null);
case WorkflowChange.ParameterChanged parameter ->
new ChangeResponse(
"PARAMETER_CHANGED",
parameter.targetId(),
parameter.propertyKey(),
parameter.oldValue(),
parameter.newValue());
case WorkflowChange.FieldChanged field ->
new ChangeResponse(
field.elementKind().name() + "_FIELD_CHANGED",
field.targetId(),
field.fieldPath(),
field.oldValue(),
field.newValue());
};
}
}
/**
* Audit response for a completed non-destructive restore.
*
* @param branch restored branch
* @param targetCommitId historical source commit
* @param previousHeadCommitId HEAD protected by optimistic concurrency
* @param restoredCommitId newly created audit commit
*/
public record RestoreResponse(
String branch, String targetCommitId, String previousHeadCommitId, String restoredCommitId) {
static RestoreResponse from(WorkflowRestoreResult result) {
return new RestoreResponse(
result.branch(),
result.targetCommit().value(),
result.previousHead().value(),
result.restoredCommit().value());
}
}
}