1use ironflow_store::models::{Run, RunActor, TriggerKind};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7const ID_PREFIX_LEN: usize = 8;
9
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum CreatedByKind {
15 User,
17 ApiKey,
19 System,
21}
22
23#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct CreatedBy {
46 pub kind: CreatedByKind,
48 pub id: Option<Uuid>,
51 pub label: String,
56}
57
58fn short_id(id: Uuid) -> String {
60 let text = id.to_string();
61 text[..ID_PREFIX_LEN.min(text.len())].to_string()
62}
63
64fn trigger_label(trigger: &TriggerKind) -> String {
66 match trigger {
67 TriggerKind::Manual => "manual".to_string(),
68 TriggerKind::Api => "api".to_string(),
69 TriggerKind::Workflow => "workflow".to_string(),
70 TriggerKind::Retry { .. } => "retry".to_string(),
71 TriggerKind::Replay { .. } => "replay".to_string(),
72 TriggerKind::Webhook { path } => path.clone(),
73 TriggerKind::Cron { schedule, .. } => schedule.clone(),
74 TriggerKind::Nats { subject } => format!("nats:{subject}"),
75 TriggerKind::RunEvent { event_kind, .. } => format!("event:{event_kind}"),
76 TriggerKind::Polling { probe } => format!("polling:{probe}"),
77 }
78}
79
80impl From<&Run> for CreatedBy {
81 fn from(run: &Run) -> Self {
82 match run.created_by {
83 None => CreatedBy {
84 kind: CreatedByKind::System,
85 id: None,
86 label: trigger_label(&run.trigger),
87 },
88 Some(RunActor::User { user_id }) => CreatedBy {
89 kind: CreatedByKind::User,
90 id: Some(user_id),
91 label: run
92 .created_by_label
93 .clone()
94 .unwrap_or_else(|| format!("user {}", short_id(user_id))),
95 },
96 Some(RunActor::ApiKey { api_key_id, .. }) => CreatedBy {
97 kind: CreatedByKind::ApiKey,
98 id: Some(api_key_id),
99 label: run
100 .created_by_label
101 .clone()
102 .unwrap_or_else(|| format!("key {}", short_id(api_key_id))),
103 },
104 }
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use std::collections::HashMap;
111
112 use ironflow_store::api_key_store::ApiKeyStore;
113 use ironflow_store::memory::InMemoryStore;
114 use ironflow_store::models::{ApiKeyScope, NewApiKey, NewRun, NewUser};
115 use ironflow_store::store::RunStore;
116 use ironflow_store::user_store::UserStore;
117 use serde_json::json;
118
119 use super::*;
120
121 async fn run_with(
124 store: &InMemoryStore,
125 trigger: TriggerKind,
126 created_by: Option<RunActor>,
127 ) -> Run {
128 store
129 .create_run(NewRun {
130 workflow_name: "deploy".to_string(),
131 trigger,
132 payload: json!({}),
133 max_retries: 0,
134 handler_version: None,
135 labels: HashMap::new(),
136 scheduled_at: None,
137 created_by,
138 idempotency_key: None,
139 concurrency_key: None,
140 priority: 0,
141 concurrency_limits: Vec::new(),
142 max_cost_usd: None,
143 worker_tags: Vec::new(),
144 })
145 .await
146 .expect("create run")
147 .into_run()
148 }
149
150 async fn seed_user(store: &InMemoryStore, username: &str) -> Uuid {
151 store
152 .create_user(NewUser {
153 email: format!("{username}@example.com"),
154 username: username.to_string(),
155 password_hash: "hash".to_string(),
156 is_admin: Some(false),
157 })
158 .await
159 .expect("create user")
160 .id
161 }
162
163 async fn seed_api_key(store: &InMemoryStore, user_id: Uuid, name: &str) -> Uuid {
164 store
165 .create_api_key(NewApiKey {
166 user_id,
167 name: name.to_string(),
168 key_hash: "hash".to_string(),
169 key_prefix: "irfl_0000".to_string(),
170 scopes: vec![ApiKeyScope::RunsWrite],
171 expires_at: None,
172 rate_limit_override: None,
173 })
174 .await
175 .expect("create api key")
176 .id
177 }
178
179 #[tokio::test]
180 async fn user_actor_uses_the_resolved_username() {
181 let store = InMemoryStore::new();
182 let user_id = seed_user(&store, "alice").await;
183 let run = run_with(&store, TriggerKind::Api, Some(RunActor::User { user_id })).await;
184
185 let created_by = CreatedBy::from(&run);
186 assert_eq!(created_by.kind, CreatedByKind::User);
187 assert_eq!(created_by.id, Some(user_id));
188 assert_eq!(created_by.label, "alice");
189 }
190
191 #[tokio::test]
192 async fn user_actor_falls_back_to_a_short_id() {
193 let store = InMemoryStore::new();
194 let user_id = Uuid::now_v7();
195 let run = run_with(&store, TriggerKind::Api, Some(RunActor::User { user_id })).await;
196
197 let created_by = CreatedBy::from(&run);
198 assert_eq!(created_by.kind, CreatedByKind::User);
199 assert_eq!(created_by.id, Some(user_id));
200 assert_eq!(created_by.label, format!("user {}", short_id(user_id)));
201 }
202
203 #[tokio::test]
204 async fn api_key_actor_exposes_the_key_id_and_a_combined_label() {
205 let store = InMemoryStore::new();
206 let user_id = seed_user(&store, "alice").await;
207 let api_key_id = seed_api_key(&store, user_id, "ci-deploy").await;
208 let run = run_with(
209 &store,
210 TriggerKind::Api,
211 Some(RunActor::ApiKey {
212 api_key_id,
213 user_id,
214 }),
215 )
216 .await;
217
218 let created_by = CreatedBy::from(&run);
219 assert_eq!(created_by.kind, CreatedByKind::ApiKey);
220 assert_eq!(created_by.id, Some(api_key_id));
221 assert_eq!(created_by.label, "ci-deploy (alice)");
222 }
223
224 #[tokio::test]
225 async fn api_key_actor_falls_back_to_a_short_id() {
226 let store = InMemoryStore::new();
227 let api_key_id = Uuid::now_v7();
228 let run = run_with(
229 &store,
230 TriggerKind::Api,
231 Some(RunActor::ApiKey {
232 api_key_id,
233 user_id: Uuid::now_v7(),
234 }),
235 )
236 .await;
237
238 let created_by = CreatedBy::from(&run);
239 assert_eq!(created_by.kind, CreatedByKind::ApiKey);
240 assert_eq!(created_by.label, format!("key {}", short_id(api_key_id)));
241 }
242
243 #[tokio::test]
244 async fn system_label_is_derived_from_every_trigger() {
245 let store = InMemoryStore::new();
246 let cases = [
247 (TriggerKind::Manual, "manual"),
248 (TriggerKind::Api, "api"),
249 (TriggerKind::Workflow, "workflow"),
250 (
251 TriggerKind::Retry {
252 parent_run_id: Uuid::now_v7(),
253 },
254 "retry",
255 ),
256 (
257 TriggerKind::Replay {
258 original_run_id: Uuid::now_v7(),
259 },
260 "replay",
261 ),
262 (
263 TriggerKind::Webhook {
264 path: "/hooks/github".to_string(),
265 },
266 "/hooks/github",
267 ),
268 (
269 TriggerKind::Cron {
270 schedule: "0 */5 * * * *".to_string(),
271 schedule_id: None,
272 scheduled_for: None,
273 },
274 "0 */5 * * * *",
275 ),
276 (
277 TriggerKind::Nats {
278 subject: "orders.created".to_string(),
279 },
280 "nats:orders.created",
281 ),
282 (
283 TriggerKind::RunEvent {
284 source_run_id: Uuid::now_v7(),
285 event_kind: "completed".to_string(),
286 },
287 "event:completed",
288 ),
289 (
290 TriggerKind::Polling {
291 probe: "http".to_string(),
292 },
293 "polling:http",
294 ),
295 ];
296
297 for (trigger, expected) in cases {
298 let run = run_with(&store, trigger, None).await;
299 let created_by = CreatedBy::from(&run);
300
301 assert_eq!(created_by.kind, CreatedByKind::System);
302 assert_eq!(created_by.id, None);
303 assert_eq!(created_by.label, expected);
304 }
305 }
306
307 #[tokio::test]
308 async fn a_pre_migration_run_reports_a_system_author() {
309 let store = InMemoryStore::new();
310 let run = run_with(&store, TriggerKind::Manual, None).await;
312
313 let created_by = CreatedBy::from(&run);
314 assert_eq!(created_by.kind, CreatedByKind::System);
315 assert_eq!(created_by.id, None);
316 assert_eq!(created_by.label, "manual");
317 }
318
319 #[tokio::test]
320 async fn label_is_never_empty() {
321 let store = InMemoryStore::new();
322 let run = run_with(
323 &store,
324 TriggerKind::Webhook {
325 path: "/h".to_string(),
326 },
327 None,
328 )
329 .await;
330
331 assert!(!CreatedBy::from(&run).label.is_empty());
332 }
333
334 #[test]
335 fn short_id_keeps_eight_characters() {
336 let id = Uuid::now_v7();
337 assert_eq!(short_id(id).len(), ID_PREFIX_LEN);
338 assert!(id.to_string().starts_with(&short_id(id)));
339 }
340
341 #[test]
342 fn serde_uses_snake_case_kinds() {
343 let created_by = CreatedBy {
344 kind: CreatedByKind::ApiKey,
345 id: Some(Uuid::now_v7()),
346 label: "ci (alice)".to_string(),
347 };
348
349 let json = serde_json::to_value(&created_by).expect("serialize");
350 assert_eq!(json["kind"], "api_key");
351
352 let back: CreatedBy = serde_json::from_value(json).expect("deserialize");
353 assert_eq!(back, created_by);
354 }
355}