1use std::path::Path;
28
29use serde::{Deserialize, Serialize};
30
31use super::workflow_bundle::{
32 load_workflow_bundle, preview_workflow_bundle, validate_workflow_bundle, WorkflowBundle,
33};
34use super::workflow_patch::{bundle_capability_ceiling, validate_workflow_patch, WorkflowPatch};
35use super::CapabilityPolicy;
36use crate::tool_annotations::{SideEffectLevel, ToolAnnotations, ToolArgSchema, ToolKind};
37
38pub const SAFE_FUNCTION_TOOL_SCHEMA_VERSION: u32 = 1;
39
40#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
43pub struct SafeFunctionToolDescriptor {
44 pub name: String,
45 pub description: String,
46 pub annotations: ToolAnnotations,
47 pub arg_schema: serde_json::Value,
48}
49
50#[derive(Clone)]
53pub struct SafeFunctionTool {
54 pub descriptor: SafeFunctionToolDescriptor,
55 pub dispatch: fn(&serde_json::Value) -> Result<serde_json::Value, SafeFunctionToolError>,
56}
57
58#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
59pub struct SafeFunctionToolError {
60 pub code: String,
61 pub message: String,
62}
63
64impl std::fmt::Display for SafeFunctionToolError {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "{}: {}", self.code, self.message)
67 }
68}
69
70impl std::error::Error for SafeFunctionToolError {}
71
72impl SafeFunctionToolError {
73 pub fn invalid_args(message: impl Into<String>) -> Self {
74 Self {
75 code: "invalid_arguments".to_string(),
76 message: message.into(),
77 }
78 }
79 pub fn io(message: impl Into<String>) -> Self {
80 Self {
81 code: "io_error".to_string(),
82 message: message.into(),
83 }
84 }
85}
86
87pub fn safe_function_tools() -> Vec<SafeFunctionTool> {
92 vec![
93 SafeFunctionTool {
94 descriptor: SafeFunctionToolDescriptor {
95 name: "workflow_bundle_validate".to_string(),
96 description:
97 "Validate a workflow bundle JSON file at PATH and return the validator report."
98 .to_string(),
99 annotations: read_only_annotations("workspace", &["read_text"]),
100 arg_schema: bundle_path_schema(),
101 },
102 dispatch: dispatch_workflow_bundle_validate,
103 },
104 SafeFunctionTool {
105 descriptor: SafeFunctionToolDescriptor {
106 name: "workflow_bundle_preview".to_string(),
107 description:
108 "Preview a workflow bundle: return the normalized graph, validation report, mermaid text, triggers, connectors, and editable fields."
109 .to_string(),
110 annotations: read_only_annotations("workspace", &["read_text"]),
111 arg_schema: bundle_path_schema(),
112 },
113 dispatch: dispatch_workflow_bundle_preview,
114 },
115 SafeFunctionTool {
116 descriptor: SafeFunctionToolDescriptor {
117 name: "workflow_bundle_capability_ceiling".to_string(),
118 description:
119 "Compute the capability ceiling that running this bundle would request of its parent runtime."
120 .to_string(),
121 annotations: think_annotations(),
122 arg_schema: bundle_path_schema(),
123 },
124 dispatch: dispatch_workflow_bundle_capability_ceiling,
125 },
126 SafeFunctionTool {
127 descriptor: SafeFunctionToolDescriptor {
128 name: "workflow_patch_validate".to_string(),
129 description:
130 "Apply a workflow patch JSON to a bundle in memory, run bundle validation, compute the structural diff and capability delta, and return the report."
131 .to_string(),
132 annotations: read_only_annotations("workspace", &["read_text"]),
133 arg_schema: patch_validate_schema(),
134 },
135 dispatch: dispatch_workflow_patch_validate,
136 },
137 ]
138}
139
140pub fn find_safe_function_tool(name: &str) -> Option<SafeFunctionTool> {
142 safe_function_tools()
143 .into_iter()
144 .find(|tool| tool.descriptor.name == name)
145}
146
147fn read_only_annotations(capability: &str, ops: &[&str]) -> ToolAnnotations {
148 let mut capabilities = std::collections::BTreeMap::new();
149 capabilities.insert(
150 capability.to_string(),
151 ops.iter().map(|s| (*s).to_string()).collect(),
152 );
153 ToolAnnotations {
154 kind: ToolKind::Read,
155 side_effect_level: SideEffectLevel::ReadOnly,
156 arg_schema: ToolArgSchema {
157 path_params: vec!["bundle".to_string()],
158 ..ToolArgSchema::default()
159 },
160 capabilities,
161 emits_artifacts: false,
162 result_readers: Vec::new(),
163 inline_result: true,
164 }
165}
166
167fn think_annotations() -> ToolAnnotations {
168 ToolAnnotations {
169 kind: ToolKind::Think,
170 side_effect_level: SideEffectLevel::ReadOnly,
171 arg_schema: ToolArgSchema {
172 path_params: vec!["bundle".to_string()],
173 ..ToolArgSchema::default()
174 },
175 capabilities: std::collections::BTreeMap::new(),
176 emits_artifacts: false,
177 result_readers: Vec::new(),
178 inline_result: true,
179 }
180}
181
182fn bundle_path_schema() -> serde_json::Value {
183 serde_json::json!({
184 "type": "object",
185 "required": ["bundle"],
186 "additionalProperties": false,
187 "properties": {
188 "bundle": {
189 "type": "string",
190 "description": "Filesystem path to a portable workflow bundle JSON file.",
191 }
192 }
193 })
194}
195
196fn patch_validate_schema() -> serde_json::Value {
197 serde_json::json!({
198 "type": "object",
199 "required": ["bundle", "patch"],
200 "additionalProperties": false,
201 "properties": {
202 "bundle": {
203 "type": "string",
204 "description": "Filesystem path to a portable workflow bundle JSON file.",
205 },
206 "patch": {
207 "description": "Either an inline workflow patch object or a path string to a patch JSON file.",
208 "oneOf": [
209 { "type": "string" },
210 { "type": "object" }
211 ]
212 },
213 "parent_ceiling": {
214 "description": "Optional CapabilityPolicy of the executing parent context. The patch is rejected when it widens this ceiling.",
215 "type": "object"
216 }
217 }
218 })
219}
220
221fn dispatch_workflow_bundle_validate(
222 args: &serde_json::Value,
223) -> Result<serde_json::Value, SafeFunctionToolError> {
224 let bundle = load_bundle_arg(args)?;
225 let report = validate_workflow_bundle(&bundle);
226 serde_json::to_value(report).map_err(|err| SafeFunctionToolError::io(err.to_string()))
227}
228
229fn dispatch_workflow_bundle_preview(
230 args: &serde_json::Value,
231) -> Result<serde_json::Value, SafeFunctionToolError> {
232 let bundle = load_bundle_arg(args)?;
233 let preview = preview_workflow_bundle(&bundle);
234 serde_json::to_value(preview).map_err(|err| SafeFunctionToolError::io(err.to_string()))
235}
236
237fn dispatch_workflow_bundle_capability_ceiling(
238 args: &serde_json::Value,
239) -> Result<serde_json::Value, SafeFunctionToolError> {
240 let bundle = load_bundle_arg(args)?;
241 let ceiling = bundle_capability_ceiling(&bundle);
242 serde_json::to_value(ceiling).map_err(|err| SafeFunctionToolError::io(err.to_string()))
243}
244
245fn dispatch_workflow_patch_validate(
246 args: &serde_json::Value,
247) -> Result<serde_json::Value, SafeFunctionToolError> {
248 let bundle = load_bundle_arg(args)?;
249 let patch = load_patch_arg(args)?;
250 let parent_ceiling = match args.get("parent_ceiling") {
251 Some(value) if !value.is_null() => Some(
252 serde_json::from_value::<CapabilityPolicy>(value.clone())
253 .map_err(|err| SafeFunctionToolError::invalid_args(err.to_string()))?,
254 ),
255 _ => None,
256 };
257 let report = validate_workflow_patch(&bundle, &patch, parent_ceiling.as_ref());
258 serde_json::to_value(report).map_err(|err| SafeFunctionToolError::io(err.to_string()))
259}
260
261fn load_bundle_arg(args: &serde_json::Value) -> Result<WorkflowBundle, SafeFunctionToolError> {
262 let path = args
263 .get("bundle")
264 .and_then(|value| value.as_str())
265 .ok_or_else(|| SafeFunctionToolError::invalid_args("bundle must be a string path"))?;
266 load_workflow_bundle(Path::new(path))
267 .map_err(|err| SafeFunctionToolError::io(format!("failed to load bundle {path}: {err}")))
268}
269
270fn load_patch_arg(args: &serde_json::Value) -> Result<WorkflowPatch, SafeFunctionToolError> {
271 let value = args
272 .get("patch")
273 .ok_or_else(|| SafeFunctionToolError::invalid_args("patch is required"))?;
274 let raw = match value {
275 serde_json::Value::String(path) => std::fs::read_to_string(path)
276 .map(|text| serde_json::from_str(&text))
277 .map_err(|err| {
278 SafeFunctionToolError::io(format!("failed to read patch {path}: {err}"))
279 })?
280 .map_err(|err| SafeFunctionToolError::invalid_args(err.to_string()))?,
281 other => serde_json::from_value(other.clone())
282 .map_err(|err| SafeFunctionToolError::invalid_args(err.to_string()))?,
283 };
284 Ok(raw)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn registry_entries_are_readonly_or_think_only() {
293 for tool in safe_function_tools() {
294 let kind = tool.descriptor.annotations.kind;
295 assert!(
296 matches!(
297 kind,
298 ToolKind::Read | ToolKind::Search | ToolKind::Think | ToolKind::Fetch
299 ),
300 "tool {} has non-read kind {kind:?}",
301 tool.descriptor.name
302 );
303 let level = tool.descriptor.annotations.side_effect_level;
304 assert!(
305 matches!(level, SideEffectLevel::None | SideEffectLevel::ReadOnly),
306 "tool {} has non-readonly side-effect level {level:?}",
307 tool.descriptor.name
308 );
309 }
310 }
311
312 #[test]
313 fn registry_entries_have_object_argument_schemas() {
314 for tool in safe_function_tools() {
315 let schema = &tool.descriptor.arg_schema;
316 assert_eq!(
317 schema.get("type").and_then(|t| t.as_str()),
318 Some("object"),
319 "tool {} arg_schema must declare type=object",
320 tool.descriptor.name,
321 );
322 assert!(
323 schema.get("properties").is_some(),
324 "tool {} arg_schema must declare properties",
325 tool.descriptor.name,
326 );
327 }
328 }
329
330 #[test]
331 fn find_safe_function_tool_returns_none_for_unknown_name() {
332 assert!(find_safe_function_tool("does_not_exist").is_none());
333 assert!(find_safe_function_tool("workflow_bundle_validate").is_some());
334 }
335
336 fn write_pr_monitor_fixture(dir: &std::path::Path) -> std::path::PathBuf {
337 let path = dir.join("pr-monitor.bundle.json");
338 std::fs::write(
339 &path,
340 super::super::workflow_test_fixtures::PR_MONITOR_BUNDLE_JSON,
341 )
342 .unwrap();
343 path
344 }
345
346 #[test]
347 fn dispatch_validate_returns_validator_report() {
348 let temp = tempfile::tempdir().unwrap();
349 let bundle_path = write_pr_monitor_fixture(temp.path());
350 let tool = find_safe_function_tool("workflow_bundle_validate").unwrap();
351 let result = (tool.dispatch)(&serde_json::json!({"bundle": bundle_path})).unwrap();
352 assert_eq!(result["valid"], serde_json::Value::Bool(true));
353 assert_eq!(result["bundle_id"], "github-pr-monitor");
354 }
355
356 #[test]
357 fn dispatch_patch_validate_handles_inline_patch() {
358 let temp = tempfile::tempdir().unwrap();
359 let bundle_path = write_pr_monitor_fixture(temp.path());
360 let tool = find_safe_function_tool("workflow_patch_validate").unwrap();
361 let inline_patch = serde_json::json!({
362 "schema_version": 1,
363 "id": "test-patch",
364 "operations": [
365 { "op": "insert_node", "node_id": "verifier", "node": {"kind": "action", "task_label": "verify"} },
366 { "op": "add_edge", "from": "query_logs", "to": "verifier" }
367 ]
368 });
369 let result = (tool.dispatch)(&serde_json::json!({
370 "bundle": bundle_path,
371 "patch": inline_patch
372 }))
373 .unwrap();
374 assert_eq!(result["valid"], serde_json::Value::Bool(true));
375 assert_eq!(result["patch_id"], "test-patch");
376 }
377
378 #[test]
379 fn dispatch_invalid_arguments_returns_error_code() {
380 let tool = find_safe_function_tool("workflow_bundle_validate").unwrap();
381 let err = (tool.dispatch)(&serde_json::json!({"bundle": 42})).unwrap_err();
382 assert_eq!(err.code, "invalid_arguments");
383 }
384}