Skip to main content

codewhale_workflow_js/
driver.rs

1//! The driver seam between the sandboxed VM and the subagent engine.
2//!
3//! The QuickJS VM lives on a dedicated thread and its `'js` values can never
4//! cross an `.await` onto another thread, so everything that leaves the VM is
5//! plain `Send` data: a [`TaskRequest`] goes out, a [`TaskCompletion`] comes
6//! back over a oneshot. The [`WorkflowDriver`] trait is the host-side contract
7//! the tui wiring implements over `SubAgentManager` (spawn is fire-and-forget
8//! there; the driver's completion pump resolves the oneshot from the mailbox
9//! `Completed` signal keyed by `agent_id`, then reads the full untruncated
10//! text via `get_result`). Tests implement it with
11//! [`crate::testing::FakeDriver`].
12//!
13//! Budget ownership: token accounting and the §5.3 reservation semantics live
14//! entirely on the driver side (the manager's budget scopes). The VM only
15//! reads [`BudgetSnapshot`]s — it performs a fast-fail `spent >= total` check
16//! before spawning and exposes the numbers to JS as `budget.*`, but it never
17//! reserves or debits tokens itself. A driver that admits a spawn is the
18//! authority; its rejection surfaces as a JS throw on that `task()` call.
19
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22use tokio::sync::oneshot;
23
24use crate::error::DriverError;
25
26/// One `task()` invocation, fully resolved and validated on the VM side.
27///
28/// Field semantics mirror the `agent` tool's spawn options; `profile` is the
29/// Fleet roster profile token, already trimmed + lowercased and checked
30/// against the same token rule as `crates/workflow`'s leaf profiles. Roster
31/// membership is resolved by the driver (tui) at spawn time — this crate
32/// never sees the saved Fleet roster.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct TaskRequest {
35    /// The child prompt (JS `description` or `prompt`; required).
36    pub description: String,
37    /// Subagent role (JS `subagentType` or `type`); `None` lets the driver
38    /// apply its default (`general`).
39    pub subagent_type: Option<String>,
40    /// Fleet profile token, normalized (trimmed, lowercased) and validated.
41    pub profile: Option<String>,
42    /// Explicit model override; always wins over `model_strength`.
43    pub model: Option<String>,
44    /// Relative model strength (`same`/`faster`, plus driver-side aliases).
45    pub model_strength: Option<String>,
46    /// Reasoning effort (`inherit`/`off`/`low`/`medium`/`high`/`max`).
47    pub thinking: Option<String>,
48    /// Run the child in a fresh git worktree for parallel edits.
49    pub worktree: bool,
50    /// Explicit tool allowlist; required by the driver for `custom` roles.
51    pub allowed_tools: Option<Vec<String>>,
52    /// Per-call spawn-depth override (driver clamps to its ceiling).
53    pub max_depth: Option<u32>,
54    /// Explicit token budget: forks an isolated pool on the driver side.
55    /// Omit it so the child inherits (and debits) the shared run pool.
56    pub token_budget: Option<u64>,
57    /// JSON schema the reply must satisfy; validated in the VM after the
58    /// driver returns the raw text (see [`crate`] docs for decode rules).
59    pub response_schema: Option<serde_json::Value>,
60    /// Short human label for progress surfaces.
61    pub label: Option<String>,
62    /// Phase name this task belongs to, for progress grouping.
63    pub phase: Option<String>,
64}
65
66/// Terminal outcome of one spawned task, delivered over the completion
67/// oneshot. Everything except `Completed` becomes a JS throw on the awaiting
68/// `task()` call.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum TaskCompletion {
71    /// The child finished; `text` is the full, untruncated result.
72    Completed { text: String },
73    /// The child failed (error result, timeout, ...).
74    Failed { message: String },
75    /// The child was cancelled (cascade or explicit).
76    Cancelled,
77    /// The child's budget scope drained mid-flight.
78    BudgetExhausted { message: String },
79}
80
81/// A successfully admitted spawn: the driver-assigned task id (the engine's
82/// `agent_id`) plus the oneshot the driver resolves on completion.
83///
84/// Dropping the receiver must not wedge the driver; drivers should treat a
85/// closed completion channel as "nobody is listening" and move on.
86#[derive(Debug)]
87pub struct SpawnedTask {
88    /// Driver-assigned id, unique within the run (engine `agent_id`).
89    pub task_id: String,
90    /// Resolved exactly once with the terminal [`TaskCompletion`].
91    pub completion: oneshot::Receiver<TaskCompletion>,
92}
93
94/// Live view of the run's shared token pool, owned by the driver.
95///
96/// `total == None` means no ceiling is configured; JS then sees
97/// `budget.total === null` and `budget.remaining() === Infinity`.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub struct BudgetSnapshot {
100    /// Pool ceiling in tokens, if one is configured.
101    pub total: Option<u64>,
102    /// Tokens spent (plus driver-side reservations) against the pool.
103    pub spent: u64,
104}
105
106impl BudgetSnapshot {
107    /// Tokens left before the ceiling; `None` when the pool is unbounded.
108    pub fn remaining(&self) -> Option<u64> {
109        self.total.map(|total| total.saturating_sub(self.spent))
110    }
111
112    /// True once the pool has a ceiling and it is fully consumed.
113    pub fn exhausted(&self) -> bool {
114        matches!(self.total, Some(total) if self.spent >= total)
115    }
116}
117
118/// Progress events emitted by the script (`log(..)` / `phase(..)`), delivered
119/// to the driver synchronously and in script order.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ProgressEvent {
122    /// `log(msg)` — a narrator line for the UI.
123    Log {
124        /// The stringified message.
125        message: String,
126    },
127    /// `phase(title)` — the script entered a named phase.
128    Phase {
129        /// The phase title.
130        title: String,
131    },
132    /// A completed child returned text that failed the caller's
133    /// `responseSchema`. The VM emits this before throwing the validation
134    /// error back into the script so host-side receipts can mark the leaf as
135    /// failed instead of reporting a successful child beside a `null` result.
136    TaskSchemaValidationFailed {
137        /// Driver-assigned task id (engine `agent_id`).
138        task_id: String,
139        /// The validation error already surfaced to JS.
140        message: String,
141    },
142}
143
144/// Host-side executor for a Workflow run.
145///
146/// Implementations must be cheap to call from the VM thread: `spawn_task`
147/// admits the task and returns immediately (fire-and-forget spawn — never
148/// await the child inline), while `budget`, `progress`, and `cancel_all` are
149/// synchronous. `cancel_all` must be idempotent; it is invoked when the
150/// script errors, when the run future is dropped, and once more never hurts.
151#[async_trait]
152pub trait WorkflowDriver: Send + Sync {
153    /// Admit and start one task. Errors surface as a JS throw on the
154    /// corresponding `task()` call.
155    async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError>;
156
157    /// Cancel every in-flight task belonging to this run. Idempotent.
158    fn cancel_all(&self);
159
160    /// Current snapshot of the run's shared token pool.
161    fn budget(&self) -> BudgetSnapshot;
162
163    /// Receive a script progress event (ordered, synchronous).
164    fn progress(&self, event: ProgressEvent);
165}
166
167/// Normalize and validate a Fleet profile token: trim, lowercase, then apply
168/// the same token rule as `crates/workflow`'s `validate_leaf_profile` —
169/// non-empty, no whitespace, and none of `"`, `'`, `` ` ``, `=`.
170pub fn normalize_profile(raw: &str) -> Result<String, String> {
171    let normalized = raw.trim().to_lowercase();
172    let invalid = normalized.is_empty()
173        || normalized
174            .chars()
175            .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='));
176    if invalid {
177        return Err(format!(
178            "invalid profile token {raw:?}: profiles must be non-empty and contain no whitespace, quotes, backticks, or '='"
179        ));
180    }
181    Ok(normalized)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn normalize_profile_trims_and_lowercases() {
190        assert_eq!(normalize_profile("  ALpha-1  ").unwrap(), "alpha-1");
191    }
192
193    #[test]
194    fn normalize_profile_rejects_bad_tokens() {
195        for bad in ["", "   ", "two words", "a=b", "a\"b", "a'b", "a`b"] {
196            assert!(
197                normalize_profile(bad).is_err(),
198                "expected rejection: {bad:?}"
199            );
200        }
201    }
202
203    #[test]
204    fn budget_snapshot_math() {
205        let unbounded = BudgetSnapshot {
206            total: None,
207            spent: 10,
208        };
209        assert_eq!(unbounded.remaining(), None);
210        assert!(!unbounded.exhausted());
211
212        let pool = BudgetSnapshot {
213            total: Some(100),
214            spent: 40,
215        };
216        assert_eq!(pool.remaining(), Some(60));
217        assert!(!pool.exhausted());
218
219        let drained = BudgetSnapshot {
220            total: Some(100),
221            spent: 120,
222        };
223        assert_eq!(drained.remaining(), Some(0));
224        assert!(drained.exhausted());
225    }
226}