sayiir_runtime/client.rs
1//! Centralised workflow lifecycle client.
2//!
3//! [`WorkflowClient`] provides a single entry-point for submitting workflows
4//! with idempotency (via [`ConflictPolicy`]) and for lifecycle operations
5//! (cancel, pause, unpause, send event, status) without requiring a runner
6//! or worker.
7//!
8//! This is the recommended API for the distributed model where a
9//! [`PooledWorker`](crate::worker::PooledWorker) executes tasks but a
10//! separate process or service needs to submit workflows and control them.
11
12use std::sync::Arc;
13
14use bytes::Bytes;
15use sayiir_core::codec::sealed;
16use sayiir_core::codec::{Codec, EnvelopeCodec};
17use sayiir_core::snapshot::{SignalKind, SignalRequest};
18use sayiir_core::task::TaskIdentifier;
19use sayiir_core::workflow::{ConflictPolicy, Workflow, WorkflowStatus};
20use sayiir_persistence::{SignalStore, SnapshotStore, TaskResultStore};
21
22use crate::error::RuntimeError;
23use crate::{PrepareRunOutcome, check_existing_instance, prepare_run};
24
25/// A client for submitting and controlling workflow instances.
26///
27/// Unlike [`CheckpointingRunner`](crate::CheckpointingRunner), the client does
28/// **not** execute tasks — it only creates initial snapshots and stores
29/// lifecycle signals. A [`PooledWorker`](crate::worker::PooledWorker) (or
30/// `CheckpointingRunner::resume`) picks up and executes the work.
31///
32/// # Example
33///
34/// ```rust,no_run
35/// use sayiir_runtime::WorkflowClient;
36/// use sayiir_runtime::persistence::InMemoryBackend;
37/// use sayiir_core::workflow::ConflictPolicy;
38///
39/// let backend = InMemoryBackend::new();
40/// let client = WorkflowClient::new(backend)
41/// .with_conflict_policy(ConflictPolicy::UseExisting);
42/// ```
43pub struct WorkflowClient<B> {
44 backend: Arc<B>,
45 conflict_policy: ConflictPolicy,
46}
47
48impl<B> WorkflowClient<B> {
49 /// Create a new client wrapping the given backend.
50 ///
51 /// The default conflict policy is [`ConflictPolicy::Fail`].
52 pub fn new(backend: B) -> Self {
53 Self {
54 backend: Arc::new(backend),
55 conflict_policy: ConflictPolicy::default(),
56 }
57 }
58
59 /// Create a client from a shared backend reference.
60 ///
61 /// Useful when the same backend is shared with a runner or worker.
62 pub fn from_shared(backend: Arc<B>) -> Self {
63 Self {
64 backend,
65 conflict_policy: ConflictPolicy::default(),
66 }
67 }
68
69 /// Set the conflict policy for duplicate instance IDs.
70 #[must_use]
71 pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
72 self.conflict_policy = policy;
73 self
74 }
75
76 /// Get a reference to the backend.
77 #[must_use]
78 pub fn backend(&self) -> &Arc<B> {
79 &self.backend
80 }
81}
82
83impl<B> WorkflowClient<B>
84where
85 B: SnapshotStore + SignalStore,
86{
87 /// Submit a workflow for execution.
88 ///
89 /// Creates an initial snapshot in the backend so that a
90 /// [`PooledWorker`](crate::worker::PooledWorker) can pick it up.
91 /// Does **not** execute any tasks.
92 ///
93 /// Returns `(WorkflowStatus, Option<Bytes>)`:
94 /// - `(InProgress, None)` when a fresh snapshot was created.
95 /// - `(status, output)` when the conflict policy returns an existing instance.
96 ///
97 /// # Errors
98 ///
99 /// Returns [`RuntimeError::InstanceAlreadyExists`] when the policy is `Fail`
100 /// and the instance already exists, or propagates backend I/O errors.
101 pub async fn submit<C, Input, M>(
102 &self,
103 workflow: &Workflow<C, Input, M>,
104 instance_id: impl Into<String>,
105 input: Input,
106 ) -> Result<(WorkflowStatus, Option<Bytes>), RuntimeError>
107 where
108 Input: Send + 'static,
109 M: Send + Sync + 'static,
110 C: Codec + EnvelopeCodec + sealed::EncodeValue<Input> + 'static,
111 {
112 let instance_id = instance_id.into();
113 let definition_hash = *workflow.definition_hash();
114 let conflict_policy = self.conflict_policy;
115
116 // Phase 1: check for existing instance before encoding input.
117 if let Some(early) = check_existing_instance(
118 &instance_id,
119 &definition_hash,
120 self.backend.as_ref(),
121 conflict_policy,
122 )
123 .await?
124 {
125 return Ok(early);
126 }
127
128 // Phase 2: encode input and create snapshot.
129 let input_bytes = workflow.context().codec.encode(&input)?;
130 let first_task = workflow.continuation().first_task_hint();
131
132 match prepare_run(
133 &instance_id,
134 definition_hash,
135 input_bytes,
136 first_task,
137 self.backend.as_ref(),
138 conflict_policy,
139 )
140 .await?
141 {
142 PrepareRunOutcome::Fresh(_) => Ok((WorkflowStatus::InProgress, None)),
143 PrepareRunOutcome::ExistingStatus(status, output) => Ok((status, output)),
144 }
145 }
146
147 /// Request cancellation of a workflow instance.
148 ///
149 /// Stores a cancel signal in the backend. The worker picks it up
150 /// at the next task boundary.
151 ///
152 /// # Errors
153 ///
154 /// Returns an error if the signal cannot be stored.
155 pub async fn cancel(
156 &self,
157 instance_id: &str,
158 reason: Option<String>,
159 cancelled_by: Option<String>,
160 ) -> Result<(), RuntimeError> {
161 self.backend
162 .store_signal(
163 instance_id,
164 SignalKind::Cancel,
165 SignalRequest::new(reason, cancelled_by),
166 )
167 .await?;
168 Ok(())
169 }
170
171 /// Request pausing of a workflow instance.
172 ///
173 /// Stores a pause signal in the backend. The worker picks it up
174 /// at the next task boundary.
175 ///
176 /// # Errors
177 ///
178 /// Returns an error if the signal cannot be stored.
179 pub async fn pause(
180 &self,
181 instance_id: &str,
182 reason: Option<String>,
183 paused_by: Option<String>,
184 ) -> Result<(), RuntimeError> {
185 self.backend
186 .store_signal(
187 instance_id,
188 SignalKind::Pause,
189 SignalRequest::new(reason, paused_by),
190 )
191 .await?;
192 Ok(())
193 }
194
195 /// Unpause a paused workflow instance.
196 ///
197 /// # Errors
198 ///
199 /// Returns an error if the workflow is not found or not paused.
200 pub async fn unpause(&self, instance_id: &str) -> Result<(), RuntimeError> {
201 self.backend.unpause(instance_id).await?;
202 Ok(())
203 }
204
205 /// Send an external event (signal) to a workflow instance.
206 ///
207 /// The payload is buffered in FIFO order per (`instance_id`, `signal_name`).
208 ///
209 /// # Errors
210 ///
211 /// Returns an error if the event cannot be stored.
212 pub async fn send_event(
213 &self,
214 instance_id: &str,
215 signal_name: &str,
216 payload: Bytes,
217 ) -> Result<(), RuntimeError> {
218 self.backend
219 .send_event(instance_id, signal_name, payload)
220 .await?;
221 Ok(())
222 }
223
224 /// Get the current status of a workflow instance.
225 ///
226 /// # Errors
227 ///
228 /// Returns an error if the snapshot cannot be loaded.
229 pub async fn status(&self, instance_id: &str) -> Result<WorkflowStatus, RuntimeError> {
230 let snapshot = self.backend.load_snapshot(instance_id).await?;
231 Ok(snapshot.state.as_status())
232 }
233}
234
235impl<B> WorkflowClient<B>
236where
237 B: SnapshotStore + SignalStore + TaskResultStore,
238{
239 /// Get a single task result from a workflow instance.
240 ///
241 /// Returns `Ok(Some(bytes))` if the task has completed, `Ok(None)` if the
242 /// task was never executed. For completed/failed workflows, the result is
243 /// recovered from the backend's history or cache.
244 ///
245 /// # Errors
246 ///
247 /// Returns an error if the snapshot cannot be loaded.
248 pub async fn get_task_result(
249 &self,
250 instance_id: &str,
251 task_id: &str,
252 ) -> Result<Option<Bytes>, RuntimeError> {
253 Ok(self
254 .backend
255 .load_task_result(instance_id, &sayiir_core::TaskId::from(task_id))
256 .await?)
257 }
258
259 /// Type-safe variant of [`get_task_result`](Self::get_task_result) that
260 /// derives the `task_id` from a [`TaskIdentifier`] implementor (e.g. a
261 /// `#[task]`-generated struct).
262 ///
263 /// ```rust,ignore
264 /// let result = client.get_task_result_of::<ValidateOrderTask>("order-42").await?;
265 /// ```
266 ///
267 /// # Errors
268 ///
269 /// Returns an error if the snapshot cannot be loaded.
270 pub async fn get_task_result_of<T: TaskIdentifier>(
271 &self,
272 instance_id: &str,
273 ) -> Result<Option<Bytes>, RuntimeError> {
274 self.get_task_result(instance_id, T::task_id()).await
275 }
276}