Skip to main content

netget/server/nfs/
actions.rs

1//! NFS protocol actions implementation.
2//!
3//! The filesystem is entirely LLM-supplied: nothing here stores a file, a directory or an
4//! attribute. `LlmNfsFileSystem` (`src/server/nfs/mod.rs`) implements `nfsserve`'s
5//! `NFSFileSystem` by turning every NFSv3 procedure into an `nfs_operation` event and reading
6//! the answer back out of the model's action. Consistency of file IDs across calls is the
7//! model's job, which is why the instruction matters so much for this protocol.
8//!
9//! **Text only.** `nfs_read_response.data` and the `data` field of a write event are UTF-8
10//! strings, because actions must not carry raw bytes or base64 — models cannot reliably
11//! produce or parse them. The consequence is real and worth stating: this server cannot serve
12//! or faithfully receive binary files. Inbound writes go through `String::from_utf8_lossy`, so
13//! any non-UTF-8 byte a client writes reaches the model as U+FFFD and is unrecoverable.
14
15use crate::llm::actions::{
16    protocol_trait::{ActionResult, Protocol, Server},
17    ActionDefinition, Parameter,
18};
19use crate::protocol::log_template::LogTemplate;
20use crate::protocol::EventType;
21use crate::state::app_state::AppState;
22use anyhow::{Context, Result};
23use serde_json::json;
24use std::sync::LazyLock;
25
26/// NFS protocol action handler
27pub struct NfsProtocol;
28
29impl NfsProtocol {
30    pub fn new() -> Self {
31        Self
32    }
33}
34
35impl Protocol for NfsProtocol {
36    /// No async actions.
37    ///
38    /// `mount_filesystem` / `unmount_filesystem` used to be declared here. Both parsed a
39    /// `path`, discarded it and returned `ActionResult::NoAction`: nothing in `nfsserve` or in
40    /// `LlmNfsFileSystem` has any notion of an export to mount, so they could not have done
41    /// anything even if something had called them. Removed rather than left as a promise.
42    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
43        Vec::new()
44    }
45
46    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
47        nfs_response_actions()
48    }
49
50    fn protocol_name(&self) -> &'static str {
51        "NFS"
52    }
53
54    fn get_event_types(&self) -> Vec<EventType> {
55        get_nfs_event_types()
56    }
57
58    fn stack_name(&self) -> &'static str {
59        "ETH>IP>TCP>NFS"
60    }
61
62    fn keywords(&self) -> Vec<&'static str> {
63        vec!["nfs", "file server"]
64    }
65
66    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
67        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
68
69        ProtocolMetadataV2::builder()
70            .state(DevelopmentState::Experimental)
71            .well_known_port(2049)
72            // Port 2049 is above 1023, so no privilege is required to bind it. (A client
73            // mounting from it may still need privileges of its own; that is the client's
74            // problem, not this server's.)
75            .implementation(
76                "nfsserve v0.10 NFSv3 server; LlmNfsFileSystem implements NFSFileSystem",
77            )
78            .llm_control(
79                "Every filesystem operation: lookup, getattr, setattr, read, write, create, \
80                 mkdir, remove, rename, readdir, symlink, readlink. Nothing is stored server-side.",
81            )
82            .e2e_testing("mount / showmount, and tests/server/nfs/test.rs")
83            .notes(
84                "NFSv3 over TCP only. File contents are UTF-8 strings in actions, so binary \
85                 files cannot be served or received faithfully. The LLM must keep file IDs \
86                 stable across calls or clients will misbehave. Every operation costs one \
87                 model round-trip, so real workloads are impractically slow - use script or \
88                 static handlers for anything beyond a demo.",
89            )
90            .max_inbound_bytes(crate::server::nfs::guard::MAX_RECORD_BYTES)
91            .build()
92    }
93
94    fn description(&self) -> &'static str {
95        "NFS file server"
96    }
97
98    fn example_prompt(&self) -> &'static str {
99        "Start an NFS file server on port 2049"
100    }
101
102    fn group_name(&self) -> &'static str {
103        "Web & File"
104    }
105
106    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
107        use crate::llm::actions::StartupExamples;
108
109        // Deterministic: answer every operation as a getattr on a directory,
110        // no LLM call.
111        let script = r#"import json, sys
112data = json.load(sys.stdin)
113event = data["event"]
114if data["event_type_id"] == "nfs_operation":
115    actions = [{"type": "nfs_getattr_response",
116                "file_type": "directory", "mode": 493, "size": 4096}]
117else:
118    actions = []
119print(json.dumps({"actions": actions}))"#;
120
121        StartupExamples::new(
122            // LLM mode
123            json!({
124                "type": "open_server",
125                "port": 2049,
126                "base_stack": "nfs",
127                "instruction": "NFS file server. On file reads, return content based on the path. On writes, acknowledge with updated attributes. Provide directory listings with sample files."
128            }),
129            // Script mode
130            json!({
131                "type": "open_server",
132                "port": 2049,
133                "base_stack": "nfs",
134                "event_handlers": [{
135                    "event_pattern": "nfs_operation",
136                    "handler": {
137                        "type": "script",
138                        "language": "python",
139                        "code": script
140                    }
141                }]
142            }),
143            // Static mode
144            json!({
145                "type": "open_server",
146                "port": 2049,
147                "base_stack": "nfs",
148                "event_handlers": [{
149                    "event_pattern": "nfs_operation",
150                    "handler": {
151                        "type": "static",
152                        "actions": [{
153                            "type": "nfs_getattr_response",
154                            "file_type": "regular",
155                            "mode": 420,
156                            "size": 1024,
157                            "uid": 1000,
158                            "gid": 1000
159                        }]
160                    }
161                }]
162            }),
163        )
164    }
165}
166
167impl Server for NfsProtocol {
168    fn spawn(
169        &self,
170        ctx: crate::protocol::SpawnContext,
171    ) -> std::pin::Pin<
172        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
173    > {
174        Box::pin(async move {
175            use crate::server::nfs::NfsServer;
176            NfsServer::spawn_with_llm_actions(
177                ctx.legacy_listen_addr(),
178                ctx.llm_client,
179                ctx.state,
180                ctx.status_tx,
181                ctx.server_id,
182            )
183            .await
184        })
185    }
186
187    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
188        let action_type = action
189            .get("type")
190            .and_then(|v| v.as_str())
191            .context("Missing 'type' field in action")?;
192
193        match action_type {
194            "nfs_lookup_response" => self.execute_nfs_lookup_response(action),
195            "nfs_read_response" => self.execute_nfs_read_response(action),
196            "nfs_write_response" => self.execute_nfs_write_response(action),
197            "nfs_getattr_response" => self.execute_nfs_getattr_response(action),
198            "nfs_create_response" => self.execute_nfs_create_response(action),
199            "nfs_remove_response" => self.execute_nfs_remove_response(action),
200            "nfs_mkdir_response" => self.execute_nfs_mkdir_response(action),
201            "nfs_readdir_response" => self.execute_nfs_readdir_response(action),
202            "nfs_rename_response" => self.execute_nfs_rename_response(action),
203            "nfs_setattr_response" => self.execute_nfs_setattr_response(action),
204            _ => Err(anyhow::anyhow!("Unknown NFS action: {}", action_type)),
205        }
206    }
207}
208
209impl NfsProtocol {
210    /// NFS LOOKUP response
211    fn execute_nfs_lookup_response(&self, action: serde_json::Value) -> Result<ActionResult> {
212        let response = json!({
213            "fileid": action.get("fileid").and_then(|v| v.as_u64()),
214            "error": action.get("error").and_then(|v| v.as_str()),
215        });
216        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
217    }
218
219    /// NFS READ response
220    fn execute_nfs_read_response(&self, action: serde_json::Value) -> Result<ActionResult> {
221        let response = json!({
222            "data": action.get("data").and_then(|v| v.as_str()).unwrap_or(""),
223            "eof": action.get("eof").and_then(|v| v.as_bool()).unwrap_or(true),
224            "error": action.get("error").and_then(|v| v.as_str()),
225        });
226        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
227    }
228
229    /// NFS WRITE response
230    fn execute_nfs_write_response(&self, action: serde_json::Value) -> Result<ActionResult> {
231        let response = json!({
232            "size": action.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
233            "mode": action.get("mode").and_then(|v| v.as_u64()).unwrap_or(0o644),
234            "mtime": action.get("mtime").and_then(|v| v.as_u64()),
235            "error": action.get("error").and_then(|v| v.as_str()),
236        });
237        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
238    }
239
240    /// NFS GETATTR response
241    fn execute_nfs_getattr_response(&self, action: serde_json::Value) -> Result<ActionResult> {
242        let response = json!({
243            "file_type": action.get("file_type").and_then(|v| v.as_str()).unwrap_or("regular"),
244            "mode": action.get("mode").and_then(|v| v.as_u64()).unwrap_or(0o644),
245            "size": action.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
246            "uid": action.get("uid").and_then(|v| v.as_u64()).unwrap_or(0),
247            "gid": action.get("gid").and_then(|v| v.as_u64()).unwrap_or(0),
248            "atime": action.get("atime").and_then(|v| v.as_u64()),
249            "mtime": action.get("mtime").and_then(|v| v.as_u64()),
250            "ctime": action.get("ctime").and_then(|v| v.as_u64()),
251            "error": action.get("error").and_then(|v| v.as_str()),
252        });
253        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
254    }
255
256    /// NFS CREATE response
257    fn execute_nfs_create_response(&self, action: serde_json::Value) -> Result<ActionResult> {
258        let response = json!({
259            "fileid": action.get("fileid").and_then(|v| v.as_u64()),
260            "size": action.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
261            "mode": action.get("mode").and_then(|v| v.as_u64()).unwrap_or(0o644),
262            "error": action.get("error").and_then(|v| v.as_str()),
263        });
264        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
265    }
266
267    /// NFS REMOVE response
268    fn execute_nfs_remove_response(&self, action: serde_json::Value) -> Result<ActionResult> {
269        let response = json!({
270            "success": action.get("success").and_then(|v| v.as_bool()).unwrap_or(false),
271            "error": action.get("error").and_then(|v| v.as_str()),
272        });
273        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
274    }
275
276    /// NFS MKDIR response
277    fn execute_nfs_mkdir_response(&self, action: serde_json::Value) -> Result<ActionResult> {
278        let response = json!({
279            "fileid": action.get("fileid").and_then(|v| v.as_u64()),
280            "mode": action.get("mode").and_then(|v| v.as_u64()).unwrap_or(0o755),
281            "error": action.get("error").and_then(|v| v.as_str()),
282        });
283        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
284    }
285
286    /// NFS READDIR response
287    fn execute_nfs_readdir_response(&self, action: serde_json::Value) -> Result<ActionResult> {
288        let response = json!({
289            "entries": action.get("entries").and_then(|v| v.as_array()).cloned().unwrap_or_default(),
290            "eof": action.get("eof").and_then(|v| v.as_bool()).unwrap_or(true),
291            "error": action.get("error").and_then(|v| v.as_str()),
292        });
293        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
294    }
295
296    /// NFS RENAME response
297    fn execute_nfs_rename_response(&self, action: serde_json::Value) -> Result<ActionResult> {
298        let response = json!({
299            "success": action.get("success").and_then(|v| v.as_bool()).unwrap_or(false),
300            "error": action.get("error").and_then(|v| v.as_str()),
301        });
302        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
303    }
304
305    /// NFS SETATTR response
306    fn execute_nfs_setattr_response(&self, action: serde_json::Value) -> Result<ActionResult> {
307        let response = json!({
308            "size": action.get("size").and_then(|v| v.as_u64()),
309            "mode": action.get("mode").and_then(|v| v.as_u64()),
310            "mtime": action.get("mtime").and_then(|v| v.as_u64()),
311            "error": action.get("error").and_then(|v| v.as_str()),
312        });
313        Ok(ActionResult::Output(serde_json::to_vec(&response)?))
314    }
315}
316
317/// Every response action, in the order the operations appear in `NFSFileSystem`.
318///
319/// This is both `get_sync_actions()` and the action list attached to `nfs_operation`. The
320/// event carries the operation name, so the model picks the matching response from this set;
321/// there is exactly one event type, and splitting the actions per operation would mean
322/// splitting the event type too.
323fn nfs_response_actions() -> Vec<ActionDefinition> {
324    vec![
325        nfs_lookup_response_action(),
326        nfs_getattr_response_action(),
327        nfs_setattr_response_action(),
328        nfs_read_response_action(),
329        nfs_write_response_action(),
330        nfs_create_response_action(),
331        nfs_mkdir_response_action(),
332        nfs_remove_response_action(),
333        nfs_rename_response_action(),
334        nfs_readdir_response_action(),
335    ]
336}
337
338/// Action definitions
339fn nfs_lookup_response_action() -> ActionDefinition {
340    ActionDefinition {
341        name: "nfs_lookup_response".to_string(),
342        description: "Return file ID for NFS LOOKUP operation".to_string(),
343        parameters: vec![
344            Parameter {
345                name: "fileid".to_string(),
346                type_hint: "number".to_string(),
347                description: "File ID (unique identifier) if file exists".to_string(),
348                required: false,
349            },
350            Parameter {
351                name: "error".to_string(),
352                type_hint: "string".to_string(),
353                description:
354                    "NFS error code if operation failed (e.g. 'NFS3ERR_NOENT', 'NFS3ERR_ACCES')"
355                        .to_string(),
356                required: false,
357            },
358        ],
359        example: json!({
360            "type": "nfs_lookup_response",
361            "fileid": 42
362        }),
363        log_template: Some(
364            LogTemplate::new()
365                .with_info("-> NFS lookup fileid={fileid}")
366                .with_debug("NFS nfs_lookup_response: fileid={fileid}"),
367        ),
368    }
369}
370
371fn nfs_read_response_action() -> ActionDefinition {
372    ActionDefinition {
373        name: "nfs_read_response".to_string(),
374        description: "Return file data for NFS READ operation".to_string(),
375        parameters: vec![
376            Parameter {
377                name: "data".to_string(),
378                type_hint: "string".to_string(),
379                description:
380                    "File content to return, as text. Sent to the client verbatim as UTF-8 \
381                     bytes; there is no binary or encoded form, so binary files cannot be \
382                     served."
383                        .to_string(),
384                required: true,
385            },
386            Parameter {
387                name: "eof".to_string(),
388                type_hint: "boolean".to_string(),
389                description: "True if end of file reached".to_string(),
390                required: true,
391            },
392            Parameter {
393                name: "error".to_string(),
394                type_hint: "string".to_string(),
395                description: "NFS error code if operation failed".to_string(),
396                required: false,
397            },
398        ],
399        example: json!({
400            "type": "nfs_read_response",
401            "data": "Hello from NFS!",
402            "eof": false
403        }),
404        log_template: Some(
405            LogTemplate::new()
406                .with_info("-> NFS read data")
407                .with_debug("NFS nfs_read_response: eof={eof}"),
408        ),
409    }
410}
411
412fn nfs_write_response_action() -> ActionDefinition {
413    ActionDefinition {
414        name: "nfs_write_response".to_string(),
415        description: "Return file attributes after NFS WRITE operation".to_string(),
416        parameters: vec![
417            Parameter {
418                name: "size".to_string(),
419                type_hint: "number".to_string(),
420                description: "New file size after write".to_string(),
421                required: true,
422            },
423            Parameter {
424                name: "mode".to_string(),
425                type_hint: "number".to_string(),
426                description: "File permissions as a decimal number (420 = 0644, 493 = 0755)"
427                    .to_string(),
428                required: false,
429            },
430            Parameter {
431                name: "mtime".to_string(),
432                type_hint: "number".to_string(),
433                description: "Modification time (Unix timestamp)".to_string(),
434                required: false,
435            },
436            Parameter {
437                name: "error".to_string(),
438                type_hint: "string".to_string(),
439                description: "NFS error code if operation failed".to_string(),
440                required: false,
441            },
442        ],
443        example: json!({
444            "type": "nfs_write_response",
445            "size": 1024,
446            "mode": 420
447        }),
448        log_template: Some(
449            LogTemplate::new()
450                .with_info("-> NFS write size={size}")
451                .with_debug("NFS nfs_write_response: size={size}, mode={mode}"),
452        ),
453    }
454}
455
456fn nfs_getattr_response_action() -> ActionDefinition {
457    ActionDefinition {
458        name: "nfs_getattr_response".to_string(),
459        description: "Return file/directory attributes for NFS GETATTR operation".to_string(),
460        parameters: vec![
461            Parameter {
462                name: "file_type".to_string(),
463                type_hint: "string".to_string(),
464                description: "'regular' for file, 'directory' for dir".to_string(),
465                required: true,
466            },
467            Parameter {
468                name: "mode".to_string(),
469                type_hint: "number".to_string(),
470                description:
471                    "Permissions as a decimal number (420 = 0644 for files, 493 = 0755 for dirs)"
472                        .to_string(),
473                required: true,
474            },
475            Parameter {
476                name: "size".to_string(),
477                type_hint: "number".to_string(),
478                description: "File size in bytes".to_string(),
479                required: true,
480            },
481            Parameter {
482                name: "uid".to_string(),
483                type_hint: "number".to_string(),
484                description: "Owner user ID".to_string(),
485                required: false,
486            },
487            Parameter {
488                name: "gid".to_string(),
489                type_hint: "number".to_string(),
490                description: "Owner group ID".to_string(),
491                required: false,
492            },
493            Parameter {
494                name: "atime".to_string(),
495                type_hint: "number".to_string(),
496                description: "Access time (Unix timestamp)".to_string(),
497                required: false,
498            },
499            Parameter {
500                name: "mtime".to_string(),
501                type_hint: "number".to_string(),
502                description: "Modification time (Unix timestamp)".to_string(),
503                required: false,
504            },
505            Parameter {
506                name: "ctime".to_string(),
507                type_hint: "number".to_string(),
508                description: "Status change time (Unix timestamp)".to_string(),
509                required: false,
510            },
511            Parameter {
512                name: "error".to_string(),
513                type_hint: "string".to_string(),
514                description: "NFS error code if operation failed".to_string(),
515                required: false,
516            },
517        ],
518        example: json!({
519            "type": "nfs_getattr_response",
520            "file_type": "regular",
521            "mode": 420,
522            "size": 1024,
523            "uid": 1000,
524            "gid": 1000
525        }),
526        log_template: Some(
527            LogTemplate::new()
528                .with_info("-> NFS attr {file_type} size={size}")
529                .with_debug("NFS nfs_getattr_response: type={file_type}, size={size}, mode={mode}"),
530        ),
531    }
532}
533
534fn nfs_create_response_action() -> ActionDefinition {
535    ActionDefinition {
536        name: "nfs_create_response".to_string(),
537        description: "Return new file ID and attributes for NFS CREATE operation".to_string(),
538        parameters: vec![
539            Parameter {
540                name: "fileid".to_string(),
541                type_hint: "number".to_string(),
542                description: "New file ID".to_string(),
543                required: true,
544            },
545            Parameter {
546                name: "size".to_string(),
547                type_hint: "number".to_string(),
548                description: "Initial file size (usually 0)".to_string(),
549                required: false,
550            },
551            Parameter {
552                name: "mode".to_string(),
553                type_hint: "number".to_string(),
554                description: "File permissions".to_string(),
555                required: false,
556            },
557            Parameter {
558                name: "error".to_string(),
559                type_hint: "string".to_string(),
560                description: "NFS error code if operation failed".to_string(),
561                required: false,
562            },
563        ],
564        example: json!({
565            "type": "nfs_create_response",
566            "fileid": 123,
567            "size": 0,
568            "mode": 420
569        }),
570        log_template: Some(
571            LogTemplate::new()
572                .with_info("-> NFS create fileid={fileid}")
573                .with_debug("NFS nfs_create_response: fileid={fileid}, mode={mode}"),
574        ),
575    }
576}
577
578fn nfs_remove_response_action() -> ActionDefinition {
579    ActionDefinition {
580        name: "nfs_remove_response".to_string(),
581        description: "Return success/error for NFS REMOVE operation".to_string(),
582        parameters: vec![
583            Parameter {
584                name: "success".to_string(),
585                type_hint: "boolean".to_string(),
586                description: "True if file was removed successfully".to_string(),
587                required: true,
588            },
589            Parameter {
590                name: "error".to_string(),
591                type_hint: "string".to_string(),
592                description: "NFS error code if operation failed".to_string(),
593                required: false,
594            },
595        ],
596        example: json!({
597            "type": "nfs_remove_response",
598            "success": true
599        }),
600        log_template: Some(
601            LogTemplate::new()
602                .with_info("-> NFS remove success={success}")
603                .with_debug("NFS nfs_remove_response: success={success}"),
604        ),
605    }
606}
607
608fn nfs_mkdir_response_action() -> ActionDefinition {
609    ActionDefinition {
610        name: "nfs_mkdir_response".to_string(),
611        description: "Return new directory ID for NFS MKDIR operation".to_string(),
612        parameters: vec![
613            Parameter {
614                name: "fileid".to_string(),
615                type_hint: "number".to_string(),
616                description: "New directory ID".to_string(),
617                required: true,
618            },
619            Parameter {
620                name: "mode".to_string(),
621                type_hint: "number".to_string(),
622                description: "Directory permissions as a decimal number (default 493 = 0755)"
623                    .to_string(),
624                required: false,
625            },
626            Parameter {
627                name: "error".to_string(),
628                type_hint: "string".to_string(),
629                description: "NFS error code if operation failed".to_string(),
630                required: false,
631            },
632        ],
633        example: json!({
634            "type": "nfs_mkdir_response",
635            "fileid": 456,
636            "mode": 493
637        }),
638        log_template: Some(
639            LogTemplate::new()
640                .with_info("-> NFS mkdir fileid={fileid}")
641                .with_debug("NFS nfs_mkdir_response: fileid={fileid}, mode={mode}"),
642        ),
643    }
644}
645
646fn nfs_readdir_response_action() -> ActionDefinition {
647    ActionDefinition {
648        name: "nfs_readdir_response".to_string(),
649        description: "Return directory listing for NFS READDIR operation".to_string(),
650        parameters: vec![
651            Parameter {
652                name: "entries".to_string(),
653                type_hint: "array".to_string(),
654                description:
655                    "Array of directory entries [{\"name\": \"file.txt\", \"fileid\": 42}, ...]"
656                        .to_string(),
657                required: true,
658            },
659            Parameter {
660                name: "eof".to_string(),
661                type_hint: "boolean".to_string(),
662                description: "True if no more entries".to_string(),
663                required: true,
664            },
665            Parameter {
666                name: "error".to_string(),
667                type_hint: "string".to_string(),
668                description: "NFS error code if operation failed".to_string(),
669                required: false,
670            },
671        ],
672        example: json!({
673            "type": "nfs_readdir_response",
674            "entries": [
675                {"name": "file.txt", "fileid": 42},
676                {"name": "subdir", "fileid": 43}
677            ],
678            "eof": true
679        }),
680        log_template: Some(
681            LogTemplate::new()
682                .with_info("-> NFS readdir {entries_len} entries")
683                .with_debug("NFS nfs_readdir_response: {entries_len} entries, eof={eof}"),
684        ),
685    }
686}
687
688fn nfs_rename_response_action() -> ActionDefinition {
689    ActionDefinition {
690        name: "nfs_rename_response".to_string(),
691        description: "Return success/error for NFS RENAME operation".to_string(),
692        parameters: vec![
693            Parameter {
694                name: "success".to_string(),
695                type_hint: "boolean".to_string(),
696                description: "True if file was renamed successfully".to_string(),
697                required: true,
698            },
699            Parameter {
700                name: "error".to_string(),
701                type_hint: "string".to_string(),
702                description: "NFS error code if operation failed".to_string(),
703                required: false,
704            },
705        ],
706        example: json!({
707            "type": "nfs_rename_response",
708            "success": true
709        }),
710        log_template: Some(
711            LogTemplate::new()
712                .with_info("-> NFS rename success={success}")
713                .with_debug("NFS nfs_rename_response: success={success}"),
714        ),
715    }
716}
717
718fn nfs_setattr_response_action() -> ActionDefinition {
719    ActionDefinition {
720        name: "nfs_setattr_response".to_string(),
721        description: "Return updated attributes for NFS SETATTR operation".to_string(),
722        parameters: vec![
723            Parameter {
724                name: "size".to_string(),
725                type_hint: "number".to_string(),
726                description: "New file size".to_string(),
727                required: false,
728            },
729            Parameter {
730                name: "mode".to_string(),
731                type_hint: "number".to_string(),
732                description: "New permissions".to_string(),
733                required: false,
734            },
735            Parameter {
736                name: "mtime".to_string(),
737                type_hint: "number".to_string(),
738                description: "New modification time".to_string(),
739                required: false,
740            },
741            Parameter {
742                name: "error".to_string(),
743                type_hint: "string".to_string(),
744                description: "NFS error code if operation failed".to_string(),
745                required: false,
746            },
747        ],
748        example: json!({
749            "type": "nfs_setattr_response",
750            "mode": 384
751        }),
752        log_template: Some(
753            LogTemplate::new()
754                .with_info("-> NFS setattr")
755                .with_debug("NFS nfs_setattr_response: mode={mode}"),
756        ),
757    }
758}
759
760// ============================================================================
761// NFS Event Type Constants
762// ============================================================================
763
764/// NFS operation event - triggered when NFS client requests a filesystem operation
765///
766/// The action list is the whole protocol. `call_llm` builds the model's vocabulary from
767/// `event.event_type.actions`, **not** from `get_sync_actions()`, so the empty
768/// `.with_actions(vec![])` this used to carry meant the model was offered `set_memory`,
769/// `show_message` and nothing else: every `nfs_*_response` it produced was rejected as an
770/// unknown action, retried twice, and failed. No NFS operation could ever be answered.
771pub static NFS_OPERATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
772    EventType::new(
773        "nfs_operation",
774        "NFS client requested a filesystem operation",
775        json!({"type": "nfs_getattr_response", "file_type": "regular", "mode": 420, "size": 13}),
776    )
777    .with_parameters(vec![
778        Parameter {
779            name: "operation".to_string(),
780            type_hint: "string".to_string(),
781            description: "The NFS operation type (lookup, getattr, setattr, read, write, create, mkdir, remove, rename, readdir, symlink, readlink)".to_string(),
782            required: true,
783        },
784        Parameter {
785            name: "params".to_string(),
786            type_hint: "object".to_string(),
787            description: "Operation-specific parameters (fileid, dirid, filename, offset, count, data, ...)".to_string(),
788            required: true,
789        },
790    ])
791    // The model must be able to answer every operation, and only the operation field tells it
792    // which response to pick, so all response actions are advertised on the one event.
793    .with_actions(nfs_response_actions())
794    .with_alternative_example(json!({
795        "type": "nfs_lookup_response",
796        "fileid": 2
797    }))
798    .with_alternative_example(json!({
799        "type": "nfs_read_response",
800        "data": "Hello from NFS",
801        "eof": true
802    }))
803    .with_alternative_example(json!({
804        "type": "nfs_readdir_response",
805        "entries": [{"name": "readme.txt", "fileid": 2}],
806        "eof": true
807    }))
808    .with_log_template(
809        LogTemplate::new()
810            .with_info("NFS {operation}")
811            .with_debug("NFS {operation}: {params}")
812            .with_trace("NFS: {json_pretty(.)}"),
813    )
814});
815
816/// Get NFS event types
817pub fn get_nfs_event_types() -> Vec<EventType> {
818    vec![NFS_OPERATION_EVENT.clone()]
819}