1use std::any::Any;
24use std::cell::{Cell, RefCell};
25use std::collections::{BTreeMap, HashMap};
26use std::fmt::Write as _;
27use std::io;
28use std::path::Path;
29use std::rc::{Rc, Weak};
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::sync::mpsc::Receiver;
32use std::time::{Instant, SystemTime, UNIX_EPOCH};
33
34use sema_core::cycle::GcEdge;
35use sema_core::runtime::{IdCounter, ScopeId, TaskContextHandle, TaskLocalValue, Trace};
36use sema_core::Value;
37
38use crate::event::WorkflowEvent;
39use crate::journal::Journal;
40use crate::RUNS_ROOT;
41
42const FIXED_TS_ENV: &str = "SEMA_WORKFLOW_FIXED_TS";
46
47const RUN_ID_ENV: &str = "SEMA_WORKFLOW_RUN_ID";
50
51const RUN_DIR_ENV: &str = "SEMA_WORKFLOW_RUN_DIR";
54
55pub const MEMO_MAX_COUNT: u64 = 4096;
62pub const MEMO_FILE_MAX_BYTES: usize = 1 << 20; const DIGEST_MAX_BYTES: usize = 1 << 20; struct CappedWriter {
73 buf: String,
74 cap: usize,
75 truncated: bool,
76}
77
78impl std::fmt::Write for CappedWriter {
79 fn write_str(&mut self, s: &str) -> std::fmt::Result {
80 if self.truncated {
81 return Err(std::fmt::Error);
82 }
83 let remaining = self.cap.saturating_sub(self.buf.len());
84 if s.len() <= remaining {
85 self.buf.push_str(s);
86 Ok(())
87 } else {
88 let mut end = remaining;
89 while end > 0 && !s.is_char_boundary(end) {
90 end -= 1;
91 }
92 self.buf.push_str(&s[..end]);
93 self.truncated = true;
94 Err(std::fmt::Error)
95 }
96 }
97}
98
99pub fn compact_capped(v: &Value, cap: usize) -> (String, bool) {
105 let mut w = CappedWriter {
106 buf: String::new(),
107 cap,
108 truncated: false,
109 };
110 let _ = write!(w, "{v}");
111 (w.buf, w.truncated)
112}
113
114thread_local! {
115 static WORKFLOW: Rc<WorkflowTaskState> = Rc::new(WorkflowTaskState::default());
121 static HOST_CONFIG: RefCell<Option<WorkflowHostConfig>> = const { RefCell::new(None) };
124}
125
126#[derive(Debug, Clone)]
127pub struct WorkflowHostConfig {
128 pub runs_root: String,
129 pub explicit_run_id: Option<String>,
130 pub resuming: bool,
131 pub code_version: String,
132 pub approval_code_version: String,
133 pub args_json: String,
134 pub approval_public_key: String,
135 pub entry_file: String,
136 pub workspace_root: String,
137}
138
139pub struct WorkflowHostConfigGuard {
140 previous: Option<WorkflowHostConfig>,
141}
142
143impl Drop for WorkflowHostConfigGuard {
144 fn drop(&mut self) {
145 HOST_CONFIG.with(|slot| {
146 *slot.borrow_mut() = self.previous.take();
147 });
148 }
149}
150
151pub fn install_host_config(config: WorkflowHostConfig) -> WorkflowHostConfigGuard {
152 let previous = HOST_CONFIG.with(|slot| slot.borrow_mut().replace(config));
153 WorkflowHostConfigGuard { previous }
154}
155
156fn host_config() -> Option<WorkflowHostConfig> {
157 HOST_CONFIG.with(|slot| slot.borrow().clone())
158}
159
160pub fn host_workspace_root() -> Option<std::path::PathBuf> {
161 host_config().map(|config| config.workspace_root.into())
162}
163
164pub struct WorkflowCtx {
168 pub run_id: String,
170 workflow_name: RefCell<String>,
173 journal: Rc<RefCell<Journal>>,
176 state: Rc<RefCell<BTreeMap<String, Value>>>,
179 seq: Cell<u64>,
181 event_counts: RefCell<BTreeMap<&'static str, u64>>,
183 start: Instant,
185 cost_limit: Option<f64>,
188 token_limit: Option<u64>,
189 cost_spent: Cell<f64>,
194 tokens_spent: Cell<u64>,
195 over_budget: Cell<bool>,
201 approval_failure: RefCell<Option<String>>,
205 cur_phase: RefCell<Option<(u64, String)>>,
211 agent_n: RefCell<BTreeMap<String, u64>>,
215 resuming: Cell<bool>,
224 code_version: RefCell<String>,
226 approval_code_version: RefCell<String>,
229 approval_public_key: RefCell<String>,
232 resume_memos: RefCell<HashMap<String, Value>>,
233 key_seen: RefCell<HashMap<String, u32>>,
234 memo_count: Cell<u64>,
237 args_json: String,
239 args_fingerprint: String,
242 fixed_ts: Option<String>,
245 mcp_declared: RefCell<Vec<String>>,
251 mcp_handles: RefCell<BTreeMap<String, Value>>,
258}
259
260impl WorkflowCtx {
261 pub fn new(
266 run_id: String,
267 journal: Journal,
268 budget: BTreeMap<String, Value>,
269 ) -> Rc<WorkflowCtx> {
270 Self::new_with_args(run_id, journal, budget, String::new())
271 }
272
273 pub fn new_with_args(
275 run_id: String,
276 journal: Journal,
277 budget: BTreeMap<String, Value>,
278 args_json: String,
279 ) -> Rc<WorkflowCtx> {
280 let fixed_ts = std::env::var(FIXED_TS_ENV).ok();
281 let args_fingerprint = canonical_args_fingerprint(&args_json);
282 let cost_limit = budget
284 .get("usd")
285 .and_then(|v| v.as_float().or_else(|| v.as_int().map(|i| i as f64)));
286 let token_limit = budget
289 .get("tokens")
290 .and_then(|v| v.as_int().or_else(|| v.as_float().map(|f| f as i64)))
291 .map(|i| i as u64);
292 Rc::new(WorkflowCtx {
293 run_id,
294 workflow_name: RefCell::new(String::new()),
295 journal: Rc::new(RefCell::new(journal)),
296 state: Rc::new(RefCell::new(BTreeMap::new())),
297 seq: Cell::new(0),
298 event_counts: RefCell::new(BTreeMap::new()),
299 start: Instant::now(),
300 cost_limit,
301 token_limit,
302 cost_spent: Cell::new(0.0),
303 tokens_spent: Cell::new(0),
304 over_budget: Cell::new(false),
305 approval_failure: RefCell::new(None),
306 cur_phase: RefCell::new(None),
307 agent_n: RefCell::new(BTreeMap::new()),
308 resuming: Cell::new(false),
309 code_version: RefCell::new(String::new()),
310 approval_code_version: RefCell::new(String::new()),
311 approval_public_key: RefCell::new(String::new()),
312 resume_memos: RefCell::new(HashMap::new()),
313 key_seen: RefCell::new(HashMap::new()),
314 memo_count: Cell::new(0),
315 args_json,
316 args_fingerprint,
317 fixed_ts,
318 mcp_declared: RefCell::new(Vec::new()),
319 mcp_handles: RefCell::new(BTreeMap::new()),
320 })
321 }
322
323 pub fn args_json(&self) -> &str {
325 &self.args_json
326 }
327
328 pub fn set_workflow_name(&self, name: impl Into<String>) {
331 *self.workflow_name.borrow_mut() = name.into();
332 }
333
334 pub fn workflow_name(&self) -> String {
335 self.workflow_name.borrow().clone()
336 }
337
338 pub fn approval_code_version(&self) -> String {
340 self.approval_code_version.borrow().clone()
341 }
342
343 pub fn approval_public_key(&self) -> String {
344 self.approval_public_key.borrow().clone()
345 }
346
347 pub fn approval_args_digest(&self) -> String {
350 let normalized = if self.args_json.trim().is_empty() {
351 String::new()
352 } else {
353 serde_json::from_str::<serde_json::Value>(&self.args_json)
354 .ok()
355 .and_then(|json| serde_json::to_string(&json).ok())
356 .unwrap_or_else(|| self.args_json.clone())
357 };
358 crate::approval::sha256_bytes(normalized.as_bytes())
359 }
360
361 pub fn run_dir(&self) -> std::path::PathBuf {
363 self.journal.borrow().dir().to_path_buf()
364 }
365
366 pub fn open_phase(&self, start_seq: u64, label: String) {
370 *self.cur_phase.borrow_mut() = Some((start_seq, label));
371 }
372
373 pub fn take_open_phase(&self) -> Option<(u64, String)> {
377 self.cur_phase.borrow_mut().take()
378 }
379
380 pub fn phase_seq(&self) -> Option<u64> {
382 self.cur_phase.borrow().as_ref().map(|(seq, _)| *seq)
383 }
384
385 pub fn next_agent_id(&self, name: &str) -> String {
387 let mut m = self.agent_n.borrow_mut();
388 let n = m.entry(name.to_string()).or_insert(0);
389 *n += 1;
390 format!("{name}_{n}")
391 }
392
393 pub fn content_key(&self, key: &str, value_digest: &str) -> String {
395 let h = format!(
396 "{:x}",
397 md5::compute(format!("{key}:{value_digest}").as_bytes())
398 );
399 format!("ck_{}", &h[..8])
400 }
401
402 pub fn next_seq(&self) -> u64 {
404 let n = self.seq.get();
405 self.seq.set(n + 1);
406 n
407 }
408
409 pub fn ts(&self) -> String {
414 if let Some(ref fixed) = self.fixed_ts {
415 return fixed.clone();
416 }
417 rfc3339_now()
418 }
419
420 pub fn dur_ms(&self) -> u64 {
423 if self.fixed_ts.is_some() {
424 return 0;
425 }
426 self.start.elapsed().as_millis() as u64
427 }
428
429 pub fn emit(&self, event: WorkflowEvent) {
432 let kind = event.kind();
433 let mut counts = self.event_counts.borrow_mut();
434 *counts.entry(kind).or_insert(0) += 1;
435 drop(counts);
436 self.journal.borrow().write(&event);
437 }
438
439 pub fn has_event(&self, kind: &str) -> bool {
440 self.event_counts
441 .borrow()
442 .get(kind)
443 .is_some_and(|count| *count > 0)
444 }
445
446 pub fn deterministic(&self) -> bool {
450 self.fixed_ts.is_some()
451 }
452
453 pub fn run_id(&self) -> String {
455 self.run_id.clone()
456 }
457
458 pub fn store_checkpoint(&self, key: &str, val: Value) {
460 self.state.borrow_mut().insert(key.to_string(), val);
461 }
462
463 pub fn read_checkpoint(&self, key: &str) -> Option<Value> {
465 self.state.borrow().get(key).cloned()
466 }
467
468 pub fn value_digest(&self, v: &Value) -> String {
473 let (compact, truncated) = compact_capped(v, DIGEST_MAX_BYTES);
479 if truncated {
480 return format!("oversized_{:x}", md5::compute(compact.as_bytes()));
481 }
482 let json = sema_core::json::value_to_json_lossy(v);
483 let bytes = serde_json::to_vec(&json).unwrap_or_default();
484 format!("{:x}", md5::compute(bytes))
485 }
486
487 pub fn write_result(&self, envelope: &Value) {
490 let json = sema_core::json::value_to_json_lossy(envelope);
491 self.journal.borrow().write_result(&json);
492 }
493
494 pub fn has_budget(&self) -> bool {
496 self.cost_limit.is_some() || self.token_limit.is_some()
497 }
498
499 pub fn budget_limit_for_event(&self) -> Option<u64> {
502 self.token_limit
503 }
504
505 pub fn charge(&self, cost: Option<f64>, tokens: u64) -> bool {
510 if let Some(c) = cost {
511 self.cost_spent.set(self.cost_spent.get() + c);
512 }
513 self.tokens_spent.set(self.tokens_spent.get() + tokens);
514 let over = self
515 .cost_limit
516 .is_some_and(|lim| self.cost_spent.get() > lim)
517 || self
518 .token_limit
519 .is_some_and(|lim| self.tokens_spent.get() > lim);
520 if over {
521 self.over_budget.set(true);
522 }
523 over
524 }
525
526 pub fn over_budget(&self) -> bool {
528 self.over_budget.get()
529 }
530
531 pub fn fail_approval(&self, message: impl Into<String>) {
532 let mut failure = self.approval_failure.borrow_mut();
533 if failure.is_none() {
534 *failure = Some(message.into());
535 }
536 }
537
538 pub fn approval_failure(&self) -> Option<String> {
539 self.approval_failure.borrow().clone()
540 }
541
542 pub fn set_code_version(&self, v: String) {
548 *self.code_version.borrow_mut() = v;
549 }
550
551 pub fn set_approval_code_version(&self, v: String) {
552 *self.approval_code_version.borrow_mut() = v;
553 }
554
555 pub fn set_approval_public_key(&self, v: String) {
556 *self.approval_public_key.borrow_mut() = v;
557 }
558
559 pub fn enter_resume(&self, memos: HashMap<String, Value>) {
561 self.resuming.set(true);
562 *self.resume_memos.borrow_mut() = memos;
563 }
564
565 pub fn resuming(&self) -> bool {
567 self.resuming.get()
568 }
569
570 pub fn cur_phase_label(&self) -> String {
573 self.cur_phase
574 .borrow()
575 .as_ref()
576 .map(|(_, label)| label.clone())
577 .unwrap_or_default()
578 }
579
580 fn next_occurrence(&self, base: &str) -> u32 {
584 let mut m = self.key_seen.borrow_mut();
585 let n = m.entry(base.to_string()).or_insert(0);
586 let cur = *n;
587 *n += 1;
588 cur
589 }
590
591 pub fn agent_content_key(
595 &self,
596 prompt: &str,
597 schema_repr: &str,
598 name: &str,
599 phase: &str,
600 policy_fingerprint: &str,
601 ) -> String {
602 let cv = self.code_version.borrow().clone();
603 let base = hash_fields(&[
604 "agent",
605 &cv,
606 &self.args_fingerprint,
607 phase,
608 name,
609 prompt,
610 schema_repr,
611 policy_fingerprint,
612 ]);
613 format!("{base}_{}", self.next_occurrence(&base))
614 }
615
616 pub fn checkpoint_content_key(&self, key: &str, phase: &str) -> String {
619 let cv = self.code_version.borrow().clone();
620 let base = hash_fields(&["checkpoint", &cv, &self.args_fingerprint, phase, key]);
621 format!("{base}_{}", self.next_occurrence(&base))
622 }
623
624 pub fn approval_occurrence(&self, key: &str, subject_digest: &str, phase: &str) -> u32 {
628 let cv = self.approval_code_version.borrow().clone();
629 let base = crate::approval::sha256_fields(&[
630 "approval",
631 &cv,
632 &self.args_fingerprint,
633 phase,
634 key,
635 subject_digest,
636 ]);
637 self.next_occurrence(&base)
638 }
639
640 pub fn memo_lookup(&self, content_key: &str) -> Option<Value> {
642 self.resume_memos.borrow().get(content_key).cloned()
643 }
644
645 pub fn memo_store(&self, content_key: &str, v: &Value) {
652 if self.memo_count.get() >= MEMO_MAX_COUNT {
654 return;
655 }
656 let (_, truncated) = compact_capped(v, MEMO_FILE_MAX_BYTES);
659 if truncated {
660 return;
661 }
662 let json = sema_core::json::value_to_json_lossy(v);
663 if sema_core::json::json_to_value(&json) != *v {
665 return;
666 }
667 let serialized = serde_json::to_vec(&json).unwrap_or_default();
670 if serialized.len() > MEMO_FILE_MAX_BYTES {
671 return;
672 }
673 self.memo_count.set(self.memo_count.get() + 1);
674 self.journal.borrow().write_memo(content_key, &json);
675 self.resume_memos
676 .borrow_mut()
677 .insert(content_key.to_string(), v.clone());
678 }
679
680 pub fn request_flush(&self) -> Receiver<()> {
684 self.journal.borrow().request_flush()
685 }
686
687 pub fn flush(&self) {
690 self.journal.borrow().flush_blocking();
691 }
692
693 pub fn set_mcp_declared(&self, aliases: Vec<String>) {
699 *self.mcp_declared.borrow_mut() = aliases;
700 }
701
702 pub fn is_mcp_declared(&self, alias: &str) -> bool {
704 self.mcp_declared.borrow().iter().any(|a| a == alias)
705 }
706
707 pub fn set_mcp_handles(&self, handles: BTreeMap<String, Value>) {
710 *self.mcp_handles.borrow_mut() = handles;
711 }
712
713 pub fn mcp_handle(&self, alias: &str) -> Option<Value> {
716 self.mcp_handles.borrow().get(alias).cloned()
717 }
718}
719
720impl Trace for WorkflowCtx {
721 fn trace(&self, sink: &mut dyn FnMut(GcEdge<'_>)) -> bool {
726 let (Ok(state), Ok(memos), Ok(handles)) = (
727 self.state.try_borrow(),
728 self.resume_memos.try_borrow(),
729 self.mcp_handles.try_borrow(),
730 ) else {
731 return false;
732 };
733 for value in state.values() {
734 sink(GcEdge::Value(value));
735 }
736 for value in memos.values() {
737 sink(GcEdge::Value(value));
738 }
739 for value in handles.values() {
740 sink(GcEdge::Value(value));
741 }
742 true
743 }
744}
745
746struct WorkflowScope {
753 token: Option<ScopeId>,
754 ctx: Rc<WorkflowCtx>,
755}
756
757struct WorkflowTaskInner {
758 tokens: IdCounter<ScopeId>,
759 scopes: Vec<WorkflowScope>,
760 cur_agent: Option<String>,
764}
765
766pub struct WorkflowTaskState {
770 inner: RefCell<WorkflowTaskInner>,
771}
772
773impl Default for WorkflowTaskState {
774 fn default() -> Self {
775 Self {
776 inner: RefCell::new(WorkflowTaskInner {
777 tokens: IdCounter::new(),
778 scopes: Vec::new(),
779 cur_agent: None,
780 }),
781 }
782 }
783}
784
785impl WorkflowTaskState {
786 fn install(&self, ctx: Rc<WorkflowCtx>) -> ScopeId {
788 let mut inner = self.inner.borrow_mut();
789 let token = inner
790 .tokens
791 .allocate()
792 .expect("workflow scope identity space exhausted");
793 inner.scopes.push(WorkflowScope {
794 token: Some(token),
795 ctx,
796 });
797 token
798 }
799
800 fn remove(&self, token: ScopeId) -> bool {
803 let mut inner = self.inner.borrow_mut();
804 match inner.scopes.iter().position(|s| s.token == Some(token)) {
805 Some(pos) => {
806 inner.scopes.remove(pos);
807 true
808 }
809 None => false,
810 }
811 }
812
813 fn current_ctx(&self) -> Option<Rc<WorkflowCtx>> {
815 self.inner.borrow().scopes.last().map(|s| Rc::clone(&s.ctx))
816 }
817
818 fn scope_depth(&self) -> usize {
819 self.inner.borrow().scopes.len()
820 }
821
822 fn current_scope_is_owned(&self) -> bool {
823 self.inner
824 .borrow()
825 .scopes
826 .last()
827 .is_some_and(|scope| scope.token.is_some())
828 }
829
830 fn cur_agent(&self) -> Option<String> {
831 self.inner.borrow().cur_agent.clone()
832 }
833
834 fn set_cur_agent(&self, agent_id: Option<String>) {
835 self.inner.borrow_mut().cur_agent = agent_id;
836 }
837}
838
839impl Trace for WorkflowTaskState {
840 fn trace(&self, sink: &mut dyn FnMut(GcEdge<'_>)) -> bool {
841 let Ok(inner) = self.inner.try_borrow() else {
842 return false;
843 };
844 for scope in &inner.scopes {
845 if !scope.ctx.trace(sink) {
846 return false;
847 }
848 }
849 true
850 }
851}
852
853impl TaskLocalValue for WorkflowTaskState {
854 fn inherit(&self) -> Rc<dyn TaskLocalValue> {
859 let inner = self.inner.borrow();
860 let scopes = inner
861 .scopes
862 .iter()
863 .map(|s| WorkflowScope {
864 token: None,
865 ctx: Rc::clone(&s.ctx),
866 })
867 .collect();
868 Rc::new(Self {
869 inner: RefCell::new(WorkflowTaskInner {
870 tokens: IdCounter::new(),
871 scopes,
872 cur_agent: inner.cur_agent.clone(),
873 }),
874 })
875 }
876
877 fn as_any(&self) -> &dyn Any {
878 self
879 }
880
881 fn preflight_error(&self) -> Option<sema_core::SemaError> {
882 self.current_ctx()
883 .and_then(|ctx| ctx.approval_failure())
884 .map(|message| sema_core::SemaError::WorkflowApprovalFailed { message })
885 }
886}
887
888pub struct WorkflowGuard {
895 state: Weak<WorkflowTaskState>,
896 token: ScopeId,
897}
898
899impl Drop for WorkflowGuard {
900 fn drop(&mut self) {
901 if let Some(state) = self.state.upgrade() {
902 state.remove(self.token);
903 }
904 }
905}
906
907fn host_state() -> Rc<WorkflowTaskState> {
909 WORKFLOW.with(Rc::clone)
910}
911
912fn resolve_state(task_context: Option<&TaskContextHandle>) -> Rc<WorkflowTaskState> {
916 if let Some(handle) = task_context {
917 if let Some(state) = handle.get_rc::<WorkflowTaskState>() {
918 return state;
919 }
920 let state = Rc::new(WorkflowTaskState::default());
921 handle.borrow_mut().insert(Rc::clone(&state));
922 return state;
923 }
924 host_state()
925}
926
927pub fn install_scope(
931 task_context: Option<&TaskContextHandle>,
932 ctx: Rc<WorkflowCtx>,
933) -> WorkflowGuard {
934 let state = resolve_state(task_context);
935 let token = state.install(ctx);
936 WorkflowGuard {
937 state: Rc::downgrade(&state),
938 token,
939 }
940}
941
942pub fn current_for(task_context: Option<&TaskContextHandle>) -> Option<Rc<WorkflowCtx>> {
946 if let Some(handle) = task_context {
947 if let Some(state) = handle.get_rc::<WorkflowTaskState>() {
948 if let Some(ctx) = state.current_ctx() {
949 return Some(ctx);
950 }
951 }
952 }
953 if !sema_core::in_runtime_quantum() {
954 return host_state().current_ctx();
955 }
956 None
957}
958
959pub fn approval_scope_is_root_owner(task_context: Option<&TaskContextHandle>) -> bool {
963 let state = if let Some(handle) = task_context {
964 handle.get_rc::<WorkflowTaskState>()
965 } else if !sema_core::in_runtime_quantum() {
966 Some(host_state())
967 } else {
968 None
969 };
970 state.is_some_and(|state| state.scope_depth() == 1 && state.current_scope_is_owned())
971}
972
973pub fn scope_depth_for(task_context: Option<&TaskContextHandle>) -> usize {
974 if let Some(handle) = task_context {
975 return handle
976 .get_rc::<WorkflowTaskState>()
977 .map_or(0, |state| state.scope_depth());
978 }
979 if !sema_core::in_runtime_quantum() {
980 return host_state().scope_depth();
981 }
982 0
983}
984
985pub fn cur_agent_for(task_context: Option<&TaskContextHandle>) -> Option<String> {
988 if let Some(handle) = task_context {
989 if let Some(state) = handle.get_rc::<WorkflowTaskState>() {
990 return state.cur_agent();
991 }
992 }
993 if !sema_core::in_runtime_quantum() {
994 return host_state().cur_agent();
995 }
996 None
997}
998
999pub fn set_cur_agent_for(task_context: Option<&TaskContextHandle>, agent_id: Option<String>) {
1001 resolve_state(task_context).set_cur_agent(agent_id);
1002}
1003
1004fn redact_meta_secrets(mut meta_json: serde_json::Value) -> serde_json::Value {
1017 let Some(mcp) = meta_json.get_mut("mcp").and_then(|v| v.as_object_mut()) else {
1018 return meta_json;
1019 };
1020 for spec in mcp.values_mut() {
1021 let Some(spec_obj) = spec.as_object_mut() else {
1022 continue;
1023 };
1024 for field in ["headers", "env"] {
1025 let Some(values) = spec_obj.get_mut(field).and_then(|v| v.as_object_mut()) else {
1026 continue;
1027 };
1028 for value in values.values_mut() {
1029 *value = serde_json::Value::String("<redacted>".to_string());
1030 }
1031 }
1032 }
1033 meta_json
1034}
1035
1036pub fn set_workflow_scope(
1045 name: &str,
1046 doc: &str,
1047 meta: &Value,
1048 task_context: Option<&TaskContextHandle>,
1049) -> io::Result<WorkflowGuard> {
1050 let host = host_config();
1051 let outermost = scope_depth_for(task_context) == 0;
1052 let runs_root = host
1053 .as_ref()
1054 .map(|config| config.runs_root.clone())
1055 .unwrap_or_else(resolve_runs_root_from_env);
1056 let code_version = host
1057 .as_ref()
1058 .map(|config| config.code_version.clone())
1059 .unwrap_or_else(|| std::env::var(CODE_VERSION_ENV).unwrap_or_default());
1060 let approval_code_version = host
1061 .as_ref()
1062 .map(|config| config.approval_code_version.clone())
1063 .unwrap_or_else(|| {
1064 std::env::var(APPROVAL_CODE_VERSION_ENV).unwrap_or_else(|_| code_version.clone())
1065 });
1066 let approval_public_key = host
1067 .as_ref()
1068 .map(|config| config.approval_public_key.clone())
1069 .unwrap_or_default();
1070 let resuming = outermost
1071 && host.as_ref().map_or_else(
1072 || std::env::var(RESUME_ENV).map(|v| v == "1").unwrap_or(false),
1073 |config| config.resuming,
1074 );
1075
1076 let configured_id = if outermost {
1080 host.as_ref().map_or_else(
1081 || std::env::var(RUN_ID_ENV).ok(),
1082 |config| config.explicit_run_id.clone(),
1083 )
1084 } else {
1085 None
1086 };
1087 let explicit_id = match configured_id {
1088 Some(id) if !id.is_empty() => {
1089 validate_explicit_run_id(&id)?;
1090 Some(id)
1091 }
1092 _ => None,
1093 };
1094
1095 let (run_id, journal) = if resuming {
1100 let id = explicit_id.ok_or_else(|| {
1102 io::Error::new(
1103 io::ErrorKind::InvalidInput,
1104 "workflow resume requires an explicit run id (set SEMA_WORKFLOW_RUN_ID)",
1105 )
1106 })?;
1107 let events = Path::new(&runs_root).join(&id).join("events.jsonl");
1108 if !events.exists() {
1109 return Err(io::Error::new(
1110 io::ErrorKind::NotFound,
1111 format!(
1112 "cannot resume: no prior run journal at {}",
1113 events.display()
1114 ),
1115 ));
1116 }
1117 let journal = crate::journal::next_resume_segment(&runs_root, &id)?;
1118 (id, journal)
1119 } else if let Some(id) = explicit_id {
1120 let journal = Journal::open(&runs_root, &id).map_err(|e| annotate_fresh_open(e, &id))?;
1122 (id, journal)
1123 } else {
1124 open_fresh_generated(&runs_root)?
1127 };
1128 let metadata = serde_json::json!({
1131 "workflow": name,
1132 "doc": doc,
1133 "run_id": run_id,
1134 "code_version": code_version,
1135 "approval_code_version": approval_code_version,
1136 "approval_authority_public_key": approval_public_key,
1137 "entry_file": host.as_ref().map(|config| config.entry_file.as_str()).unwrap_or(""),
1138 "meta": redact_meta_secrets(sema_core::json::value_to_json_lossy(meta)),
1139 });
1140 journal.write_metadata(&metadata);
1141 let args_json = host
1144 .as_ref()
1145 .map(|config| config.args_json.clone())
1146 .unwrap_or_else(|| std::env::var("SEMA_WORKFLOW_ARGS_JSON").unwrap_or_default());
1147 let ctx = WorkflowCtx::new_with_args(run_id.clone(), journal, parse_budget(meta), args_json);
1148 ctx.set_workflow_name(name);
1149 ctx.set_code_version(code_version);
1150 ctx.set_approval_code_version(approval_code_version);
1151 ctx.set_approval_public_key(approval_public_key);
1152 if resuming {
1153 let memos: HashMap<String, Value> = crate::journal::load_memos(&runs_root, &run_id)
1154 .into_iter()
1155 .map(|(ck, json)| (ck, sema_core::json::json_to_value(&json)))
1156 .collect();
1157 ctx.enter_resume(memos);
1158 }
1159 Ok(install_scope(task_context, ctx))
1160}
1161
1162pub fn parse_budget(meta: &Value) -> BTreeMap<String, Value> {
1167 let mut out = BTreeMap::new();
1168 if let Some(m) = meta.as_map_rc() {
1169 if let Some(b) = m.get(&Value::keyword("budget")).and_then(|v| v.as_map_rc()) {
1170 for (k, v) in b.iter() {
1171 if let Some(name) = k.as_keyword() {
1172 out.insert(name, v.clone());
1173 }
1174 }
1175 }
1176 }
1177 out
1178}
1179
1180pub fn resolve_runs_root() -> String {
1183 host_config()
1184 .map(|config| config.runs_root)
1185 .unwrap_or_else(resolve_runs_root_from_env)
1186}
1187
1188fn resolve_runs_root_from_env() -> String {
1189 std::env::var(RUN_DIR_ENV).unwrap_or_else(|_| RUNS_ROOT.to_string())
1190}
1191
1192fn hash_fields(fields: &[&str]) -> String {
1196 let mut buf = Vec::new();
1197 for f in fields {
1198 buf.extend_from_slice(&(f.len() as u64).to_le_bytes());
1199 buf.extend_from_slice(f.as_bytes());
1200 }
1201 let h = format!("{:x}", md5::compute(&buf));
1202 h[..16].to_string()
1203}
1204
1205fn canonical_args_fingerprint(args_json: &str) -> String {
1206 let normalized = if args_json.trim().is_empty() {
1207 String::new()
1208 } else {
1209 serde_json::from_str::<serde_json::Value>(args_json)
1210 .ok()
1211 .and_then(|json| serde_json::to_string(&json).ok())
1212 .unwrap_or_else(|| args_json.to_string())
1213 };
1214 hash_fields(&["args", &normalized])
1215}
1216
1217const RESUME_ENV: &str = "SEMA_WORKFLOW_RESUME";
1219const CODE_VERSION_ENV: &str = "SEMA_WORKFLOW_CODE_VERSION";
1221const APPROVAL_CODE_VERSION_ENV: &str = "SEMA_WORKFLOW_APPROVAL_CODE_VERSION";
1223
1224static RUN_ID_NONCE: AtomicU64 = AtomicU64::new(0);
1227
1228const MAX_FRESH_ATTEMPTS: u32 = 8;
1233
1234fn generate_run_id() -> String {
1239 let now = SystemTime::now()
1240 .duration_since(UNIX_EPOCH)
1241 .unwrap_or_default();
1242 let nonce = RUN_ID_NONCE.fetch_add(1, Ordering::Relaxed);
1243 format!(
1244 "wf_{}_{}_{}_{}",
1245 now.as_secs(),
1246 now.subsec_nanos(),
1247 std::process::id(),
1248 nonce
1249 )
1250}
1251
1252pub fn validate_explicit_run_id(id: &str) -> io::Result<()> {
1258 let reject = |why: &str| {
1259 io::Error::new(
1260 io::ErrorKind::InvalidInput,
1261 format!("workflow run id {id:?} is not a safe directory name: {why}"),
1262 )
1263 };
1264 if id.is_empty() {
1265 return Err(reject("must not be empty"));
1266 }
1267 if id.contains('/') || id.contains('\\') {
1268 return Err(reject("must not contain a path separator"));
1269 }
1270 if id.contains("..") {
1271 return Err(reject("must not contain '..'"));
1272 }
1273 if id.bytes().all(|b| b == b'.') {
1274 return Err(reject("must not be only '.' characters"));
1275 }
1276 if id.chars().any(|c| c == '\0' || c.is_control()) {
1277 return Err(reject("must not contain NUL or control characters"));
1278 }
1279 Ok(())
1280}
1281
1282pub fn resolve_run_id() -> io::Result<String> {
1287 match std::env::var(RUN_ID_ENV) {
1288 Ok(id) if !id.is_empty() => {
1289 validate_explicit_run_id(&id)?;
1290 Ok(id)
1291 }
1292 _ => Ok(generate_run_id()),
1293 }
1294}
1295
1296fn open_fresh_generated(runs_root: &str) -> io::Result<(String, Journal)> {
1299 open_fresh_with(runs_root, generate_run_id)
1300}
1301
1302fn open_fresh_with(
1305 runs_root: &str,
1306 mut next_id: impl FnMut() -> String,
1307) -> io::Result<(String, Journal)> {
1308 let mut last_err = None;
1309 for _ in 0..MAX_FRESH_ATTEMPTS {
1310 let id = next_id();
1311 match Journal::open(runs_root, &id) {
1312 Ok(journal) => return Ok((id, journal)),
1313 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last_err = Some(e),
1314 Err(e) => return Err(e),
1315 }
1316 }
1317 Err(last_err.unwrap_or_else(|| {
1318 io::Error::new(
1319 io::ErrorKind::AlreadyExists,
1320 "could not allocate a unique workflow run directory",
1321 )
1322 }))
1323}
1324
1325fn annotate_fresh_open(err: io::Error, run_id: &str) -> io::Error {
1329 if err.kind() == io::ErrorKind::AlreadyExists {
1330 io::Error::new(
1331 io::ErrorKind::AlreadyExists,
1332 format!(
1333 "a workflow run journal for {run_id:?} already exists; \
1334 choose a fresh run id or resume it with --resume"
1335 ),
1336 )
1337 } else {
1338 err
1339 }
1340}
1341
1342pub(crate) fn rfc3339_now() -> String {
1346 let dur = SystemTime::now()
1347 .duration_since(UNIX_EPOCH)
1348 .unwrap_or_default();
1349 let secs = dur.as_secs();
1350 let days = (secs / 86_400) as i64;
1351 let rem = secs % 86_400;
1352 let (hour, min, sec) = (rem / 3600, (rem % 3600) / 60, rem % 60);
1353 let (y, m, d) = civil_from_days(days);
1354 format!("{y:04}-{m:02}-{d:02}T{hour:02}:{min:02}:{sec:02}Z")
1355}
1356
1357fn civil_from_days(z: i64) -> (i64, u32, u32) {
1360 let z = z + 719_468;
1361 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1362 let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
1365 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375
1376 #[test]
1377 fn seq_is_monotonic_from_zero() {
1378 let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new());
1379 assert_eq!(ctx.next_seq(), 0);
1380 assert_eq!(ctx.next_seq(), 1);
1381 assert_eq!(ctx.next_seq(), 2);
1382 }
1383
1384 #[test]
1385 fn fixed_ts_freezes_ts_and_dur() {
1386 std::env::set_var(FIXED_TS_ENV, "1970-01-01T00:00:00Z");
1387 let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new());
1388 assert_eq!(ctx.ts(), "1970-01-01T00:00:00Z");
1389 assert_eq!(ctx.dur_ms(), 0);
1390 std::env::remove_var(FIXED_TS_ENV);
1391 }
1392
1393 fn ctx_with_budget(pairs: &[(&str, Value)]) -> Rc<WorkflowCtx> {
1394 let mut b = BTreeMap::new();
1395 for (k, v) in pairs {
1396 b.insert(k.to_string(), v.clone());
1397 }
1398 WorkflowCtx::new_with_args("wf_t".into(), Journal::null(), b, String::new())
1399 }
1400
1401 #[test]
1402 fn charge_trips_usd_cap_and_latches() {
1403 let ctx = ctx_with_budget(&[("usd", Value::float(0.01))]);
1404 assert!(!ctx.charge(Some(0.005), 10), "under cap must not trip");
1405 assert!(!ctx.over_budget());
1406 assert!(ctx.charge(Some(0.02), 100), "crossing cap trips");
1407 assert!(ctx.over_budget(), "latch is sticky");
1408 let _ = ctx.charge(Some(0.0), 0);
1410 assert!(ctx.over_budget());
1411 }
1412
1413 #[test]
1414 fn charge_enforces_tokens_when_cost_unknown() {
1415 let ctx = ctx_with_budget(&[("tokens", Value::int(50))]);
1416 assert!(!ctx.charge(None, 40), "cost None still counts tokens");
1417 assert!(!ctx.over_budget());
1418 assert!(ctx.charge(None, 20), "60 > 50 trips on tokens alone");
1419 assert!(ctx.over_budget());
1420 assert_eq!(ctx.budget_limit_for_event(), Some(50));
1421 }
1422
1423 #[test]
1424 fn no_budget_never_trips() {
1425 let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new());
1426 assert!(!ctx.has_budget());
1427 assert!(!ctx.charge(Some(9999.0), 9_999_999));
1428 assert!(!ctx.over_budget());
1429 assert_eq!(ctx.budget_limit_for_event(), None);
1430 }
1431
1432 #[test]
1433 fn parse_budget_extracts_caps_and_tolerates_absence() {
1434 let mut bm = BTreeMap::new();
1435 bm.insert(Value::keyword("usd"), Value::float(2.5));
1436 bm.insert(Value::keyword("tokens"), Value::int(1000));
1437 let mut meta = BTreeMap::new();
1438 meta.insert(Value::keyword("budget"), Value::map(bm));
1439 let parsed = parse_budget(&Value::map(meta));
1440 assert_eq!(parsed.get("usd").and_then(|v| v.as_float()), Some(2.5));
1441 assert_eq!(parsed.get("tokens").and_then(|v| v.as_int()), Some(1000));
1442 assert!(parse_budget(&Value::map(BTreeMap::new())).is_empty());
1444 }
1445
1446 #[test]
1447 fn content_keys_are_stable_distinct_and_length_prefixed() {
1448 let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new());
1449 ctx.set_code_version("v1".into());
1450 let k_a = ctx.agent_content_key("audit a.php", "[:list :string]", "auditor", "Audit", "");
1452 let k_b = ctx.agent_content_key("audit b.php", "[:list :string]", "auditor", "Audit", "");
1453 assert_ne!(k_a, k_b, "different prompts ⇒ different keys");
1454 let k1 = ctx.agent_content_key("a", "bc", "n", "p", "");
1456 let k2 = ctx.agent_content_key("ab", "c", "n", "p", "");
1457 assert_ne!(
1458 k1, k2,
1459 "length-prefixed fields can't collide via concatenation"
1460 );
1461 let r1 = ctx.checkpoint_content_key("files", "Inventory");
1463 let r2 = ctx.checkpoint_content_key("files", "Inventory");
1464 assert_ne!(
1465 r1, r2,
1466 "repeated identical checkpoint ⇒ distinct occurrence key"
1467 );
1468 }
1469
1470 #[test]
1471 fn code_version_changes_invalidate_keys() {
1472 let ctx1 = WorkflowCtx::new("a".into(), Journal::null(), BTreeMap::new());
1473 ctx1.set_code_version("v1".into());
1474 let ctx2 = WorkflowCtx::new("b".into(), Journal::null(), BTreeMap::new());
1475 ctx2.set_code_version("v2".into());
1476 assert_ne!(
1477 ctx1.agent_content_key("p", "s", "n", "ph", ""),
1478 ctx2.agent_content_key("p", "s", "n", "ph", ""),
1479 "a changed code-version produces different content-keys (auto-invalidation)"
1480 );
1481 }
1482
1483 #[test]
1484 fn args_changes_invalidate_keys() {
1485 let ctx1 = WorkflowCtx::new_with_args(
1486 "a".into(),
1487 Journal::null(),
1488 BTreeMap::new(),
1489 r#"{"batch":1}"#.into(),
1490 );
1491 ctx1.set_code_version("v1".into());
1492 let ctx2 = WorkflowCtx::new_with_args(
1493 "b".into(),
1494 Journal::null(),
1495 BTreeMap::new(),
1496 r#"{"batch":2}"#.into(),
1497 );
1498 ctx2.set_code_version("v1".into());
1499 assert_ne!(
1500 ctx1.checkpoint_content_key("files", "ph"),
1501 ctx2.checkpoint_content_key("files", "ph"),
1502 "changed workflow args produce different content-keys"
1503 );
1504 }
1505
1506 #[test]
1507 fn memo_store_round_trip_guard_skips_unsurvivable_values() {
1508 let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new());
1509 ctx.memo_store("ck_text", &Value::string("hello"));
1511 assert_eq!(ctx.memo_lookup("ck_text"), Some(Value::string("hello")));
1512 let mut m = BTreeMap::new();
1514 m.insert(Value::keyword("body"), Value::string("x"));
1515 let kw_map = Value::map(m);
1516 ctx.memo_store("ck_map", &kw_map);
1517 assert_eq!(ctx.memo_lookup("ck_map"), Some(kw_map));
1518 let mut bad = BTreeMap::new();
1522 bad.insert(Value::int(1), Value::int(2));
1523 ctx.memo_store("ck_bad", &Value::map(bad));
1524 assert_eq!(
1525 ctx.memo_lookup("ck_bad"),
1526 None,
1527 "a non-round-trippable value must be left un-memoized"
1528 );
1529 }
1530
1531 #[test]
1532 fn checkpoint_round_trips() {
1533 let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new());
1534 assert_eq!(ctx.read_checkpoint("files"), None);
1535 ctx.store_checkpoint("files", Value::int(3));
1536 assert_eq!(ctx.read_checkpoint("files"), Some(Value::int(3)));
1537 }
1538
1539 #[test]
1542 fn mcp_handle_registry_starts_empty_and_undeclared() {
1543 let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new());
1544 assert_eq!(ctx.mcp_handle("asana"), None);
1545 assert!(!ctx.is_mcp_declared("asana"));
1546 }
1547
1548 #[test]
1549 fn mcp_declared_tracks_aliases_before_handles_resolve() {
1550 let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new());
1551 ctx.set_mcp_declared(vec!["asana".to_string(), "fs".to_string()]);
1552 assert!(ctx.is_mcp_declared("asana"));
1554 assert_eq!(ctx.mcp_handle("asana"), None);
1555 assert!(!ctx.is_mcp_declared("zebra"));
1556 }
1557
1558 #[test]
1559 fn mcp_handle_returns_resolved_handle_by_alias() {
1560 let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new());
1561 ctx.set_mcp_declared(vec!["asana".to_string(), "fs".to_string()]);
1562 let mut handles = BTreeMap::new();
1563 handles.insert("asana".to_string(), Value::string("mcp-1"));
1564 handles.insert("fs".to_string(), Value::string("mcp-2"));
1565 ctx.set_mcp_handles(handles);
1566 assert_eq!(ctx.mcp_handle("asana"), Some(Value::string("mcp-1")));
1567 assert_eq!(ctx.mcp_handle("fs"), Some(Value::string("mcp-2")));
1568 assert_eq!(ctx.mcp_handle("nope"), None);
1569 }
1570
1571 #[test]
1572 fn workflow_ctx_traces_state_memo_and_mcp_values() {
1573 let ctx = WorkflowCtx::new("t".into(), Journal::null(), BTreeMap::new());
1576 ctx.store_checkpoint("k", Value::int(1));
1577 let mut memos = HashMap::new();
1578 memos.insert("ck".to_string(), Value::int(2));
1579 ctx.enter_resume(memos);
1580 let mut handles = BTreeMap::new();
1581 handles.insert("asana".to_string(), Value::string("handle"));
1582 ctx.set_mcp_handles(handles);
1583
1584 let mut edges = 0;
1585 assert!(ctx.trace(&mut |edge| {
1586 assert!(matches!(edge, GcEdge::Value(_)));
1587 edges += 1;
1588 }));
1589 assert_eq!(
1590 edges, 3,
1591 "state bag + resume memo + MCP handle each trace once"
1592 );
1593 }
1594
1595 #[test]
1596 fn host_scope_restores_previous_on_drop() {
1597 assert!(current_for(None).is_none());
1600 let outer = WorkflowCtx::new("outer".into(), Journal::null(), BTreeMap::new());
1601 let g_outer = install_scope(None, outer);
1602 assert_eq!(
1603 current_for(None).map(|c| c.run_id.clone()).as_deref(),
1604 Some("outer")
1605 );
1606 {
1607 let inner = WorkflowCtx::new("inner".into(), Journal::null(), BTreeMap::new());
1608 let _g_inner = install_scope(None, inner);
1609 assert_eq!(
1610 current_for(None).map(|c| c.run_id.clone()).as_deref(),
1611 Some("inner")
1612 );
1613 }
1614 assert_eq!(
1616 current_for(None).map(|c| c.run_id.clone()).as_deref(),
1617 Some("outer")
1618 );
1619 drop(g_outer);
1620 assert!(current_for(None).is_none());
1621 }
1622
1623 #[test]
1624 fn task_state_removes_the_exact_token_out_of_lifo() {
1625 let state = WorkflowTaskState::default();
1628 let outer = WorkflowCtx::new("outer".into(), Journal::null(), BTreeMap::new());
1629 let inner = WorkflowCtx::new("inner".into(), Journal::null(), BTreeMap::new());
1630 let outer_token = state.install(outer);
1631 let inner_token = state.install(inner);
1632 assert_eq!(
1633 state.current_ctx().map(|c| c.run_id.clone()).as_deref(),
1634 Some("inner")
1635 );
1636
1637 assert!(state.remove(outer_token));
1638 assert!(
1639 !state.remove(outer_token),
1640 "removing the same token twice is idempotent"
1641 );
1642 assert_eq!(
1643 state.current_ctx().map(|c| c.run_id.clone()).as_deref(),
1644 Some("inner"),
1645 "removing the outer token leaves the inner scope live and on top"
1646 );
1647 assert!(state.remove(inner_token));
1648 assert!(state.current_ctx().is_none());
1649 }
1650
1651 #[test]
1652 fn child_inherits_run_and_agent_but_not_removal_authority() {
1653 let state = Rc::new(WorkflowTaskState::default());
1656 let run = WorkflowCtx::new("shared-run".into(), Journal::null(), BTreeMap::new());
1657 let parent_token = state.install(run);
1658 state.set_cur_agent(Some("scout_1".to_string()));
1659
1660 let child = state.inherit();
1661 let child = child
1662 .as_any()
1663 .downcast_ref::<WorkflowTaskState>()
1664 .expect("inherited workflow state");
1665 assert_eq!(
1666 child.current_ctx().map(|c| c.run_id.clone()).as_deref(),
1667 Some("shared-run"),
1668 "child observes the spawner's active run"
1669 );
1670 assert_eq!(child.cur_agent().as_deref(), Some("scout_1"));
1671 assert!(!child.remove(parent_token));
1673 assert_eq!(
1674 child.current_ctx().map(|c| c.run_id.clone()).as_deref(),
1675 Some("shared-run")
1676 );
1677 assert!(state.remove(parent_token));
1679 assert!(state.current_ctx().is_none());
1680 }
1681
1682 #[test]
1685 fn generated_run_id_has_secs_nanos_pid_and_nonce() {
1686 let a = generate_run_id();
1687 let b = generate_run_id();
1688 assert_ne!(a, b, "the process nonce makes back-to-back ids distinct");
1689 for id in [&a, &b] {
1690 assert!(id.starts_with("wf_"), "id keeps the wf_ prefix: {id}");
1691 let parts: Vec<&str> = id.split('_').collect();
1692 assert_eq!(parts.len(), 5, "wf_<secs>_<nanos>_<pid>_<nonce>: {id}");
1693 for field in &parts[1..] {
1694 assert!(
1695 !field.is_empty() && field.bytes().all(|c| c.is_ascii_digit()),
1696 "each generated id field is a non-empty number: {id}"
1697 );
1698 }
1699 }
1700 }
1701
1702 #[test]
1703 fn validate_explicit_run_id_accepts_safe_names_and_rejects_unsafe() {
1704 for ok in ["wf_test_0001", "run-42", "abc.def", "a"] {
1705 assert!(validate_explicit_run_id(ok).is_ok(), "should accept {ok:?}");
1706 }
1707 for bad in [
1708 "", "a/b", "a\\b", "..", "a..b", ".", "...", "a\0b", "a\nb", ] {
1718 let err = validate_explicit_run_id(bad).expect_err(&format!("should reject {bad:?}"));
1719 assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "for {bad:?}");
1720 }
1721 }
1722
1723 #[test]
1724 fn open_fresh_with_retries_past_a_colliding_id() {
1725 let mut root = std::env::temp_dir();
1726 root.push(format!(
1727 "sema-wf-fresh-retry-{}-{}",
1728 std::process::id(),
1729 SystemTime::now()
1730 .duration_since(UNIX_EPOCH)
1731 .unwrap()
1732 .as_nanos()
1733 ));
1734 let root_str = root.to_string_lossy().to_string();
1735 for taken in ["taken_1", "taken_2"] {
1738 std::fs::create_dir_all(root.join(taken)).unwrap();
1739 std::fs::write(root.join(taken).join("events.jsonl"), "{}\n").unwrap();
1740 }
1741 let mut candidates = ["taken_1", "taken_2", "free_3"].into_iter();
1742 let (id, _journal) =
1743 open_fresh_with(&root_str, || candidates.next().unwrap().to_string()).unwrap();
1744 assert_eq!(id, "free_3", "opener retried past the colliding ids");
1745 std::fs::remove_dir_all(&root).ok();
1746 }
1747
1748 #[test]
1749 fn civil_date_epoch() {
1750 assert_eq!(civil_from_days(0), (1970, 1, 1));
1751 assert_eq!(civil_from_days(20_628), (2026, 6, 24));
1753 }
1754
1755 #[test]
1758 fn redacts_mcp_headers_and_env_values() {
1759 let meta = serde_json::json!({
1760 "budget": {"usd": 1.0},
1761 "mcp": {
1762 "asana": {
1763 "url": "https://mcp.asana.com/mcp",
1764 "headers": {"Authorization": "Bearer secret-token"},
1765 "persist": "workflow"
1766 },
1767 "fs": {
1768 "command": "npx",
1769 "env": {"API_TOKEN": "supersecret", "PLAIN": "not-a-secret-name"}
1770 }
1771 }
1772 });
1773 let redacted = redact_meta_secrets(meta);
1774 assert_eq!(
1775 redacted["mcp"]["asana"]["headers"]["Authorization"],
1776 "<redacted>"
1777 );
1778 assert_eq!(redacted["mcp"]["fs"]["env"]["API_TOKEN"], "<redacted>");
1779 assert_eq!(redacted["mcp"]["fs"]["env"]["PLAIN"], "<redacted>");
1780 }
1781
1782 #[test]
1783 fn redaction_keeps_header_and_env_keys_and_sibling_fields() {
1784 let meta = serde_json::json!({
1785 "mcp": {
1786 "asana": {
1787 "url": "https://mcp.asana.com/mcp",
1788 "headers": {"Authorization": "Bearer secret-token", "X-Trace": "abc"},
1789 "tools": ["create_task"],
1790 "persist": "workflow"
1791 }
1792 }
1793 });
1794 let redacted = redact_meta_secrets(meta);
1795 assert!(redacted["mcp"]["asana"]["headers"]
1797 .as_object()
1798 .unwrap()
1799 .contains_key("Authorization"));
1800 assert!(redacted["mcp"]["asana"]["headers"]
1801 .as_object()
1802 .unwrap()
1803 .contains_key("X-Trace"));
1804 assert_eq!(redacted["mcp"]["asana"]["url"], "https://mcp.asana.com/mcp");
1806 assert_eq!(redacted["mcp"]["asana"]["tools"][0], "create_task");
1807 assert_eq!(redacted["mcp"]["asana"]["persist"], "workflow");
1808 }
1809
1810 #[test]
1811 fn meta_without_mcp_passes_through_unchanged() {
1812 let meta = serde_json::json!({
1813 "budget": {"usd": 1.0},
1814 "args": {"repo": "sema-lisp/sema"},
1815 "phases": ["Triage"],
1816 });
1817 let redacted = redact_meta_secrets(meta.clone());
1818 assert_eq!(redacted, meta);
1819 }
1820
1821 #[test]
1822 fn mcp_alias_without_headers_or_env_passes_through_unchanged() {
1823 let meta = serde_json::json!({
1824 "mcp": {"asana": {"url": "https://mcp.asana.com/mcp", "persist": "workflow"}}
1825 });
1826 let redacted = redact_meta_secrets(meta.clone());
1827 assert_eq!(redacted, meta);
1828 }
1829}