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