返回 CodeWhale
driver.rs
根目录 / crates / workflow-js / src / 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
20 use async_trait::async_trait;
21 use serde::{Deserialize, Serialize};
22 use tokio::sync::oneshot;
23
24 use 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)]
36 pub 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 /// Optional existing working directory, relative to the parent workspace.
54 /// The host validates that it exists and remains inside the workspace.
55 pub cwd: Option<String>,
56 /// Run the child in a fresh git worktree for parallel edits.
57 pub worktree: bool,
58 /// Explicit child mutation authority. A write-capable value remains
59 /// fail-closed unless at least one bounded scope below is declared.
60 #[serde(default)]
61 pub write_authority: Option<String>,
62 /// Repo-relative directory trees the child expects to mutate.
63 #[serde(default)]
64 pub write_roots: Vec<String>,
65 /// Repo-relative exact files the child expects to mutate.
66 #[serde(default)]
67 pub exact_files: Vec<String>,
68 /// Named shared contracts owned by this child while active.
69 #[serde(default)]
70 pub coordination_contracts: Vec<String>,
71 /// Bounded prerequisite facts relevant to this child.
72 #[serde(default)]
73 pub dependencies: Vec<String>,
74 /// Bounded observable checks for child completion.
75 #[serde(default)]
76 pub acceptance: Vec<String>,
77 /// Explicit tool allowlist; required by the driver for `custom` roles.
78 pub allowed_tools: Option<Vec<String>>,
79 /// Host-imposed tool deny list. Deny always wins over allow, including over
80 /// `allowed_tools` and over the role posture.
81 ///
82 /// Deliberately **not** settable from a workflow script: it is how a host
83 /// enforces a ceiling it derived (an exact Fleet member's
84 /// `network_tool = false`, for instance) on the child that actually runs.
85 /// A script that could write it could also clear it.
86 #[serde(default)]
87 pub disallowed_tools: Vec<String>,
88 /// Per-call spawn-depth override (driver clamps to its ceiling).
89 pub max_depth: Option<u32>,
90 /// Explicit token budget: forks an isolated pool on the driver side.
91 /// Omit it so the child inherits (and debits) the shared run pool.
92 pub token_budget: Option<u64>,
93 /// Maximum model turns for this child (driver clamps to its ceiling).
94 pub max_steps: Option<u32>,
95 /// Hard wall-clock limit for this child in seconds.
96 pub wall_time_secs: Option<u64>,
97 /// JSON schema the reply must satisfy; validated in the VM after the
98 /// driver returns the raw text (see [`crate`] docs for decode rules).
99 pub response_schema: Option<serde_json::Value>,
100 /// Short human label for progress surfaces.
101 pub label: Option<String>,
102 /// Phase name this task belongs to, for progress grouping.
103 pub phase: Option<String>,
104 }
105
106 /// Terminal outcome of one spawned task, delivered over the completion
107 /// oneshot. Everything except `Completed` becomes a JS throw on the awaiting
108 /// `task()` call.
109 #[derive(Debug, Clone, PartialEq, Eq)]
110 pub enum TaskCompletion {
111 /// The child finished; `text` is the full, untruncated result.
112 Completed { text: String },
113 /// The child failed (error result, timeout, ...).
114 Failed { message: String },
115 /// The child was cancelled (cascade or explicit).
116 Cancelled,
117 /// The child's budget scope drained mid-flight.
118 BudgetExhausted { message: String },
119 }
120
121 /// A successfully admitted spawn: the driver-assigned task id (the engine's
122 /// `agent_id`) plus the oneshot the driver resolves on completion.
123 ///
124 /// Dropping the receiver must not wedge the driver; drivers should treat a
125 /// closed completion channel as "nobody is listening" and move on.
126 #[derive(Debug)]
127 pub struct SpawnedTask {
128 /// Driver-assigned id, unique within the run (engine `agent_id`).
129 pub task_id: String,
130 /// Resolved exactly once with the terminal [`TaskCompletion`].
131 pub completion: oneshot::Receiver<TaskCompletion>,
132 }
133
134 /// Live view of the run's shared token pool, owned by the driver.
135 ///
136 /// `total == None` means no ceiling is configured; JS then sees
137 /// `budget.total === null` and `budget.remaining() === Infinity`.
138 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139 pub struct BudgetSnapshot {
140 /// Pool ceiling in tokens, if one is configured.
141 pub total: Option<u64>,
142 /// Tokens spent (plus driver-side reservations) against the pool.
143 pub spent: u64,
144 }
145
146 impl BudgetSnapshot {
147 /// Tokens left before the ceiling; `None` when the pool is unbounded.
148 pub fn remaining(&self) -> Option<u64> {
149 self.total.map(|total| total.saturating_sub(self.spent))
150 }
151
152 /// True once the pool has a ceiling and it is fully consumed.
153 pub fn exhausted(&self) -> bool {
154 matches!(self.total, Some(total) if self.spent >= total)
155 }
156 }
157
158 /// Progress events emitted by the script (`log(..)` / `phase(..)`), delivered
159 /// to the driver synchronously and in script order.
160 #[derive(Debug, Clone, PartialEq, Eq)]
161 pub enum ProgressEvent {
162 /// `log(msg)` — a narrator line for the UI.
163 Log {
164 /// The stringified message.
165 message: String,
166 },
167 /// `phase(title)` — the script entered a named phase.
168 Phase {
169 /// The phase title.
170 title: String,
171 },
172 /// A completed child returned text that failed the caller's
173 /// `responseSchema`. The VM emits this before throwing the validation
174 /// error back into the script so host-side receipts can mark the leaf as
175 /// failed instead of reporting a successful child beside a `null` result.
176 TaskSchemaValidationFailed {
177 /// Driver-assigned task id (engine `agent_id`).
178 task_id: String,
179 /// The validation error already surfaced to JS.
180 message: String,
181 },
182 /// A `task()` call was rejected before any child agent existed —
183 /// malformed options, a bad `responseSchema`, the lifetime cap, or an
184 /// exhausted budget. Inside `parallel()` the JS throw collapses to a
185 /// `null` slot, so without this event the host would have no record that
186 /// a slot was ever requested; drivers fold it into the same ledger as
187 /// `spawn_task` rejections so run status can stay honest.
188 TaskRejected {
189 /// Best-effort `label` from the raw options, when parseable.
190 label: Option<String>,
191 /// Best-effort `phase` from the raw options, when parseable.
192 phase: Option<String>,
193 /// The rejection already surfaced to JS.
194 message: String,
195 },
196 }
197
198 /// Host-side executor for a Workflow run.
199 ///
200 /// Implementations must be cheap to call from the VM thread: `spawn_task`
201 /// admits the task and returns immediately (fire-and-forget spawn — never
202 /// await the child inline), while `budget`, `progress`, and `cancel_all` are
203 /// synchronous. `cancel_all` must be idempotent; it is invoked when the
204 /// script errors, when the run future is dropped, and once more never hurts.
205 #[async_trait]
206 pub trait WorkflowDriver: Send + Sync {
207 /// Admit and start one task. Errors surface as a JS throw on the
208 /// corresponding `task()` call.
209 async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError>;
210
211 /// Cancel every in-flight task belonging to this run. Idempotent.
212 fn cancel_all(&self);
213
214 /// Current snapshot of the run's shared token pool.
215 fn budget(&self) -> BudgetSnapshot;
216
217 /// Receive a script progress event (ordered, synchronous).
218 fn progress(&self, event: ProgressEvent);
219 }
220
221 /// Normalize and validate a Fleet profile token: trim, lowercase, then apply
222 /// the same token rule as `crates/workflow`'s `validate_leaf_profile` —
223 /// non-empty, no whitespace, and none of `"`, `'`, `` ` ``, `=`.
224 pub fn normalize_profile(raw: &str) -> Result<String, String> {
225 let normalized = raw.trim().to_lowercase();
226 let invalid = normalized.is_empty()
227 || normalized
228 .chars()
229 .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='));
230 if invalid {
231 return Err(format!(
232 "invalid profile token {raw:?}: profiles must be non-empty and contain no whitespace, quotes, backticks, or '='"
233 ));
234 }
235 Ok(normalized)
236 }
237
238 #[cfg(test)]
239 mod tests {
240 use super::*;
241
242 #[test]
243 fn normalize_profile_trims_and_lowercases() {
244 assert_eq!(normalize_profile(" ALpha-1 ").unwrap(), "alpha-1");
245 }
246
247 #[test]
248 fn normalize_profile_rejects_bad_tokens() {
249 for bad in ["", " ", "two words", "a=b", "a\"b", "a'b", "a`b"] {
250 assert!(
251 normalize_profile(bad).is_err(),
252 "expected rejection: {bad:?}"
253 );
254 }
255 }
256
257 #[test]
258 fn budget_snapshot_math() {
259 let unbounded = BudgetSnapshot {
260 total: None,
261 spent: 10,
262 };
263 assert_eq!(unbounded.remaining(), None);
264 assert!(!unbounded.exhausted());
265
266 let pool = BudgetSnapshot {
267 total: Some(100),
268 spent: 40,
269 };
270 assert_eq!(pool.remaining(), Some(60));
271 assert!(!pool.exhausted());
272
273 let drained = BudgetSnapshot {
274 total: Some(100),
275 spent: 120,
276 };
277 assert_eq!(drained.remaining(), Some(0));
278 assert!(drained.exhausted());
279 }
280
281 #[test]
282 fn legacy_task_request_defaults_new_coordination_fields() {
283 let legacy = serde_json::json!({
284 "description": "inspect the candidate",
285 "subagent_type": null,
286 "role": "reviewer",
287 "profile": null,
288 "model": null,
289 "model_strength": null,
290 "thinking": null,
291 "cwd": null,
292 "worktree": false,
293 "allowed_tools": null,
294 "max_depth": null,
295 "token_budget": null,
296 "max_steps": null,
297 "wall_time_secs": null,
298 "response_schema": null,
299 "label": null,
300 "phase": null
301 });
302
303 let request: TaskRequest = serde_json::from_value(legacy).unwrap();
304 assert_eq!(request.write_authority, None);
305 assert!(request.write_roots.is_empty());
306 assert!(request.exact_files.is_empty());
307 assert!(request.coordination_contracts.is_empty());
308 assert!(request.dependencies.is_empty());
309 assert!(request.acceptance.is_empty());
310 }
311 }
312
312 lines RUST