Skip to main content

nusy_codegraph/
executor.rs

1//! CodeGraph execution layer — execute code objects from the graph.
2//!
3//! V14.0 scope: Read CodeNode body from Arrow, execute via external Python
4//! process, capture output. No file materialization — code lives in the graph.
5//!
6//! # Future roadmap
7//!
8//! - V14.0: subprocess execution (current) — spawns `python3 -c` with code body
9//! - V14.1: PyO3 in-process execution (requires Captain approval + PyO3 dep)
10//! - Post-V14: Rust-native interpretation (long-term goal)
11//!
12//! # Security (V14.0)
13//!
14//! - **Timeout:** Enforced via `child.wait_timeout()` — process killed after deadline.
15//! - **stdout/stderr:** Captured, not printed to parent process.
16//! - **Import sandboxing:** NOT enforced in V14.0 — `call_args` is NOT sanitized.
17//!   This is an internal API for trusted agent use. Do NOT expose to untrusted input.
18//!   Sandboxing deferred to V14.1 (Captain escalation).
19
20use crate::schema::node_col;
21use arrow::array::{Array, RecordBatch, StringArray};
22use std::process::Command;
23use std::time::Duration;
24
25/// Errors from code execution.
26#[derive(Debug, thiserror::Error)]
27pub enum ExecutorError {
28    #[error("Node not found: {0}")]
29    NodeNotFound(String),
30
31    #[error("Node has no body (body_hash is null): {0}")]
32    NoBody(String),
33
34    #[error("Node kind '{kind}' is not executable (only function/method supported): {id}")]
35    NotExecutable { id: String, kind: String },
36
37    #[error("Execution timed out after {0:?}")]
38    Timeout(Duration),
39
40    #[error("Execution failed: {0}")]
41    ExecutionFailed(String),
42
43    #[error("IO error: {0}")]
44    Io(#[from] std::io::Error),
45}
46
47pub type Result<T> = std::result::Result<T, ExecutorError>;
48
49/// Result of executing a code object.
50#[derive(Debug, Clone)]
51pub struct ExecutionResult {
52    /// The node ID that was executed.
53    pub node_id: String,
54    /// Captured stdout.
55    pub stdout: String,
56    /// Captured stderr.
57    pub stderr: String,
58    /// Exit code (0 = success).
59    pub exit_code: i32,
60    /// Whether execution completed within timeout.
61    pub completed: bool,
62}
63
64/// Default execution timeout.
65const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
66
67/// Execute a CodeNode's body as Python code.
68///
69/// Reads the node's signature and docstring from the Arrow batch, constructs
70/// a minimal Python script, and executes it via `python3 -c`. This proves
71/// the D4 principle ("there are no files") — code executes from graph objects.
72///
73/// # Arguments
74///
75/// * `nodes_batch` — The CodeNodes RecordBatch
76/// * `node_id` — ID of the function/method to execute
77/// * `call_args` — Arguments to pass to the function (Python expression)
78/// * `timeout` — Maximum execution time (None = 30 seconds)
79///
80/// # Safety — UNSAFE: `call_args` is not sanitized
81///
82/// `call_args` is interpolated directly into a Python string and executed.
83/// This is an internal API for trusted agent callers only. Do NOT pass
84/// untrusted user input as `call_args` — it enables arbitrary code execution.
85/// Input validation deferred to V14.1 sandboxing.
86///
87/// # Limitations (V14.0)
88///
89/// - Only functions/methods with pure computation (no I/O, no imports beyond stdlib)
90/// - Body is reconstructed from signature + "pass" (actual body storage is Phase 2)
91/// - No import sandboxing in V14.0
92/// - subprocess-based, not in-process (PyO3 deferred to V14.1)
93pub fn execute_object(
94    nodes_batch: &RecordBatch,
95    node_id: &str,
96    call_args: &str,
97    timeout: Option<Duration>,
98) -> Result<ExecutionResult> {
99    let timeout = timeout.unwrap_or(DEFAULT_TIMEOUT);
100
101    let ids = nodes_batch
102        .column(node_col::ID)
103        .as_any()
104        .downcast_ref::<StringArray>()
105        .expect("id column");
106    let names = nodes_batch
107        .column(node_col::NAME)
108        .as_any()
109        .downcast_ref::<StringArray>()
110        .expect("name column");
111    let signatures = nodes_batch
112        .column(node_col::SIGNATURE)
113        .as_any()
114        .downcast_ref::<StringArray>()
115        .expect("signature column");
116    let body_hashes = nodes_batch
117        .column(node_col::BODY_HASH)
118        .as_any()
119        .downcast_ref::<StringArray>()
120        .expect("body_hash column");
121
122    // Extract kind from dictionary
123    let kind_col = nodes_batch.column(node_col::KIND);
124    let kind_dict = kind_col
125        .as_any()
126        .downcast_ref::<arrow::array::Int8DictionaryArray>()
127        .expect("kind dict");
128    let kind_values = kind_dict
129        .values()
130        .as_any()
131        .downcast_ref::<StringArray>()
132        .expect("kind values");
133
134    // Find the node
135    let row_idx = (0..nodes_batch.num_rows())
136        .find(|&i| ids.value(i) == node_id)
137        .ok_or_else(|| ExecutorError::NodeNotFound(node_id.to_string()))?;
138
139    // Validate kind
140    let kind_key = kind_dict.keys().value(row_idx) as usize;
141    let kind_str = kind_values.value(kind_key);
142    if kind_str != "function" && kind_str != "method" && kind_str != "test" {
143        return Err(ExecutorError::NotExecutable {
144            id: node_id.to_string(),
145            kind: kind_str.to_string(),
146        });
147    }
148
149    // Check body exists
150    if body_hashes.is_null(row_idx) {
151        return Err(ExecutorError::NoBody(node_id.to_string()));
152    }
153
154    let name = names.value(row_idx);
155    let signature = if signatures.is_null(row_idx) {
156        format!("def {}():", name)
157    } else {
158        format!("{}:", signatures.value(row_idx))
159    };
160
161    // Construct Python script with timeout enforcement.
162    // V14.0: We don't have the actual body stored in the graph yet (only body_hash).
163    // For now, construct a minimal callable that proves the execution path works.
164    // Full body storage is tracked in the "body" column (to be added).
165    //
166    // Timeout: inject Python-level signal.alarm (Unix) so the subprocess
167    // self-terminates after the deadline. No external crates needed.
168    let timeout_secs = timeout.as_secs().max(1);
169    let python_code = format!(
170        "import signal\nsignal.alarm({})\n{}\n    pass\n\nresult = {}({})\nif result is not None:\n    print(result)",
171        timeout_secs, signature, name, call_args
172    );
173
174    let output = Command::new("python3")
175        .arg("-c")
176        .arg(&python_code)
177        .output()?;
178
179    // exit_code: None means killed by signal (SIGKILL, SIGSEGV, SIGALRM, etc.)
180    let exit_code = output.status.code().unwrap_or(-1);
181    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
182    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
183
184    // Check if killed by SIGALRM (timeout)
185    if exit_code == -1 && stderr.contains("AlarmError") {
186        return Err(ExecutorError::Timeout(timeout));
187    }
188
189    Ok(ExecutionResult {
190        node_id: node_id.to_string(),
191        stdout,
192        stderr,
193        exit_code,
194        completed: true,
195    })
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::schema::{CodeNode, CodeNodeKind, build_code_nodes_batch};
202
203    fn sample_nodes() -> Vec<CodeNode> {
204        vec![
205            CodeNode {
206                id: "func:math.py::add".into(),
207                kind: CodeNodeKind::Function,
208                parent_id: None,
209                name: "add".into(),
210                signature: Some("def add(a, b)".into()),
211                docstring: None,
212                body_hash: Some("hash_add".into()),
213                body: None,
214                loc: Some(3),
215                cyclomatic_complexity: Some(1),
216                coverage_pct: None,
217                last_modified: None,
218                ..Default::default()
219            },
220            CodeNode {
221                id: "class:store.py::Store".into(),
222                kind: CodeNodeKind::Class,
223                parent_id: None,
224                name: "Store".into(),
225                signature: None,
226                docstring: None,
227                body_hash: Some("hash_store".into()),
228                body: None,
229                loc: Some(50),
230                cyclomatic_complexity: None,
231                coverage_pct: None,
232                last_modified: None,
233                ..Default::default()
234            },
235            CodeNode {
236                id: "func:math.py::no_body".into(),
237                kind: CodeNodeKind::Function,
238                parent_id: None,
239                name: "no_body".into(),
240                signature: Some("def no_body()".into()),
241                docstring: None,
242                body_hash: None, // No body
243                body: None,
244                loc: None,
245                cyclomatic_complexity: None,
246                coverage_pct: None,
247                last_modified: None,
248                ..Default::default()
249            },
250        ]
251    }
252
253    #[test]
254    fn test_execute_function() {
255        let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
256        // Pass valid args since add(a,b) expects two arguments
257        let result = execute_object(&batch, "func:math.py::add", "1, 2", None).unwrap();
258        assert_eq!(result.node_id, "func:math.py::add");
259        assert!(result.completed);
260        assert_eq!(result.exit_code, 0);
261    }
262
263    #[test]
264    fn test_execute_not_found() {
265        let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
266        let result = execute_object(&batch, "func:nonexistent::foo", "", None);
267        assert!(result.is_err());
268        assert!(matches!(
269            result.unwrap_err(),
270            ExecutorError::NodeNotFound(_)
271        ));
272    }
273
274    #[test]
275    fn test_execute_non_executable_kind() {
276        let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
277        let result = execute_object(&batch, "class:store.py::Store", "", None);
278        assert!(result.is_err());
279        assert!(matches!(
280            result.unwrap_err(),
281            ExecutorError::NotExecutable { .. }
282        ));
283    }
284
285    #[test]
286    fn test_execute_no_body() {
287        let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
288        let result = execute_object(&batch, "func:math.py::no_body", "", None);
289        assert!(result.is_err());
290        assert!(matches!(result.unwrap_err(), ExecutorError::NoBody(_)));
291    }
292
293    #[test]
294    fn test_execution_result_captures_stderr() {
295        let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
296        // Call with wrong number of args to trigger an error
297        let result = execute_object(&batch, "func:math.py::add", "", None).unwrap();
298        assert!(result.completed);
299        // Should fail because add() expects 2 args
300        assert_ne!(result.exit_code, 0);
301        assert!(!result.stderr.is_empty());
302        assert!(result.stderr.contains("TypeError"));
303    }
304}