feat: persist process history events, listeners, and ACTION executions

Give hosts an append-only timeline, post-commit OrdoEventListener hooks, and durable ACTION results without blocking the approval flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-15 10:14:44 +08:00
co-authored by Cursor
parent 77d1e5198b
commit f3220fd0a0
26 changed files with 1063 additions and 67 deletions
@@ -0,0 +1,94 @@
package com.jetlumen.ordo.storage.jdbc;
import com.jetlumen.ordo.api.ActionExecution;
import com.jetlumen.ordo.api.ActionExecutionStatus;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.repository.ActionExecutionRepository;
import com.jetlumen.ordo.storage.jdbc.mapper.ActionExecutionMapper;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** JDBC implementation of the ACTION execution port. */
public final class JdbcActionExecutionRepository implements ActionExecutionRepository {
private static final String COLUMNS =
"id, instance_id, step_id, action_key, status, error_message, started_at, finished_at";
private static final String INSERT =
"INSERT INTO ordo_action_execution (id, instance_id, step_id, action_key, status, started_at)"
+ " VALUES (?, ?, ?, ?, ?, ?)";
private static final String COMPLETE_IF_PENDING =
"UPDATE ordo_action_execution SET status = ?, error_message = ?, finished_at = ?"
+ " WHERE id = ? AND status = 'PENDING'";
private final JdbcConnectionProvider connectionProvider;
public JdbcActionExecutionRepository(JdbcConnectionProvider connectionProvider) {
this.connectionProvider = Objects.requireNonNull(connectionProvider, "connectionProvider must not be null");
}
@Override
public void insert(ActionExecution execution) {
Objects.requireNonNull(execution, "execution must not be null");
Connection connection = connectionProvider.getConnection();
try (PreparedStatement insert = connection.prepareStatement(INSERT)) {
ActionExecutionMapper.bindInsert(insert, execution);
insert.executeUpdate();
} catch (SQLException e) {
throw new JdbcStorageException("failed to insert action execution: " + execution.id(), e);
} finally {
connectionProvider.close(connection);
}
}
@Override
public boolean complete(String executionId, ActionExecutionStatus status, String errorMessage, Instant finishedAt) {
Objects.requireNonNull(executionId, "executionId must not be null");
Objects.requireNonNull(status, "status must not be null");
Objects.requireNonNull(finishedAt, "finishedAt must not be null");
Connection connection = connectionProvider.getConnection();
try (PreparedStatement update = connection.prepareStatement(COMPLETE_IF_PENDING)) {
ActionExecutionMapper.bindComplete(update, executionId, status, errorMessage, finishedAt);
return update.executeUpdate() == 1;
} catch (SQLException e) {
throw new JdbcStorageException("failed to complete action execution: " + executionId, e);
} finally {
connectionProvider.close(connection);
}
}
@Override
public Page<ActionExecution> query(String instanceId, PageRequest pageRequest) {
Objects.requireNonNull(instanceId, "instanceId must not be null");
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
Connection connection = connectionProvider.getConnection();
try {
long total = PageSupport.count(connection,
"SELECT COUNT(*) FROM ordo_action_execution WHERE instance_id = ?", List.of(instanceId));
String sql = "SELECT " + COLUMNS + " FROM ordo_action_execution WHERE instance_id = ?"
+ " ORDER BY started_at ASC, id ASC LIMIT ? OFFSET ?";
List<ActionExecution> content = new ArrayList<>();
try (PreparedStatement select = connection.prepareStatement(sql)) {
select.setString(1, instanceId);
select.setInt(2, pageRequest.size());
select.setInt(3, pageRequest.offset());
try (ResultSet resultSet = select.executeQuery()) {
while (resultSet.next()) {
content.add(ActionExecutionMapper.read(resultSet));
}
}
}
return new Page<>(content, total, pageRequest.page(), pageRequest.size());
} catch (SQLException e) {
throw new JdbcStorageException("failed to query action executions for instance: " + instanceId, e);
} finally {
connectionProvider.close(connection);
}
}
}
@@ -0,0 +1,73 @@
package com.jetlumen.ordo.storage.jdbc;
import com.jetlumen.ordo.api.ProcessEvent;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.repository.ProcessHistoryRepository;
import com.jetlumen.ordo.storage.jdbc.mapper.ProcessEventMapper;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** JDBC implementation of the process history port. */
public final class JdbcProcessHistoryRepository implements ProcessHistoryRepository {
private static final String COLUMNS =
"id, instance_id, task_id, step_id, event_type, actor, detail, occurred_at";
private static final String INSERT =
"INSERT INTO ordo_process_event (id, instance_id, task_id, step_id, event_type, actor, detail, occurred_at)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
private final JdbcConnectionProvider connectionProvider;
public JdbcProcessHistoryRepository(JdbcConnectionProvider connectionProvider) {
this.connectionProvider = Objects.requireNonNull(connectionProvider, "connectionProvider must not be null");
}
@Override
public void append(ProcessEvent event) {
Objects.requireNonNull(event, "event must not be null");
Connection connection = connectionProvider.getConnection();
try (PreparedStatement insert = connection.prepareStatement(INSERT)) {
ProcessEventMapper.bindInsert(insert, event);
insert.executeUpdate();
} catch (SQLException e) {
throw new JdbcStorageException("failed to append process event: " + event.id(), e);
} finally {
connectionProvider.close(connection);
}
}
@Override
public Page<ProcessEvent> query(String instanceId, PageRequest pageRequest) {
Objects.requireNonNull(instanceId, "instanceId must not be null");
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
Connection connection = connectionProvider.getConnection();
try {
long total = PageSupport.count(connection,
"SELECT COUNT(*) FROM ordo_process_event WHERE instance_id = ?", List.of(instanceId));
String sql = "SELECT " + COLUMNS + " FROM ordo_process_event WHERE instance_id = ?"
+ " ORDER BY occurred_at ASC, id ASC LIMIT ? OFFSET ?";
List<ProcessEvent> content = new ArrayList<>();
try (PreparedStatement select = connection.prepareStatement(sql)) {
select.setString(1, instanceId);
select.setInt(2, pageRequest.size());
select.setInt(3, pageRequest.offset());
try (ResultSet resultSet = select.executeQuery()) {
while (resultSet.next()) {
content.add(ProcessEventMapper.read(resultSet));
}
}
}
return new Page<>(content, total, pageRequest.page(), pageRequest.size());
} catch (SQLException e) {
throw new JdbcStorageException("failed to query process events for instance: " + instanceId, e);
} finally {
connectionProvider.close(connection);
}
}
}
@@ -0,0 +1,50 @@
package com.jetlumen.ordo.storage.jdbc.mapper;
import com.jetlumen.ordo.api.ActionExecution;
import com.jetlumen.ordo.api.ActionExecutionStatus;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
/** Maps rows of {@code ordo_action_execution} to {@link ActionExecution} objects and back. */
public final class ActionExecutionMapper {
private ActionExecutionMapper() {
}
public static void bindInsert(PreparedStatement statement, ActionExecution execution) throws SQLException {
statement.setString(1, execution.id());
statement.setString(2, execution.instanceId());
statement.setString(3, execution.stepId());
statement.setString(4, execution.actionKey());
statement.setString(5, execution.status().name());
statement.setTimestamp(6, Timestamp.from(execution.startedAt()));
}
public static ActionExecution read(ResultSet resultSet) throws SQLException {
Timestamp finishedAt = resultSet.getTimestamp("finished_at");
return new ActionExecution(
resultSet.getString("id"),
resultSet.getString("instance_id"),
resultSet.getString("step_id"),
resultSet.getString("action_key"),
ActionExecutionStatus.valueOf(resultSet.getString("status")),
resultSet.getString("error_message"),
resultSet.getTimestamp("started_at").toInstant(),
finishedAt == null ? null : finishedAt.toInstant());
}
public static void bindComplete(PreparedStatement statement, String executionId, ActionExecutionStatus status,
String errorMessage, java.time.Instant finishedAt) throws SQLException {
statement.setString(1, status.name());
if (errorMessage == null || errorMessage.isBlank()) {
statement.setNull(2, Types.VARCHAR);
} else {
statement.setString(2, errorMessage);
}
statement.setTimestamp(3, Timestamp.from(finishedAt));
statement.setString(4, executionId);
}
}
@@ -0,0 +1,47 @@
package com.jetlumen.ordo.storage.jdbc.mapper;
import com.jetlumen.ordo.api.ProcessEvent;
import com.jetlumen.ordo.api.ProcessEventType;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
/** Maps rows of {@code ordo_process_event} to {@link ProcessEvent} objects and back. */
public final class ProcessEventMapper {
private ProcessEventMapper() {
}
public static void bindInsert(PreparedStatement statement, ProcessEvent event) throws SQLException {
statement.setString(1, event.id());
statement.setString(2, event.instanceId());
setNullableString(statement, 3, event.taskId());
setNullableString(statement, 4, event.stepId());
statement.setString(5, event.type().name());
setNullableString(statement, 6, event.actor());
setNullableString(statement, 7, event.detail());
statement.setTimestamp(8, Timestamp.from(event.occurredAt()));
}
public static ProcessEvent read(ResultSet resultSet) throws SQLException {
return new ProcessEvent(
resultSet.getString("id"),
resultSet.getString("instance_id"),
resultSet.getString("task_id"),
resultSet.getString("step_id"),
ProcessEventType.valueOf(resultSet.getString("event_type")),
resultSet.getString("actor"),
resultSet.getString("detail"),
resultSet.getTimestamp("occurred_at").toInstant());
}
private static void setNullableString(PreparedStatement statement, int index, String value) throws SQLException {
if (value == null) {
statement.setNull(index, Types.VARCHAR);
} else {
statement.setString(index, value);
}
}
}
@@ -0,0 +1,29 @@
-- Process history events and ACTION-step execution records.
CREATE TABLE ordo_process_event (
id VARCHAR(36) PRIMARY KEY,
instance_id VARCHAR(36) NOT NULL,
task_id VARCHAR(36),
step_id VARCHAR(64),
event_type VARCHAR(32) NOT NULL,
actor VARCHAR(255),
detail TEXT,
occurred_at TIMESTAMP NOT NULL,
CONSTRAINT fk_process_event_instance FOREIGN KEY (instance_id) REFERENCES ordo_process_instance (id)
);
CREATE INDEX idx_process_event_instance_time ON ordo_process_event (instance_id, occurred_at, id);
CREATE TABLE ordo_action_execution (
id VARCHAR(36) PRIMARY KEY,
instance_id VARCHAR(36) NOT NULL,
step_id VARCHAR(64) NOT NULL,
action_key VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL,
error_message TEXT,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
CONSTRAINT fk_action_execution_instance FOREIGN KEY (instance_id) REFERENCES ordo_process_instance (id)
);
CREATE INDEX idx_action_execution_instance ON ordo_action_execution (instance_id);