kaish_kernel/dispatch.rs
1//! Command dispatch — the single execution path for all commands.
2//!
3//! The `CommandDispatcher` trait defines how a single command is resolved and
4//! executed. The Kernel implements this trait with the full dispatch chain:
5//! user tools → builtins → .kai scripts → external commands → backend tools.
6//!
7//! `PipelineRunner` calls `dispatcher.dispatch()` for each command in a
8//! pipeline, handling I/O routing (stdin piping, redirects) around each call.
9//!
10//! ```text
11//! Stmt::Command ──┐
12//! ├──▶ execute_pipeline() ──▶ PipelineRunner::run(dispatcher, commands, ctx)
13//! Stmt::Pipeline ──┘ │
14//! for each command:
15//! dispatcher.dispatch(cmd, ctx)
16//! │
17//! ┌─────┼──────────────┐
18//! │ │ │
19//! user_tools builtins .kai scripts
20//! external cmds
21//! backend tools
22//! ```
23
24use std::sync::Arc;
25
26use anyhow::Result;
27use async_trait::async_trait;
28
29use crate::ast::{Command, Expr, Value};
30use crate::interpreter::ExecResult;
31use crate::tools::ExecContext;
32
33// The following imports are only used by the test-only `BackendDispatcher`.
34#[cfg(test)]
35use crate::ast::Arg;
36#[cfg(test)]
37use crate::backend::BackendError;
38#[cfg(test)]
39use crate::interpreter::apply_output_format;
40#[cfg(test)]
41use crate::scheduler::build_tool_args;
42#[cfg(test)]
43use crate::tools::{GlobalFlags, ToolRegistry};
44#[cfg(all(test, feature = "subprocess"))]
45use crate::tools::resolve_in_path;
46
47/// Position of a command within a pipeline.
48///
49/// Used by external command execution to decide stdio inheritance:
50/// - `Only` or `Last` in interactive mode → inherit terminal
51/// - `First` or `Middle` → always capture
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum PipelinePosition {
54 /// Single command, no pipe.
55 #[default]
56 Only,
57 /// First command in a pipeline (no stdin from pipe).
58 First,
59 /// Middle of a pipeline (piped stdin, piped stdout).
60 Middle,
61 /// Last command in a pipeline (piped stdin, final output).
62 Last,
63}
64
65/// Trait for dispatching a single command through the full resolution chain.
66///
67/// Implementations handle argument parsing, tool lookup, and execution.
68/// The pipeline runner handles I/O routing (stdin, redirects, piping).
69#[async_trait]
70pub trait CommandDispatcher: Send + Sync {
71 /// Dispatch a single command for execution.
72 ///
73 /// The `ctx` provides stdin (from pipe or redirect), scope, and backend.
74 /// Implementations should handle schema-aware argument parsing and
75 /// output format extraction internally.
76 async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult>;
77
78 /// Evaluate an expression through the full async chain.
79 ///
80 /// Unlike the runner's sync `eval_simple_expr`, this can run command
81 /// substitution (`$(...)`) because it has access to pipeline execution.
82 /// Used for redirect targets and heredoc bodies so `cat < $(cmd)`,
83 /// `echo x > $(cmd)`, and `$(...)` inside heredoc bodies work. The `ctx`
84 /// carries scope/cwd/backend for dispatchers that evaluate against it;
85 /// stateful dispatchers (Kernel) snapshot their own session state and
86 /// only let command output escape (side effects like `cd` do not).
87 async fn eval_expr(&self, expr: &Expr, ctx: &ExecContext) -> Result<Value>;
88
89 /// Fork the dispatcher for concurrent execution (detached).
90 ///
91 /// Returns a subsidiary dispatcher with independent mutable state, safe
92 /// to run concurrently with the parent and other forks without data
93 /// races on shared scope/cwd/aliases. Used by background `&` jobs,
94 /// where the fork must survive parent cancellation.
95 ///
96 /// For stateful dispatchers (e.g. Kernel) this snapshots per-session
97 /// state into a fresh instance. Stateless dispatchers may clone.
98 async fn fork(&self) -> Arc<dyn CommandDispatcher>;
99
100 /// Fork the dispatcher for concurrent execution (attached to parent cancel).
101 ///
102 /// Like [`Self::fork`] but the fork's cancellation token is a *child* of
103 /// the parent's. Cancelling the parent (timeout, Ctrl-C, embedder
104 /// `Kernel::cancel`) cascades into the fork, which then kills its own
105 /// external children via the usual SIGTERM/SIGKILL discipline.
106 ///
107 /// Used for foreground concurrency: scatter workers, concurrent pipeline
108 /// stages, command substitution. Default implementation delegates to
109 /// [`Self::fork`] for stateless dispatchers that don't track cancellation.
110 async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
111 self.fork().await
112 }
113}
114
115/// Minimal stateless dispatcher used by pipeline/runner unit tests.
116///
117/// Production code uses `Kernel` (via `Kernel::fork` for concurrent contexts).
118/// This test-only dispatcher routes directly through `backend.call_tool()` so
119/// the pipeline runner can be exercised without spinning up a full Kernel.
120///
121/// Limitations (intentional — these are test-only constraints):
122/// - No user-defined tools
123/// - No .kai script resolution
124/// - No async argument evaluation (command substitution in args won't work)
125#[cfg(test)]
126pub(crate) struct BackendDispatcher {
127 tools: Arc<ToolRegistry>,
128}
129
130#[cfg(test)]
131impl BackendDispatcher {
132 /// Create a new backend dispatcher with the given tool registry.
133 pub(crate) fn new(tools: Arc<ToolRegistry>) -> Self {
134 Self { tools }
135 }
136
137 /// Try to execute an external command (PATH lookup + process spawn).
138 ///
139 /// Used as fallback when no builtin/backend tool matches. Returns None if
140 /// the command is not found in PATH. Always captures stdout/stderr (never
141 /// inherits terminal — pipeline stages don't need interactive I/O).
142 #[cfg(not(feature = "subprocess"))]
143 async fn try_external(
144 &self,
145 _name: &str,
146 _args: &[Arg],
147 _ctx: &mut ExecContext,
148 ) -> Option<ExecResult> {
149 None
150 }
151
152 /// Try to execute an external command (PATH lookup + process spawn).
153 #[cfg(feature = "subprocess")]
154 async fn try_external(
155 &self,
156 name: &str,
157 args: &[Arg],
158 ctx: &mut ExecContext,
159 ) -> Option<ExecResult> {
160 if !ctx.allow_external_commands {
161 return None;
162 }
163
164 // Get real working directory (needed for relative path resolution and child cwd).
165 // If the CWD is virtual (no real path), skip external execution entirely.
166 let real_cwd = match ctx.backend.resolve_real_path(&ctx.cwd) {
167 Some(p) => p,
168 None => return None,
169 };
170
171 // Resolve command: absolute/relative path or PATH lookup
172 let executable = if name.contains('/') {
173 // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
174 let resolved = if std::path::Path::new(name).is_absolute() {
175 std::path::PathBuf::from(name)
176 } else {
177 real_cwd.join(name)
178 };
179 if resolved.exists() {
180 resolved.to_string_lossy().into_owned()
181 } else {
182 return Some(ExecResult::failure(127, format!("{}: No such file or directory", name)));
183 }
184 } else {
185 // PATH from scope only — never OS env (keeps this test-only spawn
186 // site in sync with kernel.rs::try_execute_external).
187 let path_var = ctx.scope.get("PATH")
188 .map(crate::interpreter::value_to_string)
189 .unwrap_or_default();
190 resolve_in_path(name, &path_var)?
191 };
192
193 // Build flat argv from args. A for-loop (not filter_map) so the
194 // Decision D collection-argv guard can short-circuit the whole spawn
195 // — kept in sync with the production build in kernel.rs::build_args_flat.
196 let mut argv: Vec<String> = Vec::new();
197 for arg in args {
198 match arg {
199 Arg::Positional(expr) => match expr {
200 Expr::Literal(Value::String(s)) => argv.push(s.clone()),
201 Expr::Literal(Value::Int(i)) => argv.push(i.to_string()),
202 Expr::Literal(Value::Float(f)) => argv.push(f.to_string()),
203 Expr::VarRef(path) => {
204 if let Ok(v) = ctx.scope.resolve_path(path) {
205 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &v) {
206 return Some(ExecResult::failure(1, msg));
207 }
208 // Text sink: binary goes loud (kept in sync with
209 // kernel.rs::build_args_flat).
210 match crate::interpreter::value_to_text_sink(&v) {
211 Ok(s) => argv.push(s),
212 Err(e) => return Some(ExecResult::failure(1, e.to_string())),
213 }
214 }
215 }
216 // Remaining literal types (Bool/Json/Null/Bytes) — kept in
217 // sync with the production build_args_flat, which resolves
218 // every positional through value_to_text_sink (binary loud).
219 Expr::Literal(other) => match crate::interpreter::value_to_text_sink(other) {
220 Ok(s) => argv.push(s),
221 Err(e) => return Some(ExecResult::failure(1, e.to_string())),
222 },
223 _ => {}
224 },
225 Arg::ShortFlag(f) => argv.push(format!("-{f}")),
226 Arg::LongFlag(f) => argv.push(format!("--{f}")),
227 Arg::Named { key, value } => match value {
228 Expr::Literal(Value::String(s)) => argv.push(format!("--{key}={s}")),
229 _ => argv.push(format!("--{key}=")),
230 },
231 Arg::WordAssign { key, value } => match value {
232 Expr::Literal(Value::String(s)) => argv.push(format!("{key}={s}")),
233 _ => argv.push(format!("{key}=")),
234 },
235 Arg::DoubleDash => argv.push("--".to_string()),
236 }
237 }
238
239 // Check for streaming pipes
240 let has_pipe_stdin = ctx.pipe_stdin.is_some();
241 // pipe_stdout checked later when deciding buffered vs streaming output
242 let has_buffered_stdin = ctx.stdin.is_some();
243
244 // Spawn process
245 use tokio::process::Command;
246 use tokio::io::{AsyncReadExt, AsyncWriteExt};
247
248 let mut cmd = Command::new(&executable);
249 cmd.args(&argv);
250 cmd.current_dir(&real_cwd);
251 cmd.kill_on_drop(true);
252
253 // Hermetic env: child sees only kaish's exported vars, not the kaish
254 // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
255 // populate it via KernelConfig::initial_vars at construction.
256 cmd.env_clear();
257 let exported = ctx.scope.exported_vars();
258 // A structured value can't cross the process boundary; refuse rather than
259 // silently JSON-serialize it into the child's environment. Kept in sync
260 // with the production spawn site in kernel.rs::try_execute_external.
261 if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
262 return Some(ExecResult::failure(1, msg));
263 }
264 for (var_name, value) in exported {
265 cmd.env(var_name, crate::interpreter::value_to_string(&value));
266 }
267
268 // Stdin: pipe_stdin or buffered string or inherit (interactive) or null
269 cmd.stdin(if has_pipe_stdin || has_buffered_stdin {
270 std::process::Stdio::piped()
271 } else if ctx.interactive && matches!(ctx.pipeline_position, PipelinePosition::First | PipelinePosition::Only) {
272 std::process::Stdio::inherit()
273 } else {
274 std::process::Stdio::null()
275 });
276 cmd.stdout(std::process::Stdio::piped());
277 cmd.stderr(std::process::Stdio::piped());
278
279 let mut child = match cmd.spawn() {
280 Ok(c) => c,
281 Err(e) => return Some(ExecResult::failure(127, format!("{}: {}", name, e))),
282 };
283 // Open a pidfd (Linux) for race-free direct-child kill via wait_or_kill.
284 let kill_target = crate::pidfd::KillTarget::from_child(&child);
285
286 // Stream stdin: copy pipe_stdin → child stdin in chunks (bounded memory)
287 let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = ctx.pipe_stdin.take() {
288 child.stdin.take().map(|mut child_stdin| {
289 tokio::spawn(async move {
290 let mut buf = [0u8; 8192];
291 loop {
292 match pipe_in.read(&mut buf).await {
293 Ok(0) => break, // EOF
294 Ok(n) => {
295 if child_stdin.write_all(&buf[..n]).await.is_err() {
296 break; // child closed stdin
297 }
298 }
299 Err(_) => break,
300 }
301 }
302 // Drop child_stdin signals EOF to child
303 })
304 })
305 } else if let Some(data) = ctx.stdin.take() {
306 // Buffered string stdin written from a DETACHED task, not inline:
307 // an inline write deadlocks once the stdin pipe fills before the
308 // output drain below has spawned (mirrors the kernel.rs fix; keeps
309 // the two spawn sites in sync). Drop signals EOF; a broken pipe
310 // (child closed stdin early) is fine.
311 child.stdin.take().map(|mut child_stdin| {
312 tokio::spawn(async move {
313 let _ = child_stdin.write_all(data.as_bytes()).await;
314 })
315 })
316 } else {
317 None
318 };
319
320 // Stream stdout: copy child stdout → pipe_stdout in chunks (bounded memory)
321 if let Some(mut pipe_out) = ctx.pipe_stdout.take() {
322 // Safety: stdout/stderr were set to piped() above, so take() always returns Some
323 let Some(mut child_stdout) = child.stdout.take() else {
324 return Some(ExecResult::failure(1, "internal: stdout not available"));
325 };
326 let Some(mut child_stderr_reader) = child.stderr.take() else {
327 return Some(ExecResult::failure(1, "internal: stderr not available"));
328 };
329 // Stream stderr to the kernel's stderr stream (if available) for
330 // real-time delivery. Otherwise buffer with a cap.
331 let stderr_stream_handle = ctx.stderr.clone();
332 let stderr_task = tokio::spawn(async move {
333 let mut buf = Vec::new();
334 let mut chunk = [0u8; 8192];
335 loop {
336 match child_stderr_reader.read(&mut chunk).await {
337 Ok(0) => break,
338 Ok(n) => {
339 if let Some(ref stream) = stderr_stream_handle {
340 // Stream raw bytes — no decode here, lossy decode at drain site
341 stream.write(&chunk[..n]);
342 } else {
343 buf.extend_from_slice(&chunk[..n]);
344 }
345 }
346 Err(_) => break,
347 }
348 }
349 if stderr_stream_handle.is_some() {
350 // Already streamed — return empty
351 String::new()
352 } else {
353 String::from_utf8_lossy(&buf).into_owned()
354 }
355 });
356
357 // Copy child stdout → pipe_stdout in chunks
358 let mut buf = [0u8; 8192];
359 loop {
360 match child_stdout.read(&mut buf).await {
361 Ok(0) => break,
362 Ok(n) => {
363 if pipe_out.write_all(&buf[..n]).await.is_err() {
364 break; // next stage dropped its reader (broken pipe)
365 }
366 }
367 Err(_) => break,
368 }
369 }
370 let _ = pipe_out.shutdown().await;
371 drop(pipe_out);
372 let cancel = ctx.cancel.clone();
373 let status = crate::kernel::wait_or_kill(
374 &mut child,
375 kill_target.as_ref(),
376 &cancel,
377 std::time::Duration::from_secs(2),
378 ).await;
379 // Child has exited (naturally or via kill). Abort the stdin writer
380 // (nothing more to feed a dead child). Let the stderr drain FINISH
381 // — the child's stderr pipe EOFs now that it exited, so awaiting it
382 // captures all stderr; aborting first would truncate it. Only abort
383 // the drain if we were cancelled (then we don't care about output).
384 if let Some(task) = stdin_task { task.abort(); }
385 if cancel.is_cancelled() {
386 stderr_task.abort();
387 }
388 let stderr = stderr_task.await.unwrap_or_default();
389 let code = status.map(|s| s.code().unwrap_or(1) as i64).unwrap_or(1);
390 // Output was streamed to pipe, so result.out is empty
391 Some(ExecResult::from_output(code, String::new(), stderr))
392 } else {
393 // No pipe_stdout — last stage or non-pipeline.
394 // Use spill-aware collection if output limits are configured.
395 let Some(child_stdout) = child.stdout.take() else {
396 return Some(ExecResult::failure(1, "internal: stdout not available"));
397 };
398 let Some(child_stderr) = child.stderr.take() else {
399 return Some(ExecResult::failure(1, "internal: stderr not available"));
400 };
401
402 // Always use spill_aware_collect — it handles both limited and
403 // unlimited modes, and correctly streams stderr to ctx.stderr.
404 // (wait_with_output would bypass stderr streaming.)
405 let (stdout, stderr, did_spill) = crate::output_limit::spill_aware_collect(
406 child_stdout,
407 child_stderr,
408 ctx.stderr.clone(),
409 &ctx.output_limit,
410 ).await;
411
412 let cancel = ctx.cancel.clone();
413 let status = crate::kernel::wait_or_kill(
414 &mut child,
415 kill_target.as_ref(),
416 &cancel,
417 std::time::Duration::from_secs(2),
418 ).await;
419 if let Some(task) = stdin_task { task.abort(); }
420 let code = status.map(|s| s.code().unwrap_or(1) as i64).unwrap_or(1);
421 // stdout came back as raw bytes: text if valid UTF-8, else a Bytes
422 // result (so `curl url`, `curl url > file.bin`, etc. keep binary intact).
423 let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
424 result.err = stderr;
425 result.did_spill = did_spill;
426 Some(result)
427 }
428 }
429}
430
431#[cfg(test)]
432#[async_trait]
433impl CommandDispatcher for BackendDispatcher {
434 async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
435 // Handle built-in true/false
436 match cmd.name.as_str() {
437 "true" => return Ok(ExecResult::success("")),
438 "false" => return Ok(ExecResult::failure(1, "")),
439 _ => {}
440 }
441
442 // Build tool args with schema-aware parsing (sync — no command substitution).
443 // A bad/subscripted collection access is a genuine PathError here too —
444 // propagate it via `?` rather than swallowing, same as the production
445 // Kernel::dispatch_command's `execute_command(..).await?`.
446 let schema = self.tools.get(&cmd.name).map(|t| t.schema());
447 let tool_args = build_tool_args(&cmd.args, ctx, schema.as_ref())
448 .map_err(|e| anyhow::anyhow!(e))?;
449
450 // Honor --json before the tool runs so a parse failure inside the
451 // builtin doesn't drop the format on the floor. See kernel.rs for the
452 // matching call in the production path.
453 GlobalFlags::apply_from_args(&tool_args, ctx);
454
455 // Execute via backend
456 let backend = ctx.backend.clone();
457 let result = match backend.call_tool(&cmd.name, tool_args, ctx).await {
458 Ok(tool_result) => {
459 let mut exec = ExecResult::from_output(
460 tool_result.code as i64,
461 tool_result.stdout,
462 tool_result.stderr,
463 );
464 exec.set_output(tool_result.output);
465 exec.content_type = tool_result.content_type;
466 exec.baggage = tool_result.baggage;
467 exec.latch = tool_result.latch;
468 // Restore structured data from ToolResult (preserved through backend roundtrip)
469 if let Some(json_data) = tool_result.data {
470 exec.data = Some(Value::Json(json_data));
471 }
472 exec
473 }
474 Err(BackendError::ToolNotFound(_)) => {
475 // Fall back to external command execution
476 match self.try_external(&cmd.name, &cmd.args, ctx).await {
477 Some(result) => result,
478 None => ExecResult::failure(127, format!("command not found: {}", cmd.name)),
479 }
480 }
481 Err(e) => ExecResult::failure(127, e.to_string()),
482 };
483
484 // Migrated builtins parse --json via the GlobalFlags flatten and
485 // write ctx.output_format. The kernel just applies it.
486 let result = match ctx.output_format {
487 Some(format) => apply_output_format(result, format),
488 None => result,
489 };
490
491 Ok(result)
492 }
493
494 /// Sync-only evaluation (no command substitution) — matches this
495 /// test dispatcher's documented "no async argument evaluation" limit.
496 async fn eval_expr(&self, expr: &Expr, ctx: &ExecContext) -> Result<Value> {
497 crate::scheduler::pipeline::eval_simple_expr(expr, ctx)
498 .map_err(|e| anyhow::anyhow!(e))?
499 .ok_or_else(|| anyhow::anyhow!("cannot evaluate expression in test dispatcher"))
500 }
501
502 /// BackendDispatcher is stateless, so a fork is just a clone.
503 async fn fork(&self) -> Arc<dyn CommandDispatcher> {
504 Arc::new(Self { tools: Arc::clone(&self.tools) })
505 }
506}