1use std::collections::BTreeMap;
2use std::fs;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9
10use crate::{
11 BeamPaths, EventDraft, EventLog, ParamDef, WorkflowActor, WorkflowDefinition,
12 parse_workflow_definition,
13};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "camelCase")]
17pub struct RunChatBinding {
18 pub chat_id: String,
19 pub lark_app_id: String,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "camelCase")]
24pub struct WorkflowOutputRef {
25 pub output_hash: String,
26 pub output_path: String,
27 pub output_bytes: usize,
28 pub output_schema_version: u32,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub content_type: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct WorkflowRunBootstrap {
35 pub run_id: String,
36 pub workflow_id: String,
37 pub revision_id: String,
38 pub input_ref: WorkflowOutputRef,
39}
40
41#[derive(Debug, Clone)]
42pub struct BootstrapWorkflowRunInput<'a> {
43 pub run_id: &'a str,
44 pub workflow_json: &'a str,
45 pub expected_workflow_id: Option<&'a str>,
46 pub params: &'a BTreeMap<String, Value>,
47 pub initiator: &'a str,
48 pub chat_binding: Option<RunChatBinding>,
49}
50
51pub fn mint_workflow_run_id(workflow_id: &str, now_ms: u64) -> String {
52 let safe = workflow_id
53 .chars()
54 .map(|ch| match ch {
55 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => ch,
56 _ => '_',
57 })
58 .collect::<String>();
59 format!("{}-{}", safe, now_ms)
60}
61
62pub fn bootstrap_workflow_run(
63 paths: &BeamPaths,
64 input: BootstrapWorkflowRunInput<'_>,
65) -> Result<WorkflowRunBootstrap> {
66 let workflow = parse_workflow_definition(input.workflow_json)?;
67 let workflow_id = workflow.workflow_id.clone();
68 if let Some(expected) = input.expected_workflow_id {
69 if expected != workflow_id {
70 anyhow::bail!(
71 "workflowId mismatch: requested={} file={}",
72 expected,
73 workflow_id
74 );
75 }
76 }
77
78 let normalized_params = normalize_workflow_params(&workflow, input.params)?;
80
81 let run_dir = paths.workflow_run_dir(input.run_id);
82 fs::create_dir_all(run_dir.join("blobs"))?;
83 fs::write(run_dir.join("workflow.json"), input.workflow_json)?;
84 if let Some(binding) = input.chat_binding {
85 fs::write(
86 run_dir.join("chat-binding.json"),
87 serde_json::to_vec_pretty(&binding)?,
88 )?;
89 }
90
91 let params_json = serde_json::to_vec(&normalized_params)?;
92 let params_hash = sha256_hex(¶ms_json);
93 let input_path = run_dir.join("blobs").join(¶ms_hash);
94 fs::write(&input_path, ¶ms_json)?;
95 let input_ref = WorkflowOutputRef {
96 output_hash: format!("sha256:{}", params_hash),
97 output_path: input_path.display().to_string(),
98 output_bytes: params_json.len(),
99 output_schema_version: 1,
100 content_type: Some("application/json".to_string()),
101 };
102
103 let mut log = EventLog::new(input.run_id.to_string(), paths.workflow_runs_dir())?;
104 let revision_id = sha256_hex(&serde_json::to_vec(&workflow)?);
105 let _run_created = log.append(EventDraft {
106 event_type: "runCreated".to_string(),
107 actor: WorkflowActor::System,
108 payload: serde_json::json!({
109 "workflowId": workflow_id,
110 "revisionId": revision_id,
111 "inputRef": input_ref,
112 "initiator": input.initiator,
113 }),
114 timestamp: None,
115 payload_hash: None,
116 })?;
117 let _run_started = log.append(EventDraft {
118 event_type: "runStarted".to_string(),
119 actor: WorkflowActor::Scheduler,
120 payload: serde_json::json!({}),
121 timestamp: None,
122 payload_hash: None,
123 })?;
124
125 Ok(WorkflowRunBootstrap {
126 run_id: input.run_id.to_string(),
127 workflow_id,
128 revision_id,
129 input_ref,
130 })
131}
132
133pub fn normalize_workflow_params(
140 def: &WorkflowDefinition,
141 input: &BTreeMap<String, Value>,
142) -> Result<BTreeMap<String, Value>> {
143 let Some(params_def) = &def.params else {
144 if input.is_empty() {
146 return Ok(BTreeMap::new());
147 }
148 let unknown: Vec<String> = input.keys().cloned().collect();
149 anyhow::bail!(
150 "unknown workflow parameter(s): {}. No parameters are declared for this workflow.",
151 unknown.join(", ")
152 );
153 };
154
155 if params_def.is_empty() {
157 if input.is_empty() {
158 return Ok(BTreeMap::new());
159 }
160 let unknown: Vec<String> = input.keys().cloned().collect();
161 anyhow::bail!(
162 "unknown workflow parameter(s): {}. No parameters are declared for this workflow.",
163 unknown.join(", ")
164 );
165 }
166
167 let defined_keys: std::collections::HashSet<&String> = params_def.keys().collect();
169 let unknown: Vec<String> = input
170 .keys()
171 .filter(|k| !defined_keys.contains(*k))
172 .cloned()
173 .collect();
174
175 if !unknown.is_empty() {
176 let available: Vec<&str> = params_def.keys().map(String::as_str).collect();
177 anyhow::bail!(
178 "unknown workflow parameter(s): {}. Available parameters: [{}]",
179 unknown.join(", "),
180 available.join(", ")
181 );
182 }
183
184 let mut normalized = BTreeMap::new();
186 let mut missing: Vec<String> = Vec::new();
187
188 for (name, param_def) in params_def {
189 match input.get(name) {
190 Some(value) => {
191 let coerced = coerce_param_value(name, param_def, value)?;
195
196 validate_param_type(name, param_def, &coerced)?;
197 validate_param_format(name, param_def, &coerced)?;
198
199 if param_def.required == Some(true) && param_def.param_type == "string" {
202 if let Value::String(s) = &coerced {
203 if s.trim().is_empty() {
204 missing.push(name.clone());
205 continue;
206 }
207 }
208 }
209 normalized.insert(name.clone(), coerced);
212 }
213 None => {
214 if param_def.required == Some(true) {
215 missing.push(name.clone());
216 } else if let Some(default) = ¶m_def.default {
217 validate_param_type(name, param_def, default)?;
220 validate_param_format(name, param_def, default)?;
221 normalized.insert(name.clone(), default.clone());
222 }
223 }
225 }
226 }
227
228 if !missing.is_empty() {
229 anyhow::bail!(
230 "missing required workflow parameter(s): {}",
231 missing.join(", ")
232 );
233 }
234
235 Ok(normalized)
236}
237
238fn validate_param_type(name: &str, def: &ParamDef, value: &Value) -> Result<()> {
240 match def.param_type.as_str() {
241 "string" => {
242 if !value.is_string() {
243 anyhow::bail!(
244 "workflow parameter '{}' expects type 'string', got {}",
245 name,
246 describe_value_kind(value)
247 );
248 }
249 }
250 "number" => {
251 if !value.is_number() {
252 anyhow::bail!(
253 "workflow parameter '{}' expects type 'number', got {}",
254 name,
255 describe_value_kind(value)
256 );
257 }
258 }
259 "integer" => match value {
260 Value::Number(n) => {
261 let is_int = n.as_i64().is_some()
262 || n.as_u64().is_some()
263 || n.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false);
264 if !is_int {
265 anyhow::bail!(
266 "workflow parameter '{}' expects type 'integer', got non-integer number",
267 name
268 );
269 }
270 }
271 _ => anyhow::bail!(
272 "workflow parameter '{}' expects type 'integer', got {}",
273 name,
274 describe_value_kind(value)
275 ),
276 },
277 "boolean" => {
278 if !value.is_boolean() {
279 anyhow::bail!(
280 "workflow parameter '{}' expects type 'boolean', got {}",
281 name,
282 describe_value_kind(value)
283 );
284 }
285 }
286 "object" => {
287 if !value.is_object() {
288 anyhow::bail!(
289 "workflow parameter '{}' expects type 'object', got {}",
290 name,
291 describe_value_kind(value)
292 );
293 }
294 }
295 "array" => {
296 if !value.is_array() {
297 anyhow::bail!(
298 "workflow parameter '{}' expects type 'array', got {}",
299 name,
300 describe_value_kind(value)
301 );
302 }
303 }
304 unknown => {
305 anyhow::bail!(
306 "workflow parameter '{}' has unknown type '{}'",
307 name,
308 unknown
309 );
310 }
311 }
312 Ok(())
313}
314
315fn validate_param_format(name: &str, def: &ParamDef, value: &Value) -> Result<()> {
320 let Some(format) = &def.format else {
321 return Ok(());
322 };
323
324 if def.param_type != "string" {
326 anyhow::bail!(
327 "workflow parameter '{}' has format '{}' but type is '{}'; format is only valid for string type",
328 name,
329 format,
330 def.param_type,
331 );
332 }
333
334 let Value::String(s) = value else {
335 return Ok(());
337 };
338
339 match format.as_str() {
340 "date" => validate_date(name, s),
341 "date-time" => validate_date_time(name, s),
342 "email" => validate_email(name, s),
343 unknown => {
344 anyhow::bail!(
345 "workflow parameter '{}' has unknown format '{}'",
346 name,
347 unknown,
348 );
349 }
350 }
351}
352
353fn validate_date(name: &str, value: &str) -> Result<()> {
355 chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
356 anyhow::anyhow!(
357 "workflow parameter '{}' with format 'date' must be a valid date (YYYY-MM-DD), got: {}",
358 name,
359 value,
360 )
361 })?;
362 Ok(())
363}
364
365fn validate_date_time(name: &str, value: &str) -> Result<()> {
367 chrono::DateTime::parse_from_rfc3339(value).map_err(|_| {
369 anyhow::anyhow!(
370 "workflow parameter '{}' with format 'date-time' must be valid RFC 3339, got: {}",
371 name,
372 value,
373 )
374 })?;
375 Ok(())
376}
377
378fn validate_email(name: &str, value: &str) -> Result<()> {
382 let at_count = value.chars().filter(|&c| c == '@').count();
384 if at_count != 1 {
385 anyhow::bail!(
386 "workflow parameter '{}' with format 'email' must contain exactly one '@', got: {}",
387 name,
388 value,
389 );
390 }
391
392 let at_pos = value.find('@').unwrap();
393 let local = &value[..at_pos];
394 let domain = &value[at_pos + 1..];
395
396 if local.is_empty() {
397 anyhow::bail!(
398 "workflow parameter '{}' with format 'email' has empty local part, got: {}",
399 name,
400 value,
401 );
402 }
403 if domain.is_empty() {
404 anyhow::bail!(
405 "workflow parameter '{}' with format 'email' has empty domain part, got: {}",
406 name,
407 value,
408 );
409 }
410
411 if !domain.contains('.') {
413 anyhow::bail!(
414 "workflow parameter '{}' with format 'email' domain must contain at least one dot, got: {}",
415 name,
416 value,
417 );
418 }
419
420 if value.chars().any(|c| c.is_whitespace()) {
422 anyhow::bail!(
423 "workflow parameter '{}' with format 'email' must not contain whitespace, got: {}",
424 name,
425 value,
426 );
427 }
428
429 for label in domain.split('.') {
431 if label.is_empty() {
432 anyhow::bail!(
433 "workflow parameter '{}' with format 'email' domain has empty label, got: {}",
434 name,
435 value,
436 );
437 }
438 if label.starts_with('-') || label.ends_with('-') {
439 anyhow::bail!(
440 "workflow parameter '{}' with format 'email' domain label must not start or end with '-', got: {}",
441 name,
442 value,
443 );
444 }
445 }
446
447 Ok(())
448}
449
450fn describe_value_kind(value: &Value) -> &'static str {
451 match value {
452 Value::Null => "null",
453 Value::Bool(_) => "bool",
454 Value::Number(_) => "number",
455 Value::String(_) => "string",
456 Value::Array(_) => "array",
457 Value::Object(_) => "object",
458 }
459}
460
461fn coerce_param_value(name: &str, def: &ParamDef, value: &Value) -> Result<Value> {
482 let target_type = def.param_type.as_str();
483
484 let already_typed = match target_type {
486 "string" => value.is_string(),
487 "number" | "integer" => value.is_number(),
488 "boolean" => value.is_boolean(),
489 "object" => value.is_object(),
490 "array" => value.is_array(),
491 _ => false,
492 };
493 if already_typed {
494 return Ok(value.clone());
495 }
496
497 if !value.is_string() {
499 return Ok(value.clone());
501 }
502
503 let s = value.as_str().unwrap();
504
505 match target_type {
506 "string" => {
507 Ok(value.clone())
509 }
510 "boolean" => {
511 let trimmed = s.trim();
512 if trimmed.is_empty() {
513 anyhow::bail!(
514 "workflow parameter '{}' expects type 'boolean', got empty string",
515 name
516 );
517 }
518 match trimmed {
519 "true" => Ok(Value::Bool(true)),
520 "false" => Ok(Value::Bool(false)),
521 _ => anyhow::bail!(
522 "workflow parameter '{}' expects type 'boolean', got string '{}' (only 'true' or 'false' are accepted)",
523 name, trimmed
524 ),
525 }
526 }
527 "number" => {
528 let trimmed = s.trim();
529 if trimmed.is_empty() {
530 anyhow::bail!(
531 "workflow parameter '{}' expects type 'number', got empty string",
532 name
533 );
534 }
535 let v: Value = serde_json::from_str(trimmed).map_err(|e| {
536 anyhow::anyhow!(
537 "workflow parameter '{}' expects type 'number', failed to parse '{}' as a number: {}",
538 name, trimmed, e
539 )
540 })?;
541 if !v.is_number() {
542 anyhow::bail!(
543 "workflow parameter '{}' expects type 'number', but string '{}' parsed to {}",
544 name, trimmed, describe_value_kind(&v)
545 );
546 }
547 Ok(v)
548 }
549 "integer" => {
550 let trimmed = s.trim();
551 if trimmed.is_empty() {
552 anyhow::bail!(
553 "workflow parameter '{}' expects type 'integer', got empty string",
554 name
555 );
556 }
557
558 let is_decimal_integer = trimmed
561 .chars()
562 .enumerate()
563 .all(|(i, c)| {
564 if i == 0 && c == '-' {
565 trimmed.len() > 1 } else {
567 c.is_ascii_digit()
568 }
569 });
570
571 if !is_decimal_integer {
572 anyhow::bail!(
573 "workflow parameter '{}' expects type 'integer', got string '{}' (only decimal integer strings like '42' or '-1' are accepted)",
574 name, trimmed
575 );
576 }
577
578 let n: i128 = trimmed.parse().map_err(|_| {
580 anyhow::anyhow!(
581 "workflow parameter '{}' expects type 'integer', failed to parse '{}'",
582 name, trimmed
583 )
584 })?;
585 let num = serde_json::Number::from_i128(n).ok_or_else(|| {
586 anyhow::anyhow!(
587 "workflow parameter '{}' expects type 'integer', number out of range: '{}'",
588 name, trimmed
589 )
590 })?;
591 Ok(Value::Number(num))
592 }
593 "object" => {
594 let trimmed = s.trim();
595 if trimmed.is_empty() {
596 anyhow::bail!(
597 "workflow parameter '{}' expects type 'object', got empty string",
598 name
599 );
600 }
601 let v: Value = serde_json::from_str(trimmed).map_err(|e| {
602 anyhow::anyhow!(
603 "workflow parameter '{}' expects type 'object', but string value must be valid JSON (failed to parse: {})",
604 name, e
605 )
606 })?;
607 if !v.is_object() {
608 anyhow::bail!(
609 "workflow parameter '{}' expects type 'object', but string value parsed to {} (must be a JSON object like '{{\"a\":1}}')",
610 name, describe_value_kind(&v)
611 );
612 }
613 Ok(v)
614 }
615 "array" => {
616 let trimmed = s.trim();
617 if trimmed.is_empty() {
618 anyhow::bail!(
619 "workflow parameter '{}' expects type 'array', got empty string",
620 name
621 );
622 }
623 let v: Value = serde_json::from_str(trimmed).map_err(|e| {
624 anyhow::anyhow!(
625 "workflow parameter '{}' expects type 'array', but string value must be valid JSON (failed to parse: {})",
626 name, e
627 )
628 })?;
629 if !v.is_array() {
630 anyhow::bail!(
631 "workflow parameter '{}' expects type 'array', but string value parsed to {} (must be a JSON array like '[\"a\",\"b\"]')",
632 name, describe_value_kind(&v)
633 );
634 }
635 Ok(v)
636 }
637 unknown => {
638 anyhow::bail!(
639 "workflow parameter '{}' has unknown type '{}'",
640 name,
641 unknown
642 );
643 }
644 }
645}
646
647fn sha256_hex(bytes: &[u8]) -> String {
648 let mut hasher = Sha256::new();
649 hasher.update(bytes);
650 format!("{:x}", hasher.finalize())
651}
652
653pub fn read_workflow_definition_from_path(path: &Path) -> Result<String> {
654 Ok(fs::read_to_string(path).with_context(|| format!("读取 {} 失败", path.display()))?)
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use std::collections::BTreeMap;
661 use std::time::{SystemTime, UNIX_EPOCH};
662
663 fn temp_paths(label: &str) -> BeamPaths {
664 let nanos = SystemTime::now()
665 .duration_since(UNIX_EPOCH)
666 .unwrap_or_default()
667 .as_nanos();
668 BeamPaths::from_root(std::env::temp_dir().join(format!(
669 "beam-workflow-run-{label}-{nanos}-{}",
670 std::process::id()
671 )))
672 }
673
674 #[test]
675 fn mint_workflow_run_id_sanitizes_input() {
676 let run_id = mint_workflow_run_id("flow/a:b", 123);
677 assert!(run_id.starts_with("flow_a_b-123"));
678 assert!(!run_id.contains('/'));
679 assert!(!run_id.contains(':'));
680 }
681
682 #[test]
683 fn bootstrap_workflow_run_writes_snapshot_and_events() {
684 let paths = temp_paths("bootstrap");
685 let params: BTreeMap<String, Value> =
686 BTreeMap::from([(String::from("foo"), Value::String("bar".to_string()))]);
687 let result = bootstrap_workflow_run(
688 &paths,
689 BootstrapWorkflowRunInput {
690 run_id: "run-1",
691 workflow_json: r#"{"workflowId":"flow-a","version":1,"params":{"foo":{"type":"string"}},"nodes":{"node-a":{"type":"subagent","bot":"bot-a","prompt":"hi"}}}"#,
692 expected_workflow_id: Some("flow-a"),
693 params: ¶ms,
694 initiator: "cli",
695 chat_binding: Some(RunChatBinding {
696 chat_id: "chat-1".to_string(),
697 lark_app_id: "app-1".to_string(),
698 }),
699 },
700 )
701 .expect("bootstrap");
702 assert_eq!(result.run_id, "run-1");
703 assert_eq!(result.workflow_id, "flow-a");
704 assert!(
705 paths
706 .workflow_run_dir("run-1")
707 .join("workflow.json")
708 .exists()
709 );
710 assert!(
711 paths
712 .workflow_run_dir("run-1")
713 .join("chat-binding.json")
714 .exists()
715 );
716 assert!(paths.workflow_run_dir("run-1").join("blobs").exists());
717 let log = EventLog::new("run-1", paths.workflow_runs_dir()).expect("log");
718 let events = log.read_all().expect("events");
719 assert_eq!(events.len(), 2);
720 assert_eq!(events[0].event_type, "runCreated");
721 assert_eq!(events[1].event_type, "runStarted");
722 let _ = std::fs::remove_dir_all(paths.root());
723 }
724
725 #[test]
726 fn bootstrap_workflow_run_hashes_canonical_definition_bytes() {
727 let params: BTreeMap<String, Value> = BTreeMap::new();
728 let raw_a = r#"{"workflowId":"flow-a","version":1,"nodes":{"node-a":{"type":"subagent","bot":"bot-a","prompt":"hi","workingDir":"/tmp/demo"}}}"#;
729 let raw_b = r#"
730 {
731 "nodes": {
732 "node-a": {
733 "prompt": "hi",
734 "type": "subagent",
735 "bot": "bot-a",
736 "workingDir": "/tmp/demo"
737 }
738 },
739 "version": 1,
740 "workflowId": "flow-a"
741 }
742 "#;
743
744 let paths_a = temp_paths("bootstrap-canonical-a");
745 let paths_b = temp_paths("bootstrap-canonical-b");
746 let rev_a = bootstrap_workflow_run(
747 &paths_a,
748 BootstrapWorkflowRunInput {
749 run_id: "run-a",
750 workflow_json: raw_a,
751 expected_workflow_id: Some("flow-a"),
752 params: ¶ms,
753 initiator: "cli",
754 chat_binding: None,
755 },
756 )
757 .expect("bootstrap a")
758 .revision_id;
759 let rev_b = bootstrap_workflow_run(
760 &paths_b,
761 BootstrapWorkflowRunInput {
762 run_id: "run-b",
763 workflow_json: raw_b,
764 expected_workflow_id: Some("flow-a"),
765 params: ¶ms,
766 initiator: "cli",
767 chat_binding: None,
768 },
769 )
770 .expect("bootstrap b")
771 .revision_id;
772 assert_eq!(rev_a, rev_b);
773 let _ = std::fs::remove_dir_all(paths_a.root());
774 let _ = std::fs::remove_dir_all(paths_b.root());
775 }
776
777 #[test]
780 fn bootstrap_rejects_missing_required_param() {
781 let paths = temp_paths("missing-param");
782 let params: BTreeMap<String, Value> = BTreeMap::new(); let workflow_json = r#"{
784 "workflowId": "flow-req",
785 "version": 1,
786 "params": {
787 "task": {
788 "type": "string",
789 "required": true,
790 "description": "what to do"
791 }
792 },
793 "nodes": {
794 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
795 }
796 }"#;
797 let err = bootstrap_workflow_run(
798 &paths,
799 BootstrapWorkflowRunInput {
800 run_id: "run-missing",
801 workflow_json,
802 expected_workflow_id: Some("flow-req"),
803 params: ¶ms,
804 initiator: "test",
805 chat_binding: None,
806 },
807 )
808 .unwrap_err();
809 let msg = err.to_string();
810 assert!(
811 msg.contains("missing required workflow parameter"),
812 "got: {msg}"
813 );
814 assert!(msg.contains("task"), "expected 'task' in error, got: {msg}");
815 let run_dir = paths.workflow_run_dir("run-missing");
817 assert!(
818 !run_dir.exists(),
819 "run directory should NOT exist after param validation failure, but found: {}",
820 run_dir.display()
821 );
822 let _ = std::fs::remove_dir_all(paths.root());
823 }
824
825 #[test]
826 fn bootstrap_rejects_empty_required_param_value() {
827 let paths = temp_paths("empty-param");
828 let params: BTreeMap<String, Value> =
829 BTreeMap::from([(String::from("task"), Value::String(" ".to_string()))]); let workflow_json = r#"{
831 "workflowId": "flow-req",
832 "version": 1,
833 "params": {
834 "task": {
835 "type": "string",
836 "required": true,
837 "description": "what to do"
838 }
839 },
840 "nodes": {
841 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
842 }
843 }"#;
844 let err = bootstrap_workflow_run(
845 &paths,
846 BootstrapWorkflowRunInput {
847 run_id: "run-empty",
848 workflow_json,
849 expected_workflow_id: Some("flow-req"),
850 params: ¶ms,
851 initiator: "test",
852 chat_binding: None,
853 },
854 )
855 .unwrap_err();
856 let msg = err.to_string();
857 assert!(
858 msg.contains("missing required workflow parameter"),
859 "got: {msg}"
860 );
861 assert!(msg.contains("task"), "expected 'task' in error, got: {msg}");
862 let run_dir = paths.workflow_run_dir("run-empty");
864 assert!(
865 !run_dir.exists(),
866 "run directory should NOT exist after param validation failure, but found: {}",
867 run_dir.display()
868 );
869 let _ = std::fs::remove_dir_all(paths.root());
870 }
871
872 #[test]
873 fn bootstrap_succeeds_with_required_params_provided() {
874 let paths = temp_paths("provided-param");
875 let params: BTreeMap<String, Value> =
876 BTreeMap::from([(String::from("task"), Value::String("build XYZ".to_string()))]);
877 let workflow_json = r#"{
878 "workflowId": "flow-req",
879 "version": 1,
880 "params": {
881 "task": {
882 "type": "string",
883 "required": true,
884 "description": "what to do"
885 }
886 },
887 "nodes": {
888 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
889 }
890 }"#;
891 let result = bootstrap_workflow_run(
892 &paths,
893 BootstrapWorkflowRunInput {
894 run_id: "run-provided",
895 workflow_json,
896 expected_workflow_id: Some("flow-req"),
897 params: ¶ms,
898 initiator: "test",
899 chat_binding: None,
900 },
901 )
902 .expect("bootstrap with required params provided should succeed");
903 assert_eq!(result.workflow_id, "flow-req");
904 assert!(
905 paths
906 .workflow_run_dir("run-provided")
907 .join("workflow.json")
908 .exists()
909 );
910 let log = EventLog::new("run-provided", paths.workflow_runs_dir()).expect("log");
911 let events = log.read_all().expect("events");
912 assert_eq!(events.len(), 2);
913 assert_eq!(events[0].event_type, "runCreated");
914 assert_eq!(events[1].event_type, "runStarted");
915 let _ = std::fs::remove_dir_all(paths.root());
916 }
917
918 #[test]
919 fn bootstrap_ignores_optional_param_when_missing() {
920 let paths = temp_paths("optional-param");
921 let params: BTreeMap<String, Value> = BTreeMap::new(); let workflow_json = r#"{
923 "workflowId": "flow-opt",
924 "version": 1,
925 "params": {
926 "verbose": {
927 "type": "boolean",
928 "required": false,
929 "default": false
930 }
931 },
932 "nodes": {
933 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
934 }
935 }"#;
936 let result = bootstrap_workflow_run(
937 &paths,
938 BootstrapWorkflowRunInput {
939 run_id: "run-opt",
940 workflow_json,
941 expected_workflow_id: Some("flow-opt"),
942 params: ¶ms,
943 initiator: "test",
944 chat_binding: None,
945 },
946 )
947 .expect("bootstrap should ignore missing optional param");
948 assert_eq!(result.workflow_id, "flow-opt");
949 let _ = std::fs::remove_dir_all(paths.root());
950 }
951
952 #[test]
953 fn bootstrap_rejects_multiple_missing_required_params() {
954 let paths = temp_paths("multi-missing");
955 let params: BTreeMap<String, Value> = BTreeMap::new(); let workflow_json = r#"{
957 "workflowId": "flow-multi",
958 "version": 1,
959 "params": {
960 "task": {
961 "type": "string",
962 "required": true,
963 "description": "what to do"
964 },
965 "target": {
966 "type": "string",
967 "required": true,
968 "description": "where to deploy"
969 }
970 },
971 "nodes": {
972 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
973 }
974 }"#;
975 let err = bootstrap_workflow_run(
976 &paths,
977 BootstrapWorkflowRunInput {
978 run_id: "run-multi",
979 workflow_json,
980 expected_workflow_id: Some("flow-multi"),
981 params: ¶ms,
982 initiator: "test",
983 chat_binding: None,
984 },
985 )
986 .unwrap_err();
987 let msg = err.to_string();
988 assert!(
989 msg.contains("missing required workflow parameter"),
990 "got: {msg}"
991 );
992 assert!(msg.contains("task"), "expected 'task' in error, got: {msg}");
993 assert!(
994 msg.contains("target"),
995 "expected 'target' in error, got: {msg}"
996 );
997 let run_dir = paths.workflow_run_dir("run-multi");
998 assert!(!run_dir.exists(), "run directory should NOT exist on failure");
999 let _ = std::fs::remove_dir_all(paths.root());
1000 }
1001
1002 #[test]
1005 fn normalize_writes_default_value_for_optional_param() {
1006 let def = parse_workflow_definition(
1007 r#"{
1008 "workflowId": "flow-default",
1009 "version": 1,
1010 "params": {
1011 "verbose": {
1012 "type": "boolean",
1013 "required": false,
1014 "default": false
1015 },
1016 "level": {
1017 "type": "integer",
1018 "required": false,
1019 "default": 3
1020 }
1021 },
1022 "nodes": {
1023 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1024 }
1025 }"#,
1026 )
1027 .unwrap();
1028 let input: BTreeMap<String, Value> = BTreeMap::new();
1029 let normalized = normalize_workflow_params(&def, &input).expect("should succeed");
1030 assert_eq!(normalized.get("verbose"), Some(&Value::Bool(false)));
1031 assert_eq!(
1032 normalized.get("level"),
1033 Some(&serde_json::json!(3))
1034 );
1035 assert!(!normalized.contains_key("unknown_key"));
1037 }
1038
1039 #[test]
1040 fn normalize_type_validation_success() {
1041 let def = parse_workflow_definition(
1042 r#"{
1043 "workflowId": "flow-types",
1044 "version": 1,
1045 "params": {
1046 "name": { "type": "string" },
1047 "count": { "type": "integer" },
1048 "ratio": { "type": "number" },
1049 "enabled": { "type": "boolean" },
1050 "tags": { "type": "array" },
1051 "meta": { "type": "object" }
1052 },
1053 "nodes": {
1054 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1055 }
1056 }"#,
1057 )
1058 .unwrap();
1059 let input: BTreeMap<String, Value> = BTreeMap::from([
1060 (String::from("name"), Value::String("test".to_string())),
1061 (String::from("count"), serde_json::json!(42)),
1062 (String::from("ratio"), serde_json::json!(3.14)),
1063 (String::from("enabled"), Value::Bool(true)),
1064 (
1065 String::from("tags"),
1066 serde_json::json!(["a", "b"]),
1067 ),
1068 (
1069 String::from("meta"),
1070 serde_json::json!({"key": "val"}),
1071 ),
1072 ]);
1073 let _ = normalize_workflow_params(&def, &input).expect("all types valid");
1074 }
1075
1076 #[test]
1077 fn normalize_type_mismatch_fails() {
1078 let def = parse_workflow_definition(
1079 r#"{
1080 "workflowId": "flow-type-err",
1081 "version": 1,
1082 "params": {
1083 "enabled": { "type": "boolean" }
1084 },
1085 "nodes": {
1086 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1087 }
1088 }"#,
1089 )
1090 .unwrap();
1091 let input: BTreeMap<String, Value> =
1092 BTreeMap::from([(String::from("enabled"), Value::String("yes".to_string()))]);
1093 let err = normalize_workflow_params(&def, &input).unwrap_err();
1094 let msg = err.to_string();
1095 assert!(msg.contains("enabled"), "got: {msg}");
1096 assert!(msg.contains("boolean"), "got: {msg}");
1097 }
1098
1099 #[test]
1100 fn normalize_unknown_key_fails_when_schema_defined() {
1101 let def = parse_workflow_definition(
1102 r#"{
1103 "workflowId": "flow-unknown",
1104 "version": 1,
1105 "params": {
1106 "task": { "type": "string" }
1107 },
1108 "nodes": {
1109 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1110 }
1111 }"#,
1112 )
1113 .unwrap();
1114 let input: BTreeMap<String, Value> = BTreeMap::from([
1115 (String::from("task"), Value::String("hello".to_string())),
1116 (String::from("extra"), Value::String("bad".to_string())),
1117 ]);
1118 let err = normalize_workflow_params(&def, &input).unwrap_err();
1119 let msg = err.to_string();
1120 assert!(msg.contains("unknown workflow parameter"), "got: {msg}");
1121 assert!(msg.contains("extra"), "got: {msg}");
1122 assert!(msg.contains("task"), "expected available params list, got: {msg}");
1123 }
1124
1125 #[test]
1126 fn normalize_no_schema_rejects_extra_params() {
1127 let def = parse_workflow_definition(
1128 r#"{
1129 "workflowId": "flow-no-schema",
1130 "version": 1,
1131 "nodes": {
1132 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1133 }
1134 }"#,
1135 )
1136 .unwrap();
1137 let input: BTreeMap<String, Value> = BTreeMap::from([
1138 (String::from("anything"), Value::String("goes".to_string())),
1139 (String::from("extra"), serde_json::json!({"deep": true})),
1140 ]);
1141 let err = normalize_workflow_params(&def, &input).unwrap_err();
1142 let msg = err.to_string();
1143 assert!(
1144 msg.contains("unknown workflow parameter"),
1145 "got: {msg}"
1146 );
1147 assert!(msg.contains("anything"), "got: {msg}");
1148 assert!(msg.contains("extra"), "got: {msg}");
1149 assert!(
1150 msg.contains("No parameters are declared"),
1151 "got: {msg}"
1152 );
1153 }
1154
1155 #[test]
1156 fn normalize_no_schema_with_empty_input_succeeds() {
1157 let def = parse_workflow_definition(
1158 r#"{
1159 "workflowId": "flow-no-schema",
1160 "version": 1,
1161 "nodes": {
1162 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1163 }
1164 }"#,
1165 )
1166 .unwrap();
1167 let input: BTreeMap<String, Value> = BTreeMap::new();
1168 let normalized = normalize_workflow_params(&def, &input).expect("empty params with no schema should succeed");
1169 assert!(normalized.is_empty());
1170 }
1171
1172 #[test]
1173 fn normalize_empty_params_schema_rejects_extra_params() {
1174 let def = parse_workflow_definition(
1175 r#"{
1176 "workflowId": "flow-empty-params",
1177 "version": 1,
1178 "params": {},
1179 "nodes": {
1180 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1181 }
1182 }"#,
1183 )
1184 .unwrap();
1185 let input: BTreeMap<String, Value> =
1186 BTreeMap::from([(String::from("x"), Value::String("y".to_string()))]);
1187 let err = normalize_workflow_params(&def, &input).unwrap_err();
1188 let msg = err.to_string();
1189 assert!(msg.contains("unknown workflow parameter"), "got: {msg}");
1190 assert!(msg.contains("No parameters are declared"), "got: {msg}");
1191 }
1192
1193 #[test]
1194 fn normalize_empty_params_schema_with_empty_input_succeeds() {
1195 let def = parse_workflow_definition(
1196 r#"{
1197 "workflowId": "flow-empty-params",
1198 "version": 1,
1199 "params": {},
1200 "nodes": {
1201 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1202 }
1203 }"#,
1204 )
1205 .unwrap();
1206 let input: BTreeMap<String, Value> = BTreeMap::new();
1207 let normalized = normalize_workflow_params(&def, &input)
1208 .expect("empty params with empty schema should succeed");
1209 assert!(normalized.is_empty());
1210 }
1211
1212 #[test]
1213 fn normalize_default_value_type_mismatch_fails() {
1214 let def = parse_workflow_definition(
1216 r#"{
1217 "workflowId": "flow-bad-default",
1218 "version": 1,
1219 "params": {
1220 "enabled": {
1221 "type": "boolean",
1222 "required": false,
1223 "default": "not-a-bool"
1224 }
1225 },
1226 "nodes": {
1227 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1228 }
1229 }"#,
1230 )
1231 .unwrap();
1232 let input: BTreeMap<String, Value> = BTreeMap::new();
1233 let err = normalize_workflow_params(&def, &input).unwrap_err();
1234 let msg = err.to_string();
1235 assert!(msg.contains("enabled"), "got: {msg}");
1236 assert!(msg.contains("boolean"), "got: {msg}");
1237 }
1238
1239 #[test]
1240 fn normalize_unknown_param_type_fails() {
1241 let def = parse_workflow_definition(
1242 r#"{
1243 "workflowId": "flow-unknown-type",
1244 "version": 1,
1245 "params": {
1246 "x": {
1247 "type": "unknown-type-xyz",
1248 "required": false
1249 }
1250 },
1251 "nodes": {
1252 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1253 }
1254 }"#,
1255 )
1256 .unwrap();
1257 let input: BTreeMap<String, Value> =
1258 BTreeMap::from([(String::from("x"), Value::String("v".to_string()))]);
1259 let err = normalize_workflow_params(&def, &input).unwrap_err();
1260 let msg = err.to_string();
1261 assert!(msg.contains("unknown type"), "got: {msg}");
1262 assert!(msg.contains("x"), "got: {msg}");
1263 assert!(msg.contains("unknown-type-xyz"), "got: {msg}");
1264 }
1265
1266 #[test]
1267 fn normalize_integer_rejects_non_integer_number() {
1268 let def = parse_workflow_definition(
1269 r#"{
1270 "workflowId": "flow-int",
1271 "version": 1,
1272 "params": {
1273 "count": { "type": "integer" }
1274 },
1275 "nodes": {
1276 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1277 }
1278 }"#,
1279 )
1280 .unwrap();
1281 let input: BTreeMap<String, Value> =
1282 BTreeMap::from([(String::from("count"), serde_json::json!(3.5))]);
1283 let err = normalize_workflow_params(&def, &input).unwrap_err();
1284 let msg = err.to_string();
1285 assert!(msg.contains("integer"), "got: {msg}");
1286 assert!(msg.contains("count"), "got: {msg}");
1287 }
1288
1289 #[test]
1290 fn normalize_integer_accepts_integer_number() {
1291 let def = parse_workflow_definition(
1292 r#"{
1293 "workflowId": "flow-int-ok",
1294 "version": 1,
1295 "params": {
1296 "count": { "type": "integer" }
1297 },
1298 "nodes": {
1299 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1300 }
1301 }"#,
1302 )
1303 .unwrap();
1304 let input: BTreeMap<String, Value> =
1305 BTreeMap::from([(String::from("count"), serde_json::json!(42))]);
1306 let _ = normalize_workflow_params(&def, &input).expect("integer 42 OK");
1307 let input2: BTreeMap<String, Value> =
1309 BTreeMap::from([(String::from("count"), serde_json::json!(42.0))]);
1310 let _ = normalize_workflow_params(&def, &input2).expect("integer 42.0 OK");
1311 }
1312
1313 #[test]
1314 fn bootstrap_integration_rejects_unknown_key_with_params_schema() {
1315 let paths = temp_paths("bootstrap-unknown");
1316 let params: BTreeMap<String, Value> = BTreeMap::from([
1317 (String::from("task"), Value::String("hello".to_string())),
1318 (String::from("bad_key"), Value::String("x".to_string())),
1319 ]);
1320 let workflow_json = r#"{
1321 "workflowId": "flow-req",
1322 "version": 1,
1323 "params": {
1324 "task": {
1325 "type": "string",
1326 "required": true,
1327 "description": "what to do"
1328 }
1329 },
1330 "nodes": {
1331 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1332 }
1333 }"#;
1334 let err = bootstrap_workflow_run(
1335 &paths,
1336 BootstrapWorkflowRunInput {
1337 run_id: "run-unk",
1338 workflow_json,
1339 expected_workflow_id: Some("flow-req"),
1340 params: ¶ms,
1341 initiator: "test",
1342 chat_binding: None,
1343 },
1344 )
1345 .unwrap_err();
1346 let msg = err.to_string();
1347 assert!(msg.contains("unknown workflow parameter"), "got: {msg}");
1348 assert!(msg.contains("bad_key"), "got: {msg}");
1349 let run_dir = paths.workflow_run_dir("run-unk");
1350 assert!(!run_dir.exists(), "run dir should NOT exist");
1351 let _ = std::fs::remove_dir_all(paths.root());
1352 }
1353
1354 #[test]
1355 fn bootstrap_integration_default_written_to_params_blob() {
1356 let paths = temp_paths("bootstrap-default");
1357 let params: BTreeMap<String, Value> = BTreeMap::from([(
1358 String::from("task"),
1359 Value::String("build".to_string()),
1360 )]);
1361 let workflow_json = r#"{
1362 "workflowId": "flow-def",
1363 "version": 1,
1364 "params": {
1365 "task": { "type": "string", "required": true },
1366 "verbose": { "type": "boolean", "required": false, "default": false }
1367 },
1368 "nodes": {
1369 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1370 }
1371 }"#;
1372 let result = bootstrap_workflow_run(
1373 &paths,
1374 BootstrapWorkflowRunInput {
1375 run_id: "run-def",
1376 workflow_json,
1377 expected_workflow_id: Some("flow-def"),
1378 params: ¶ms,
1379 initiator: "test",
1380 chat_binding: None,
1381 },
1382 )
1383 .expect("bootstrap with default param");
1384 assert_eq!(result.workflow_id, "flow-def");
1385 let blob_path = result.input_ref.output_path;
1387 let blob_bytes = std::fs::read(&blob_path).expect("read params blob");
1388 let blob: BTreeMap<String, Value> =
1389 serde_json::from_slice(&blob_bytes).expect("parse params blob");
1390 assert_eq!(blob.get("task"), Some(&Value::String("build".to_string())));
1391 assert_eq!(blob.get("verbose"), Some(&Value::Bool(false)));
1392 let _ = std::fs::remove_dir_all(paths.root());
1393 }
1394
1395 #[test]
1398 fn format_date_success() {
1399 let def = parse_workflow_definition(
1400 r#"{
1401 "workflowId": "flow-fmt",
1402 "version": 1,
1403 "params": {
1404 "d": { "type": "string", "format": "date" }
1405 },
1406 "nodes": {
1407 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1408 }
1409 }"#,
1410 )
1411 .unwrap();
1412 let input: BTreeMap<String, Value> =
1413 BTreeMap::from([(String::from("d"), Value::String("2024-02-29".to_string()))]);
1414 let _ = normalize_workflow_params(&def, &input).expect("leap date should pass");
1415 }
1416
1417 #[test]
1418 fn format_date_failure_invalid_date() {
1419 let def = parse_workflow_definition(
1420 r#"{
1421 "workflowId": "flow-fmt",
1422 "version": 1,
1423 "params": {
1424 "d": { "type": "string", "format": "date" }
1425 },
1426 "nodes": {
1427 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1428 }
1429 }"#,
1430 )
1431 .unwrap();
1432 let input: BTreeMap<String, Value> =
1433 BTreeMap::from([(String::from("d"), Value::String("2023-02-29".to_string()))]);
1434 let err = normalize_workflow_params(&def, &input).unwrap_err();
1435 let msg = err.to_string();
1436 assert!(msg.contains("date"), "got: {msg}");
1437 assert!(msg.contains("2023-02-29"), "got: {msg}");
1438 }
1439
1440 #[test]
1441 fn format_date_failure_invalid_month() {
1442 let def = parse_workflow_definition(
1443 r#"{
1444 "workflowId": "flow-fmt",
1445 "version": 1,
1446 "params": {
1447 "d": { "type": "string", "format": "date" }
1448 },
1449 "nodes": {
1450 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1451 }
1452 }"#,
1453 )
1454 .unwrap();
1455 let input: BTreeMap<String, Value> =
1456 BTreeMap::from([(String::from("d"), Value::String("2024-13-01".to_string()))]);
1457 let err = normalize_workflow_params(&def, &input).unwrap_err();
1458 let msg = err.to_string();
1459 assert!(msg.contains("date"), "got: {msg}");
1460 assert!(msg.contains("2024-13-01"), "got: {msg}");
1461 }
1462
1463 #[test]
1464 fn format_date_failure_wrong_format() {
1465 let def = parse_workflow_definition(
1466 r#"{
1467 "workflowId": "flow-fmt",
1468 "version": 1,
1469 "params": {
1470 "d": { "type": "string", "format": "date" }
1471 },
1472 "nodes": {
1473 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1474 }
1475 }"#,
1476 )
1477 .unwrap();
1478 let input: BTreeMap<String, Value> =
1479 BTreeMap::from([(String::from("d"), Value::String("01-01-2024".to_string()))]);
1480 let err = normalize_workflow_params(&def, &input).unwrap_err();
1481 let msg = err.to_string();
1482 assert!(msg.contains("date"), "got: {msg}");
1483 }
1484
1485 #[test]
1486 fn format_date_time_success() {
1487 let def = parse_workflow_definition(
1488 r#"{
1489 "workflowId": "flow-fmt",
1490 "version": 1,
1491 "params": {
1492 "ts": { "type": "string", "format": "date-time" }
1493 },
1494 "nodes": {
1495 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1496 }
1497 }"#,
1498 )
1499 .unwrap();
1500 let input: BTreeMap<String, Value> = BTreeMap::from([(
1501 String::from("ts"),
1502 Value::String("2026-06-17T12:34:56Z".to_string()),
1503 )]);
1504 let _ = normalize_workflow_params(&def, &input).expect("RFC3339 should pass");
1505 let input2: BTreeMap<String, Value> = BTreeMap::from([(
1507 String::from("ts"),
1508 Value::String("2026-06-17T12:34:56+08:00".to_string()),
1509 )]);
1510 let _ = normalize_workflow_params(&def, &input2).expect("RFC3339 with offset should pass");
1511 }
1512
1513 #[test]
1514 fn format_date_time_failure() {
1515 let def = parse_workflow_definition(
1516 r#"{
1517 "workflowId": "flow-fmt",
1518 "version": 1,
1519 "params": {
1520 "ts": { "type": "string", "format": "date-time" }
1521 },
1522 "nodes": {
1523 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1524 }
1525 }"#,
1526 )
1527 .unwrap();
1528 let input: BTreeMap<String, Value> = BTreeMap::from([(
1529 String::from("ts"),
1530 Value::String("2026-06-17 12:34:56".to_string()),
1531 )]);
1532 let err = normalize_workflow_params(&def, &input).unwrap_err();
1533 let msg = err.to_string();
1534 assert!(msg.contains("date-time"), "got: {msg}");
1535 }
1536
1537 #[test]
1538 fn format_email_success() {
1539 let def = parse_workflow_definition(
1540 r#"{
1541 "workflowId": "flow-fmt",
1542 "version": 1,
1543 "params": {
1544 "email": { "type": "string", "format": "email" }
1545 },
1546 "nodes": {
1547 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1548 }
1549 }"#,
1550 )
1551 .unwrap();
1552 let input: BTreeMap<String, Value> = BTreeMap::from([(
1553 String::from("email"),
1554 Value::String("user@example.com".to_string()),
1555 )]);
1556 let _ = normalize_workflow_params(&def, &input).expect("valid email should pass");
1557 }
1558
1559 #[test]
1560 fn format_email_failure_no_at() {
1561 let def = parse_workflow_definition(
1562 r#"{
1563 "workflowId": "flow-fmt",
1564 "version": 1,
1565 "params": {
1566 "email": { "type": "string", "format": "email" }
1567 },
1568 "nodes": {
1569 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1570 }
1571 }"#,
1572 )
1573 .unwrap();
1574 let input: BTreeMap<String, Value> = BTreeMap::from([(
1575 String::from("email"),
1576 Value::String("notanemail".to_string()),
1577 )]);
1578 let err = normalize_workflow_params(&def, &input).unwrap_err();
1579 let msg = err.to_string();
1580 assert!(msg.contains("email"), "got: {msg}");
1581 }
1582
1583 #[test]
1584 fn format_email_failure_double_at() {
1585 let def = parse_workflow_definition(
1586 r#"{
1587 "workflowId": "flow-fmt",
1588 "version": 1,
1589 "params": {
1590 "email": { "type": "string", "format": "email" }
1591 },
1592 "nodes": {
1593 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1594 }
1595 }"#,
1596 )
1597 .unwrap();
1598 let input: BTreeMap<String, Value> = BTreeMap::from([(
1599 String::from("email"),
1600 Value::String("a@b@c.com".to_string()),
1601 )]);
1602 let err = normalize_workflow_params(&def, &input).unwrap_err();
1603 let msg = err.to_string();
1604 assert!(msg.contains("email"), "got: {msg}");
1605 }
1606
1607 #[test]
1608 fn format_email_failure_empty_local() {
1609 let def = parse_workflow_definition(
1610 r#"{
1611 "workflowId": "flow-fmt",
1612 "version": 1,
1613 "params": {
1614 "email": { "type": "string", "format": "email" }
1615 },
1616 "nodes": {
1617 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1618 }
1619 }"#,
1620 )
1621 .unwrap();
1622 let input: BTreeMap<String, Value> = BTreeMap::from([(
1623 String::from("email"),
1624 Value::String("@example.com".to_string()),
1625 )]);
1626 let err = normalize_workflow_params(&def, &input).unwrap_err();
1627 let msg = err.to_string();
1628 assert!(msg.contains("email"), "got: {msg}");
1629 }
1630
1631 #[test]
1632 fn format_email_failure_no_dot_in_domain() {
1633 let def = parse_workflow_definition(
1634 r#"{
1635 "workflowId": "flow-fmt",
1636 "version": 1,
1637 "params": {
1638 "email": { "type": "string", "format": "email" }
1639 },
1640 "nodes": {
1641 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1642 }
1643 }"#,
1644 )
1645 .unwrap();
1646 let input: BTreeMap<String, Value> = BTreeMap::from([(
1647 String::from("email"),
1648 Value::String("user@localhost".to_string()),
1649 )]);
1650 let err = normalize_workflow_params(&def, &input).unwrap_err();
1651 let msg = err.to_string();
1652 assert!(msg.contains("dot"), "got: {msg}");
1653 }
1654
1655 #[test]
1656 fn format_email_failure_whitespace() {
1657 let def = parse_workflow_definition(
1658 r#"{
1659 "workflowId": "flow-fmt",
1660 "version": 1,
1661 "params": {
1662 "email": { "type": "string", "format": "email" }
1663 },
1664 "nodes": {
1665 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1666 }
1667 }"#,
1668 )
1669 .unwrap();
1670 let input: BTreeMap<String, Value> = BTreeMap::from([(
1671 String::from("email"),
1672 Value::String("user @example.com".to_string()),
1673 )]);
1674 let err = normalize_workflow_params(&def, &input).unwrap_err();
1675 let msg = err.to_string();
1676 assert!(msg.contains("whitespace"), "got: {msg}");
1677 }
1678
1679 #[test]
1680 fn format_on_non_string_type_fails() {
1681 let def = parse_workflow_definition(
1682 r#"{
1683 "workflowId": "flow-fmt-nonstring",
1684 "version": 1,
1685 "params": {
1686 "count": { "type": "integer", "format": "date" }
1687 },
1688 "nodes": {
1689 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1690 }
1691 }"#,
1692 )
1693 .unwrap();
1694 let input: BTreeMap<String, Value> =
1695 BTreeMap::from([(String::from("count"), serde_json::json!(42))]);
1696 let err = normalize_workflow_params(&def, &input).unwrap_err();
1697 let msg = err.to_string();
1698 assert!(msg.contains("count"), "got: {msg}");
1699 assert!(msg.contains("format"), "got: {msg}");
1700 assert!(msg.contains("integer"), "got: {msg}");
1701 assert!(msg.contains("string"), "got: {msg}");
1702 }
1703
1704 #[test]
1705 fn format_unknown_format_fails() {
1706 let def = parse_workflow_definition(
1707 r#"{
1708 "workflowId": "flow-fmt-unknown",
1709 "version": 1,
1710 "params": {
1711 "x": { "type": "string", "format": "unknown-format-xyz" }
1712 },
1713 "nodes": {
1714 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1715 }
1716 }"#,
1717 )
1718 .unwrap();
1719 let input: BTreeMap<String, Value> =
1720 BTreeMap::from([(String::from("x"), Value::String("v".to_string()))]);
1721 let err = normalize_workflow_params(&def, &input).unwrap_err();
1722 let msg = err.to_string();
1723 assert!(msg.contains("unknown format"), "got: {msg}");
1724 assert!(msg.contains("x"), "got: {msg}");
1725 assert!(msg.contains("unknown-format-xyz"), "got: {msg}");
1726 }
1727
1728 #[test]
1731 fn coerce_string_to_boolean_true_and_false() {
1732 let def = parse_workflow_definition(
1733 r#"{
1734 "workflowId": "flow-coerce",
1735 "version": 1,
1736 "params": {
1737 "enabled": { "type": "boolean" }
1738 },
1739 "nodes": {
1740 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1741 }
1742 }"#,
1743 )
1744 .unwrap();
1745
1746 let input: BTreeMap<String, Value> =
1748 BTreeMap::from([(String::from("enabled"), Value::String("true".to_string()))]);
1749 let normalized = normalize_workflow_params(&def, &input).expect("true coerces to bool");
1750 assert_eq!(normalized.get("enabled"), Some(&Value::Bool(true)));
1751
1752 let input2: BTreeMap<String, Value> =
1754 BTreeMap::from([(String::from("enabled"), Value::String("false".to_string()))]);
1755 let normalized2 =
1756 normalize_workflow_params(&def, &input2).expect("false coerces to bool");
1757 assert_eq!(normalized2.get("enabled"), Some(&Value::Bool(false)));
1758 }
1759
1760 #[test]
1761 fn coerce_boolean_rejects_case_insensitive_and_junk() {
1762 let def = parse_workflow_definition(
1763 r#"{
1764 "workflowId": "flow-coerce",
1765 "version": 1,
1766 "params": {
1767 "enabled": { "type": "boolean" }
1768 },
1769 "nodes": {
1770 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1771 }
1772 }"#,
1773 )
1774 .unwrap();
1775
1776 for bad in &["True", "TRUE", "yes", "1", "0", "FALSE", "on", "off"] {
1777 let input: BTreeMap<String, Value> =
1778 BTreeMap::from([(String::from("enabled"), Value::String(bad.to_string()))]);
1779 let err = normalize_workflow_params(&def, &input).unwrap_err();
1780 let msg = err.to_string();
1781 assert!(
1782 msg.contains("boolean"),
1783 "expected 'boolean' in error for input '{}', got: {}",
1784 bad, msg
1785 );
1786 assert!(
1787 msg.contains("enabled"),
1788 "expected 'enabled' in error for input '{}', got: {}",
1789 bad, msg
1790 );
1791 }
1792 }
1793
1794 #[test]
1795 fn coerce_boolean_accepts_already_typed_value() {
1796 let def = parse_workflow_definition(
1797 r#"{
1798 "workflowId": "flow-coerce",
1799 "version": 1,
1800 "params": {
1801 "enabled": { "type": "boolean" }
1802 },
1803 "nodes": {
1804 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1805 }
1806 }"#,
1807 )
1808 .unwrap();
1809
1810 let input: BTreeMap<String, Value> =
1812 BTreeMap::from([(String::from("enabled"), Value::Bool(true))]);
1813 let normalized =
1814 normalize_workflow_params(&def, &input).expect("bool passthrough");
1815 assert_eq!(normalized.get("enabled"), Some(&Value::Bool(true)));
1816 }
1817
1818 #[test]
1819 fn coerce_number_from_strings() {
1820 let def = parse_workflow_definition(
1821 r#"{
1822 "workflowId": "flow-coerce",
1823 "version": 1,
1824 "params": {
1825 "ratio": { "type": "number" }
1826 },
1827 "nodes": {
1828 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1829 }
1830 }"#,
1831 )
1832 .unwrap();
1833
1834 let cases: Vec<(&str, Value)> = vec![
1835 ("1", serde_json::json!(1)),
1836 ("1.5", serde_json::json!(1.5)),
1837 ("-2", serde_json::json!(-2)),
1838 ("1e3", serde_json::json!(1000.0)),
1839 ];
1840 for (input_str, expected) in &cases {
1841 let input: BTreeMap<String, Value> =
1842 BTreeMap::from([(String::from("ratio"), Value::String(input_str.to_string()))]);
1843 let normalized =
1844 normalize_workflow_params(&def, &input).expect("number string should coerce");
1845 assert_eq!(
1846 normalized.get("ratio"),
1847 Some(expected),
1848 "failed for input '{}'",
1849 input_str
1850 );
1851 }
1852 }
1853
1854 #[test]
1855 fn coerce_number_rejects_junk() {
1856 let def = parse_workflow_definition(
1857 r#"{
1858 "workflowId": "flow-coerce",
1859 "version": 1,
1860 "params": {
1861 "ratio": { "type": "number" }
1862 },
1863 "nodes": {
1864 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1865 }
1866 }"#,
1867 )
1868 .unwrap();
1869
1870 for bad in &["NaN", "Infinity", "-Infinity", "not-a-number", "true", "\"hi\"", "[]"] {
1871 let input: BTreeMap<String, Value> =
1872 BTreeMap::from([(String::from("ratio"), Value::String(bad.to_string()))]);
1873 let err = normalize_workflow_params(&def, &input).unwrap_err();
1874 let msg = err.to_string();
1875 assert!(
1876 msg.contains("number") || msg.contains("number"),
1877 "expected 'number' in error for input '{}', got: {}",
1878 bad, msg
1879 );
1880 }
1881 }
1882
1883 #[test]
1884 fn coerce_integer_from_decimal_strings() {
1885 let def = parse_workflow_definition(
1886 r#"{
1887 "workflowId": "flow-coerce",
1888 "version": 1,
1889 "params": {
1890 "count": { "type": "integer" }
1891 },
1892 "nodes": {
1893 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1894 }
1895 }"#,
1896 )
1897 .unwrap();
1898
1899 let cases: Vec<(&str, Value)> = vec![
1900 ("42", serde_json::json!(42)),
1901 ("-1", serde_json::json!(-1)),
1902 ("0", serde_json::json!(0)),
1903 ];
1904 for (input_str, expected) in &cases {
1905 let input: BTreeMap<String, Value> =
1906 BTreeMap::from([(String::from("count"), Value::String(input_str.to_string()))]);
1907 let normalized =
1908 normalize_workflow_params(&def, &input).expect("integer string should coerce");
1909 assert_eq!(
1910 normalized.get("count"),
1911 Some(expected),
1912 "failed for input '{}'",
1913 input_str
1914 );
1915 }
1916 }
1917
1918 #[test]
1919 fn coerce_integer_with_whitespace_padding() {
1920 let def = parse_workflow_definition(
1921 r#"{
1922 "workflowId": "flow-coerce",
1923 "version": 1,
1924 "params": {
1925 "count": { "type": "integer" }
1926 },
1927 "nodes": {
1928 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1929 }
1930 }"#,
1931 )
1932 .unwrap();
1933
1934 let input: BTreeMap<String, Value> =
1936 BTreeMap::from([(String::from("count"), Value::String(" 42 ".to_string()))]);
1937 let normalized =
1938 normalize_workflow_params(&def, &input).expect("padded integer should coerce");
1939 assert_eq!(normalized.get("count"), Some(&serde_json::json!(42)));
1940 }
1941
1942 #[test]
1943 fn coerce_integer_rejects_non_decimal_formats() {
1944 let def = parse_workflow_definition(
1945 r#"{
1946 "workflowId": "flow-coerce",
1947 "version": 1,
1948 "params": {
1949 "count": { "type": "integer" }
1950 },
1951 "nodes": {
1952 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1953 }
1954 }"#,
1955 )
1956 .unwrap();
1957
1958 for bad in &["1.0", "1.5", "1e3", "0x10", "3.0"] {
1959 let input: BTreeMap<String, Value> =
1960 BTreeMap::from([(String::from("count"), Value::String(bad.to_string()))]);
1961 let err = normalize_workflow_params(&def, &input).unwrap_err();
1962 let msg = err.to_string();
1963 assert!(
1964 msg.contains("integer"),
1965 "expected 'integer' in error for input '{}', got: {}",
1966 bad, msg
1967 );
1968 }
1969 }
1970
1971 #[test]
1972 fn coerce_integer_accepts_already_typed_value() {
1973 let def = parse_workflow_definition(
1974 r#"{
1975 "workflowId": "flow-coerce",
1976 "version": 1,
1977 "params": {
1978 "count": { "type": "integer" }
1979 },
1980 "nodes": {
1981 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1982 }
1983 }"#,
1984 )
1985 .unwrap();
1986
1987 let input: BTreeMap<String, Value> =
1989 BTreeMap::from([(String::from("count"), serde_json::json!(42))]);
1990 let normalized =
1991 normalize_workflow_params(&def, &input).expect("integer passthrough");
1992 assert_eq!(normalized.get("count"), Some(&serde_json::json!(42)));
1993 }
1994
1995 #[test]
1996 fn coerce_object_from_json_string() {
1997 let def = parse_workflow_definition(
1998 r#"{
1999 "workflowId": "flow-coerce",
2000 "version": 1,
2001 "params": {
2002 "payload": { "type": "object" }
2003 },
2004 "nodes": {
2005 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2006 }
2007 }"#,
2008 )
2009 .unwrap();
2010
2011 let input: BTreeMap<String, Value> = BTreeMap::from([(
2012 String::from("payload"),
2013 Value::String(r#"{"a":1,"b":"x"}"#.to_string()),
2014 )]);
2015 let normalized =
2016 normalize_workflow_params(&def, &input).expect("object string should coerce");
2017 assert_eq!(
2018 normalized.get("payload"),
2019 Some(&serde_json::json!({"a":1,"b":"x"}))
2020 );
2021 }
2022
2023 #[test]
2024 fn coerce_array_from_json_string() {
2025 let def = parse_workflow_definition(
2026 r#"{
2027 "workflowId": "flow-coerce",
2028 "version": 1,
2029 "params": {
2030 "tags": { "type": "array" }
2031 },
2032 "nodes": {
2033 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2034 }
2035 }"#,
2036 )
2037 .unwrap();
2038
2039 let input: BTreeMap<String, Value> = BTreeMap::from([(
2040 String::from("tags"),
2041 Value::String(r#"["a","b"]"#.to_string()),
2042 )]);
2043 let normalized =
2044 normalize_workflow_params(&def, &input).expect("array string should coerce");
2045 assert_eq!(
2046 normalized.get("tags"),
2047 Some(&serde_json::json!(["a", "b"]))
2048 );
2049 }
2050
2051 #[test]
2052 fn coerce_object_rejects_non_object_json() {
2053 let def = parse_workflow_definition(
2054 r#"{
2055 "workflowId": "flow-coerce",
2056 "version": 1,
2057 "params": {
2058 "payload": { "type": "object" }
2059 },
2060 "nodes": {
2061 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2062 }
2063 }"#,
2064 )
2065 .unwrap();
2066
2067 let input: BTreeMap<String, Value> = BTreeMap::from([(
2069 String::from("payload"),
2070 Value::String(r#"["a"]"#.to_string()),
2071 )]);
2072 let err = normalize_workflow_params(&def, &input).unwrap_err();
2073 let msg = err.to_string();
2074 assert!(msg.contains("object"), "got: {msg}");
2075 assert!(msg.contains("payload"), "got: {msg}");
2076 assert!(msg.contains("array"), "got: {msg}");
2077 }
2078
2079 #[test]
2080 fn coerce_array_rejects_non_array_json() {
2081 let def = parse_workflow_definition(
2082 r#"{
2083 "workflowId": "flow-coerce",
2084 "version": 1,
2085 "params": {
2086 "tags": { "type": "array" }
2087 },
2088 "nodes": {
2089 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2090 }
2091 }"#,
2092 )
2093 .unwrap();
2094
2095 let input: BTreeMap<String, Value> = BTreeMap::from([(
2097 String::from("tags"),
2098 Value::String("1".to_string()),
2099 )]);
2100 let err = normalize_workflow_params(&def, &input).unwrap_err();
2101 let msg = err.to_string();
2102 assert!(msg.contains("array"), "got: {msg}");
2103 assert!(msg.contains("tags"), "got: {msg}");
2104 }
2105
2106 #[test]
2107 fn coerce_object_invalid_json_fails() {
2108 let def = parse_workflow_definition(
2109 r#"{
2110 "workflowId": "flow-coerce",
2111 "version": 1,
2112 "params": {
2113 "payload": { "type": "object" }
2114 },
2115 "nodes": {
2116 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2117 }
2118 }"#,
2119 )
2120 .unwrap();
2121
2122 let input: BTreeMap<String, Value> = BTreeMap::from([(
2123 String::from("payload"),
2124 Value::String("not-json".to_string()),
2125 )]);
2126 let err = normalize_workflow_params(&def, &input).unwrap_err();
2127 let msg = err.to_string();
2128 assert!(msg.contains("object"), "got: {msg}");
2129 assert!(msg.contains("valid JSON"), "got: {msg}");
2130 }
2131
2132 #[test]
2133 fn coerce_default_not_coerced_for_boolean() {
2134 let def = parse_workflow_definition(
2137 r#"{
2138 "workflowId": "flow-coerce",
2139 "version": 1,
2140 "params": {
2141 "enabled": {
2142 "type": "boolean",
2143 "required": false,
2144 "default": "true"
2145 }
2146 },
2147 "nodes": {
2148 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2149 }
2150 }"#,
2151 )
2152 .unwrap();
2153
2154 let input: BTreeMap<String, Value> = BTreeMap::new(); let err = normalize_workflow_params(&def, &input).unwrap_err();
2156 let msg = err.to_string();
2157 assert!(
2158 msg.contains("boolean"),
2159 "default string 'true' should fail type check for boolean, got: {}",
2160 msg
2161 );
2162 }
2163
2164 #[test]
2165 fn coerce_string_param_unchanged() {
2166 let def = parse_workflow_definition(
2168 r#"{
2169 "workflowId": "flow-coerce",
2170 "version": 1,
2171 "params": {
2172 "name": { "type": "string" }
2173 },
2174 "nodes": {
2175 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2176 }
2177 }"#,
2178 )
2179 .unwrap();
2180
2181 let input: BTreeMap<String, Value> =
2182 BTreeMap::from([(String::from("name"), Value::String("true".to_string()))]);
2183 let normalized = normalize_workflow_params(&def, &input)
2184 .expect("string param should not be coerced");
2185 assert_eq!(
2187 normalized.get("name"),
2188 Some(&Value::String("true".to_string()))
2189 );
2190 }
2191
2192 #[test]
2193 fn coerce_boolean_with_whitespace_padding() {
2194 let def = parse_workflow_definition(
2195 r#"{
2196 "workflowId": "flow-coerce",
2197 "version": 1,
2198 "params": {
2199 "enabled": { "type": "boolean" }
2200 },
2201 "nodes": {
2202 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2203 }
2204 }"#,
2205 )
2206 .unwrap();
2207
2208 let input: BTreeMap<String, Value> =
2210 BTreeMap::from([(String::from("enabled"), Value::String(" true ".to_string()))]);
2211 let normalized =
2212 normalize_workflow_params(&def, &input).expect("padded true coerces to bool");
2213 assert_eq!(normalized.get("enabled"), Some(&Value::Bool(true)));
2214 }
2215
2216 #[test]
2217 fn coerce_empty_string_to_boolean_fails() {
2218 let def = parse_workflow_definition(
2219 r#"{
2220 "workflowId": "flow-coerce",
2221 "version": 1,
2222 "params": {
2223 "enabled": { "type": "boolean" }
2224 },
2225 "nodes": {
2226 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2227 }
2228 }"#,
2229 )
2230 .unwrap();
2231
2232 let input: BTreeMap<String, Value> =
2233 BTreeMap::from([(String::from("enabled"), Value::String("".to_string()))]);
2234 let err = normalize_workflow_params(&def, &input).unwrap_err();
2235 let msg = err.to_string();
2236 assert!(msg.contains("boolean"), "got: {msg}");
2237 assert!(msg.contains("empty"), "got: {msg}");
2238 }
2239}