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}