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