1use super::model::{OnError, Step, Workflow};
12use super::template::{self, Data};
13use crate::state::now_ms;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value, json};
16use std::collections::BTreeMap;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "snake_case")]
20pub enum StepStatus {
21 #[default]
22 Pending,
23 Running,
24 Done,
25 Failed,
26 Skipped,
27 Cancelled,
28 Timeout,
29 Suspended,
31 Pruned,
41}
42
43impl StepStatus {
44 pub fn is_terminal(self) -> bool {
45 matches!(
46 self,
47 StepStatus::Done
48 | StepStatus::Failed
49 | StepStatus::Skipped
50 | StepStatus::Pruned
51 | StepStatus::Cancelled
52 | StepStatus::Timeout
53 )
54 }
55 pub fn is_satisfied(self) -> bool {
57 matches!(self, StepStatus::Done | StepStatus::Skipped)
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
63pub struct StepState {
64 #[serde(default)]
65 pub status: StepStatus,
66 #[serde(default)]
67 pub attempt: u32,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub started: Option<u64>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub finished: Option<u64>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub output: Option<Value>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub error: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub wait: Option<Value>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub cache_key: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub worker: Option<String>,
92 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
95 pub forced: bool,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
99#[serde(rename_all = "snake_case")]
100pub enum RunStatus {
101 #[default]
102 Pending,
103 Running,
104 Suspended,
106 Paused,
107 Completed,
108 Failed,
109 Refused,
110 Cancelled,
111 Stalled,
112}
113
114impl RunStatus {
115 pub fn is_terminal(self) -> bool {
116 matches!(
117 self,
118 RunStatus::Completed
119 | RunStatus::Failed
120 | RunStatus::Refused
121 | RunStatus::Cancelled
122 | RunStatus::Stalled
123 )
124 }
125 pub fn as_str(self) -> &'static str {
126 match self {
127 RunStatus::Pending => "pending",
128 RunStatus::Running => "running",
129 RunStatus::Suspended => "suspended",
130 RunStatus::Paused => "paused",
131 RunStatus::Completed => "completed",
132 RunStatus::Failed => "failed",
133 RunStatus::Refused => "refused",
134 RunStatus::Cancelled => "cancelled",
135 RunStatus::Stalled => "stalled",
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
143pub struct Start {
144 pub node: String,
145 #[serde(default)]
146 pub payload: Value,
147 #[serde(default)]
148 pub ts: u64,
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct RunState {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub break_before: Option<String>,
160 pub id: String,
161 pub workflow: String,
162 pub workflow_hash: String,
163 #[serde(default)]
164 pub inputs: Value,
165 #[serde(default)]
166 pub status: RunStatus,
167 #[serde(default)]
168 pub start: Start,
169 #[serde(default)]
170 pub steps: BTreeMap<String, StepState>,
171 #[serde(default)]
172 pub vars: Map<String, Value>,
173 #[serde(default)]
174 pub tokens: u64,
175 #[serde(default)]
176 pub steps_run: u32,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub output: Option<Value>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub error: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub task: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub principal: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub conversation: Option<String>,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub children: Vec<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub parent: Option<Value>,
191 #[serde(default)]
200 pub msg_depth: u32,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub key: Option<String>,
206 #[serde(default)]
207 pub attempt: u32,
208 #[serde(default)]
209 pub created: u64,
210 #[serde(default)]
211 pub updated: u64,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub finished: Option<u64>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub deadline_ms: Option<u64>,
216 #[serde(default = "default_durable")]
221 pub durable: bool,
222 #[serde(skip)]
223 pub dirty: bool,
224}
225
226fn default_durable() -> bool {
227 true
228}
229
230impl RunState {
231 pub fn new(id: &str, wf: &Workflow, start: Start, inputs: Value) -> RunState {
232 let now = now_ms();
233 let mut steps = BTreeMap::new();
234 for s in wf.steps.keys() {
235 steps.insert(s.clone(), StepState::default());
236 }
237 for s in wf.start_steps() {
240 let st = steps.get_mut(&s.id).expect("present");
241 if s.id == start.node {
242 st.status = StepStatus::Done;
243 st.output = Some(start.payload.clone());
244 st.started = Some(now);
245 st.finished = Some(now);
246 st.attempt = 1;
247 } else {
248 st.status = StepStatus::Skipped;
249 }
250 }
251 RunState {
252 break_before: None,
253 id: id.to_string(),
254 workflow: wf.name.clone(),
255 workflow_hash: wf.hash.clone(),
256 inputs,
257 status: RunStatus::Running,
258 start,
259 steps,
260 vars: Map::new(),
261 tokens: 0,
262 steps_run: 0,
263 output: None,
264 error: None,
265 task: None,
266 principal: None,
267 conversation: None,
268 children: Vec::new(),
269 parent: None,
270 msg_depth: 0,
271 key: None,
272 attempt: 1,
273 created: now,
274 updated: now,
275 finished: None,
276 deadline_ms: wf.limits.deadline_ms.map(|d| now + d),
277 durable: wf.durable.unwrap_or(true),
278 dirty: true,
279 }
280 }
281
282 pub fn touch(&mut self) {
283 self.updated = now_ms();
284 self.dirty = true;
285 }
286
287 pub fn step(&self, id: &str) -> Option<&StepState> {
288 self.steps.get(id)
289 }
290
291 pub fn data(&self, env: Value, memory: Value) -> Data {
296 let mut d = Data::new();
297 d.insert("inputs".into(), self.inputs.clone());
298 d.insert(
299 "run".into(),
300 json!({"id": self.id, "workflow": self.workflow, "start": self.start, "principal": self.principal, "task": self.task, "attempt": self.attempt, "status": self.status}),
301 );
302 d.insert(
303 "steps".into(),
304 Value::Object(
305 self.steps
306 .iter()
307 .map(|(k, s)| (k.clone(), json!({"status": s.status, "output": s.output, "error": s.error, "attempt": s.attempt})))
308 .collect(),
309 ),
310 );
311 d.insert("vars".into(), Value::Object(self.vars.clone()));
312 d.insert("env".into(), env);
313 d.insert("memory".into(), memory);
314 d
315 }
316
317 pub fn begin_step(&mut self, id: &str) -> u32 {
319 let attempt = {
320 let st = self.steps.entry(id.to_string()).or_default();
321 st.status = StepStatus::Running;
322 st.attempt += 1;
323 st.started = Some(now_ms());
324 st.finished = None;
325 st.error = None;
326 st.wait = None;
327 st.cache_key = None;
328 st.forced = false;
329 st.attempt
330 };
331 if !self.status.is_terminal() {
332 self.status = RunStatus::Running;
333 }
334 self.touch();
335 attempt
336 }
337
338 pub fn end_step(
340 &mut self,
341 id: &str,
342 status: StepStatus,
343 output: Option<Value>,
344 error: Option<String>,
345 ) {
346 let st = self.steps.entry(id.to_string()).or_default();
347 st.status = status;
348 st.finished = Some(now_ms());
349 st.output = output;
350 st.error = error;
351 st.wait = None;
352 st.cache_key = None;
353 st.worker = None;
354 self.steps_run += 1;
355 self.touch();
356 }
357
358 pub fn suspend_step(&mut self, id: &str, wait: Value) {
360 let st = self.steps.entry(id.to_string()).or_default();
361 st.status = StepStatus::Suspended;
362 st.wait = Some(wait);
363 self.touch();
364 }
365
366 pub fn finish(&mut self, status: RunStatus, output: Option<Value>, error: Option<String>) {
368 self.status = status;
369 self.output = output;
370 self.error = error;
371 self.finished = Some(now_ms());
372 for st in self.steps.values_mut() {
374 if !st.status.is_terminal() {
375 st.status = StepStatus::Cancelled;
376 st.finished = Some(now_ms());
377 }
378 }
379 self.touch();
380 }
381
382 pub fn write_var(&mut self, key: &str, value: Value, mode: &str) {
384 let cur = self.vars.remove(key);
385 let next = match (mode, cur) {
386 ("append", Some(Value::Array(mut a))) => {
387 match value {
388 Value::Array(more) => a.extend(more),
389 other => a.push(other),
390 }
391 Value::Array(a)
392 }
393 ("append", Some(other)) => json!([other, value]),
394 ("append", None) => match value {
395 Value::Array(a) => Value::Array(a),
396 other => json!([other]),
397 },
398 ("merge", Some(Value::Object(mut o))) => {
399 if let Value::Object(more) = value {
400 for (k, v) in more {
401 o.insert(k, v);
402 }
403 }
404 Value::Object(o)
405 }
406 ("union", Some(Value::Array(mut a))) => {
407 if let Value::Array(more) = value {
408 for v in more {
409 if !a.contains(&v) {
410 a.push(v);
411 }
412 }
413 } else if !a.contains(&value) {
414 a.push(value);
415 }
416 Value::Array(a)
417 }
418 (_, _) => value,
419 };
420 self.vars.insert(key.to_string(), next);
421 self.touch();
422 }
423
424 pub fn progress(&self) -> Value {
426 let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
427 for s in self.steps.values() {
428 *counts
429 .entry(match s.status {
430 StepStatus::Pending => "pending",
431 StepStatus::Running => "running",
432 StepStatus::Done => "done",
433 StepStatus::Failed => "failed",
434 StepStatus::Skipped => "skipped",
435 StepStatus::Pruned => "pruned",
436 StepStatus::Cancelled => "cancelled",
437 StepStatus::Timeout => "timeout",
438 StepStatus::Suspended => "suspended",
439 })
440 .or_default() += 1;
441 }
442 json!(counts)
443 }
444
445 pub fn summary(&self) -> Value {
446 json!({
447 "id": self.id, "workflow": self.workflow, "status": self.status, "start": self.start.node,
448 "steps": self.progress(), "tokens": self.tokens, "created": self.created, "updated": self.updated,
449 "finished": self.finished, "output": self.output, "error": self.error, "task": self.task, "principal": self.principal,
450 })
451 }
452}
453
454#[derive(Debug, Clone, PartialEq)]
456pub enum Next {
457 Ready(Vec<String>),
459 Waiting,
461 Stalled,
463 Terminal,
465}
466
467pub fn schedule(wf: &Workflow, run: &mut RunState, data: &Data) -> Result<Next, String> {
470 if run.status.is_terminal() {
471 return Ok(Next::Terminal);
472 }
473 let mut ready = Vec::new();
474 let mut in_flight = false;
475 let mut changed = true;
476 while changed {
478 changed = false;
479 for id in wf.topo_order() {
480 let step = &wf.steps[&id];
481 let st = run.steps.get(&id).cloned().unwrap_or_default();
482 match st.status {
483 StepStatus::Running => {
484 in_flight = true;
485 continue;
486 }
487 StepStatus::Suspended => {
488 in_flight = true;
489 continue;
490 }
491 s if s.is_terminal() => continue,
492 _ => {}
493 }
494 if ready.contains(&id) {
495 continue;
496 }
497 if st.forced {
498 ready.push(id.clone());
499 continue;
500 }
501 if step.depends_on.is_empty()
505 && !step.is_start()
506 && wf
507 .steps
508 .values()
509 .any(|s| s.field_str("on_timeout") == Some(id.as_str()))
510 {
511 continue;
512 }
513 let pruned_deps = step
519 .depends_on
520 .iter()
521 .filter(|d| {
522 run.steps
523 .get(*d)
524 .is_some_and(|s| s.status == StepStatus::Pruned)
525 })
526 .count();
527 if !step.depends_on.is_empty() && pruned_deps == step.depends_on.len() {
528 run.end_step(&id, StepStatus::Pruned, None, None);
529 changed = true;
530 continue;
531 }
532 let deps_ok = step.depends_on.iter().all(|d| {
534 run.steps
535 .get(d)
536 .is_some_and(|s| s.status.is_satisfied() || s.status == StepStatus::Pruned)
537 });
538 let deps_failed = step.depends_on.iter().any(|d| {
539 run.steps.get(d).is_some_and(|s| {
540 matches!(
541 s.status,
542 StepStatus::Failed | StepStatus::Cancelled | StepStatus::Timeout
543 )
544 })
545 });
546 if deps_failed {
547 continue;
550 }
551 if !deps_ok {
552 continue;
553 }
554 if let Some(w) = &step.when {
555 let expr = w.trim().trim_start_matches("CEL:").trim();
556 let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
557 match crate::cel::eval_bool(expr, &vars) {
558 Ok(true) => {}
559 Ok(false) => {
560 run.end_step(&id, StepStatus::Pruned, None, None);
562 changed = true;
563 continue;
564 }
565 Err(e) => return Err(format!("step {id:?}: when: {e}")),
566 }
567 }
568 ready.push(id.clone());
569 }
570 }
571 if !ready.is_empty() {
572 return Ok(Next::Ready(ready));
573 }
574 if in_flight {
575 return Ok(Next::Waiting);
576 }
577 Ok(Next::Stalled)
578}
579
580pub fn route_failure(
583 wf: &Workflow,
584 run: &mut RunState,
585 step: &Step,
586 error: &str,
587) -> Result<Vec<String>, String> {
588 match &step.on_error {
589 OnError::Fail => Err(format!("step {:?} failed: {error}", step.id)),
590 OnError::Continue => {
591 let st = run.steps.entry(step.id.clone()).or_default();
594 st.status = StepStatus::Done;
595 st.error = Some(error.to_string());
596 if st.output.is_none() {
597 st.output = Some(json!({"error": error}));
598 }
599 run.touch();
600 Ok(Vec::new())
601 }
602 OnError::Goto(target) => {
603 if !wf.steps.contains_key(target) {
604 return Err(format!(
605 "step {:?}: on_error goto {target:?} does not exist",
606 step.id
607 ));
608 }
609 let st = run.steps.entry(target.clone()).or_default();
611 st.status = StepStatus::Pending;
612 st.forced = true;
613 run.touch();
614 Ok(vec![target.clone()])
615 }
616 }
617}
618
619pub fn deadline_passed(run: &RunState) -> bool {
621 run.deadline_ms.is_some_and(|d| now_ms() >= d)
622}
623
624pub fn idempotency_key(run_id: &str, step_id: &str) -> String {
638 let h = crate::sha::sha256_hex(format!("{run_id}.{step_id}").as_bytes());
639 h[..32].to_string()
640}
641
642pub fn env_view(
643 instance: &str,
644 run_id: &str,
645 instruction: Option<&str>,
646 prompt: Option<&str>,
647) -> Value {
648 json!({
649 "instance": instance,
650 "run": run_id,
651 "ts": now_ms(),
652 "instruction": instruction,
653 "prompt": prompt,
655 })
656}
657
658pub fn render_spec(step: &Step, data: &Data) -> Result<Map<String, Value>, String> {
660 let mut out = Map::new();
661 for (k, v) in &step.spec {
662 if super::model::is_raw_field(&step.kind, k) {
663 out.insert(k.clone(), v.clone());
664 continue;
665 }
666 out.insert(
667 k.clone(),
668 template::render(v, data).map_err(|e| format!("step {:?}: {k}: {e}", step.id))?,
669 );
670 }
671 Ok(out)
672}
673
674#[cfg(all(test, feature = "cel"))]
677mod tests {
678 use super::*;
679 use crate::engine::model::parse_workflow;
680
681 #[test]
684 fn idempotency_keys_are_stable_per_step_and_opaque() {
685 let a = idempotency_key("run-01ABC", "charge");
686 assert_eq!(
687 a,
688 idempotency_key("run-01ABC", "charge"),
689 "a retry carries the SAME key"
690 );
691 assert_ne!(
692 a,
693 idempotency_key("run-01ABC", "refund"),
694 "another step is another operation"
695 );
696 assert_ne!(
697 a,
698 idempotency_key("run-02XYZ", "charge"),
699 "another run is another operation"
700 );
701 assert_ne!(
703 idempotency_key("r", "each[0].call"),
704 idempotency_key("r", "each[1].call")
705 );
706 assert_eq!(a.len(), 32);
707 assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {a}");
708 assert!(
709 !a.contains("run-01ABC") && !a.contains("charge"),
710 "leaks nothing"
711 );
712 }
713
714 fn start_at(node: &str) -> Start {
715 Start {
716 node: node.into(),
717 payload: json!({}),
718 ts: 0,
719 }
720 }
721
722 #[test]
727 fn an_untaken_branch_prunes_its_tail_but_not_a_live_join() {
728 let w = parse_workflow(&json!({
729 "name": "w", "steps": {
730 "go": {"kind": "once"},
731 "la": {"kind": "noop", "depends_on": ["go"]},
732 "ra": {"kind": "noop", "depends_on": ["go"]},
733 "la2": {"kind": "noop", "depends_on": ["la"]},
734 "ra2": {"kind": "noop", "depends_on": ["ra"]},
735 "fin": {"kind": "finish", "depends_on": ["la2", "ra2"], "status": "completed"}
736 }
737 }))
738 .unwrap();
739 let mut run = RunState::new("r", &w, start_at("go"), json!({}));
740 run.end_step("ra", StepStatus::Pruned, None, None);
741 run.end_step("la", StepStatus::Done, None, None);
742 let data = run.data(env_view("i", "r", None, None), json!({}));
743 let _ = schedule(&w, &mut run, &data).unwrap();
744 assert_eq!(
745 run.steps["ra2"].status,
746 StepStatus::Pruned,
747 "the dead branch's tail must be pruned, not run"
748 );
749
750 run.end_step("la2", StepStatus::Done, None, None);
751 let data = run.data(env_view("i", "r", None, None), json!({}));
752 match schedule(&w, &mut run, &data).unwrap() {
753 Next::Ready(r) => assert!(
754 r.iter().any(|s| s == "fin"),
755 "a join with one pruned and one live parent must run, got {r:?}"
756 ),
757 other => panic!("expected fin ready, got {other:?}"),
758 }
759 }
760
761 #[test]
765 fn sibling_start_nodes_still_satisfy_their_dependents() {
766 let w = parse_workflow(&json!({
767 "name": "w", "steps": {
768 "a": {"kind": "once"},
769 "b": {"kind": "manual"},
770 "work": {"kind": "noop", "depends_on": ["a", "b"]},
771 "fin": {"kind": "finish", "depends_on": ["work"], "status": "completed"}
772 }
773 }))
774 .unwrap();
775 let mut run = RunState::new("r", &w, start_at("a"), json!({}));
776 assert_eq!(run.steps["b"].status, StepStatus::Skipped);
777 let data = run.data(env_view("i", "r", None, None), json!({}));
778 match schedule(&w, &mut run, &data).unwrap() {
779 Next::Ready(r) => assert!(
780 r.iter().any(|s| s == "work"),
781 "a step below several start nodes must run when one fired, got {r:?}"
782 ),
783 other => panic!("expected work ready, got {other:?}"),
784 }
785 }
786
787 fn wf() -> Workflow {
788 parse_workflow(&json!({
789 "name": "w", "steps": {
790 "s": {"kind": "once"},
791 "a": {"kind": "noop", "depends_on": ["s"]},
792 "b": {"kind": "noop", "depends_on": ["s"], "when": "CEL: inputs.go == true"},
793 "c": {"kind": "noop", "depends_on": ["a", "b"], "on_error": "goto:fix"},
794 "fix": {"kind": "noop", "depends_on": ["c"]},
795 "f": {"kind": "finish", "depends_on": ["c"], "status": "completed", "output": "{{vars.x | none}}"}
796 }
797 }))
798 .unwrap()
799 }
800
801 #[cfg(feature = "cel")]
802 #[test]
803 fn scheduling_guards_failures_and_terminal_states() {
804 let w = wf();
805 let mut run = RunState::new(
806 "r1",
807 &w,
808 Start {
809 node: "s".into(),
810 payload: json!({"p": 1}),
811 ts: 0,
812 },
813 json!({"go": false}),
814 );
815 assert_eq!(run.steps["s"].status, StepStatus::Done);
816 assert_eq!(run.steps["s"].output, Some(json!({"p": 1})));
817 let data = run.data(env_view("i", "r1", None, None), json!({}));
818 assert_eq!(
820 schedule(&w, &mut run, &data).unwrap(),
821 Next::Ready(vec!["a".to_string()])
822 );
823 assert_eq!(run.steps["b"].status, StepStatus::Pruned);
827 run.begin_step("a");
828 let data = run.data(env_view("i", "r1", None, None), json!({}));
829 assert_eq!(schedule(&w, &mut run, &data).unwrap(), Next::Waiting);
830 run.end_step("a", StepStatus::Done, Some(json!("A")), None);
831 let data = run.data(env_view("i", "r1", None, None), json!({}));
832 assert_eq!(
833 schedule(&w, &mut run, &data).unwrap(),
834 Next::Ready(vec!["c".to_string()])
835 );
836 run.begin_step("c");
838 run.end_step("c", StepStatus::Failed, None, Some("boom".into()));
839 let next = route_failure(&w, &mut run, w.step("c").unwrap(), "boom").unwrap();
840 assert_eq!(next, vec!["fix".to_string()]);
841 run.begin_step("fix");
842 run.end_step("fix", StepStatus::Done, None, None);
843 let data = run.data(env_view("i", "r1", None, None), json!({}));
845 assert_eq!(schedule(&w, &mut run, &data).unwrap(), Next::Stalled);
846 run.finish(RunStatus::Stalled, None, Some("stalled".into()));
847 assert!(run.status.is_terminal());
848 let data = run.data(env_view("i", "r1", None, None), json!({}));
849 assert_eq!(schedule(&w, &mut run, &data).unwrap(), Next::Terminal);
850 let mut run2 = RunState::new(
852 "r2",
853 &w,
854 Start {
855 node: "s".into(),
856 payload: json!({}),
857 ts: 0,
858 },
859 json!({"go": true}),
860 );
861 let mut c = w.step("c").unwrap().clone();
862 c.on_error = OnError::Continue;
863 run2.begin_step("c");
864 run2.end_step("c", StepStatus::Failed, None, Some("e".into()));
865 assert!(route_failure(&w, &mut run2, &c, "e").unwrap().is_empty());
866 assert_eq!(run2.steps["c"].status, StepStatus::Done);
867 assert_eq!(run2.steps["c"].error.as_deref(), Some("e"));
868 let mut a = w.step("a").unwrap().clone();
870 a.on_error = OnError::Fail;
871 assert!(route_failure(&w, &mut run2, &a, "e").is_err());
872 }
873
874 #[test]
875 fn vars_reducers_and_serialization() {
876 let w = wf();
877 let mut run = RunState::new("r", &w, Start::default(), json!({}));
878 run.write_var("l", json!([1]), "overwrite");
879 run.write_var("l", json!(2), "append");
880 run.write_var("l", json!([3, 4]), "append");
881 assert_eq!(run.vars["l"], json!([1, 2, 3, 4]));
882 run.write_var("l", json!([4, 5]), "union");
883 assert_eq!(run.vars["l"], json!([1, 2, 3, 4, 5]));
884 run.write_var("o", json!({"a": 1}), "overwrite");
885 run.write_var("o", json!({"b": 2}), "merge");
886 assert_eq!(run.vars["o"], json!({"a": 1, "b": 2}));
887 run.write_var("o", json!(7), "overwrite");
888 assert_eq!(run.vars["o"], json!(7));
889 let v = serde_json::to_value(&run).unwrap();
890 let back: RunState = serde_json::from_value(v).unwrap();
891 assert_eq!(back.vars, run.vars);
892 assert!(!back.dirty);
893 assert_eq!(back.summary()["workflow"], json!("w"));
894 let data = run.data(
896 env_view("inst", "r", Some("brief"), None),
897 json!({"k": "v"}),
898 );
899 let mut s = w.step("f").unwrap().clone();
900 s.spec
901 .insert("extra".into(), json!("{{env.instruction}}/{{memory.k}}"));
902 let rendered = render_spec(&s, &data).unwrap();
903 assert_eq!(rendered["output"], json!("none"));
904 assert_eq!(rendered["extra"], json!("brief/v"));
905 assert!(!deadline_passed(&run));
906 }
907}