Files
wfe/wfe-deno/src/bridge.rs
Sienna Meridian Satterwhite d9b9c5651e feat(wfe-core): human-friendly workflow names
Add a `name` field to both `WorkflowDefinition` (optional display name
declared in YAML, e.g. "Continuous Integration") and `WorkflowInstance`
(required, unique alongside the UUID primary key). Instance names are
auto-assigned as `{definition_id}-{N}` via a per-definition monotonic
counter so the 42nd run of `ci` becomes `ci-42`.

Persistence trait gains two methods:

* `get_workflow_instance_by_name` — name-based lookup for Get/Cancel/
  Suspend/Resume/Watch/Logs RPCs so callers can address instances
  interchangeably as either UUID or human name.
* `next_definition_sequence` — atomic per-definition counter used by
  the host at start time to allocate the next N.

This commit wires the in-memory test provider and touches the deno
bridge test helper; the real postgres/sqlite impls follow in the next
commit. UUIDs remain the primary key throughout — names are a second
unique index, never a replacement.
2026-04-07 18:58:12 +01:00

450 lines
14 KiB
Rust

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use wfe_core::WfeError;
use wfe_core::models::ExecutionResult;
use wfe_core::traits::step::{StepBody, StepExecutionContext};
/// A request sent from the executor (tokio) to the V8 thread.
pub struct StepRequest {
pub request_id: u32,
pub step_type: String,
pub context: serde_json::Value,
pub response_tx: oneshot::Sender<Result<serde_json::Value, String>>,
}
/// A `StepBody` implementation that bridges to JavaScript via channels.
///
/// When the workflow executor calls `run()`, this sends a serialized
/// `StepExecutionContext` to the V8 thread and awaits the response.
pub struct JsStepBody {
request_tx: mpsc::Sender<StepRequest>,
request_id_counter: std::sync::Arc<std::sync::atomic::AtomicU32>,
}
impl JsStepBody {
pub fn new(
request_tx: mpsc::Sender<StepRequest>,
request_id_counter: std::sync::Arc<std::sync::atomic::AtomicU32>,
) -> Self {
Self {
request_tx,
request_id_counter,
}
}
}
#[async_trait]
impl StepBody for JsStepBody {
async fn run(
&mut self,
context: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let ctx_json = serialize_context(context);
let (tx, rx) = oneshot::channel();
let request_id = self
.request_id_counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.request_tx
.send(StepRequest {
request_id,
step_type: context.step.step_type.clone(),
context: ctx_json,
response_tx: tx,
})
.await
.map_err(|_| WfeError::StepExecution("step request channel closed".into()))?;
let result_json = rx
.await
.map_err(|_| WfeError::StepExecution("step response channel dropped".into()))?
.map_err(WfeError::StepExecution)?;
deserialize_execution_result(&result_json)
}
}
/// Serializable view of `StepExecutionContext` for passing to JavaScript.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JsStepContext {
pub item: Option<serde_json::Value>,
pub persistence_data: Option<serde_json::Value>,
pub step: JsStepInfo,
pub workflow: JsWorkflowInfo,
pub pointer: JsPointerInfo,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JsStepInfo {
pub id: usize,
pub name: Option<String>,
pub step_type: String,
pub step_config: Option<serde_json::Value>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JsWorkflowInfo {
pub id: String,
pub definition_id: String,
pub version: u32,
pub status: String,
pub data: serde_json::Value,
pub create_time: DateTime<Utc>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JsPointerInfo {
pub id: String,
pub step_id: usize,
pub step_name: Option<String>,
pub retry_count: u32,
}
/// Serialize a `StepExecutionContext` into a JSON value for JavaScript.
pub fn serialize_context(ctx: &StepExecutionContext<'_>) -> serde_json::Value {
let js_ctx = JsStepContext {
item: ctx.item.cloned(),
persistence_data: ctx.persistence_data.cloned(),
step: JsStepInfo {
id: ctx.step.id,
name: ctx.step.name.clone(),
step_type: ctx.step.step_type.clone(),
step_config: ctx.step.step_config.clone(),
},
workflow: JsWorkflowInfo {
id: ctx.workflow.id.clone(),
definition_id: ctx.workflow.workflow_definition_id.clone(),
version: ctx.workflow.version,
status: format!("{:?}", ctx.workflow.status),
data: ctx.workflow.data.clone(),
create_time: ctx.workflow.create_time,
},
pointer: JsPointerInfo {
id: ctx.execution_pointer.id.clone(),
step_id: ctx.execution_pointer.step_id,
step_name: ctx.execution_pointer.step_name.clone(),
retry_count: ctx.execution_pointer.retry_count,
},
};
serde_json::to_value(js_ctx).unwrap_or(serde_json::Value::Null)
}
/// Shape of an `ExecutionResult` as returned from JavaScript.
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct JsExecutionResult {
#[serde(default = "default_true")]
pub proceed: bool,
pub outcome_value: Option<serde_json::Value>,
pub sleep_for: Option<u64>,
pub persistence_data: Option<serde_json::Value>,
pub event_name: Option<String>,
pub event_key: Option<String>,
pub branch_values: Option<Vec<serde_json::Value>>,
pub output_data: Option<serde_json::Value>,
}
fn default_true() -> bool {
true
}
/// Deserialize a JavaScript execution result into the Rust `ExecutionResult`.
pub fn deserialize_execution_result(
value: &serde_json::Value,
) -> wfe_core::Result<ExecutionResult> {
let js_result: JsExecutionResult = serde_json::from_value(value.clone()).map_err(|e| {
WfeError::StepExecution(format!(
"failed to deserialize ExecutionResult from JS: {e}"
))
})?;
Ok(ExecutionResult {
proceed: js_result.proceed,
outcome_value: js_result.outcome_value,
sleep_for: js_result.sleep_for.map(Duration::from_millis),
persistence_data: js_result.persistence_data,
event_name: js_result.event_name,
event_key: js_result.event_key,
event_as_of: None,
branch_values: js_result.branch_values,
poll_endpoint: None,
output_data: js_result.output_data,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use wfe_core::models::{ExecutionPointer, WorkflowInstance, WorkflowStatus, WorkflowStep};
fn make_test_context() -> (WorkflowInstance, WorkflowStep, ExecutionPointer) {
let instance = WorkflowInstance {
id: "wf-1".into(),
name: "test-def-1".into(),
workflow_definition_id: "test-def".into(),
version: 1,
description: None,
reference: None,
execution_pointers: vec![],
next_execution: None,
status: WorkflowStatus::Runnable,
data: serde_json::json!({"name": "World"}),
create_time: Utc::now(),
complete_time: None,
};
let step = WorkflowStep::new(0, "MyStep");
let pointer = ExecutionPointer::new(0);
(instance, step, pointer)
}
#[test]
fn serialize_context_produces_valid_json() {
let (instance, mut step, pointer) = make_test_context();
step.name = Some("greet".into());
step.step_config = Some(serde_json::json!({"key": "val"}));
let ctx = StepExecutionContext {
item: None,
execution_pointer: &pointer,
persistence_data: None,
step: &step,
workflow: &instance,
cancellation_token: tokio_util::sync::CancellationToken::new(),
host_context: None,
log_sink: None,
};
let json = serialize_context(&ctx);
assert_eq!(json["step"]["name"], "greet");
assert_eq!(json["step"]["stepConfig"]["key"], "val");
assert_eq!(json["workflow"]["data"]["name"], "World");
assert_eq!(json["workflow"]["definitionId"], "test-def");
assert_eq!(json["pointer"]["stepId"], 0);
}
#[test]
fn serialize_context_with_item() {
let (instance, step, pointer) = make_test_context();
let item = serde_json::json!({"id": 42});
let ctx = StepExecutionContext {
item: Some(&item),
execution_pointer: &pointer,
persistence_data: Some(&serde_json::json!({"saved": true})),
step: &step,
workflow: &instance,
cancellation_token: tokio_util::sync::CancellationToken::new(),
host_context: None,
log_sink: None,
};
let json = serialize_context(&ctx);
assert_eq!(json["item"]["id"], 42);
assert_eq!(json["persistenceData"]["saved"], true);
}
#[test]
fn deserialize_next_result() {
let json = serde_json::json!({"proceed": true});
let result = deserialize_execution_result(&json).unwrap();
assert!(result.proceed);
assert!(result.outcome_value.is_none());
}
#[test]
fn deserialize_outcome_result() {
let json = serde_json::json!({
"proceed": true,
"outcomeValue": "branch-a"
});
let result = deserialize_execution_result(&json).unwrap();
assert!(result.proceed);
assert_eq!(result.outcome_value, Some(serde_json::json!("branch-a")));
}
#[test]
fn deserialize_sleep_result() {
let json = serde_json::json!({
"proceed": false,
"sleepFor": 5000
});
let result = deserialize_execution_result(&json).unwrap();
assert!(!result.proceed);
assert_eq!(result.sleep_for, Some(Duration::from_millis(5000)));
}
#[test]
fn deserialize_persist_result() {
let json = serde_json::json!({
"proceed": false,
"persistenceData": {"page": 2}
});
let result = deserialize_execution_result(&json).unwrap();
assert!(!result.proceed);
assert_eq!(
result.persistence_data,
Some(serde_json::json!({"page": 2}))
);
}
#[test]
fn deserialize_wait_for_event_result() {
let json = serde_json::json!({
"proceed": false,
"eventName": "order.paid",
"eventKey": "order-123"
});
let result = deserialize_execution_result(&json).unwrap();
assert_eq!(result.event_name, Some("order.paid".into()));
assert_eq!(result.event_key, Some("order-123".into()));
}
#[test]
fn deserialize_branch_result() {
let json = serde_json::json!({
"proceed": false,
"branchValues": [1, 2, 3]
});
let result = deserialize_execution_result(&json).unwrap();
assert_eq!(
result.branch_values,
Some(vec![
serde_json::json!(1),
serde_json::json!(2),
serde_json::json!(3)
])
);
}
#[test]
fn deserialize_output_data_result() {
let json = serde_json::json!({
"proceed": true,
"outputData": {"greeted": "World"}
});
let result = deserialize_execution_result(&json).unwrap();
assert!(result.proceed);
assert_eq!(
result.output_data,
Some(serde_json::json!({"greeted": "World"}))
);
}
#[test]
fn deserialize_empty_object_defaults_to_proceed() {
let json = serde_json::json!({});
let result = deserialize_execution_result(&json).unwrap();
assert!(result.proceed);
}
#[test]
fn deserialize_invalid_json_returns_error() {
let json = serde_json::json!("not an object");
let result = deserialize_execution_result(&json);
assert!(result.is_err());
}
#[tokio::test]
async fn js_step_body_channel_round_trip() {
let (tx, mut rx) = mpsc::channel::<StepRequest>(16);
let counter = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let mut body = JsStepBody::new(tx, counter);
let (instance, step, pointer) = make_test_context();
let ctx = StepExecutionContext {
item: None,
execution_pointer: &pointer,
persistence_data: None,
step: &step,
workflow: &instance,
cancellation_token: tokio_util::sync::CancellationToken::new(),
host_context: None,
log_sink: None,
};
// Spawn a "JS side" that responds to the request.
let responder = tokio::spawn(async move {
let req = rx.recv().await.unwrap();
assert_eq!(req.step_type, "MyStep");
assert_eq!(req.request_id, 0);
req.response_tx
.send(Ok(
serde_json::json!({"proceed": true, "outputData": {"done": true}}),
))
.unwrap();
});
let result = body.run(&ctx).await.unwrap();
assert!(result.proceed);
assert_eq!(result.output_data, Some(serde_json::json!({"done": true})));
responder.await.unwrap();
}
#[tokio::test]
async fn js_step_body_propagates_js_error() {
let (tx, mut rx) = mpsc::channel::<StepRequest>(16);
let counter = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let mut body = JsStepBody::new(tx, counter);
let (instance, step, pointer) = make_test_context();
let ctx = StepExecutionContext {
item: None,
execution_pointer: &pointer,
persistence_data: None,
step: &step,
workflow: &instance,
cancellation_token: tokio_util::sync::CancellationToken::new(),
host_context: None,
log_sink: None,
};
tokio::spawn(async move {
let req = rx.recv().await.unwrap();
req.response_tx
.send(Err("TypeError: undefined is not a function".into()))
.unwrap();
});
let result = body.run(&ctx).await;
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("TypeError"));
}
#[tokio::test]
async fn js_step_body_handles_dropped_responder() {
let (tx, mut rx) = mpsc::channel::<StepRequest>(16);
let counter = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let mut body = JsStepBody::new(tx, counter);
let (instance, step, pointer) = make_test_context();
let ctx = StepExecutionContext {
item: None,
execution_pointer: &pointer,
persistence_data: None,
step: &step,
workflow: &instance,
cancellation_token: tokio_util::sync::CancellationToken::new(),
host_context: None,
log_sink: None,
};
tokio::spawn(async move {
let req = rx.recv().await.unwrap();
drop(req.response_tx); // Drop without sending
});
let result = body.run(&ctx).await;
assert!(result.is_err());
}
}