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