WorkflowEditorService.java
package org.hammer.audio.workflow.editor;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Supplier;
import org.hammer.audio.workflow.Workflow;
import org.hammer.audio.workflow.WorkflowOperation;
import org.hammer.audio.workflow.WorkflowOperationLog;
import org.hammer.audio.workflow.WorkflowValidator;
import org.hammer.audio.workflow.dsl.WorkflowDslParser;
import org.hammer.audio.workflow.dsl.WorkflowDslSerializer;
import org.hammer.audio.workflow.store.CommitId;
import org.hammer.audio.workflow.store.CommitInfo;
import org.hammer.audio.workflow.store.CommitMetadata;
import org.hammer.audio.workflow.store.VersionedWorkflowStore;
import org.hammer.audio.workflow.store.WorkflowSnapshot;
/**
* Application-service boundary for the single-user React Flow workbench MVP (issue #210).
*
* <p>This service is the single server-authoritative entry point for graph editing, validation,
* save/reload/history and execution handoff. Browser adapters call this service through a thin HTTP
* layer; they must not access DSL, JGit, storage internals or mutable UI state as canonical
* workflow state.
*
* <p><b>Thread safety</b>: public state access is serialised on a private reentrant lock.
* Validation, dirty-state checks and graph replacement therefore form one atomic editor transition.
*
* <p><b>Dependency rules</b>: this class must not depend on Swing, JGit, React, Yjs, Selenium,
* Playwright, Testcontainers or any web framework. It is a pure Java application service.
*/
public final class WorkflowEditorService {
private final Lock stateLock = new ReentrantLock();
private final WorkflowOperationLog operationLog;
private final WorkflowValidator validator;
private final VersionedWorkflowStore workflowStore;
private final WorkflowDslSerializer serializer;
private final WorkflowDslParser parser;
private boolean dirty;
/**
* Creates a service backed by the given operation log and validator.
*
* @param operationLog operation log holding the current workflow state
* @param validator structural workflow validator
*/
public WorkflowEditorService(WorkflowOperationLog operationLog, WorkflowValidator validator) {
this(operationLog, validator, null);
}
/**
* Creates a service backed by the given operation log, validator and checkpoint store.
*
* @param operationLog operation log holding the current workflow state
* @param validator structural workflow validator
* @param workflowStore workflow checkpoint store; may be {@code null} for non-persistent usage
*/
public WorkflowEditorService(
WorkflowOperationLog operationLog,
WorkflowValidator validator,
VersionedWorkflowStore workflowStore) {
this.operationLog = Objects.requireNonNull(operationLog, "operationLog");
this.validator = Objects.requireNonNull(validator, "validator");
this.workflowStore = workflowStore;
this.serializer = new WorkflowDslSerializer();
this.parser = new WorkflowDslParser();
}
/**
* Applies a workflow operation if it produces a valid workflow, or rejects it otherwise.
*
* <p>The operation is first applied to a candidate copy of the current workflow (no side
* effects). If the candidate passes validation the operation is recorded in the log and the
* updated projection is returned. If validation fails the log is left unchanged and {@link
* WorkflowOperationRejectedException} is thrown.
*
* @param operation operation to apply
* @return updated projection reflecting the new workflow state
* @throws WorkflowOperationRejectedException if the resulting workflow violates structural rules
* @throws IllegalArgumentException if the operation references nodes or ports that do not exist
*/
public WorkflowProjection applyOperation(WorkflowOperation operation) {
return withStateLock(
() -> {
Objects.requireNonNull(operation, "operation");
Workflow candidate = operation.apply(operationLog.currentWorkflow());
List<String> violations = validator.validate(candidate);
if (!violations.isEmpty()) {
throw new WorkflowOperationRejectedException(violations);
}
operationLog.apply(operation);
dirty = true;
return WorkflowProjection.fromWorkflow(operationLog.currentWorkflow());
});
}
/** Returns whether the editable workflow differs from its last clean load/checkpoint state. */
public boolean isDirty() {
return withStateLock(() -> dirty);
}
/**
* Returns the projection of the current workflow state without applying any operation.
*
* @return current projection
*/
public WorkflowProjection currentProjection() {
return withStateLock(() -> WorkflowProjection.fromWorkflow(operationLog.currentWorkflow()));
}
/**
* Loads the given workflow into the editor as a clean canonical state.
*
* @param workflow workflow to load
* @return projection of the loaded workflow
* @throws WorkflowOperationRejectedException if the workflow is structurally invalid
*/
public WorkflowProjection loadGraph(Workflow workflow) {
return withStateLock(() -> replaceGraph(workflow, false));
}
/**
* Replaces the editable workflow with an already confirmed imported setup and marks it unsaved.
*
* @param workflow already previewed workflow to import
* @return projection of the imported workflow
* @throws WorkflowOperationRejectedException if the workflow is structurally invalid
*/
public WorkflowProjection importGraph(Workflow workflow) {
return withStateLock(() -> replaceGraph(workflow, true));
}
/**
* Atomically imports a workflow, rejecting unsaved current state unless discard was confirmed.
*
* @param workflow already previewed workflow to import
* @param discardDirty whether the caller explicitly confirmed discarding unsaved state
* @return projection of the imported workflow
* @throws DirtyWorkflowException when current state is dirty and discard was not confirmed
* @throws WorkflowOperationRejectedException if the workflow is structurally invalid
*/
public WorkflowProjection importGraph(Workflow workflow, boolean discardDirty) {
return withStateLock(
() -> {
if (dirty && !discardDirty) {
throw new DirtyWorkflowException();
}
return replaceGraph(workflow, true);
});
}
/**
* Loads the graph from the current HEAD of a branch in the configured store.
*
* @param branch branch to load
* @return projection of the loaded workflow
* @throws IllegalArgumentException if branch is blank or snapshot and DSL workflow IDs diverge
*/
public WorkflowProjection loadGraph(String branch) {
return withStateLock(
() -> {
Objects.requireNonNull(branch, "branch");
if (branch.isBlank()) {
throw new IllegalArgumentException("branch must not be blank");
}
VersionedWorkflowStore store = requireStore();
WorkflowSnapshot snapshot = store.loadHead(branch);
Workflow workflow = parser.parse(snapshot.dslText());
assertSnapshotIdMatchesDsl(snapshot.workflowId(), workflow.id());
return replaceGraph(workflow, false);
});
}
/**
* Loads the graph at a specific commit in the configured store.
*
* @param commitId commit identifier to load
* @return projection of the loaded workflow
* @throws IllegalArgumentException if snapshot and DSL workflow IDs diverge
*/
public WorkflowProjection loadGraph(CommitId commitId) {
return withStateLock(
() -> {
Objects.requireNonNull(commitId, "commitId");
VersionedWorkflowStore store = requireStore();
WorkflowSnapshot snapshot = store.loadAtCommit(commitId);
Workflow workflow = parser.parse(snapshot.dslText());
assertSnapshotIdMatchesDsl(snapshot.workflowId(), workflow.id());
return replaceGraph(workflow, false);
});
}
/**
* Validates the current graph state.
*
* @return list of structural validation violations; empty when valid
*/
public List<String> validate() {
return withStateLock(() -> validator.validate(operationLog.currentWorkflow()));
}
/**
* Persists a checkpoint of the current graph state in the configured store.
*
* <p>The current workflow is validated before persisting; if it is structurally invalid the
* checkpoint is rejected and no commit is created.
*
* @param branch branch to commit to
* @param metadata commit metadata (author/message/timestamp)
* @return commit id of the created checkpoint
* @throws IllegalArgumentException if branch is blank
* @throws WorkflowOperationRejectedException if the current workflow is structurally invalid
*/
public CommitId checkpoint(String branch, CommitMetadata metadata) {
return withStateLock(
() -> {
Objects.requireNonNull(branch, "branch");
if (branch.isBlank()) {
throw new IllegalArgumentException("branch must not be blank");
}
Objects.requireNonNull(metadata, "metadata");
Workflow workflow = operationLog.currentWorkflow();
List<String> violations = validator.validate(workflow);
if (!violations.isEmpty()) {
throw new WorkflowOperationRejectedException(violations);
}
VersionedWorkflowStore store = requireStore();
WorkflowSnapshot snapshot =
new WorkflowSnapshot(workflow.id(), serializer.serialize(workflow));
CommitId commitId = store.commit(branch, snapshot, metadata);
dirty = false;
return commitId;
});
}
/**
* Lists recent checkpoint commits for a branch/reference.
*
* @param refName branch or ref
* @param limit max entries to return; must be ≥ 0
* @return reverse-chronological commit summaries
* @throws IllegalArgumentException if refName is blank or limit is negative
*/
public List<CommitInfo> history(String refName, int limit) {
return withStateLock(
() -> {
Objects.requireNonNull(refName, "refName");
if (refName.isBlank()) {
throw new IllegalArgumentException("refName must not be blank");
}
if (limit < 0) {
throw new IllegalArgumentException("limit must be >= 0");
}
VersionedWorkflowStore store = requireStore();
return store.history(refName, limit);
});
}
/**
* Produces a deterministic DSL snapshot of the current graph state for execution handoff.
*
* <p>This is not yet a full execution plan. It is the stable workflow snapshot consumed by the
* future execution-integration layer from issue #211.
*
* @return immutable workflow snapshot
*/
public WorkflowSnapshot executeSnapshot() {
return withStateLock(
() -> {
Workflow workflow = operationLog.currentWorkflow();
return new WorkflowSnapshot(workflow.id(), serializer.serialize(workflow));
});
}
private <T> T withStateLock(Supplier<T> action) {
Objects.requireNonNull(action, "action");
stateLock.lock();
try {
return action.get();
} finally {
stateLock.unlock();
}
}
private WorkflowProjection replaceGraph(Workflow workflow, boolean imported) {
Objects.requireNonNull(workflow, "workflow");
List<String> violations = validator.validate(workflow);
if (!violations.isEmpty()) {
throw new WorkflowOperationRejectedException(violations);
}
operationLog.reset(workflow);
dirty = imported;
return WorkflowProjection.fromWorkflow(workflow);
}
private VersionedWorkflowStore requireStore() {
if (workflowStore == null) {
throw new IllegalStateException("workflowStore is not configured");
}
return workflowStore;
}
private static void assertSnapshotIdMatchesDsl(String snapshotId, String dslId) {
if (!snapshotId.equals(dslId)) {
throw new IllegalArgumentException(
"snapshot workflowId '"
+ snapshotId
+ "' does not match DSL workflow id '"
+ dslId
+ "'");
}
}
}