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.
This commit is contained in:
@@ -9,7 +9,14 @@ use super::service::ServiceDefinition;
|
|||||||
/// A compiled workflow definition ready for execution.
|
/// A compiled workflow definition ready for execution.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkflowDefinition {
|
pub struct WorkflowDefinition {
|
||||||
|
/// Stable slug used as the primary key (e.g. "ci", "checkout"). Must be
|
||||||
|
/// unique within a host. Referenced by other workflows, webhooks, and
|
||||||
|
/// clients when starting new instances.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
/// Optional human-friendly display name surfaced in UIs, listings, and
|
||||||
|
/// logs (e.g. "Continuous Integration"). Falls back to `id` when unset.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub steps: Vec<WorkflowStep>,
|
pub steps: Vec<WorkflowStep>,
|
||||||
@@ -25,6 +32,7 @@ impl WorkflowDefinition {
|
|||||||
pub fn new(id: impl Into<String>, version: u32) -> Self {
|
pub fn new(id: impl Into<String>, version: u32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
|
name: None,
|
||||||
version,
|
version,
|
||||||
description: None,
|
description: None,
|
||||||
steps: Vec::new(),
|
steps: Vec::new(),
|
||||||
@@ -33,6 +41,11 @@ impl WorkflowDefinition {
|
|||||||
services: Vec::new(),
|
services: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the display name when set, otherwise fall back to the slug id.
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
self.name.as_deref().unwrap_or(&self.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single step in a workflow definition.
|
/// A single step in a workflow definition.
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ use super::status::{PointerStatus, WorkflowStatus};
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkflowInstance {
|
pub struct WorkflowInstance {
|
||||||
|
/// UUID — the primary key, always unique, never changes.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
/// Human-friendly unique name, e.g. "ci-42". Auto-assigned as
|
||||||
|
/// `{definition_id}-{N}` via a per-definition monotonic counter when
|
||||||
|
/// the caller does not supply an override. Used interchangeably with
|
||||||
|
/// `id` in lookup APIs. Empty when the instance has not yet been
|
||||||
|
/// persisted (the host fills it in before the first insert).
|
||||||
|
pub name: String,
|
||||||
pub workflow_definition_id: String,
|
pub workflow_definition_id: String,
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
@@ -20,9 +27,15 @@ pub struct WorkflowInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorkflowInstance {
|
impl WorkflowInstance {
|
||||||
pub fn new(workflow_definition_id: impl Into<String>, version: u32, data: serde_json::Value) -> Self {
|
pub fn new(
|
||||||
|
workflow_definition_id: impl Into<String>,
|
||||||
|
version: u32,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
// Filled in by WorkflowHost::start_workflow before persisting.
|
||||||
|
name: String::new(),
|
||||||
workflow_definition_id: workflow_definition_id.into(),
|
workflow_definition_id: workflow_definition_id.into(),
|
||||||
version,
|
version,
|
||||||
description: None,
|
description: None,
|
||||||
@@ -134,7 +147,10 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&instance).unwrap();
|
let json = serde_json::to_string(&instance).unwrap();
|
||||||
let deserialized: WorkflowInstance = serde_json::from_str(&json).unwrap();
|
let deserialized: WorkflowInstance = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(instance.id, deserialized.id);
|
assert_eq!(instance.id, deserialized.id);
|
||||||
assert_eq!(instance.workflow_definition_id, deserialized.workflow_definition_id);
|
assert_eq!(
|
||||||
|
instance.workflow_definition_id,
|
||||||
|
deserialized.workflow_definition_id
|
||||||
|
);
|
||||||
assert_eq!(instance.version, deserialized.version);
|
assert_eq!(instance.version, deserialized.version);
|
||||||
assert_eq!(instance.status, deserialized.status);
|
assert_eq!(instance.status, deserialized.status);
|
||||||
assert_eq!(instance.data, deserialized.data);
|
assert_eq!(instance.data, deserialized.data);
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{Event, EventSubscription, ExecutionError, ScheduledCommand, WorkflowInstance};
|
||||||
Event, EventSubscription, ExecutionError, ScheduledCommand, WorkflowInstance,
|
|
||||||
};
|
|
||||||
use crate::traits::{
|
use crate::traits::{
|
||||||
EventRepository, PersistenceProvider, ScheduledCommandRepository, SubscriptionRepository,
|
EventRepository, PersistenceProvider, ScheduledCommandRepository, SubscriptionRepository,
|
||||||
WorkflowRepository,
|
WorkflowRepository,
|
||||||
@@ -22,6 +20,9 @@ pub struct InMemoryPersistenceProvider {
|
|||||||
subscriptions: Arc<RwLock<HashMap<String, EventSubscription>>>,
|
subscriptions: Arc<RwLock<HashMap<String, EventSubscription>>>,
|
||||||
errors: Arc<RwLock<Vec<ExecutionError>>>,
|
errors: Arc<RwLock<Vec<ExecutionError>>>,
|
||||||
scheduled_commands: Arc<RwLock<Vec<ScheduledCommand>>>,
|
scheduled_commands: Arc<RwLock<Vec<ScheduledCommand>>>,
|
||||||
|
/// Per-definition monotonic counter used to generate human-friendly
|
||||||
|
/// workflow instance names of the form `{definition_id}-{N}`.
|
||||||
|
sequences: Arc<RwLock<HashMap<String, u64>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InMemoryPersistenceProvider {
|
impl InMemoryPersistenceProvider {
|
||||||
@@ -32,6 +33,7 @@ impl InMemoryPersistenceProvider {
|
|||||||
subscriptions: Arc::new(RwLock::new(HashMap::new())),
|
subscriptions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
errors: Arc::new(RwLock::new(Vec::new())),
|
errors: Arc::new(RwLock::new(Vec::new())),
|
||||||
scheduled_commands: Arc::new(RwLock::new(Vec::new())),
|
scheduled_commands: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
sequences: Arc::new(RwLock::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +109,23 @@ impl WorkflowRepository for InMemoryPersistenceProvider {
|
|||||||
.ok_or_else(|| WfeError::WorkflowNotFound(id.to_string()))
|
.ok_or_else(|| WfeError::WorkflowNotFound(id.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_workflow_instance_by_name(&self, name: &str) -> Result<WorkflowInstance> {
|
||||||
|
self.workflows
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.find(|w| w.name == name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| WfeError::WorkflowNotFound(name.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_definition_sequence(&self, definition_id: &str) -> Result<u64> {
|
||||||
|
let mut seqs = self.sequences.write().await;
|
||||||
|
let next = seqs.get(definition_id).copied().unwrap_or(0) + 1;
|
||||||
|
seqs.insert(definition_id.to_string(), next);
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_workflow_instances(&self, ids: &[String]) -> Result<Vec<WorkflowInstance>> {
|
async fn get_workflow_instances(&self, ids: &[String]) -> Result<Vec<WorkflowInstance>> {
|
||||||
let workflows = self.workflows.read().await;
|
let workflows = self.workflows.read().await;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
@@ -121,10 +140,7 @@ impl WorkflowRepository for InMemoryPersistenceProvider {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SubscriptionRepository for InMemoryPersistenceProvider {
|
impl SubscriptionRepository for InMemoryPersistenceProvider {
|
||||||
async fn create_event_subscription(
|
async fn create_event_subscription(&self, subscription: &EventSubscription) -> Result<String> {
|
||||||
&self,
|
|
||||||
subscription: &EventSubscription,
|
|
||||||
) -> Result<String> {
|
|
||||||
let id = if subscription.id.is_empty() {
|
let id = if subscription.id.is_empty() {
|
||||||
uuid::Uuid::new_v4().to_string()
|
uuid::Uuid::new_v4().to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -217,11 +233,7 @@ impl SubscriptionRepository for InMemoryPersistenceProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn clear_subscription_token(
|
async fn clear_subscription_token(&self, subscription_id: &str, token: &str) -> Result<()> {
|
||||||
&self,
|
|
||||||
subscription_id: &str,
|
|
||||||
token: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut subs = self.subscriptions.write().await;
|
let mut subs = self.subscriptions.write().await;
|
||||||
match subs.get_mut(subscription_id) {
|
match subs.get_mut(subscription_id) {
|
||||||
Some(sub) => {
|
Some(sub) => {
|
||||||
@@ -282,7 +294,9 @@ impl EventRepository for InMemoryPersistenceProvider {
|
|||||||
let events = self.events.read().await;
|
let events = self.events.read().await;
|
||||||
let ids = events
|
let ids = events
|
||||||
.values()
|
.values()
|
||||||
.filter(|e| e.event_name == event_name && e.event_key == event_key && e.event_time <= as_of)
|
.filter(|e| {
|
||||||
|
e.event_name == event_name && e.event_key == event_key && e.event_time <= as_of
|
||||||
|
})
|
||||||
.map(|e| e.id.clone())
|
.map(|e| e.id.clone())
|
||||||
.collect();
|
.collect();
|
||||||
Ok(ids)
|
Ok(ids)
|
||||||
@@ -325,9 +339,14 @@ impl ScheduledCommandRepository for InMemoryPersistenceProvider {
|
|||||||
async fn process_commands(
|
async fn process_commands(
|
||||||
&self,
|
&self,
|
||||||
as_of: DateTime<Utc>,
|
as_of: DateTime<Utc>,
|
||||||
handler: &(dyn Fn(ScheduledCommand) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send>>
|
handler: &(
|
||||||
+ Send
|
dyn Fn(
|
||||||
+ Sync),
|
ScheduledCommand,
|
||||||
|
)
|
||||||
|
-> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send>>
|
||||||
|
+ Send
|
||||||
|
+ Sync
|
||||||
|
),
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let as_of_millis = as_of.timestamp_millis();
|
let as_of_millis = as_of.timestamp_millis();
|
||||||
let due: Vec<ScheduledCommand> = {
|
let due: Vec<ScheduledCommand> = {
|
||||||
@@ -360,7 +379,7 @@ impl PersistenceProvider for InMemoryPersistenceProvider {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::models::{Event, EventSubscription, ExecutionError, ScheduledCommand, CommandName};
|
use crate::models::{CommandName, Event, EventSubscription, ExecutionError, ScheduledCommand};
|
||||||
use crate::traits::{
|
use crate::traits::{
|
||||||
EventRepository, PersistenceProvider, ScheduledCommandRepository, SubscriptionRepository,
|
EventRepository, PersistenceProvider, ScheduledCommandRepository, SubscriptionRepository,
|
||||||
WorkflowRepository,
|
WorkflowRepository,
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{Event, EventSubscription, ExecutionError, ScheduledCommand, WorkflowInstance};
|
||||||
Event, EventSubscription, ExecutionError, ScheduledCommand, WorkflowInstance,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Persistence for workflow instances.
|
/// Persistence for workflow instances.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -17,7 +15,15 @@ pub trait WorkflowRepository: Send + Sync {
|
|||||||
) -> crate::Result<()>;
|
) -> crate::Result<()>;
|
||||||
async fn get_runnable_instances(&self, as_at: DateTime<Utc>) -> crate::Result<Vec<String>>;
|
async fn get_runnable_instances(&self, as_at: DateTime<Utc>) -> crate::Result<Vec<String>>;
|
||||||
async fn get_workflow_instance(&self, id: &str) -> crate::Result<WorkflowInstance>;
|
async fn get_workflow_instance(&self, id: &str) -> crate::Result<WorkflowInstance>;
|
||||||
|
async fn get_workflow_instance_by_name(&self, name: &str)
|
||||||
|
-> crate::Result<WorkflowInstance>;
|
||||||
async fn get_workflow_instances(&self, ids: &[String]) -> crate::Result<Vec<WorkflowInstance>>;
|
async fn get_workflow_instances(&self, ids: &[String]) -> crate::Result<Vec<WorkflowInstance>>;
|
||||||
|
|
||||||
|
/// Atomically allocate the next sequence number for a given workflow
|
||||||
|
/// definition id. Used by the host to assign human-friendly names of the
|
||||||
|
/// form `{definition_id}-{N}` before inserting a new workflow instance.
|
||||||
|
/// Guaranteed monotonic per definition_id; no guarantees across definitions.
|
||||||
|
async fn next_definition_sequence(&self, definition_id: &str) -> crate::Result<u64>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persistence for event subscriptions.
|
/// Persistence for event subscriptions.
|
||||||
@@ -79,9 +85,14 @@ pub trait ScheduledCommandRepository: Send + Sync {
|
|||||||
async fn process_commands(
|
async fn process_commands(
|
||||||
&self,
|
&self,
|
||||||
as_of: DateTime<Utc>,
|
as_of: DateTime<Utc>,
|
||||||
handler: &(dyn Fn(ScheduledCommand) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<()>> + Send>>
|
handler: &(
|
||||||
+ Send
|
dyn Fn(
|
||||||
+ Sync),
|
ScheduledCommand,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = crate::Result<()>> + Send>,
|
||||||
|
> + Send
|
||||||
|
+ Sync
|
||||||
|
),
|
||||||
) -> crate::Result<()>;
|
) -> crate::Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
use wfe_core::WfeError;
|
||||||
use wfe_core::models::ExecutionResult;
|
use wfe_core::models::ExecutionResult;
|
||||||
use wfe_core::traits::step::{StepBody, StepExecutionContext};
|
use wfe_core::traits::step::{StepBody, StepExecutionContext};
|
||||||
use wfe_core::WfeError;
|
|
||||||
|
|
||||||
/// A request sent from the executor (tokio) to the V8 thread.
|
/// A request sent from the executor (tokio) to the V8 thread.
|
||||||
pub struct StepRequest {
|
pub struct StepRequest {
|
||||||
@@ -160,7 +160,9 @@ pub fn deserialize_execution_result(
|
|||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
) -> wfe_core::Result<ExecutionResult> {
|
) -> wfe_core::Result<ExecutionResult> {
|
||||||
let js_result: JsExecutionResult = serde_json::from_value(value.clone()).map_err(|e| {
|
let js_result: JsExecutionResult = serde_json::from_value(value.clone()).map_err(|e| {
|
||||||
WfeError::StepExecution(format!("failed to deserialize ExecutionResult from JS: {e}"))
|
WfeError::StepExecution(format!(
|
||||||
|
"failed to deserialize ExecutionResult from JS: {e}"
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(ExecutionResult {
|
Ok(ExecutionResult {
|
||||||
@@ -186,6 +188,7 @@ mod tests {
|
|||||||
fn make_test_context() -> (WorkflowInstance, WorkflowStep, ExecutionPointer) {
|
fn make_test_context() -> (WorkflowInstance, WorkflowStep, ExecutionPointer) {
|
||||||
let instance = WorkflowInstance {
|
let instance = WorkflowInstance {
|
||||||
id: "wf-1".into(),
|
id: "wf-1".into(),
|
||||||
|
name: "test-def-1".into(),
|
||||||
workflow_definition_id: "test-def".into(),
|
workflow_definition_id: "test-def".into(),
|
||||||
version: 1,
|
version: 1,
|
||||||
description: None,
|
description: None,
|
||||||
@@ -373,7 +376,9 @@ mod tests {
|
|||||||
assert_eq!(req.step_type, "MyStep");
|
assert_eq!(req.step_type, "MyStep");
|
||||||
assert_eq!(req.request_id, 0);
|
assert_eq!(req.request_id, 0);
|
||||||
req.response_tx
|
req.response_tx
|
||||||
.send(Ok(serde_json::json!({"proceed": true, "outputData": {"done": true}})))
|
.send(Ok(
|
||||||
|
serde_json::json!({"proceed": true, "outputData": {"done": true}}),
|
||||||
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user