Skip to main content

sema_workflow/
context.rs

1//! Run-scoped dynamic context for a workflow run.
2//!
3//! A workflow run installs a [`WorkflowCtx`] as a scope on the OWNING TASK via
4//! [`set_workflow_scope`]; every builtin (`workflow/phase`, `checkpoint`, …) reaches the
5//! live context through [`current_for`], reading the task-local [`WorkflowTaskState`]
6//! extension. The scope is a token-keyed stack entry restored on drop via a panic-safe
7//! RAII guard (mirrors `DynamicTaskState`'s `ScopeId` removal), so a nested run — or a
8//! panic unwinding through a phase thunk — cannot leave a stale context installed, and a
9//! sibling task interleaved on the same thread never observes another task's run.
10//!
11//! `WorkflowCtx` holds live checkpoint/memo/MCP `Value`s, so the extension is TRACED
12//! (Invariant I2): the `TaskContextHandle` traces each extension, `WorkflowTaskState`
13//! traces its scope stack, and each `WorkflowCtx` traces its `Value`-bearing bags.
14//!
15//! The `WORKFLOW` thread-local survives ONLY as the HOST-ADAPTER fallback for callers
16//! outside a runtime quantum (a synchronous host `call_function`, the non-runtime
17//! restricted VM); it is read only when [`sema_core::in_runtime_quantum`] is false.
18//!
19//! The context owns the run's monotonic `seq` counter, the wall-clock seam (`ts` /
20//! `dur_ms`, both frozen under `SEMA_WORKFLOW_FIXED_TS` for byte-identical goldens),
21//! the append-only [`Journal`], and a Mastra-style checkpoint/state bag.
22
23use 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
42/// Env var that pins the timestamp string AND forces every `dur_ms` to 0, so the
43/// golden `events.jsonl` is byte-identical across runs. When set, its value is used
44/// verbatim as the `ts` field of every event (e.g. `SEMA_WORKFLOW_FIXED_TS=0`).
45const FIXED_TS_ENV: &str = "SEMA_WORKFLOW_FIXED_TS";
46
47/// Env var that pins the run id (otherwise a process-derived id is generated). Used
48/// both to name the run directory and to seed the journal path.
49const RUN_ID_ENV: &str = "SEMA_WORKFLOW_RUN_ID";
50
51/// Env var that overrides the run-directory base (the CLI sets it from `--run-dir`).
52/// Default is [`RUNS_ROOT`] (`./.sema/runs`).
53const RUN_DIR_ENV: &str = "SEMA_WORKFLOW_RUN_DIR";
54
55/// A3 hard caps captured before any VM-thread encode. They bound the CPU/memory a single
56/// leaf can spend materializing state on the quantum; the on-disk writes themselves are
57/// off the VM thread (see `writer.rs`).
58///
59/// Max memos stored per run. Past it a leaf is simply not memoized (it re-runs on
60/// resume) — the same fallback the round-trip guard already uses.
61pub const MEMO_MAX_COUNT: u64 = 4096;
62/// Max serialized bytes per memo. An over-cap value is NOT stored (never JSON-encoded in
63/// full on the VM thread — the compact form is bounded-checked first).
64pub const MEMO_FILE_MAX_BYTES: usize = 1 << 20; // 1 MiB
65/// Cap for the `value_digest` bounded encode. A value larger than this gets a stable
66/// marker digest instead of a full JSON materialization on the VM thread.
67const DIGEST_MAX_BYTES: usize = 1 << 20; // 1 MiB
68
69/// A `fmt::Write` sink that accepts at most `cap` bytes of a value's compact `Display`
70/// form and then aborts (returns `fmt::Error`), so a huge value is never fully
71/// materialized on the VM thread. Char-boundary safe.
72struct 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
99/// Render `v`'s compact `Display` form into at most `cap` bytes. Returns `(text,
100/// truncated)`: `truncated` ⇒ `v` exceeds `cap` and was NOT fully materialized (rendering
101/// aborted at the cap). For a `v` within `cap`, `text` is its exact compact form. Shared
102/// by the rendered-value / digest / memo caps so none of them can be tricked into
103/// materializing an unbounded value on the quantum.
104pub 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    /// HOST-ADAPTER-ONLY fallback scope store for callers outside a runtime quantum.
116    /// A per-thread [`WorkflowTaskState`] (same shape as the task-local extension) that
117    /// only synchronous host paths install into / read from; the runtime path lives on
118    /// the owning task's [`TaskContextHandle`] instead. Read only when
119    /// `!sema_core::in_runtime_quantum()`.
120    static WORKFLOW: Rc<WorkflowTaskState> = Rc::new(WorkflowTaskState::default());
121    /// Immutable host-owned run configuration. The CLI installs this around evaluation;
122    /// Sema code can mutate process environment variables but cannot reach this slot.
123    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
164/// Run-scoped dynamic context. Cheap to clone-share via `Rc`; all interior state is
165/// `RefCell`/`Cell`, never `&mut self`, so the same `Rc<WorkflowCtx>` handed out by
166/// [`current`] can be used while the run is still executing.
167pub struct WorkflowCtx {
168    /// Stable identifier for this run; names the run dir (`./.sema/runs/<run_id>/`).
169    pub run_id: String,
170    /// Declared `defworkflow` name. Stored separately from the run id so approval
171    /// requests can bind decisions to the workflow definition that produced them.
172    workflow_name: RefCell<String>,
173    /// Append-only JSONL journal sink. `RefCell` because `emit` needs `&mut` access
174    /// to the underlying writer while the ctx itself is shared `Rc`.
175    journal: Rc<RefCell<Journal>>,
176    /// Mastra-style run state / checkpoint bag, keyed by the checkpoint name. Doubles
177    /// as the `(checkpoint :files)` read-back store for later phases in the same run.
178    state: Rc<RefCell<BTreeMap<String, Value>>>,
179    /// Monotonic event sequence counter (0-based; first `next_seq()` returns 0).
180    seq: Cell<u64>,
181    /// Bounded completion ledger keyed only by the frozen event vocabulary.
182    event_counts: RefCell<BTreeMap<&'static str, u64>>,
183    /// Wall-clock origin for `dur_ms`. Ignored when the fixed-ts seam is active.
184    start: Instant,
185    /// Parsed spend caps (absent ⇒ that dimension is unenforced). `usd` is best-effort
186    /// (depends on the pricing table); `tokens` is deterministic from usage.
187    cost_limit: Option<f64>,
188    token_limit: Option<u64>,
189    /// Running totals charged from each agent leaf's usage. Single-thread `Cell` is
190    /// sound (the VM + scheduler are cooperative single-thread); under a concurrent
191    /// fan-out the per-leaf attribution is BEST-EFFORT (the `LAST_USAGE` thread-local
192    /// the snapshot reads is not swapped per task), but the cap still trips reliably.
193    cost_spent: Cell<f64>,
194    tokens_spent: Cell<u64>,
195    /// Sticky "a cap was exceeded" latch. Set by [`Self::charge`] once a total passes
196    /// its cap; checked at agent ENTRY (to refuse launching further leaves) and by
197    /// `workflow/run` after the body (to force a `:failed` envelope). A latch — not
198    /// `Err` propagation — because the `__fanout-tagged` engine swallows a leaf `Err`
199    /// into `nil`, so an exception can't stop a concurrent batch.
200    over_budget: Cell<bool>,
201    /// Sticky fail-closed latch for an approval attempted from an invalid child/nested
202    /// position. Shared with inherited tasks so the owning run cannot report success
203    /// after a detached child tried to create a gate.
204    approval_failure: RefCell<Option<String>>,
205    /// `(start_seq, label)` of the currently-open marker-style phase — `start_seq` is
206    /// the phase.started event's seq, so checkpoints/agents/budget events can be
207    /// attributed to their phase; `label` is needed to emit the matching `phase.ended`
208    /// when the next marker (or the run end) closes the phase. `None` when no phase
209    /// is open.
210    cur_phase: RefCell<Option<(u64, String)>>,
211    /// Per-name agent invocation counter, for minting unique `agent_id`s. Run-shared
212    /// (via the `Rc<WorkflowCtx>`), so ids stay unique even across concurrent tasks; the
213    /// per-task ACTIVE-agent attribution slot moved to [`WorkflowTaskState::set_cur_agent`].
214    agent_n: RefCell<BTreeMap<String, u64>>,
215    /// Resume state. `resuming` ⇒ this run was launched with `--resume`, so leaves whose
216    /// content-key is in `resume_memos` short-circuit (return the recorded value, skip
217    /// the model + events). `resume_memos` is loaded from the prior run's `memo/` dir at
218    /// scope open. `code_version` and the args fingerprint are folded into every
219    /// content-key, so a changed workflow or changed args produce different keys ⇒ no
220    /// memo hits ⇒ full re-run (automatic invalidation, no guard file). `key_seen`
221    /// mints a per-base occurrence ordinal so identical-prompt repeats in source order
222    /// line up across runs.
223    resuming: Cell<bool>,
224    /// Existing short resume fingerprint. Kept stable for memo compatibility.
225    code_version: RefCell<String>,
226    /// Collision-resistant source fingerprint used to bind human decisions. The CLI
227    /// supplies SHA-256; library callers fall back to `code_version`.
228    approval_code_version: RefCell<String>,
229    /// Ed25519 public key selected by the host before evaluation. Decisions must verify
230    /// against this authority; the matching private key is never exposed to Sema code.
231    approval_public_key: RefCell<String>,
232    resume_memos: RefCell<HashMap<String, Value>>,
233    key_seen: RefCell<HashMap<String, u32>>,
234    /// Number of memos stored this run, capped at [`MEMO_MAX_COUNT`] so an unbounded fan-out
235    /// can't spill an unbounded number of memo sidecars.
236    memo_count: Cell<u64>,
237    /// The run's `--args` JSON string (for the run.started event). Empty if none.
238    args_json: String,
239    /// Canonical fingerprint of `--args`, folded into resume content-keys. Kept
240    /// separate so `args_json` can remain the operator's original journal text.
241    args_fingerprint: String,
242    /// Cached fixed-ts override (read once at construction). `Some` ⇒ deterministic
243    /// seam: `ts()` returns this string and `dur_ms()` returns 0.
244    fixed_ts: Option<String>,
245    /// Aliases declared in this run's `:mcp` meta (set once, right after the meta
246    /// map's `:mcp` key parses successfully — BEFORE auth-resolution runs), so
247    /// `workflow/mcp-handle` can tell "not declared" apart from "declared but this
248    /// run hasn't resolved its MCP servers yet" (docs/plans/2026-06-24-workflow-mcp-auth.md
249    /// §3). Empty for a workflow with no `:mcp`.
250    mcp_declared: RefCell<Vec<String>>,
251    /// Opaque, resolved MCP connection handles, keyed by declared alias. Populated
252    /// ONCE by `workflow/run`'s auth-resolution step, after every declared server
253    /// resolves to `Connected` (never partially — a `NeedsAuth`/`Failed` outcome
254    /// ends the run before the body runs at all). Values are `Value`s the resolver
255    /// handed back; this crate stays MCP-ignorant and never interprets them —
256    /// see `crates/sema-stdlib/src/workflow_mcp.rs`'s resolver seam.
257    mcp_handles: RefCell<BTreeMap<String, Value>>,
258}
259
260impl WorkflowCtx {
261    /// Build a fresh context for a run.
262    ///
263    /// `run_id` selection (the caller resolves this, but the helper [`resolve_run_id`]
264    /// implements the policy): `SEMA_WORKFLOW_RUN_ID` if set, else a generated id.
265    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    /// As [`Self::new`], plus the run's `--args` JSON string for `run.started`.
274    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        // Parse spend caps from the budget submap (tolerate an int usd, e.g. `:usd 2`).
283        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        // Tolerate an int OR a float token cap (`:tokens 5` or `:tokens 5.0`), so a
287        // float never silently drops the cap.
288        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    /// The run's `--args` JSON string (empty if none).
324    pub fn args_json(&self) -> &str {
325        &self.args_json
326    }
327
328    /// Bind this context to its declared workflow name. Called once while opening the
329    /// scope, before the body can evaluate an approval gate.
330    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    /// Collision-resistant workflow revision used by durable approval requests.
339    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    /// Full SHA-256 of canonicalized workflow arguments for approval bindings. Resume
348    /// keeps its historical short content-key fingerprint; approvals use the full digest.
349    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    /// Run directory containing the approval authority sidecars.
362    pub fn run_dir(&self) -> std::path::PathBuf {
363        self.journal.borrow().dir().to_path_buf()
364    }
365
366    /// Open a marker-style phase: record its `phase.started` seq AND label so the next
367    /// marker (or the run end) can emit the matching `phase.ended`. Subsequent
368    /// checkpoints / agents / budget events attribute to `start_seq`.
369    pub fn open_phase(&self, start_seq: u64, label: String) {
370        *self.cur_phase.borrow_mut() = Some((start_seq, label));
371    }
372
373    /// Close the currently-open phase, returning its `(start_seq, label)` so the caller
374    /// can emit `phase.ended`. Clears the open-phase tracking; returns `None` when no
375    /// phase is open (e.g. a workflow with no `(phase …)` markers).
376    pub fn take_open_phase(&self) -> Option<(u64, String)> {
377        self.cur_phase.borrow_mut().take()
378    }
379
380    /// `start_seq` of the open phase, if any.
381    pub fn phase_seq(&self) -> Option<u64> {
382        self.cur_phase.borrow().as_ref().map(|(seq, _)| *seq)
383    }
384
385    /// Mint a unique `agent_id` for an agent of role `name` (`<name>_<n>`, 1-based).
386    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    /// A stable short resume key for a checkpoint (`ck_<hex>` over key + digest).
394    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    /// Next monotonic sequence number (post-increment: first call yields 0).
403    pub fn next_seq(&self) -> u64 {
404        let n = self.seq.get();
405        self.seq.set(n + 1);
406        n
407    }
408
409    /// Timestamp for an event. Under the fixed-ts seam this is the verbatim env value
410    /// (so goldens are byte-identical); otherwise an RFC3339 UTC instant derived from
411    /// `SystemTime` (no `chrono` dependency — this crate only pulls `sema-core` +
412    /// `sema-otel` + serde).
413    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    /// Milliseconds elapsed since `start`. Always 0 under the fixed-ts seam so the
421    /// golden does not depend on real timing.
422    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    /// Append one event to the journal. Write errors are swallowed by the journal
430    /// (same trust model as the OTel file exporter); journaling never aborts the run.
431    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    /// True under the fixed-timestamp test seam (`SEMA_WORKFLOW_FIXED_TS`). Callers
447    /// that measure their own per-leaf durations force them to 0 in this mode so
448    /// goldens stay byte-identical.
449    pub fn deterministic(&self) -> bool {
450        self.fixed_ts.is_some()
451    }
452
453    /// This run's stable identifier (also the run-dir name).
454    pub fn run_id(&self) -> String {
455        self.run_id.clone()
456    }
457
458    /// Store a checkpoint / run-state value under `key`, replacing any prior value.
459    pub fn store_checkpoint(&self, key: &str, val: Value) {
460        self.state.borrow_mut().insert(key.to_string(), val);
461    }
462
463    /// Read a checkpoint / run-state value. `None` if the key was never set in this run.
464    pub fn read_checkpoint(&self, key: &str) -> Option<Value> {
465        self.state.borrow().get(key).cloned()
466    }
467
468    /// Opaque, lossy digest of a checkpoint value for the event stream: the md5 hex
469    /// of the value's lossy-JSON encoding. The digest is for journal compactness and
470    /// diffing — NOT resume identity (resume keys on the input-derived content-key and
471    /// stores the real value in `memo/`, round-trip-guarded). Stable within a process.
472    pub fn value_digest(&self, v: &Value) -> String {
473        // Bound the work: a value larger than the digest cap is never JSON-encoded in full
474        // on the VM thread — it gets a stable marker digest over its bounded compact prefix.
475        // The digest is NOT the resume identity (memo content-keys are, round-trip-guarded),
476        // and the byte-identical goldens only ever digest tiny values, so a capped path here
477        // never changes a golden digest.
478        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    /// Write the final `{:status …}` envelope to `result.json` (best-effort; a write
488    /// failure is swallowed like a journal write).
489    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    /// True when a `:budget` cap (usd and/or tokens) is in force for this run.
495    pub fn has_budget(&self) -> bool {
496        self.cost_limit.is_some() || self.token_limit.is_some()
497    }
498
499    /// The token cap, for the `budget_limit` field of a `Budget` event (typed `u64`).
500    /// `None` for a usd-only budget (the event field is tokens; usd has no slot).
501    pub fn budget_limit_for_event(&self) -> Option<u64> {
502        self.token_limit
503    }
504
505    /// Add one agent leaf's usage to the running totals and, if either cap is now
506    /// exceeded, set the sticky [`Self::over_budget`] latch. Returns `true` once the
507    /// run is over budget. Charge AFTER the leaf's events are journaled, so the leaf
508    /// that tips the cap is itself fully recorded; the NEXT leaf is the one refused.
509    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    /// Whether a cap has been exceeded this run (the sticky latch).
527    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    // ── Resume / content-key memoization ──────────────────────────────────────
543
544    /// Set the workflow's code version (folded into every content-key alongside args).
545    /// A changed workflow ⇒ different version ⇒ different keys ⇒ no memo hits ⇒ full
546    /// re-run.
547    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    /// Enter resume mode with the prior run's memos (content-key → value).
560    pub fn enter_resume(&self, memos: HashMap<String, Value>) {
561        self.resuming.set(true);
562        *self.resume_memos.borrow_mut() = memos;
563    }
564
565    /// True when this run is a `--resume` continuation.
566    pub fn resuming(&self) -> bool {
567        self.resuming.get()
568    }
569
570    /// The label of the currently-open phase (empty outside any phase). Part of a
571    /// content-key so the same leaf in different phases keys distinctly.
572    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    /// Next 0-based occurrence ordinal for a content-key base, so identical-input leaves
581    /// repeated in body order get distinct keys that line up across runs (deterministic
582    /// for a sequential body; best-effort under a concurrent fan-out).
583    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    /// Content-key for an agent leaf: a stable hash over (kind, code-version, args,
592    /// phase, name, prompt, schema-repr, effective-policy) plus an occurrence ordinal.
593    /// Length-prefixed so `("a","bc")` and `("ab","c")` never collide.
594    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    /// Content-key for a checkpoint write: hash over (kind, code-version, args, phase,
617    /// key) plus an occurrence ordinal.
618    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    /// Next occurrence for an explicit approval gate. The base binds the same inputs as
625    /// the durable request, so repeated identical gates in deterministic body order get
626    /// distinct request ids that line up on resume.
627    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    /// Look up a memoized value by content-key (only meaningful while `resuming`).
641    pub fn memo_lookup(&self, content_key: &str) -> Option<Value> {
642        self.resume_memos.borrow().get(content_key).cloned()
643    }
644
645    /// Persist a leaf's value as a memo sidecar AND into the in-run map — but ONLY if it
646    /// round-trips through JSON identically (`value_to_json_lossy`→`json_to_value` is
647    /// lossy for keyword/string keys, records, typed arrays) AND fits the A3 caps. A value
648    /// that doesn't survive, or exceeds [`MEMO_MAX_COUNT`]/[`MEMO_FILE_MAX_BYTES`], is left
649    /// un-memoized, so it re-runs on resume rather than resuming wrong. The whole-file
650    /// memo write itself is enqueued to the writer thread (no fs on the VM thread).
651    pub fn memo_store(&self, content_key: &str, v: &Value) {
652        // Cap 1 — per-run memo count.
653        if self.memo_count.get() >= MEMO_MAX_COUNT {
654            return;
655        }
656        // Cap 2 (pre-encode) — bound the compact form so an oversized value is never
657        // JSON-encoded in full on the VM thread. Truncated ⇒ over-cap ⇒ not stored.
658        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        // Round-trip guard: a value that doesn't survive JSON is left un-memoized.
664        if sema_core::json::json_to_value(&json) != *v {
665            return;
666        }
667        // Cap 2 (exact) — a value can be compact-small but JSON-large (deep nesting of
668        // short atoms); reject on the serialized size too.
669        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    /// Enqueue a terminal flush barrier, returning the ack receiver WITHOUT waiting — the
681    /// runtime terminal path parks on it via an External wait (see `workflow/run`'s
682    /// `finish_run`).
683    pub fn request_flush(&self) -> Receiver<()> {
684        self.journal.borrow().request_flush()
685    }
686
687    /// Bounded blocking flush of the journal writer (host / non-quantum path). NEVER call
688    /// inside a runtime quantum — park on the External flush-ack instead.
689    pub fn flush(&self) {
690        self.journal.borrow().flush_blocking();
691    }
692
693    // ── MCP handle registry (docs/plans/2026-06-24-workflow-mcp-auth.md §3) ────
694
695    /// Record the aliases declared in this run's `:mcp` meta, BEFORE
696    /// auth-resolution runs. `workflow/mcp-handle` uses this to distinguish an
697    /// undeclared alias from one that's declared but not resolved yet.
698    pub fn set_mcp_declared(&self, aliases: Vec<String>) {
699        *self.mcp_declared.borrow_mut() = aliases;
700    }
701
702    /// Whether `alias` appears in this run's `:mcp` declarations.
703    pub fn is_mcp_declared(&self, alias: &str) -> bool {
704        self.mcp_declared.borrow().iter().any(|a| a == alias)
705    }
706
707    /// Install the resolved MCP handles for this run — called once, after every
708    /// declared server resolves to `Connected` and before the body thunk runs.
709    pub fn set_mcp_handles(&self, handles: BTreeMap<String, Value>) {
710        *self.mcp_handles.borrow_mut() = handles;
711    }
712
713    /// The resolved handle for a declared alias, if any (`None` before
714    /// resolution completes, or if `alias` was never declared).
715    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    /// Expose every live `Value` a run holds so the CORE-2 collector never frees a
722    /// checkpoint/memo/MCP handle it can still reach through the owning task (Invariant
723    /// I2). A conflicting borrow means the bag is mid-mutation; report incomplete
724    /// (`false`) so the collector retries rather than under-tracing.
725    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
746/// One published workflow scope on a task's stack. A scope this task INSTALLED carries
747/// `Some(token)` and is removed by that exact token (mirrors `DynamicTaskState`'s
748/// `ScopeId` removal — out-of-LIFO teardown across interleaved tasks restores the exact
749/// outer scope). An INHERITED scope (a spawned child observing its spawner's run) carries
750/// `None`: the child sees the workflow for attribution but cannot tear down an ancestor's
751/// scope. The `Rc<WorkflowCtx>` is SCOPE-SHARED, so the child journals into the same run.
752struct WorkflowScope {
753    token: Option<ScopeId>,
754    ctx: Rc<WorkflowCtx>,
755}
756
757struct WorkflowTaskInner {
758    tokens: IdCounter<ScopeId>,
759    scopes: Vec<WorkflowScope>,
760    /// The `agent_id` of the step currently executing ON THIS TASK, so
761    /// `workflow/tool-call` attributes to it. TASK-PRIVATE: two concurrent steps on
762    /// sibling tasks keep distinct active agents (no cross-attribution).
763    cur_agent: Option<String>,
764}
765
766/// Task-local workflow scope: the run stack plus the per-task active-step attribution.
767/// Installed on the owning task's [`TaskContextHandle`] (traced), inherited clone-shared
768/// by spawned children.
769pub 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    /// Push `ctx` as the live scope, minting a fresh removal token for it.
787    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    /// Remove the scope carrying exactly `token`. Returns `false` if it is already gone
801    /// (idempotent teardown).
802    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    /// The innermost live run scope, if any.
814    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    /// A spawned child clone-shares the run stack (each `Rc<WorkflowCtx>` is shared, so
855    /// the child journals into the SAME run) and copies the spawner's active-step
856    /// attribution, but inherited scopes reset to `token: None` (the child cannot tear
857    /// down an ancestor's scope) and it mints from a fresh counter.
858    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
888/// Panic-safe RAII guard for one installed workflow scope. Drop removes the EXACT token
889/// it minted from the task (or host) state — an `Err` short-circuit, a panic unwinding
890/// through the run body, OR a continuation dropped without resume all reinstate the
891/// outer scope and never leak. Holds a `Weak` so the guard never keeps the traced state
892/// alive on its own (Invariant I2): the `WorkflowCtx` `Value`s are reachable ONLY through
893/// the traced [`TaskContextHandle`] / host store, never through this guard.
894pub 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
907/// The per-thread HOST-ADAPTER fallback scope state (used only outside a runtime quantum).
908fn host_state() -> Rc<WorkflowTaskState> {
909    WORKFLOW.with(Rc::clone)
910}
911
912/// Resolve the [`WorkflowTaskState`] a scope installs into: the runtime path's task
913/// context (get-or-create the extension) or the host fallback when there is no task
914/// context.
915fn 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
927/// Low-level: install an already-built `ctx` as a scope on `task_context` (or the host
928/// fallback), returning a guard whose drop removes that exact scope. Used by unit tests
929/// and by [`set_workflow_scope`].
930pub 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
942/// The live workflow context for `task_context`, if a run is in progress on that task.
943/// Reads the task-local extension first; the `WORKFLOW` thread-local is consulted ONLY as
944/// the host-adapter fallback outside a runtime quantum.
945pub 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
959/// Approval gates are valid only on the owning root workflow task. Spawned children
960/// inherit a read-only scope (`token: None`), and nested `workflow/run` calls have depth
961/// greater than one; neither can safely suspend the outer workflow at a sequential gate.
962pub 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
985/// The `agent_id` of the step currently executing on `task_context` (TASK-PRIVATE
986/// attribution), for `workflow/tool-call`.
987pub 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
999/// Set (or clear) the step currently executing on `task_context`.
1000pub 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
1004/// Redact secret-bearing values out of the workflow meta map's lossy-JSON form
1005/// before it is written to `metadata.json`. `:mcp` declarations may carry bearer
1006/// tokens or API keys in `:headers` (http servers) or `:env` (stdio servers) — see
1007/// `docs/plans/2026-06-24-workflow-mcp-auth.md` §4 "redaction everywhere": secrets
1008/// must never land in the journal, `result.json`, `metadata.json`, or OTel spans.
1009/// Every value under `meta.mcp.<alias>.headers` and `meta.mcp.<alias>.env` is
1010/// replaced with the literal string `"<redacted>"`; the keys (header/env-var
1011/// names) are kept so the manifest still documents WHAT was configured, just not
1012/// its value. Everything else in `meta` — including a `meta` with no `:mcp` key at
1013/// all — passes through unchanged. Pure JSON shaping, no MCP semantics, which is
1014/// why it lives here rather than requiring a `sema-mcp` dependency (a leaf crate
1015/// must not gain one).
1016fn 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
1036/// High-level entry the `workflow/run` builtin calls: resolve the run id + run-dir,
1037/// open the journal, build the `WorkflowCtx`, write `metadata.json`, install the
1038/// scope, and return the guard. The journal-open error propagates so the runtime can
1039/// fail the run cleanly (per-event writes below are best-effort).
1040///
1041/// `meta` is the workflow's metadata map (`{:phases … :budget … :args …}`); it is
1042/// recorded into `metadata.json`, and `:budget` is parsed into the run's spend caps.
1043/// `:permissions` is enforced by the CLI before the interpreter is built.
1044pub 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    // An explicit run id (the `SEMA_WORKFLOW_RUN_ID` seam, or a future library caller) is
1077    // validated HERE as exactly one safe path component before it is ever joined into a
1078    // filesystem path — the library is the authoritative gate, not just the CLI.
1079    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    // Resolve the run id AND open its journal together, because the two decisions are
1096    // coupled: a fresh run creates its dir fresh (a pre-existing dir is an error, a
1097    // generated-id collision retries with a new nonce), while a resume claims a new
1098    // sibling `events.resume-<n>.jsonl` segment in the ALREADY-existing dir.
1099    let (run_id, journal) = if resuming {
1100        // Resume requires an existing, explicitly-named run — never a generated id.
1101        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        // Fresh run with an operator-chosen id: fail loudly if that dir already exists.
1121        let journal = Journal::open(&runs_root, &id).map_err(|e| annotate_fresh_open(e, &id))?;
1122        (id, journal)
1123    } else {
1124        // Fresh run with a generated id: retry past the (astronomically unlikely) dir
1125        // collision with a new nonce each time.
1126        open_fresh_generated(&runs_root)?
1127    };
1128    // metadata.json — self-describing run header. Best-effort; not part of the
1129    // byte-identical events.jsonl oracle.
1130    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    // The `:budget` submap of meta becomes the run's enforced spend caps.
1142    // The CLI sets SEMA_WORKFLOW_ARGS_JSON to the verbatim `--args` string.
1143    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
1162/// Extract the `:budget` submap from a workflow `meta` map, flattening its keyword
1163/// keys (`:usd`, `:tokens`) to the `String` keys [`WorkflowCtx`] parses. Returns an
1164/// empty map when there is no (or a malformed) `:budget` — caps stay unenforced, never
1165/// a panic.
1166pub 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
1180/// Resolve the run-directory base: the `SEMA_WORKFLOW_RUN_DIR` seam (set by the CLI
1181/// `--run-dir`) if present, else the project-local [`RUNS_ROOT`].
1182pub 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
1192/// Length-prefixed md5 over a field list → short hex. Length-prefixing each field
1193/// (`u64` LE length then bytes) means concatenation ambiguities like `("a","bc")` vs
1194/// `("ab","c")` produce different digests — the separator-collision fix.
1195fn 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
1217/// Env seam: set to "1" by the CLI `--resume` path to enter resume mode.
1218const RESUME_ENV: &str = "SEMA_WORKFLOW_RESUME";
1219/// Env seam: a stable hash of the workflow source, folded into every content-key.
1220const CODE_VERSION_ENV: &str = "SEMA_WORKFLOW_CODE_VERSION";
1221/// Env seam: collision-resistant source fingerprint for durable approval binding.
1222const APPROVAL_CODE_VERSION_ENV: &str = "SEMA_WORKFLOW_APPROVAL_CODE_VERSION";
1223
1224/// Process-wide monotonic nonce folded into every generated run id, so two runs started
1225/// in one process — even within the same nanosecond — never collide on a run directory.
1226static RUN_ID_NONCE: AtomicU64 = AtomicU64::new(0);
1227
1228/// Max attempts to place a generated run into a free directory. A generated id already
1229/// folds in a process nonce, so a collision is astronomically unlikely; the bound only
1230/// stops an infinite loop if the filesystem keeps returning `AlreadyExists` for some
1231/// other reason.
1232const MAX_FRESH_ATTEMPTS: u32 = 8;
1233
1234/// A freshly generated run id: `wf_<unix_secs>_<subsec_nanos>_<pid>_<nonce>`. No RNG
1235/// dependency, yet unique per process: the nanosecond field separates rapid runs and the
1236/// process nonce guarantees distinctness even at identical clock readings (two runs in
1237/// the same second — or nanosecond — no longer share a directory).
1238fn 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
1252/// Validate an explicit run id (the `SEMA_WORKFLOW_RUN_ID` seam or a library caller) as
1253/// exactly ONE safe path component: non-empty, no `/` or `\` separator, no `..` traversal,
1254/// not a `.`-only component, and free of NUL / control characters — it joins straight into
1255/// a filesystem path, so anything else is a traversal or a broken directory name. Returns
1256/// `InvalidInput` on rejection.
1257pub 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
1282/// Resolve the run id for a NEW run: the validated `SEMA_WORKFLOW_RUN_ID` seam if set and
1283/// non-empty, else a freshly generated id (see [`generate_run_id`]). An invalid explicit
1284/// id is an error rather than a silently sanitized path. Overridable to a fixed value in
1285/// tests (the golden oracle sets `SEMA_WORKFLOW_RUN_ID=wf_test_0001`).
1286pub 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
1296/// Open a fresh run under a generated id, retrying with a new id (fresh nonce) on the
1297/// unlikely directory collision. Returns the winning id alongside its opened journal.
1298fn open_fresh_generated(runs_root: &str) -> io::Result<(String, Journal)> {
1299    open_fresh_with(runs_root, generate_run_id)
1300}
1301
1302/// The collision-retry core, with an injectable id source so the retry path is unit
1303/// testable without racing the clock. Bounded by [`MAX_FRESH_ATTEMPTS`].
1304fn 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
1325/// Turn the bare `AlreadyExists` from a fresh journal claim into an actionable message
1326/// (keeping the error KIND so callers can still match on it), for an operator-chosen id
1327/// whose journal already exists.
1328fn 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
1342/// Format `SystemTime::now()` as an RFC3339 / ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`)
1343/// without pulling in `chrono`. Civil-date conversion via the standard
1344/// days-since-epoch algorithm (Howard Hinnant's `civil_from_days`).
1345pub(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
1357/// Convert a count of days since 1970-01-01 to a (year, month, day) civil date.
1358/// Hinnant's algorithm; valid for the full SystemTime range we will ever journal.
1359fn 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; // [0, 146096]
1363    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
1364    let y = yoe as i64 + era * 400;
1365    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
1366    let mp = (5 * doy + 2) / 153; // [0, 11]
1367    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
1368    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
1369    (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        // Once latched, it stays latched even on a tiny later charge.
1409        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        // No :budget at all → empty (unenforced).
1443        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        // First occurrence of each distinct input is stable; differing inputs differ.
1451        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        // Length-prefixing: ('a','bc') must not collide with ('ab','c').
1455        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        // Occurrence ordinal: a repeated identical leaf gets a distinct key.
1462        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        // A plain string round-trips → memoized and looked up.
1510        ctx.memo_store("ck_text", &Value::string("hello"));
1511        assert_eq!(ctx.memo_lookup("ck_text"), Some(Value::string("hello")));
1512        // A keyword-keyed map round-trips (json_to_value rebuilds keyword keys).
1513        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        // A map with a NON-string/keyword key does NOT survive JSON round-trip (the int
1519        // key becomes a string), so the guard leaves it un-memoized → it re-runs on
1520        // resume rather than resuming a different value. This exercises the FALSE branch.
1521        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    // ── MCP handle registry ──────────────────────────────────────────────
1540
1541    #[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        // Declared, but resolution hasn't populated a handle yet.
1553        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        // Invariant I2: every live `Value` a run holds must be reachable to the
1574        // collector — one edge per state-bag / memo / MCP-handle value.
1575        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        // The host fallback (no task context) behaves like a scope stack: a nested run
1598        // reveals the outer scope again once its guard drops.
1599        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        // inner guard dropped → outer reinstated
1615        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        // Two scopes installed, torn down OLDEST-first (out of LIFO order): exact-token
1626        // removal restores the surviving inner scope, not whatever happens to be on top.
1627        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        // A spawned child clone-shares the run scope + copies the active agent, but its
1654        // inherited scope is not removable by the child (token stripped to None).
1655        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        // The child cannot tear down the parent's scope with the parent's token.
1672        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        // The parent's own teardown still works.
1678        assert!(state.remove(parent_token));
1679        assert!(state.current_ctx().is_none());
1680    }
1681
1682    // ── run identity (A2) ────────────────────────────────────────────────
1683
1684    #[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            "",     // empty
1709            "a/b",  // unix separator
1710            "a\\b", // windows separator
1711            "..",   // traversal
1712            "a..b", // embedded traversal
1713            ".",    // dot-only
1714            "...",  // dots-only
1715            "a\0b", // NUL
1716            "a\nb", // control char
1717        ] {
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        // The first two candidate ids already have a JOURNAL (events.jsonl); the opener
1736        // must skip them (its create_new claim fails) and land on the first free id.
1737        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        // 2026-06-24 is 20628 days after epoch.
1752        assert_eq!(civil_from_days(20_628), (2026, 6, 24));
1753    }
1754
1755    // ── redact_meta_secrets ──────────────────────────────────────────────
1756
1757    #[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        // Keys survive.
1796        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        // Sibling fields untouched.
1805        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}