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,
524 trimmed
525 ),
526 }
527 }
528 "number" => {
529 let trimmed = s.trim();
530 if trimmed.is_empty() {
531 anyhow::bail!(
532 "workflow parameter '{}' expects type 'number', got empty string",
533 name
534 );
535 }
536 let v: Value = serde_json::from_str(trimmed).map_err(|e| {
537 anyhow::anyhow!(
538 "workflow parameter '{}' expects type 'number', failed to parse '{}' as a number: {}",
539 name, trimmed, e
540 )
541 })?;
542 if !v.is_number() {
543 anyhow::bail!(
544 "workflow parameter '{}' expects type 'number', but string '{}' parsed to {}",
545 name,
546 trimmed,
547 describe_value_kind(&v)
548 );
549 }
550 Ok(v)
551 }
552 "integer" => {
553 let trimmed = s.trim();
554 if trimmed.is_empty() {
555 anyhow::bail!(
556 "workflow parameter '{}' expects type 'integer', got empty string",
557 name
558 );
559 }
560
561 let is_decimal_integer = trimmed.chars().enumerate().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,
575 trimmed
576 );
577 }
578
579 let n: i128 = trimmed.parse().map_err(|_| {
581 anyhow::anyhow!(
582 "workflow parameter '{}' expects type 'integer', failed to parse '{}'",
583 name,
584 trimmed
585 )
586 })?;
587 let num = serde_json::Number::from_i128(n).ok_or_else(|| {
588 anyhow::anyhow!(
589 "workflow parameter '{}' expects type 'integer', number out of range: '{}'",
590 name,
591 trimmed
592 )
593 })?;
594 Ok(Value::Number(num))
595 }
596 "object" => {
597 let trimmed = s.trim();
598 if trimmed.is_empty() {
599 anyhow::bail!(
600 "workflow parameter '{}' expects type 'object', got empty string",
601 name
602 );
603 }
604 let v: Value = serde_json::from_str(trimmed).map_err(|e| {
605 anyhow::anyhow!(
606 "workflow parameter '{}' expects type 'object', but string value must be valid JSON (failed to parse: {})",
607 name, e
608 )
609 })?;
610 if !v.is_object() {
611 anyhow::bail!(
612 "workflow parameter '{}' expects type 'object', but string value parsed to {} (must be a JSON object like '{{\"a\":1}}')",
613 name,
614 describe_value_kind(&v)
615 );
616 }
617 Ok(v)
618 }
619 "array" => {
620 let trimmed = s.trim();
621 if trimmed.is_empty() {
622 anyhow::bail!(
623 "workflow parameter '{}' expects type 'array', got empty string",
624 name
625 );
626 }
627 let v: Value = serde_json::from_str(trimmed).map_err(|e| {
628 anyhow::anyhow!(
629 "workflow parameter '{}' expects type 'array', but string value must be valid JSON (failed to parse: {})",
630 name, e
631 )
632 })?;
633 if !v.is_array() {
634 anyhow::bail!(
635 "workflow parameter '{}' expects type 'array', but string value parsed to {} (must be a JSON array like '[\"a\",\"b\"]')",
636 name,
637 describe_value_kind(&v)
638 );
639 }
640 Ok(v)
641 }
642 unknown => {
643 anyhow::bail!(
644 "workflow parameter '{}' has unknown type '{}'",
645 name,
646 unknown
647 );
648 }
649 }
650}
651
652fn sha256_hex(bytes: &[u8]) -> String {
653 let mut hasher = Sha256::new();
654 hasher.update(bytes);
655 lower_hex(&hasher.finalize())
656}
657
658fn lower_hex(bytes: &[u8]) -> String {
659 const HEX: &[u8; 16] = b"0123456789abcdef";
660 let mut out = String::with_capacity(bytes.len() * 2);
661 for byte in bytes {
662 out.push(HEX[(byte >> 4) as usize] as char);
663 out.push(HEX[(byte & 0x0f) as usize] as char);
664 }
665 out
666}
667
668pub fn read_workflow_definition_from_path(path: &Path) -> Result<String> {
669 Ok(fs::read_to_string(path).with_context(|| format!("读取 {} 失败", path.display()))?)
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use std::collections::BTreeMap;
676 use std::time::{SystemTime, UNIX_EPOCH};
677
678 fn temp_paths(label: &str) -> BeamPaths {
679 let nanos = SystemTime::now()
680 .duration_since(UNIX_EPOCH)
681 .unwrap_or_default()
682 .as_nanos();
683 BeamPaths::from_root(std::env::temp_dir().join(format!(
684 "beam-workflow-run-{label}-{nanos}-{}",
685 std::process::id()
686 )))
687 }
688
689 #[test]
690 fn mint_workflow_run_id_sanitizes_input() {
691 let run_id = mint_workflow_run_id("flow/a:b", 123);
692 assert!(run_id.starts_with("flow_a_b-123"));
693 assert!(!run_id.contains('/'));
694 assert!(!run_id.contains(':'));
695 }
696
697 #[test]
698 fn bootstrap_workflow_run_writes_snapshot_and_events() {
699 let paths = temp_paths("bootstrap");
700 let params: BTreeMap<String, Value> =
701 BTreeMap::from([(String::from("foo"), Value::String("bar".to_string()))]);
702 let result = bootstrap_workflow_run(
703 &paths,
704 BootstrapWorkflowRunInput {
705 run_id: "run-1",
706 workflow_json: r#"{"workflowId":"flow-a","version":1,"params":{"foo":{"type":"string"}},"nodes":{"node-a":{"type":"subagent","bot":"bot-a","prompt":"hi"}}}"#,
707 expected_workflow_id: Some("flow-a"),
708 params: ¶ms,
709 initiator: "cli",
710 chat_binding: Some(RunChatBinding {
711 chat_id: "chat-1".to_string(),
712 lark_app_id: "app-1".to_string(),
713 }),
714 },
715 )
716 .expect("bootstrap");
717 assert_eq!(result.run_id, "run-1");
718 assert_eq!(result.workflow_id, "flow-a");
719 assert!(
720 paths
721 .workflow_run_dir("run-1")
722 .join("workflow.json")
723 .exists()
724 );
725 assert!(
726 paths
727 .workflow_run_dir("run-1")
728 .join("chat-binding.json")
729 .exists()
730 );
731 assert!(paths.workflow_run_dir("run-1").join("blobs").exists());
732 let log = EventLog::new("run-1", paths.workflow_runs_dir()).expect("log");
733 let events = log.read_all().expect("events");
734 assert_eq!(events.len(), 2);
735 assert_eq!(events[0].event_type, "runCreated");
736 assert_eq!(events[1].event_type, "runStarted");
737 let _ = std::fs::remove_dir_all(paths.root());
738 }
739
740 #[test]
741 fn bootstrap_workflow_run_hashes_canonical_definition_bytes() {
742 let params: BTreeMap<String, Value> = BTreeMap::new();
743 let raw_a = r#"{"workflowId":"flow-a","version":1,"nodes":{"node-a":{"type":"subagent","bot":"bot-a","prompt":"hi","workingDir":"/tmp/demo"}}}"#;
744 let raw_b = r#"
745 {
746 "nodes": {
747 "node-a": {
748 "prompt": "hi",
749 "type": "subagent",
750 "bot": "bot-a",
751 "workingDir": "/tmp/demo"
752 }
753 },
754 "version": 1,
755 "workflowId": "flow-a"
756 }
757 "#;
758
759 let paths_a = temp_paths("bootstrap-canonical-a");
760 let paths_b = temp_paths("bootstrap-canonical-b");
761 let rev_a = bootstrap_workflow_run(
762 &paths_a,
763 BootstrapWorkflowRunInput {
764 run_id: "run-a",
765 workflow_json: raw_a,
766 expected_workflow_id: Some("flow-a"),
767 params: ¶ms,
768 initiator: "cli",
769 chat_binding: None,
770 },
771 )
772 .expect("bootstrap a")
773 .revision_id;
774 let rev_b = bootstrap_workflow_run(
775 &paths_b,
776 BootstrapWorkflowRunInput {
777 run_id: "run-b",
778 workflow_json: raw_b,
779 expected_workflow_id: Some("flow-a"),
780 params: ¶ms,
781 initiator: "cli",
782 chat_binding: None,
783 },
784 )
785 .expect("bootstrap b")
786 .revision_id;
787 assert_eq!(rev_a, rev_b);
788 let _ = std::fs::remove_dir_all(paths_a.root());
789 let _ = std::fs::remove_dir_all(paths_b.root());
790 }
791
792 #[test]
795 fn bootstrap_rejects_missing_required_param() {
796 let paths = temp_paths("missing-param");
797 let params: BTreeMap<String, Value> = BTreeMap::new(); let workflow_json = r#"{
799 "workflowId": "flow-req",
800 "version": 1,
801 "params": {
802 "task": {
803 "type": "string",
804 "required": true,
805 "description": "what to do"
806 }
807 },
808 "nodes": {
809 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
810 }
811 }"#;
812 let err = bootstrap_workflow_run(
813 &paths,
814 BootstrapWorkflowRunInput {
815 run_id: "run-missing",
816 workflow_json,
817 expected_workflow_id: Some("flow-req"),
818 params: ¶ms,
819 initiator: "test",
820 chat_binding: None,
821 },
822 )
823 .unwrap_err();
824 let msg = err.to_string();
825 assert!(
826 msg.contains("missing required workflow parameter"),
827 "got: {msg}"
828 );
829 assert!(msg.contains("task"), "expected 'task' in error, got: {msg}");
830 let run_dir = paths.workflow_run_dir("run-missing");
832 assert!(
833 !run_dir.exists(),
834 "run directory should NOT exist after param validation failure, but found: {}",
835 run_dir.display()
836 );
837 let _ = std::fs::remove_dir_all(paths.root());
838 }
839
840 #[test]
841 fn bootstrap_rejects_empty_required_param_value() {
842 let paths = temp_paths("empty-param");
843 let params: BTreeMap<String, Value> =
844 BTreeMap::from([(String::from("task"), Value::String(" ".to_string()))]); let workflow_json = r#"{
846 "workflowId": "flow-req",
847 "version": 1,
848 "params": {
849 "task": {
850 "type": "string",
851 "required": true,
852 "description": "what to do"
853 }
854 },
855 "nodes": {
856 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
857 }
858 }"#;
859 let err = bootstrap_workflow_run(
860 &paths,
861 BootstrapWorkflowRunInput {
862 run_id: "run-empty",
863 workflow_json,
864 expected_workflow_id: Some("flow-req"),
865 params: ¶ms,
866 initiator: "test",
867 chat_binding: None,
868 },
869 )
870 .unwrap_err();
871 let msg = err.to_string();
872 assert!(
873 msg.contains("missing required workflow parameter"),
874 "got: {msg}"
875 );
876 assert!(msg.contains("task"), "expected 'task' in error, got: {msg}");
877 let run_dir = paths.workflow_run_dir("run-empty");
879 assert!(
880 !run_dir.exists(),
881 "run directory should NOT exist after param validation failure, but found: {}",
882 run_dir.display()
883 );
884 let _ = std::fs::remove_dir_all(paths.root());
885 }
886
887 #[test]
888 fn bootstrap_succeeds_with_required_params_provided() {
889 let paths = temp_paths("provided-param");
890 let params: BTreeMap<String, Value> =
891 BTreeMap::from([(String::from("task"), Value::String("build XYZ".to_string()))]);
892 let workflow_json = r#"{
893 "workflowId": "flow-req",
894 "version": 1,
895 "params": {
896 "task": {
897 "type": "string",
898 "required": true,
899 "description": "what to do"
900 }
901 },
902 "nodes": {
903 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
904 }
905 }"#;
906 let result = bootstrap_workflow_run(
907 &paths,
908 BootstrapWorkflowRunInput {
909 run_id: "run-provided",
910 workflow_json,
911 expected_workflow_id: Some("flow-req"),
912 params: ¶ms,
913 initiator: "test",
914 chat_binding: None,
915 },
916 )
917 .expect("bootstrap with required params provided should succeed");
918 assert_eq!(result.workflow_id, "flow-req");
919 assert!(
920 paths
921 .workflow_run_dir("run-provided")
922 .join("workflow.json")
923 .exists()
924 );
925 let log = EventLog::new("run-provided", paths.workflow_runs_dir()).expect("log");
926 let events = log.read_all().expect("events");
927 assert_eq!(events.len(), 2);
928 assert_eq!(events[0].event_type, "runCreated");
929 assert_eq!(events[1].event_type, "runStarted");
930 let _ = std::fs::remove_dir_all(paths.root());
931 }
932
933 #[test]
934 fn bootstrap_ignores_optional_param_when_missing() {
935 let paths = temp_paths("optional-param");
936 let params: BTreeMap<String, Value> = BTreeMap::new(); let workflow_json = r#"{
938 "workflowId": "flow-opt",
939 "version": 1,
940 "params": {
941 "verbose": {
942 "type": "boolean",
943 "required": false,
944 "default": false
945 }
946 },
947 "nodes": {
948 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
949 }
950 }"#;
951 let result = bootstrap_workflow_run(
952 &paths,
953 BootstrapWorkflowRunInput {
954 run_id: "run-opt",
955 workflow_json,
956 expected_workflow_id: Some("flow-opt"),
957 params: ¶ms,
958 initiator: "test",
959 chat_binding: None,
960 },
961 )
962 .expect("bootstrap should ignore missing optional param");
963 assert_eq!(result.workflow_id, "flow-opt");
964 let _ = std::fs::remove_dir_all(paths.root());
965 }
966
967 #[test]
968 fn bootstrap_rejects_multiple_missing_required_params() {
969 let paths = temp_paths("multi-missing");
970 let params: BTreeMap<String, Value> = BTreeMap::new(); let workflow_json = r#"{
972 "workflowId": "flow-multi",
973 "version": 1,
974 "params": {
975 "task": {
976 "type": "string",
977 "required": true,
978 "description": "what to do"
979 },
980 "target": {
981 "type": "string",
982 "required": true,
983 "description": "where to deploy"
984 }
985 },
986 "nodes": {
987 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
988 }
989 }"#;
990 let err = bootstrap_workflow_run(
991 &paths,
992 BootstrapWorkflowRunInput {
993 run_id: "run-multi",
994 workflow_json,
995 expected_workflow_id: Some("flow-multi"),
996 params: ¶ms,
997 initiator: "test",
998 chat_binding: None,
999 },
1000 )
1001 .unwrap_err();
1002 let msg = err.to_string();
1003 assert!(
1004 msg.contains("missing required workflow parameter"),
1005 "got: {msg}"
1006 );
1007 assert!(msg.contains("task"), "expected 'task' in error, got: {msg}");
1008 assert!(
1009 msg.contains("target"),
1010 "expected 'target' in error, got: {msg}"
1011 );
1012 let run_dir = paths.workflow_run_dir("run-multi");
1013 assert!(
1014 !run_dir.exists(),
1015 "run directory should NOT exist on failure"
1016 );
1017 let _ = std::fs::remove_dir_all(paths.root());
1018 }
1019
1020 #[test]
1023 fn normalize_writes_default_value_for_optional_param() {
1024 let def = parse_workflow_definition(
1025 r#"{
1026 "workflowId": "flow-default",
1027 "version": 1,
1028 "params": {
1029 "verbose": {
1030 "type": "boolean",
1031 "required": false,
1032 "default": false
1033 },
1034 "level": {
1035 "type": "integer",
1036 "required": false,
1037 "default": 3
1038 }
1039 },
1040 "nodes": {
1041 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1042 }
1043 }"#,
1044 )
1045 .unwrap();
1046 let input: BTreeMap<String, Value> = BTreeMap::new();
1047 let normalized = normalize_workflow_params(&def, &input).expect("should succeed");
1048 assert_eq!(normalized.get("verbose"), Some(&Value::Bool(false)));
1049 assert_eq!(normalized.get("level"), Some(&serde_json::json!(3)));
1050 assert!(!normalized.contains_key("unknown_key"));
1052 }
1053
1054 #[test]
1055 fn normalize_type_validation_success() {
1056 let def = parse_workflow_definition(
1057 r#"{
1058 "workflowId": "flow-types",
1059 "version": 1,
1060 "params": {
1061 "name": { "type": "string" },
1062 "count": { "type": "integer" },
1063 "ratio": { "type": "number" },
1064 "enabled": { "type": "boolean" },
1065 "tags": { "type": "array" },
1066 "meta": { "type": "object" }
1067 },
1068 "nodes": {
1069 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1070 }
1071 }"#,
1072 )
1073 .unwrap();
1074 let input: BTreeMap<String, Value> = BTreeMap::from([
1075 (String::from("name"), Value::String("test".to_string())),
1076 (String::from("count"), serde_json::json!(42)),
1077 (String::from("ratio"), serde_json::json!(3.14)),
1078 (String::from("enabled"), Value::Bool(true)),
1079 (String::from("tags"), serde_json::json!(["a", "b"])),
1080 (String::from("meta"), serde_json::json!({"key": "val"})),
1081 ]);
1082 let _ = normalize_workflow_params(&def, &input).expect("all types valid");
1083 }
1084
1085 #[test]
1086 fn normalize_type_mismatch_fails() {
1087 let def = parse_workflow_definition(
1088 r#"{
1089 "workflowId": "flow-type-err",
1090 "version": 1,
1091 "params": {
1092 "enabled": { "type": "boolean" }
1093 },
1094 "nodes": {
1095 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1096 }
1097 }"#,
1098 )
1099 .unwrap();
1100 let input: BTreeMap<String, Value> =
1101 BTreeMap::from([(String::from("enabled"), Value::String("yes".to_string()))]);
1102 let err = normalize_workflow_params(&def, &input).unwrap_err();
1103 let msg = err.to_string();
1104 assert!(msg.contains("enabled"), "got: {msg}");
1105 assert!(msg.contains("boolean"), "got: {msg}");
1106 }
1107
1108 #[test]
1109 fn normalize_unknown_key_fails_when_schema_defined() {
1110 let def = parse_workflow_definition(
1111 r#"{
1112 "workflowId": "flow-unknown",
1113 "version": 1,
1114 "params": {
1115 "task": { "type": "string" }
1116 },
1117 "nodes": {
1118 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1119 }
1120 }"#,
1121 )
1122 .unwrap();
1123 let input: BTreeMap<String, Value> = BTreeMap::from([
1124 (String::from("task"), Value::String("hello".to_string())),
1125 (String::from("extra"), Value::String("bad".to_string())),
1126 ]);
1127 let err = normalize_workflow_params(&def, &input).unwrap_err();
1128 let msg = err.to_string();
1129 assert!(msg.contains("unknown workflow parameter"), "got: {msg}");
1130 assert!(msg.contains("extra"), "got: {msg}");
1131 assert!(
1132 msg.contains("task"),
1133 "expected available params list, got: {msg}"
1134 );
1135 }
1136
1137 #[test]
1138 fn normalize_no_schema_rejects_extra_params() {
1139 let def = parse_workflow_definition(
1140 r#"{
1141 "workflowId": "flow-no-schema",
1142 "version": 1,
1143 "nodes": {
1144 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1145 }
1146 }"#,
1147 )
1148 .unwrap();
1149 let input: BTreeMap<String, Value> = BTreeMap::from([
1150 (String::from("anything"), Value::String("goes".to_string())),
1151 (String::from("extra"), serde_json::json!({"deep": true})),
1152 ]);
1153 let err = normalize_workflow_params(&def, &input).unwrap_err();
1154 let msg = err.to_string();
1155 assert!(msg.contains("unknown workflow parameter"), "got: {msg}");
1156 assert!(msg.contains("anything"), "got: {msg}");
1157 assert!(msg.contains("extra"), "got: {msg}");
1158 assert!(msg.contains("No parameters are declared"), "got: {msg}");
1159 }
1160
1161 #[test]
1162 fn normalize_no_schema_with_empty_input_succeeds() {
1163 let def = parse_workflow_definition(
1164 r#"{
1165 "workflowId": "flow-no-schema",
1166 "version": 1,
1167 "nodes": {
1168 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1169 }
1170 }"#,
1171 )
1172 .unwrap();
1173 let input: BTreeMap<String, Value> = BTreeMap::new();
1174 let normalized = normalize_workflow_params(&def, &input)
1175 .expect("empty params with no schema should succeed");
1176 assert!(normalized.is_empty());
1177 }
1178
1179 #[test]
1180 fn normalize_empty_params_schema_rejects_extra_params() {
1181 let def = parse_workflow_definition(
1182 r#"{
1183 "workflowId": "flow-empty-params",
1184 "version": 1,
1185 "params": {},
1186 "nodes": {
1187 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1188 }
1189 }"#,
1190 )
1191 .unwrap();
1192 let input: BTreeMap<String, Value> =
1193 BTreeMap::from([(String::from("x"), Value::String("y".to_string()))]);
1194 let err = normalize_workflow_params(&def, &input).unwrap_err();
1195 let msg = err.to_string();
1196 assert!(msg.contains("unknown workflow parameter"), "got: {msg}");
1197 assert!(msg.contains("No parameters are declared"), "got: {msg}");
1198 }
1199
1200 #[test]
1201 fn normalize_empty_params_schema_with_empty_input_succeeds() {
1202 let def = parse_workflow_definition(
1203 r#"{
1204 "workflowId": "flow-empty-params",
1205 "version": 1,
1206 "params": {},
1207 "nodes": {
1208 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1209 }
1210 }"#,
1211 )
1212 .unwrap();
1213 let input: BTreeMap<String, Value> = BTreeMap::new();
1214 let normalized = normalize_workflow_params(&def, &input)
1215 .expect("empty params with empty schema should succeed");
1216 assert!(normalized.is_empty());
1217 }
1218
1219 #[test]
1220 fn normalize_default_value_type_mismatch_fails() {
1221 let def = parse_workflow_definition(
1223 r#"{
1224 "workflowId": "flow-bad-default",
1225 "version": 1,
1226 "params": {
1227 "enabled": {
1228 "type": "boolean",
1229 "required": false,
1230 "default": "not-a-bool"
1231 }
1232 },
1233 "nodes": {
1234 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1235 }
1236 }"#,
1237 )
1238 .unwrap();
1239 let input: BTreeMap<String, Value> = BTreeMap::new();
1240 let err = normalize_workflow_params(&def, &input).unwrap_err();
1241 let msg = err.to_string();
1242 assert!(msg.contains("enabled"), "got: {msg}");
1243 assert!(msg.contains("boolean"), "got: {msg}");
1244 }
1245
1246 #[test]
1247 fn normalize_unknown_param_type_fails() {
1248 let def = parse_workflow_definition(
1249 r#"{
1250 "workflowId": "flow-unknown-type",
1251 "version": 1,
1252 "params": {
1253 "x": {
1254 "type": "unknown-type-xyz",
1255 "required": false
1256 }
1257 },
1258 "nodes": {
1259 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1260 }
1261 }"#,
1262 )
1263 .unwrap();
1264 let input: BTreeMap<String, Value> =
1265 BTreeMap::from([(String::from("x"), Value::String("v".to_string()))]);
1266 let err = normalize_workflow_params(&def, &input).unwrap_err();
1267 let msg = err.to_string();
1268 assert!(msg.contains("unknown type"), "got: {msg}");
1269 assert!(msg.contains("x"), "got: {msg}");
1270 assert!(msg.contains("unknown-type-xyz"), "got: {msg}");
1271 }
1272
1273 #[test]
1274 fn normalize_integer_rejects_non_integer_number() {
1275 let def = parse_workflow_definition(
1276 r#"{
1277 "workflowId": "flow-int",
1278 "version": 1,
1279 "params": {
1280 "count": { "type": "integer" }
1281 },
1282 "nodes": {
1283 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1284 }
1285 }"#,
1286 )
1287 .unwrap();
1288 let input: BTreeMap<String, Value> =
1289 BTreeMap::from([(String::from("count"), serde_json::json!(3.5))]);
1290 let err = normalize_workflow_params(&def, &input).unwrap_err();
1291 let msg = err.to_string();
1292 assert!(msg.contains("integer"), "got: {msg}");
1293 assert!(msg.contains("count"), "got: {msg}");
1294 }
1295
1296 #[test]
1297 fn normalize_integer_accepts_integer_number() {
1298 let def = parse_workflow_definition(
1299 r#"{
1300 "workflowId": "flow-int-ok",
1301 "version": 1,
1302 "params": {
1303 "count": { "type": "integer" }
1304 },
1305 "nodes": {
1306 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1307 }
1308 }"#,
1309 )
1310 .unwrap();
1311 let input: BTreeMap<String, Value> =
1312 BTreeMap::from([(String::from("count"), serde_json::json!(42))]);
1313 let _ = normalize_workflow_params(&def, &input).expect("integer 42 OK");
1314 let input2: BTreeMap<String, Value> =
1316 BTreeMap::from([(String::from("count"), serde_json::json!(42.0))]);
1317 let _ = normalize_workflow_params(&def, &input2).expect("integer 42.0 OK");
1318 }
1319
1320 #[test]
1321 fn bootstrap_integration_rejects_unknown_key_with_params_schema() {
1322 let paths = temp_paths("bootstrap-unknown");
1323 let params: BTreeMap<String, Value> = BTreeMap::from([
1324 (String::from("task"), Value::String("hello".to_string())),
1325 (String::from("bad_key"), Value::String("x".to_string())),
1326 ]);
1327 let workflow_json = r#"{
1328 "workflowId": "flow-req",
1329 "version": 1,
1330 "params": {
1331 "task": {
1332 "type": "string",
1333 "required": true,
1334 "description": "what to do"
1335 }
1336 },
1337 "nodes": {
1338 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1339 }
1340 }"#;
1341 let err = bootstrap_workflow_run(
1342 &paths,
1343 BootstrapWorkflowRunInput {
1344 run_id: "run-unk",
1345 workflow_json,
1346 expected_workflow_id: Some("flow-req"),
1347 params: ¶ms,
1348 initiator: "test",
1349 chat_binding: None,
1350 },
1351 )
1352 .unwrap_err();
1353 let msg = err.to_string();
1354 assert!(msg.contains("unknown workflow parameter"), "got: {msg}");
1355 assert!(msg.contains("bad_key"), "got: {msg}");
1356 let run_dir = paths.workflow_run_dir("run-unk");
1357 assert!(!run_dir.exists(), "run dir should NOT exist");
1358 let _ = std::fs::remove_dir_all(paths.root());
1359 }
1360
1361 #[test]
1362 fn bootstrap_integration_default_written_to_params_blob() {
1363 let paths = temp_paths("bootstrap-default");
1364 let params: BTreeMap<String, Value> =
1365 BTreeMap::from([(String::from("task"), Value::String("build".to_string()))]);
1366 let workflow_json = r#"{
1367 "workflowId": "flow-def",
1368 "version": 1,
1369 "params": {
1370 "task": { "type": "string", "required": true },
1371 "verbose": { "type": "boolean", "required": false, "default": false }
1372 },
1373 "nodes": {
1374 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1375 }
1376 }"#;
1377 let result = bootstrap_workflow_run(
1378 &paths,
1379 BootstrapWorkflowRunInput {
1380 run_id: "run-def",
1381 workflow_json,
1382 expected_workflow_id: Some("flow-def"),
1383 params: ¶ms,
1384 initiator: "test",
1385 chat_binding: None,
1386 },
1387 )
1388 .expect("bootstrap with default param");
1389 assert_eq!(result.workflow_id, "flow-def");
1390 let blob_path = result.input_ref.output_path;
1392 let blob_bytes = std::fs::read(&blob_path).expect("read params blob");
1393 let blob: BTreeMap<String, Value> =
1394 serde_json::from_slice(&blob_bytes).expect("parse params blob");
1395 assert_eq!(blob.get("task"), Some(&Value::String("build".to_string())));
1396 assert_eq!(blob.get("verbose"), Some(&Value::Bool(false)));
1397 let _ = std::fs::remove_dir_all(paths.root());
1398 }
1399
1400 #[test]
1403 fn format_date_success() {
1404 let def = parse_workflow_definition(
1405 r#"{
1406 "workflowId": "flow-fmt",
1407 "version": 1,
1408 "params": {
1409 "d": { "type": "string", "format": "date" }
1410 },
1411 "nodes": {
1412 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1413 }
1414 }"#,
1415 )
1416 .unwrap();
1417 let input: BTreeMap<String, Value> =
1418 BTreeMap::from([(String::from("d"), Value::String("2024-02-29".to_string()))]);
1419 let _ = normalize_workflow_params(&def, &input).expect("leap date should pass");
1420 }
1421
1422 #[test]
1423 fn format_date_failure_invalid_date() {
1424 let def = parse_workflow_definition(
1425 r#"{
1426 "workflowId": "flow-fmt",
1427 "version": 1,
1428 "params": {
1429 "d": { "type": "string", "format": "date" }
1430 },
1431 "nodes": {
1432 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1433 }
1434 }"#,
1435 )
1436 .unwrap();
1437 let input: BTreeMap<String, Value> =
1438 BTreeMap::from([(String::from("d"), Value::String("2023-02-29".to_string()))]);
1439 let err = normalize_workflow_params(&def, &input).unwrap_err();
1440 let msg = err.to_string();
1441 assert!(msg.contains("date"), "got: {msg}");
1442 assert!(msg.contains("2023-02-29"), "got: {msg}");
1443 }
1444
1445 #[test]
1446 fn format_date_failure_invalid_month() {
1447 let def = parse_workflow_definition(
1448 r#"{
1449 "workflowId": "flow-fmt",
1450 "version": 1,
1451 "params": {
1452 "d": { "type": "string", "format": "date" }
1453 },
1454 "nodes": {
1455 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1456 }
1457 }"#,
1458 )
1459 .unwrap();
1460 let input: BTreeMap<String, Value> =
1461 BTreeMap::from([(String::from("d"), Value::String("2024-13-01".to_string()))]);
1462 let err = normalize_workflow_params(&def, &input).unwrap_err();
1463 let msg = err.to_string();
1464 assert!(msg.contains("date"), "got: {msg}");
1465 assert!(msg.contains("2024-13-01"), "got: {msg}");
1466 }
1467
1468 #[test]
1469 fn format_date_failure_wrong_format() {
1470 let def = parse_workflow_definition(
1471 r#"{
1472 "workflowId": "flow-fmt",
1473 "version": 1,
1474 "params": {
1475 "d": { "type": "string", "format": "date" }
1476 },
1477 "nodes": {
1478 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1479 }
1480 }"#,
1481 )
1482 .unwrap();
1483 let input: BTreeMap<String, Value> =
1484 BTreeMap::from([(String::from("d"), Value::String("01-01-2024".to_string()))]);
1485 let err = normalize_workflow_params(&def, &input).unwrap_err();
1486 let msg = err.to_string();
1487 assert!(msg.contains("date"), "got: {msg}");
1488 }
1489
1490 #[test]
1491 fn format_date_time_success() {
1492 let def = parse_workflow_definition(
1493 r#"{
1494 "workflowId": "flow-fmt",
1495 "version": 1,
1496 "params": {
1497 "ts": { "type": "string", "format": "date-time" }
1498 },
1499 "nodes": {
1500 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1501 }
1502 }"#,
1503 )
1504 .unwrap();
1505 let input: BTreeMap<String, Value> = BTreeMap::from([(
1506 String::from("ts"),
1507 Value::String("2026-06-17T12:34:56Z".to_string()),
1508 )]);
1509 let _ = normalize_workflow_params(&def, &input).expect("RFC3339 should pass");
1510 let input2: BTreeMap<String, Value> = BTreeMap::from([(
1512 String::from("ts"),
1513 Value::String("2026-06-17T12:34:56+08:00".to_string()),
1514 )]);
1515 let _ = normalize_workflow_params(&def, &input2).expect("RFC3339 with offset should pass");
1516 }
1517
1518 #[test]
1519 fn format_date_time_failure() {
1520 let def = parse_workflow_definition(
1521 r#"{
1522 "workflowId": "flow-fmt",
1523 "version": 1,
1524 "params": {
1525 "ts": { "type": "string", "format": "date-time" }
1526 },
1527 "nodes": {
1528 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1529 }
1530 }"#,
1531 )
1532 .unwrap();
1533 let input: BTreeMap<String, Value> = BTreeMap::from([(
1534 String::from("ts"),
1535 Value::String("2026-06-17 12:34:56".to_string()),
1536 )]);
1537 let err = normalize_workflow_params(&def, &input).unwrap_err();
1538 let msg = err.to_string();
1539 assert!(msg.contains("date-time"), "got: {msg}");
1540 }
1541
1542 #[test]
1543 fn format_email_success() {
1544 let def = parse_workflow_definition(
1545 r#"{
1546 "workflowId": "flow-fmt",
1547 "version": 1,
1548 "params": {
1549 "email": { "type": "string", "format": "email" }
1550 },
1551 "nodes": {
1552 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1553 }
1554 }"#,
1555 )
1556 .unwrap();
1557 let input: BTreeMap<String, Value> = BTreeMap::from([(
1558 String::from("email"),
1559 Value::String("user@example.com".to_string()),
1560 )]);
1561 let _ = normalize_workflow_params(&def, &input).expect("valid email should pass");
1562 }
1563
1564 #[test]
1565 fn format_email_failure_no_at() {
1566 let def = parse_workflow_definition(
1567 r#"{
1568 "workflowId": "flow-fmt",
1569 "version": 1,
1570 "params": {
1571 "email": { "type": "string", "format": "email" }
1572 },
1573 "nodes": {
1574 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1575 }
1576 }"#,
1577 )
1578 .unwrap();
1579 let input: BTreeMap<String, Value> = BTreeMap::from([(
1580 String::from("email"),
1581 Value::String("notanemail".to_string()),
1582 )]);
1583 let err = normalize_workflow_params(&def, &input).unwrap_err();
1584 let msg = err.to_string();
1585 assert!(msg.contains("email"), "got: {msg}");
1586 }
1587
1588 #[test]
1589 fn format_email_failure_double_at() {
1590 let def = parse_workflow_definition(
1591 r#"{
1592 "workflowId": "flow-fmt",
1593 "version": 1,
1594 "params": {
1595 "email": { "type": "string", "format": "email" }
1596 },
1597 "nodes": {
1598 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1599 }
1600 }"#,
1601 )
1602 .unwrap();
1603 let input: BTreeMap<String, Value> = BTreeMap::from([(
1604 String::from("email"),
1605 Value::String("a@b@c.com".to_string()),
1606 )]);
1607 let err = normalize_workflow_params(&def, &input).unwrap_err();
1608 let msg = err.to_string();
1609 assert!(msg.contains("email"), "got: {msg}");
1610 }
1611
1612 #[test]
1613 fn format_email_failure_empty_local() {
1614 let def = parse_workflow_definition(
1615 r#"{
1616 "workflowId": "flow-fmt",
1617 "version": 1,
1618 "params": {
1619 "email": { "type": "string", "format": "email" }
1620 },
1621 "nodes": {
1622 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1623 }
1624 }"#,
1625 )
1626 .unwrap();
1627 let input: BTreeMap<String, Value> = BTreeMap::from([(
1628 String::from("email"),
1629 Value::String("@example.com".to_string()),
1630 )]);
1631 let err = normalize_workflow_params(&def, &input).unwrap_err();
1632 let msg = err.to_string();
1633 assert!(msg.contains("email"), "got: {msg}");
1634 }
1635
1636 #[test]
1637 fn format_email_failure_no_dot_in_domain() {
1638 let def = parse_workflow_definition(
1639 r#"{
1640 "workflowId": "flow-fmt",
1641 "version": 1,
1642 "params": {
1643 "email": { "type": "string", "format": "email" }
1644 },
1645 "nodes": {
1646 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1647 }
1648 }"#,
1649 )
1650 .unwrap();
1651 let input: BTreeMap<String, Value> = BTreeMap::from([(
1652 String::from("email"),
1653 Value::String("user@localhost".to_string()),
1654 )]);
1655 let err = normalize_workflow_params(&def, &input).unwrap_err();
1656 let msg = err.to_string();
1657 assert!(msg.contains("dot"), "got: {msg}");
1658 }
1659
1660 #[test]
1661 fn format_email_failure_whitespace() {
1662 let def = parse_workflow_definition(
1663 r#"{
1664 "workflowId": "flow-fmt",
1665 "version": 1,
1666 "params": {
1667 "email": { "type": "string", "format": "email" }
1668 },
1669 "nodes": {
1670 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1671 }
1672 }"#,
1673 )
1674 .unwrap();
1675 let input: BTreeMap<String, Value> = BTreeMap::from([(
1676 String::from("email"),
1677 Value::String("user @example.com".to_string()),
1678 )]);
1679 let err = normalize_workflow_params(&def, &input).unwrap_err();
1680 let msg = err.to_string();
1681 assert!(msg.contains("whitespace"), "got: {msg}");
1682 }
1683
1684 #[test]
1685 fn format_on_non_string_type_fails() {
1686 let def = parse_workflow_definition(
1687 r#"{
1688 "workflowId": "flow-fmt-nonstring",
1689 "version": 1,
1690 "params": {
1691 "count": { "type": "integer", "format": "date" }
1692 },
1693 "nodes": {
1694 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1695 }
1696 }"#,
1697 )
1698 .unwrap();
1699 let input: BTreeMap<String, Value> =
1700 BTreeMap::from([(String::from("count"), serde_json::json!(42))]);
1701 let err = normalize_workflow_params(&def, &input).unwrap_err();
1702 let msg = err.to_string();
1703 assert!(msg.contains("count"), "got: {msg}");
1704 assert!(msg.contains("format"), "got: {msg}");
1705 assert!(msg.contains("integer"), "got: {msg}");
1706 assert!(msg.contains("string"), "got: {msg}");
1707 }
1708
1709 #[test]
1710 fn format_unknown_format_fails() {
1711 let def = parse_workflow_definition(
1712 r#"{
1713 "workflowId": "flow-fmt-unknown",
1714 "version": 1,
1715 "params": {
1716 "x": { "type": "string", "format": "unknown-format-xyz" }
1717 },
1718 "nodes": {
1719 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1720 }
1721 }"#,
1722 )
1723 .unwrap();
1724 let input: BTreeMap<String, Value> =
1725 BTreeMap::from([(String::from("x"), Value::String("v".to_string()))]);
1726 let err = normalize_workflow_params(&def, &input).unwrap_err();
1727 let msg = err.to_string();
1728 assert!(msg.contains("unknown format"), "got: {msg}");
1729 assert!(msg.contains("x"), "got: {msg}");
1730 assert!(msg.contains("unknown-format-xyz"), "got: {msg}");
1731 }
1732
1733 #[test]
1736 fn coerce_string_to_boolean_true_and_false() {
1737 let def = parse_workflow_definition(
1738 r#"{
1739 "workflowId": "flow-coerce",
1740 "version": 1,
1741 "params": {
1742 "enabled": { "type": "boolean" }
1743 },
1744 "nodes": {
1745 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1746 }
1747 }"#,
1748 )
1749 .unwrap();
1750
1751 let input: BTreeMap<String, Value> =
1753 BTreeMap::from([(String::from("enabled"), Value::String("true".to_string()))]);
1754 let normalized = normalize_workflow_params(&def, &input).expect("true coerces to bool");
1755 assert_eq!(normalized.get("enabled"), Some(&Value::Bool(true)));
1756
1757 let input2: BTreeMap<String, Value> =
1759 BTreeMap::from([(String::from("enabled"), Value::String("false".to_string()))]);
1760 let normalized2 = normalize_workflow_params(&def, &input2).expect("false coerces to bool");
1761 assert_eq!(normalized2.get("enabled"), Some(&Value::Bool(false)));
1762 }
1763
1764 #[test]
1765 fn coerce_boolean_rejects_case_insensitive_and_junk() {
1766 let def = parse_workflow_definition(
1767 r#"{
1768 "workflowId": "flow-coerce",
1769 "version": 1,
1770 "params": {
1771 "enabled": { "type": "boolean" }
1772 },
1773 "nodes": {
1774 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1775 }
1776 }"#,
1777 )
1778 .unwrap();
1779
1780 for bad in &["True", "TRUE", "yes", "1", "0", "FALSE", "on", "off"] {
1781 let input: BTreeMap<String, Value> =
1782 BTreeMap::from([(String::from("enabled"), Value::String(bad.to_string()))]);
1783 let err = normalize_workflow_params(&def, &input).unwrap_err();
1784 let msg = err.to_string();
1785 assert!(
1786 msg.contains("boolean"),
1787 "expected 'boolean' in error for input '{}', got: {}",
1788 bad,
1789 msg
1790 );
1791 assert!(
1792 msg.contains("enabled"),
1793 "expected 'enabled' in error for input '{}', got: {}",
1794 bad,
1795 msg
1796 );
1797 }
1798 }
1799
1800 #[test]
1801 fn coerce_boolean_accepts_already_typed_value() {
1802 let def = parse_workflow_definition(
1803 r#"{
1804 "workflowId": "flow-coerce",
1805 "version": 1,
1806 "params": {
1807 "enabled": { "type": "boolean" }
1808 },
1809 "nodes": {
1810 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1811 }
1812 }"#,
1813 )
1814 .unwrap();
1815
1816 let input: BTreeMap<String, Value> =
1818 BTreeMap::from([(String::from("enabled"), Value::Bool(true))]);
1819 let normalized = normalize_workflow_params(&def, &input).expect("bool passthrough");
1820 assert_eq!(normalized.get("enabled"), Some(&Value::Bool(true)));
1821 }
1822
1823 #[test]
1824 fn coerce_number_from_strings() {
1825 let def = parse_workflow_definition(
1826 r#"{
1827 "workflowId": "flow-coerce",
1828 "version": 1,
1829 "params": {
1830 "ratio": { "type": "number" }
1831 },
1832 "nodes": {
1833 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1834 }
1835 }"#,
1836 )
1837 .unwrap();
1838
1839 let cases: Vec<(&str, Value)> = vec![
1840 ("1", serde_json::json!(1)),
1841 ("1.5", serde_json::json!(1.5)),
1842 ("-2", serde_json::json!(-2)),
1843 ("1e3", serde_json::json!(1000.0)),
1844 ];
1845 for (input_str, expected) in &cases {
1846 let input: BTreeMap<String, Value> =
1847 BTreeMap::from([(String::from("ratio"), Value::String(input_str.to_string()))]);
1848 let normalized =
1849 normalize_workflow_params(&def, &input).expect("number string should coerce");
1850 assert_eq!(
1851 normalized.get("ratio"),
1852 Some(expected),
1853 "failed for input '{}'",
1854 input_str
1855 );
1856 }
1857 }
1858
1859 #[test]
1860 fn coerce_number_rejects_junk() {
1861 let def = parse_workflow_definition(
1862 r#"{
1863 "workflowId": "flow-coerce",
1864 "version": 1,
1865 "params": {
1866 "ratio": { "type": "number" }
1867 },
1868 "nodes": {
1869 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1870 }
1871 }"#,
1872 )
1873 .unwrap();
1874
1875 for bad in &[
1876 "NaN",
1877 "Infinity",
1878 "-Infinity",
1879 "not-a-number",
1880 "true",
1881 "\"hi\"",
1882 "[]",
1883 ] {
1884 let input: BTreeMap<String, Value> =
1885 BTreeMap::from([(String::from("ratio"), Value::String(bad.to_string()))]);
1886 let err = normalize_workflow_params(&def, &input).unwrap_err();
1887 let msg = err.to_string();
1888 assert!(
1889 msg.contains("number") || msg.contains("number"),
1890 "expected 'number' in error for input '{}', got: {}",
1891 bad,
1892 msg
1893 );
1894 }
1895 }
1896
1897 #[test]
1898 fn coerce_integer_from_decimal_strings() {
1899 let def = parse_workflow_definition(
1900 r#"{
1901 "workflowId": "flow-coerce",
1902 "version": 1,
1903 "params": {
1904 "count": { "type": "integer" }
1905 },
1906 "nodes": {
1907 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1908 }
1909 }"#,
1910 )
1911 .unwrap();
1912
1913 let cases: Vec<(&str, Value)> = vec![
1914 ("42", serde_json::json!(42)),
1915 ("-1", serde_json::json!(-1)),
1916 ("0", serde_json::json!(0)),
1917 ];
1918 for (input_str, expected) in &cases {
1919 let input: BTreeMap<String, Value> =
1920 BTreeMap::from([(String::from("count"), Value::String(input_str.to_string()))]);
1921 let normalized =
1922 normalize_workflow_params(&def, &input).expect("integer string should coerce");
1923 assert_eq!(
1924 normalized.get("count"),
1925 Some(expected),
1926 "failed for input '{}'",
1927 input_str
1928 );
1929 }
1930 }
1931
1932 #[test]
1933 fn coerce_integer_with_whitespace_padding() {
1934 let def = parse_workflow_definition(
1935 r#"{
1936 "workflowId": "flow-coerce",
1937 "version": 1,
1938 "params": {
1939 "count": { "type": "integer" }
1940 },
1941 "nodes": {
1942 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1943 }
1944 }"#,
1945 )
1946 .unwrap();
1947
1948 let input: BTreeMap<String, Value> =
1950 BTreeMap::from([(String::from("count"), Value::String(" 42 ".to_string()))]);
1951 let normalized =
1952 normalize_workflow_params(&def, &input).expect("padded integer should coerce");
1953 assert_eq!(normalized.get("count"), Some(&serde_json::json!(42)));
1954 }
1955
1956 #[test]
1957 fn coerce_integer_rejects_non_decimal_formats() {
1958 let def = parse_workflow_definition(
1959 r#"{
1960 "workflowId": "flow-coerce",
1961 "version": 1,
1962 "params": {
1963 "count": { "type": "integer" }
1964 },
1965 "nodes": {
1966 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1967 }
1968 }"#,
1969 )
1970 .unwrap();
1971
1972 for bad in &["1.0", "1.5", "1e3", "0x10", "3.0"] {
1973 let input: BTreeMap<String, Value> =
1974 BTreeMap::from([(String::from("count"), Value::String(bad.to_string()))]);
1975 let err = normalize_workflow_params(&def, &input).unwrap_err();
1976 let msg = err.to_string();
1977 assert!(
1978 msg.contains("integer"),
1979 "expected 'integer' in error for input '{}', got: {}",
1980 bad,
1981 msg
1982 );
1983 }
1984 }
1985
1986 #[test]
1987 fn coerce_integer_accepts_already_typed_value() {
1988 let def = parse_workflow_definition(
1989 r#"{
1990 "workflowId": "flow-coerce",
1991 "version": 1,
1992 "params": {
1993 "count": { "type": "integer" }
1994 },
1995 "nodes": {
1996 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
1997 }
1998 }"#,
1999 )
2000 .unwrap();
2001
2002 let input: BTreeMap<String, Value> =
2004 BTreeMap::from([(String::from("count"), serde_json::json!(42))]);
2005 let normalized = normalize_workflow_params(&def, &input).expect("integer passthrough");
2006 assert_eq!(normalized.get("count"), Some(&serde_json::json!(42)));
2007 }
2008
2009 #[test]
2010 fn coerce_object_from_json_string() {
2011 let def = parse_workflow_definition(
2012 r#"{
2013 "workflowId": "flow-coerce",
2014 "version": 1,
2015 "params": {
2016 "payload": { "type": "object" }
2017 },
2018 "nodes": {
2019 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2020 }
2021 }"#,
2022 )
2023 .unwrap();
2024
2025 let input: BTreeMap<String, Value> = BTreeMap::from([(
2026 String::from("payload"),
2027 Value::String(r#"{"a":1,"b":"x"}"#.to_string()),
2028 )]);
2029 let normalized =
2030 normalize_workflow_params(&def, &input).expect("object string should coerce");
2031 assert_eq!(
2032 normalized.get("payload"),
2033 Some(&serde_json::json!({"a":1,"b":"x"}))
2034 );
2035 }
2036
2037 #[test]
2038 fn coerce_array_from_json_string() {
2039 let def = parse_workflow_definition(
2040 r#"{
2041 "workflowId": "flow-coerce",
2042 "version": 1,
2043 "params": {
2044 "tags": { "type": "array" }
2045 },
2046 "nodes": {
2047 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2048 }
2049 }"#,
2050 )
2051 .unwrap();
2052
2053 let input: BTreeMap<String, Value> = BTreeMap::from([(
2054 String::from("tags"),
2055 Value::String(r#"["a","b"]"#.to_string()),
2056 )]);
2057 let normalized =
2058 normalize_workflow_params(&def, &input).expect("array string should coerce");
2059 assert_eq!(normalized.get("tags"), Some(&serde_json::json!(["a", "b"])));
2060 }
2061
2062 #[test]
2063 fn coerce_object_rejects_non_object_json() {
2064 let def = parse_workflow_definition(
2065 r#"{
2066 "workflowId": "flow-coerce",
2067 "version": 1,
2068 "params": {
2069 "payload": { "type": "object" }
2070 },
2071 "nodes": {
2072 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2073 }
2074 }"#,
2075 )
2076 .unwrap();
2077
2078 let input: BTreeMap<String, Value> = BTreeMap::from([(
2080 String::from("payload"),
2081 Value::String(r#"["a"]"#.to_string()),
2082 )]);
2083 let err = normalize_workflow_params(&def, &input).unwrap_err();
2084 let msg = err.to_string();
2085 assert!(msg.contains("object"), "got: {msg}");
2086 assert!(msg.contains("payload"), "got: {msg}");
2087 assert!(msg.contains("array"), "got: {msg}");
2088 }
2089
2090 #[test]
2091 fn coerce_array_rejects_non_array_json() {
2092 let def = parse_workflow_definition(
2093 r#"{
2094 "workflowId": "flow-coerce",
2095 "version": 1,
2096 "params": {
2097 "tags": { "type": "array" }
2098 },
2099 "nodes": {
2100 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2101 }
2102 }"#,
2103 )
2104 .unwrap();
2105
2106 let input: BTreeMap<String, Value> =
2108 BTreeMap::from([(String::from("tags"), Value::String("1".to_string()))]);
2109 let err = normalize_workflow_params(&def, &input).unwrap_err();
2110 let msg = err.to_string();
2111 assert!(msg.contains("array"), "got: {msg}");
2112 assert!(msg.contains("tags"), "got: {msg}");
2113 }
2114
2115 #[test]
2116 fn coerce_object_invalid_json_fails() {
2117 let def = parse_workflow_definition(
2118 r#"{
2119 "workflowId": "flow-coerce",
2120 "version": 1,
2121 "params": {
2122 "payload": { "type": "object" }
2123 },
2124 "nodes": {
2125 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2126 }
2127 }"#,
2128 )
2129 .unwrap();
2130
2131 let input: BTreeMap<String, Value> = BTreeMap::from([(
2132 String::from("payload"),
2133 Value::String("not-json".to_string()),
2134 )]);
2135 let err = normalize_workflow_params(&def, &input).unwrap_err();
2136 let msg = err.to_string();
2137 assert!(msg.contains("object"), "got: {msg}");
2138 assert!(msg.contains("valid JSON"), "got: {msg}");
2139 }
2140
2141 #[test]
2142 fn coerce_default_not_coerced_for_boolean() {
2143 let def = parse_workflow_definition(
2146 r#"{
2147 "workflowId": "flow-coerce",
2148 "version": 1,
2149 "params": {
2150 "enabled": {
2151 "type": "boolean",
2152 "required": false,
2153 "default": "true"
2154 }
2155 },
2156 "nodes": {
2157 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2158 }
2159 }"#,
2160 )
2161 .unwrap();
2162
2163 let input: BTreeMap<String, Value> = BTreeMap::new(); let err = normalize_workflow_params(&def, &input).unwrap_err();
2165 let msg = err.to_string();
2166 assert!(
2167 msg.contains("boolean"),
2168 "default string 'true' should fail type check for boolean, got: {}",
2169 msg
2170 );
2171 }
2172
2173 #[test]
2174 fn coerce_string_param_unchanged() {
2175 let def = parse_workflow_definition(
2177 r#"{
2178 "workflowId": "flow-coerce",
2179 "version": 1,
2180 "params": {
2181 "name": { "type": "string" }
2182 },
2183 "nodes": {
2184 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2185 }
2186 }"#,
2187 )
2188 .unwrap();
2189
2190 let input: BTreeMap<String, Value> =
2191 BTreeMap::from([(String::from("name"), Value::String("true".to_string()))]);
2192 let normalized =
2193 normalize_workflow_params(&def, &input).expect("string param should not be coerced");
2194 assert_eq!(
2196 normalized.get("name"),
2197 Some(&Value::String("true".to_string()))
2198 );
2199 }
2200
2201 #[test]
2202 fn coerce_boolean_with_whitespace_padding() {
2203 let def = parse_workflow_definition(
2204 r#"{
2205 "workflowId": "flow-coerce",
2206 "version": 1,
2207 "params": {
2208 "enabled": { "type": "boolean" }
2209 },
2210 "nodes": {
2211 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2212 }
2213 }"#,
2214 )
2215 .unwrap();
2216
2217 let input: BTreeMap<String, Value> = BTreeMap::from([(
2219 String::from("enabled"),
2220 Value::String(" true ".to_string()),
2221 )]);
2222 let normalized =
2223 normalize_workflow_params(&def, &input).expect("padded true coerces to bool");
2224 assert_eq!(normalized.get("enabled"), Some(&Value::Bool(true)));
2225 }
2226
2227 #[test]
2228 fn coerce_empty_string_to_boolean_fails() {
2229 let def = parse_workflow_definition(
2230 r#"{
2231 "workflowId": "flow-coerce",
2232 "version": 1,
2233 "params": {
2234 "enabled": { "type": "boolean" }
2235 },
2236 "nodes": {
2237 "a": { "type": "subagent", "bot": "bot-a", "prompt": "hi" }
2238 }
2239 }"#,
2240 )
2241 .unwrap();
2242
2243 let input: BTreeMap<String, Value> =
2244 BTreeMap::from([(String::from("enabled"), Value::String("".to_string()))]);
2245 let err = normalize_workflow_params(&def, &input).unwrap_err();
2246 let msg = err.to_string();
2247 assert!(msg.contains("boolean"), "got: {msg}");
2248 assert!(msg.contains("empty"), "got: {msg}");
2249 }
2250}