Skip to main content

beam_core/
workflow_run.rs

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    // Validate / normalize params before creating any run artifacts
79    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(&params_json);
93    let input_path = run_dir.join("blobs").join(&params_hash);
94    fs::write(&input_path, &params_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
133/// Normalize and validate workflow parameters against the definition's params
134/// schema. Returns a canonical `BTreeMap<String, Value>` suitable for writing
135/// as the run input blob.
136///
137/// If the workflow has no `params` definition (or an empty one), any supplied
138/// params are rejected — no parameters are allowed without a declaration.
139pub 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        // No schema defined — reject any supplied params.
145        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    // Empty params schema — same behaviour as no schema.
156    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    // ── Reject unknown keys ────────────────────────────────────────────
168    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    // ── Validate and normalize each defined param ──────────────────────
185    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                // Coerce string inputs to the target type declared in the
192                // schema (e.g. "true" → bool, "42" → integer).  Type/syntax
193                // errors from coercion are surfaced here.
194                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                // Special handling: required string that is blank/whitespace
200                // is treated as missing (preserving previous semantics).
201                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                // For non-string required types with blank input, type
210                // validation already failed above (e.g. "" is not a bool).
211                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) = &param_def.default {
217                    // Default values are NOT coerced — they must already be
218                    // the correct typed JSON in the schema definition.
219                    validate_param_type(name, param_def, default)?;
220                    validate_param_format(name, param_def, default)?;
221                    normalized.insert(name.clone(), default.clone());
222                }
223                // else: not required, no default → not written.
224            }
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
238/// Validate a single param value against its declared type.
239fn 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
315/// Validate the format annotation of a param value.
316///
317/// Format only applies to string-typed parameters.  Unknown formats and
318/// format-on-non-string are hard errors (no silent ignore).
319fn validate_param_format(name: &str, def: &ParamDef, value: &Value) -> Result<()> {
320    let Some(format) = &def.format else {
321        return Ok(());
322    };
323
324    // Format is only valid for string-typed params.
325    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        // Type validation should have caught mismatches already; be safe.
336        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
353/// Validate a `date` format string: must be a real calendar date in YYYY-MM-DD.
354fn 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
365/// Validate a `date-time` format string: must parse as RFC 3339.
366fn validate_date_time(name: &str, value: &str) -> Result<()> {
367    // chrono::DateTime::parse_from_rfc3339 handles timezone offset correctly.
368    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
378/// Validate an `email` format string: lightweight checks (one @, non-empty
379/// local/domain, domain has at least one dot, domain labels are non-empty and
380/// don't start/end with `-`, no whitespace).
381fn validate_email(name: &str, value: &str) -> Result<()> {
382    // Must have exactly one '@'.
383    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    // Domain must contain at least one dot.
412    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    // Reject whitespace anywhere.
421    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    // Domain labels must be non-empty and not start/end with '-'.
430    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
461/// Coerce a string input value to the target parameter type declared in the
462/// workflow params schema.
463///
464/// If the input value is already the target JSON type, it passes through
465/// unchanged.  Only `Value::String` inputs are coerced; other mismatched
466/// types are left for the subsequent type-validation step to reject.
467///
468/// Coercion rules per type:
469/// - `string`:  pass through unchanged (no JSON parse).
470/// - `boolean`: accept case-sensitive `true` / `false` strings (trimmed).
471/// - `number`:  parse as JSON number via `serde_json`; rejects NaN/inf/
472///              blank/objects/arrays/non-numeric JSON.
473/// - `integer`: accept only decimal integer strings (e.g. `42`, `-1`);
474///              rejects `1.0`, `1.5`, `1e3` and other formats.
475/// - `object`:  parse string as JSON, must produce a JSON object.
476/// - `array`:   parse string as JSON, must produce a JSON array.
477///
478/// This coercion is only applied to external user input, never to schema
479/// default values (so schema authors cannot paper over type errors in the
480/// definition).
481fn coerce_param_value(name: &str, def: &ParamDef, value: &Value) -> Result<Value> {
482    let target_type = def.param_type.as_str();
483
484    // If the value is already the target JSON type, no coercion needed.
485    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    // Only attempt coercion from string.
498    if !value.is_string() {
499        // Not a string and not the target type — let type validation handle it.
500        return Ok(value.clone());
501    }
502
503    let s = value.as_str().unwrap();
504
505    match target_type {
506        "string" => {
507            // string → string: no coercion, pass through as-is.
508            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            // Only accept decimal integer strings: optional leading '-', then
562            // only ASCII digits. Reject floats, scientific notation, etc.
563            let is_decimal_integer = trimmed.chars().enumerate().all(|(i, c)| {
564                if i == 0 && c == '-' {
565                    trimmed.len() > 1 // "-" alone is not valid
566                } 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            // Parse as i128 and construct a JSON number.
580            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: &params,
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: &params,
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: &params,
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    // -- required param validation tests --
793
794    #[test]
795    fn bootstrap_rejects_missing_required_param() {
796        let paths = temp_paths("missing-param");
797        let params: BTreeMap<String, Value> = BTreeMap::new(); // empty — missing "task" which is required
798        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: &params,
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        // Should not have created the run directory
831        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()))]); // whitespace only
845        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: &params,
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        // Should not have created the run directory
878        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: &params,
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(); // no params, but "verbose" is not required
937        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: &params,
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(); // missing all required params
971        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: &params,
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    // -- JSON typed params tests (normalize_workflow_params) --
1021
1022    #[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        // Not-required, no default: should not be written
1051        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        // The default value itself must match the declared type.
1222        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        // Also try 42.0 (integer-value float)
1315        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: &params,
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: &params,
1384                initiator: "test",
1385                chat_binding: None,
1386            },
1387        )
1388        .expect("bootstrap with default param");
1389        assert_eq!(result.workflow_id, "flow-def");
1390        // Read the params blob to verify default was written.
1391        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    // ── format validation tests ─────────────────────────────────────────
1401
1402    #[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        // Also test with offset
1511        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    // ── coercion tests ───────────────────────────────────────────────
1734
1735    #[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        // "true" → bool true
1752        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        // "false" → bool false
1758        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        // Already bool → passes through
1817        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        // " 42 " → trim → "42" → integer 42
1949        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        // Already a JSON number → passes through
2003        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        // Array string for object type → error
2079        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        // Number string for array type → error
2107        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        // Default of "true" (string) for boolean type should fail, because
2144        // defaults are NOT coerced — they must be typed JSON.
2145        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(); // use default
2164        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        // String-type params should not be modified.
2176        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        // Should remain a string "true", not bool true
2195        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        // "  true  " → bool true (trimmed before matching)
2218        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}