Skip to main content

objectiveai_sdk/laboratories/filetree/
wire.rs

1//! Wire types for the laboratory `/filetree` SSE endpoint.
2//!
3//! The endpoint yields a full recursive tree [`Snapshot`] as its first
4//! event, then live [`Upserted`] / [`Removed`] deltas as the container
5//! filesystem changes. A [`FileTreeNode`] is one node of the tree — a
6//! tagged union of `file` / `directory` / `symlink`, where only a
7//! directory carries `children`. The snapshot is the watched root's
8//! child list; deltas address a node by its component `path`.
9//!
10//! [`Snapshot`]: FileTreeEvent::Snapshot
11//! [`Upserted`]: FileTreeEvent::Upserted
12//! [`Removed`]: FileTreeEvent::Removed
13
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17/// One node of the filesystem tree — discriminated by `type`. A
18/// `directory` carries its `children` inline; `file` and `symlink` are
19/// leaves. Symlinks are the link itself (never followed), so a broken
20/// or looping link renders as a leaf rather than confusing the tree.
21///
22/// Common metadata on every variant: `name` (basename), `created_at`
23/// (birth time when the filesystem records one), `modified_at`
24/// (mtime), and the reserved `created_by`/`modified_by` (the
25/// attribution engine fills these later; always `None` today).
26#[derive(
27    Debug,
28    Clone,
29    PartialEq,
30    Eq,
31    Serialize,
32    Deserialize,
33    JsonSchema,
34    arbitrary::Arbitrary,
35)]
36#[serde(tag = "type", rename_all = "snake_case")]
37#[schemars(rename = "laboratories.filetree.FileTreeNode")]
38pub enum FileTreeNode {
39    /// A regular file.
40    #[schemars(title = "File")]
41    File {
42        /// Basename of this file.
43        name: String,
44        /// Size in bytes.
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        #[schemars(extend("omitempty" = true))]
47        #[arbitrary(with = crate::arbitrary_util::arbitrary_option_u64)]
48        size: Option<u64>,
49        /// Creation time (unix seconds), when the filesystem records a
50        /// birth time. `None` when unsupported — best-effort display
51        /// metadata, never load-bearing.
52        #[serde(default, skip_serializing_if = "Option::is_none")]
53        #[schemars(extend("omitempty" = true))]
54        #[arbitrary(with = crate::arbitrary_util::arbitrary_option_i64)]
55        created_at: Option<i64>,
56        /// Last-modified time (unix seconds). `None` when the stat
57        /// couldn't be read.
58        #[serde(default, skip_serializing_if = "Option::is_none")]
59        #[schemars(extend("omitempty" = true))]
60        #[arbitrary(with = crate::arbitrary_util::arbitrary_option_i64)]
61        modified_at: Option<i64>,
62        /// The agent that created this entry, when known. Reserved for
63        /// the attribution engine; currently always `None`.
64        #[serde(default, skip_serializing_if = "Option::is_none")]
65        #[schemars(extend("omitempty" = true))]
66        created_by: Option<String>,
67        /// The agent that last modified this entry, when known.
68        /// Reserved; currently always `None`.
69        #[serde(default, skip_serializing_if = "Option::is_none")]
70        #[schemars(extend("omitempty" = true))]
71        modified_by: Option<String>,
72    },
73    /// A directory — carries its child nodes.
74    #[schemars(title = "Directory")]
75    Directory {
76        /// Basename of this directory (the watched root is represented
77        /// by the snapshot's child list, not by a root node).
78        name: String,
79        /// Creation time (unix seconds), when the filesystem records a
80        /// birth time.
81        #[serde(default, skip_serializing_if = "Option::is_none")]
82        #[schemars(extend("omitempty" = true))]
83        #[arbitrary(with = crate::arbitrary_util::arbitrary_option_i64)]
84        created_at: Option<i64>,
85        /// Last-modified time (unix seconds) — a directory's mtime
86        /// tracks entry add/remove.
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        #[schemars(extend("omitempty" = true))]
89        #[arbitrary(with = crate::arbitrary_util::arbitrary_option_i64)]
90        modified_at: Option<i64>,
91        /// The agent that created this entry, when known. Reserved.
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        #[schemars(extend("omitempty" = true))]
94        created_by: Option<String>,
95        /// The agent that last modified this entry, when known.
96        /// Reserved.
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        #[schemars(extend("omitempty" = true))]
99        modified_by: Option<String>,
100        /// This directory's entries. Empty for an empty directory —
101        /// never absent.
102        children: Vec<FileTreeNode>,
103    },
104    /// A symbolic link (the link itself, not its target — never
105    /// followed).
106    #[schemars(title = "Symlink")]
107    Symlink {
108        /// Basename of this link.
109        name: String,
110        /// The link's target path, exactly as stored in the link
111        /// (possibly relative, possibly dangling — never resolved or
112        /// followed). `None` only when the readlink itself failed.
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        #[schemars(extend("omitempty" = true))]
115        target: Option<String>,
116        /// Creation time (unix seconds), when the filesystem records a
117        /// birth time.
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        #[schemars(extend("omitempty" = true))]
120        #[arbitrary(with = crate::arbitrary_util::arbitrary_option_i64)]
121        created_at: Option<i64>,
122        /// Last-modified time (unix seconds).
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        #[schemars(extend("omitempty" = true))]
125        #[arbitrary(with = crate::arbitrary_util::arbitrary_option_i64)]
126        modified_at: Option<i64>,
127        /// The agent that created this entry, when known. Reserved.
128        #[serde(default, skip_serializing_if = "Option::is_none")]
129        #[schemars(extend("omitempty" = true))]
130        created_by: Option<String>,
131        /// The agent that last modified this entry, when known.
132        /// Reserved.
133        #[serde(default, skip_serializing_if = "Option::is_none")]
134        #[schemars(extend("omitempty" = true))]
135        modified_by: Option<String>,
136    },
137}
138
139impl FileTreeNode {
140    /// This node's basename.
141    pub fn name(&self) -> &str {
142        match self {
143            FileTreeNode::File { name, .. }
144            | FileTreeNode::Directory { name, .. }
145            | FileTreeNode::Symlink { name, .. } => name,
146        }
147    }
148
149    /// Mutable access to a directory's children — `None` for files and
150    /// symlinks.
151    pub fn children_mut(&mut self) -> Option<&mut Vec<FileTreeNode>> {
152        match self {
153            FileTreeNode::Directory { children, .. } => Some(children),
154            _ => None,
155        }
156    }
157}
158
159/// One event on the `/filetree` SSE stream. The first is always a
160/// [`Snapshot`](FileTreeEvent::Snapshot); every later one upserts or
161/// removes a single node as the filesystem changes.
162#[derive(
163    Debug,
164    Clone,
165    PartialEq,
166    Eq,
167    Serialize,
168    Deserialize,
169    JsonSchema,
170    arbitrary::Arbitrary,
171)]
172#[serde(tag = "type", rename_all = "snake_case")]
173#[schemars(rename = "laboratories.filetree.FileTreeEvent")]
174pub enum FileTreeEvent {
175    /// The full tree — the watched root's child nodes — sent once
176    /// immediately on connect.
177    #[schemars(title = "Snapshot")]
178    Snapshot {
179        /// The watched root's entries (its own identity is implicit —
180        /// the caller knows the watched path).
181        children: Vec<FileTreeNode>,
182    },
183    /// A single node came into existence or changed. A directory
184    /// carries its whole (re-walked) subtree.
185    #[schemars(title = "Upserted")]
186    Upserted {
187        /// The FULL component path to the node, relative to the
188        /// watched root (the last element equals `node`'s name).
189        path: Vec<String>,
190        /// The new/updated node.
191        node: FileTreeNode,
192    },
193    /// A single node (and, if a directory, its whole subtree) was
194    /// removed.
195    #[schemars(title = "Removed")]
196    Removed {
197        /// The FULL component path to the vanished node, relative to
198        /// the watched root.
199        path: Vec<String>,
200    },
201}
202
203impl FileTreeEvent {
204    /// Fold this event into a live tree (the watched root's children).
205    /// `Snapshot` replaces the whole child set; `Upserted` inserts or
206    /// replaces one node at its path (a directory node carries its
207    /// whole subtree); `Removed` drops the node at its path (and,
208    /// being a subtree, everything under it). Idempotent — replaying an
209    /// already-applied event leaves the tree unchanged, so at-least-once
210    /// delivery is safe.
211    ///
212    /// This is THE fold, shared by every holder of a materialized tree:
213    /// the SDK's `FileTree` client, the laboratory host's per-lab
214    /// state, and the CLI daemon's per-host state.
215    pub fn apply(self, root: &mut Vec<FileTreeNode>) {
216        match self {
217            FileTreeEvent::Snapshot { children } => {
218                *root = children;
219            }
220            FileTreeEvent::Upserted { path, node } => {
221                let Some((leaf, parents)) = path.split_last() else {
222                    // Empty path can't address a node — ignore.
223                    return;
224                };
225                let siblings = descend_mut(root, parents);
226                match siblings.iter().position(|c| c.name() == leaf) {
227                    Some(i) => siblings[i] = node,
228                    None => siblings.push(node),
229                }
230            }
231            FileTreeEvent::Removed { path } => {
232                let Some((leaf, parents)) = path.split_last() else {
233                    return;
234                };
235                let siblings = descend_mut(root, parents);
236                siblings.retain(|c| c.name() != leaf);
237            }
238        }
239    }
240}
241
242/// Walk `comps` from `children`, following each component into its
243/// directory's child list; a missing middle segment is created as a
244/// synthetic empty directory (defensive — the server sends parents
245/// before children, but a dropped frame shouldn't wedge the fold).
246/// Returns the child list at the end of the walk.
247fn descend_mut<'a>(
248    mut children: &'a mut Vec<FileTreeNode>,
249    comps: &[String],
250) -> &'a mut Vec<FileTreeNode> {
251    for comp in comps {
252        let idx = match children.iter().position(|c| c.name() == comp) {
253            Some(i) => i,
254            None => {
255                children.push(FileTreeNode::Directory {
256                    name: comp.clone(),
257                    created_at: None,
258                    modified_at: None,
259                    created_by: None,
260                    modified_by: None,
261                    children: Vec::new(),
262                });
263                children.len() - 1
264            }
265        };
266        // A non-directory sitting where a directory should be gets
267        // replaced with an empty directory so the walk can continue.
268        if children[idx].children_mut().is_none() {
269            children[idx] = FileTreeNode::Directory {
270                name: comp.clone(),
271                created_at: None,
272                modified_at: None,
273                created_by: None,
274                modified_by: None,
275                children: Vec::new(),
276            };
277        }
278        children = children[idx].children_mut().expect("just ensured directory");
279    }
280    children
281}