Skip to main content

clawft_kernel/
http_api.rs

1//! HTTP API for WeftOS task execution.
2//!
3//! Provides a lightweight HTTP endpoint that Paperclip (and other
4//! orchestrators) can call to submit tasks to the WeftOS kernel.
5//!
6//! # Endpoints
7//!
8//! - `POST /api/v1/execute` -- submit a task for agent execution
9//! - `POST /api/v1/govern`  -- evaluate a governance decision
10//! - `GET  /api/v1/health`  -- liveness probe
11//!
12//! # Feature Gate
13//!
14//! This module is gated behind the `http-api` feature flag and is
15//! **not** included in the default feature set.
16
17use std::collections::HashMap;
18
19use serde::{Deserialize, Serialize};
20
21use crate::governance::{
22    EffectVector, GovernanceDecision, GovernanceEngine, GovernanceRequest, GovernanceResult,
23};
24
25// ── Request / Response types ──────────────────────────────────
26
27/// Request body for `POST /api/v1/execute`.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ExecuteRequest {
30    /// Identifier of the agent to execute the task.
31    pub agent_id: String,
32
33    /// Task description or prompt.
34    pub task: String,
35
36    /// Additional context passed to the agent pipeline.
37    #[serde(default)]
38    pub context: HashMap<String, serde_json::Value>,
39}
40
41/// Response body for `POST /api/v1/execute`.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ExecuteResponse {
44    /// Execution result (agent output).
45    pub result: String,
46
47    /// Confidence or quality score (0.0 -- 1.0).
48    pub score: f64,
49
50    /// Tokens consumed during execution.
51    pub tokens_used: u64,
52
53    /// Unique execution ID for traceability.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub execution_id: Option<String>,
56}
57
58/// Request body for `POST /api/v1/govern`.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct GovernRequest {
61    /// Action being proposed.
62    pub action: String,
63
64    /// Agent requesting the action.
65    pub agent_id: String,
66
67    /// Additional context for governance evaluation.
68    #[serde(default)]
69    pub context: HashMap<String, serde_json::Value>,
70}
71
72/// Response body for `POST /api/v1/govern`.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct GovernResponse {
75    /// Governance decision: `"permit"`, `"deny"`, or `"escalate"`.
76    pub decision: String,
77
78    /// Cryptographic attestation (ExoChain hash, if available).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub attestation: Option<String>,
81
82    /// Chain hash for audit trail linkage.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub chain_hash: Option<String>,
85
86    /// Rules that were evaluated.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub evaluated_rules: Vec<String>,
89
90    /// Effect vector magnitude that was scored.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub effect_magnitude: Option<f64>,
93}
94
95/// Health check response for `GET /api/v1/health`.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct HealthResponse {
98    /// Service status: `"ok"` or `"degraded"`.
99    pub status: String,
100
101    /// Kernel version.
102    pub version: String,
103
104    /// Uptime in seconds (if tracked).
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub uptime_secs: Option<u64>,
107}
108
109/// Error response returned on failure.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ErrorResponse {
112    /// Machine-readable error code.
113    pub code: String,
114
115    /// Human-readable error message.
116    pub message: String,
117}
118
119// ── HTTP API handler ──────────────────────────────────────────
120
121/// The kernel-side HTTP API handler.
122///
123/// This is a transport-agnostic dispatcher: it accepts parsed request
124/// types and returns serializable response types. The actual HTTP
125/// server binding (hyper, axum, warp, etc.) is left to the binary
126/// crate or daemon that embeds the kernel.
127///
128/// # Example
129///
130/// ```rust,ignore
131/// let handler = HttpApiHandler::new(governance_engine);
132/// let resp = handler.handle_execute(request).await;
133/// ```
134pub struct HttpApiHandler {
135    governance: GovernanceEngine,
136}
137
138impl HttpApiHandler {
139    /// Create a new handler backed by the given governance engine.
140    pub fn new(governance: GovernanceEngine) -> Self {
141        Self { governance }
142    }
143
144    /// Handle `POST /api/v1/execute`.
145    ///
146    /// In a full integration this would:
147    /// 1. Look up the agent by `agent_id` in the process table.
148    /// 2. Feed the task through the 7-stage pipeline.
149    /// 3. Return the pipeline result.
150    ///
151    /// This stub validates the request and returns a structured
152    /// response so the adapter wire format is exercised end-to-end.
153    pub fn handle_execute(&self, req: &ExecuteRequest) -> Result<ExecuteResponse, ErrorResponse> {
154        if req.agent_id.is_empty() {
155            return Err(ErrorResponse {
156                code: "INVALID_AGENT_ID".into(),
157                message: "agent_id must not be empty".into(),
158            });
159        }
160        if req.task.is_empty() {
161            return Err(ErrorResponse {
162                code: "INVALID_TASK".into(),
163                message: "task must not be empty".into(),
164            });
165        }
166
167        // In production, this dispatches to the kernel's agent pipeline.
168        // For now, return a well-typed stub so the adapter protocol is
169        // fully exercisable.
170        let execution_id = uuid::Uuid::new_v4().to_string();
171        Ok(ExecuteResponse {
172            result: format!("Task accepted for agent '{}'", req.agent_id),
173            score: 0.0,
174            tokens_used: 0,
175            execution_id: Some(execution_id),
176        })
177    }
178
179    /// Handle `POST /api/v1/govern`.
180    ///
181    /// Evaluates the proposed action through the WeftOS governance
182    /// engine and returns the decision with optional ExoChain
183    /// attestation metadata.
184    pub fn handle_govern(&self, req: &GovernRequest) -> Result<GovernResponse, ErrorResponse> {
185        if req.agent_id.is_empty() {
186            return Err(ErrorResponse {
187                code: "INVALID_AGENT_ID".into(),
188                message: "agent_id must not be empty".into(),
189            });
190        }
191        if req.action.is_empty() {
192            return Err(ErrorResponse {
193                code: "INVALID_ACTION".into(),
194                message: "action must not be empty".into(),
195            });
196        }
197
198        // Build governance request from the HTTP payload.
199        let mut gov_req = GovernanceRequest::new(&req.agent_id, &req.action);
200
201        // Forward string context entries to governance context.
202        for (k, v) in &req.context {
203            if let Some(s) = v.as_str() {
204                gov_req = gov_req.with_context_entry(k, s);
205            }
206        }
207
208        // Extract effect dimensions from context if provided.
209        let effect = extract_effect_vector(&req.context);
210        gov_req = gov_req.with_effect(effect);
211
212        let result: GovernanceResult = self.governance.evaluate(&gov_req);
213
214        let decision_str = match &result.decision {
215            GovernanceDecision::Permit | GovernanceDecision::PermitWithWarning(_) => {
216                "permit".to_string()
217            }
218            GovernanceDecision::EscalateToHuman(_) => "escalate".to_string(),
219            GovernanceDecision::Deny(_) => "deny".to_string(),
220        };
221
222        Ok(GovernResponse {
223            decision: decision_str,
224            attestation: None, // Populated when ExoChain feature is active.
225            chain_hash: None,
226            evaluated_rules: result.evaluated_rules,
227            effect_magnitude: Some(result.effect.magnitude()),
228        })
229    }
230
231    /// Handle `GET /api/v1/health`.
232    pub fn handle_health(&self) -> HealthResponse {
233        HealthResponse {
234            status: "ok".into(),
235            version: env!("CARGO_PKG_VERSION").into(),
236            uptime_secs: None,
237        }
238    }
239}
240
241/// Extract an [`EffectVector`] from the context map.
242///
243/// Looks for keys `"risk"`, `"fairness"`, `"privacy"`, `"novelty"`,
244/// `"security"` and parses their values as f64.
245fn extract_effect_vector(context: &HashMap<String, serde_json::Value>) -> EffectVector {
246    let f = |key: &str| -> f64 { context.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0) };
247    EffectVector {
248        risk: f("risk"),
249        fairness: f("fairness"),
250        privacy: f("privacy"),
251        novelty: f("novelty"),
252        security: f("security"),
253    }
254}
255
256// ── Route dispatch helper ─────────────────────────────────────
257
258/// Route identifier for the HTTP API.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum Route {
261    /// POST /api/v1/execute
262    Execute,
263    /// POST /api/v1/govern
264    Govern,
265    /// GET /api/v1/health
266    Health,
267    /// Unknown route.
268    NotFound,
269}
270
271/// Parse a method + path pair into a [`Route`].
272pub fn match_route(method: &str, path: &str) -> Route {
273    match (method, path) {
274        ("POST", "/api/v1/execute") => Route::Execute,
275        ("POST", "/api/v1/govern") => Route::Govern,
276        ("GET", "/api/v1/health") => Route::Health,
277        _ => Route::NotFound,
278    }
279}
280
281/// Dispatch a raw JSON body to the appropriate handler and return
282/// the JSON response bytes.
283///
284/// This is a convenience function for embedding in a minimal HTTP
285/// server without pulling in a framework dependency.
286pub fn dispatch(handler: &HttpApiHandler, route: &Route, body: &[u8]) -> Result<Vec<u8>, Vec<u8>> {
287    match route {
288        Route::Execute => {
289            let req: ExecuteRequest = serde_json::from_slice(body).map_err(|e| {
290                serde_json::to_vec(&ErrorResponse {
291                    code: "PARSE_ERROR".into(),
292                    message: format!("Invalid JSON: {e}"),
293                })
294                .unwrap_or_default()
295            })?;
296            match handler.handle_execute(&req) {
297                Ok(resp) => serde_json::to_vec(&resp).map_err(|e| {
298                    serde_json::to_vec(&ErrorResponse {
299                        code: "INTERNAL".into(),
300                        message: e.to_string(),
301                    })
302                    .unwrap_or_default()
303                }),
304                Err(err) => Err(serde_json::to_vec(&err).unwrap_or_default()),
305            }
306        }
307        Route::Govern => {
308            let req: GovernRequest = serde_json::from_slice(body).map_err(|e| {
309                serde_json::to_vec(&ErrorResponse {
310                    code: "PARSE_ERROR".into(),
311                    message: format!("Invalid JSON: {e}"),
312                })
313                .unwrap_or_default()
314            })?;
315            match handler.handle_govern(&req) {
316                Ok(resp) => serde_json::to_vec(&resp).map_err(|e| {
317                    serde_json::to_vec(&ErrorResponse {
318                        code: "INTERNAL".into(),
319                        message: e.to_string(),
320                    })
321                    .unwrap_or_default()
322                }),
323                Err(err) => Err(serde_json::to_vec(&err).unwrap_or_default()),
324            }
325        }
326        Route::Health => {
327            let resp = handler.handle_health();
328            serde_json::to_vec(&resp).map_err(|e| {
329                serde_json::to_vec(&ErrorResponse {
330                    code: "INTERNAL".into(),
331                    message: e.to_string(),
332                })
333                .unwrap_or_default()
334            })
335        }
336        Route::NotFound => Err(serde_json::to_vec(&ErrorResponse {
337            code: "NOT_FOUND".into(),
338            message: "Unknown route".into(),
339        })
340        .unwrap_or_default()),
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn make_handler() -> HttpApiHandler {
349        HttpApiHandler::new(GovernanceEngine::open())
350    }
351
352    #[test]
353    fn test_execute_success() {
354        let handler = make_handler();
355        let req = ExecuteRequest {
356            agent_id: "agent-1".into(),
357            task: "summarise document".into(),
358            context: HashMap::new(),
359        };
360        let resp = handler.handle_execute(&req).unwrap();
361        assert!(resp.result.contains("agent-1"));
362        assert!(resp.execution_id.is_some());
363    }
364
365    #[test]
366    fn test_execute_empty_agent_id() {
367        let handler = make_handler();
368        let req = ExecuteRequest {
369            agent_id: String::new(),
370            task: "do something".into(),
371            context: HashMap::new(),
372        };
373        let err = handler.handle_execute(&req).unwrap_err();
374        assert_eq!(err.code, "INVALID_AGENT_ID");
375    }
376
377    #[test]
378    fn test_execute_empty_task() {
379        let handler = make_handler();
380        let req = ExecuteRequest {
381            agent_id: "a".into(),
382            task: String::new(),
383            context: HashMap::new(),
384        };
385        let err = handler.handle_execute(&req).unwrap_err();
386        assert_eq!(err.code, "INVALID_TASK");
387    }
388
389    #[test]
390    fn test_govern_permit() {
391        let handler = make_handler();
392        let req = GovernRequest {
393            action: "read_file".into(),
394            agent_id: "agent-1".into(),
395            context: HashMap::new(),
396        };
397        let resp = handler.handle_govern(&req).unwrap();
398        assert_eq!(resp.decision, "permit");
399    }
400
401    #[test]
402    fn test_govern_empty_action() {
403        let handler = make_handler();
404        let req = GovernRequest {
405            action: String::new(),
406            agent_id: "a".into(),
407            context: HashMap::new(),
408        };
409        let err = handler.handle_govern(&req).unwrap_err();
410        assert_eq!(err.code, "INVALID_ACTION");
411    }
412
413    #[test]
414    fn test_health() {
415        let handler = make_handler();
416        let resp = handler.handle_health();
417        assert_eq!(resp.status, "ok");
418        assert!(!resp.version.is_empty());
419    }
420
421    #[test]
422    fn test_route_matching() {
423        assert_eq!(match_route("POST", "/api/v1/execute"), Route::Execute);
424        assert_eq!(match_route("POST", "/api/v1/govern"), Route::Govern);
425        assert_eq!(match_route("GET", "/api/v1/health"), Route::Health);
426        assert_eq!(match_route("GET", "/api/v1/execute"), Route::NotFound);
427        assert_eq!(match_route("DELETE", "/unknown"), Route::NotFound);
428    }
429
430    #[test]
431    fn test_dispatch_execute() {
432        let handler = make_handler();
433        let body = serde_json::to_vec(&ExecuteRequest {
434            agent_id: "a1".into(),
435            task: "hello".into(),
436            context: HashMap::new(),
437        })
438        .unwrap();
439        let result = dispatch(&handler, &Route::Execute, &body);
440        assert!(result.is_ok());
441        let resp: ExecuteResponse = serde_json::from_slice(&result.unwrap()).unwrap();
442        assert!(resp.result.contains("a1"));
443    }
444
445    #[test]
446    fn test_dispatch_invalid_json() {
447        let handler = make_handler();
448        let result = dispatch(&handler, &Route::Execute, b"not json");
449        assert!(result.is_err());
450        let err: ErrorResponse = serde_json::from_slice(&result.unwrap_err()).unwrap();
451        assert_eq!(err.code, "PARSE_ERROR");
452    }
453
454    #[test]
455    fn test_dispatch_not_found() {
456        let handler = make_handler();
457        let result = dispatch(&handler, &Route::NotFound, b"{}");
458        assert!(result.is_err());
459    }
460
461    #[test]
462    fn test_effect_vector_extraction() {
463        let mut ctx = HashMap::new();
464        ctx.insert("risk".into(), serde_json::json!(0.7));
465        ctx.insert("security".into(), serde_json::json!(0.3));
466        let ev = extract_effect_vector(&ctx);
467        assert!((ev.risk - 0.7).abs() < f64::EPSILON);
468        assert!((ev.security - 0.3).abs() < f64::EPSILON);
469        assert!((ev.fairness - 0.0).abs() < f64::EPSILON);
470    }
471}