Skip to main content

bamboo_engine/session_app/child_session/
owned_tree.rs

1//! Read-only, caller-scoped tree inspection from canonical durable Sessions.
2//! The index supplies candidate IDs only; every displayed field comes from a
3//! verified Session. The same reader serves the Root tool and Host worker RPC.
4
5use std::collections::{HashMap, HashSet, VecDeque};
6
7use async_trait::async_trait;
8use bamboo_agent_core::tools::ToolResult;
9use bamboo_domain::{ActorSession, Session, SessionKind, Storage};
10use serde_json::{json, Value};
11use sha2::{Digest, Sha256};
12
13use super::{assemble_session_tree, ChildSessionEntry, SessionTreeNode, MAX_CHILD_RESULT_BYTES};
14
15pub const MAX_TREE_CURSOR_BYTES: usize = 128;
16const PAGE_NODES: usize = 32;
17// A display window, independent of the policy for creating new children.
18const DISPLAY_DEPTH: u32 = 4;
19const INDEX_NODE_CAP: usize = 5000;
20pub const INDEX_SNAPSHOT_CAP: usize = 50_000;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum OwnedTreeError {
24    InvalidLineage,
25    InvalidCursor,
26    ResultTooLarge,
27}
28
29#[async_trait]
30pub trait OwnedTreePort: Send + Sync {
31    async fn load(&self, id: &str) -> Result<Session, OwnedTreeError>;
32    async fn child_ids(&self, parent_id: &str) -> Result<Vec<String>, OwnedTreeError>;
33    /// Production stores return one bounded candidate index for the whole
34    /// inspection. Embeddings without this operation retain per-parent reads.
35    async fn child_index(&self) -> Result<Option<Vec<(String, String)>>, OwnedTreeError> {
36        Ok(None)
37    }
38}
39
40/// The Host's rebuildable index is only a source of candidate IDs. `load`
41/// always reads the canonical V2 Session before granting visibility.
42#[async_trait]
43impl OwnedTreePort for bamboo_storage::SessionStoreV2 {
44    async fn load(&self, id: &str) -> Result<Session, OwnedTreeError> {
45        Storage::load_session(self, id)
46            .await
47            .map_err(|_| OwnedTreeError::InvalidLineage)?
48            .ok_or(OwnedTreeError::InvalidLineage)
49    }
50
51    async fn child_ids(&self, parent_id: &str) -> Result<Vec<String>, OwnedTreeError> {
52        Ok(self
53            .list_index_entries()
54            .await
55            .into_iter()
56            .filter(|entry| {
57                entry.kind == SessionKind::Child
58                    && entry.parent_session_id.as_deref() == Some(parent_id)
59            })
60            .map(|entry| entry.id)
61            .collect())
62    }
63
64    async fn child_index(&self) -> Result<Option<Vec<(String, String)>>, OwnedTreeError> {
65        let entries = self.list_index_entries().await;
66        if entries.len() > INDEX_SNAPSHOT_CAP {
67            return Err(OwnedTreeError::InvalidLineage);
68        }
69        Ok(Some(
70            entries
71                .into_iter()
72                .filter(|entry| entry.kind == SessionKind::Child)
73                .filter_map(|entry| entry.parent_session_id.map(|parent| (parent, entry.id)))
74                .collect(),
75        ))
76    }
77}
78
79fn actor(session: &Session) -> Result<ActorSession, OwnedTreeError> {
80    // Both Project planes are still read by production paths. A conflicting
81    // mirror cannot select whichever one happens to be consulted first.
82    if let (Some(typed), Some(legacy)) = (
83        session
84            .runtime_metadata
85            .as_ref()
86            .and_then(|metadata| metadata.project_id.as_ref()),
87        session.metadata.get("project_id"),
88    ) {
89        if typed != legacy {
90            return Err(OwnedTreeError::InvalidLineage);
91        }
92    }
93    ActorSession::from_session(session).map_err(|_| OwnedTreeError::InvalidLineage)
94}
95
96fn edge(
97    parent: &Session,
98    child: &Session,
99    root_id: &str,
100    project_id: Option<&str>,
101) -> Result<(), OwnedTreeError> {
102    let parent_actor = actor(parent)?;
103    let child_actor = actor(child)?;
104    if child.kind != SessionKind::Child
105        || child.id == parent.id
106        || child_actor.parent_actor_id.as_deref() != Some(parent.id.as_str())
107        || parent_actor.root_actor_id != root_id
108        || child_actor.root_actor_id != root_id
109        || parent_actor.project_id.as_deref() != project_id
110        || child_actor.project_id.as_deref() != project_id
111        || child_actor.spawn_depth != parent_actor.spawn_depth.saturating_add(1)
112        || child.created_at < parent.created_at
113    {
114        return Err(OwnedTreeError::InvalidLineage);
115    }
116    Ok(())
117}
118
119async fn lineage(
120    port: &dyn OwnedTreePort,
121    caller_id: &str,
122) -> Result<Vec<Session>, OwnedTreeError> {
123    let caller = port.load(caller_id).await?;
124    if caller.id != caller_id {
125        return Err(OwnedTreeError::InvalidLineage);
126    }
127    let caller_actor = actor(&caller)?;
128    let mut lineage = vec![caller];
129    let mut seen = HashSet::from([caller_id.to_owned()]);
130    while lineage
131        .last()
132        .is_some_and(|session| session.kind == SessionKind::Child)
133    {
134        let child = lineage.last().ok_or(OwnedTreeError::InvalidLineage)?;
135        let parent_id = child
136            .parent_session_id
137            .as_deref()
138            .ok_or(OwnedTreeError::InvalidLineage)?;
139        if !seen.insert(parent_id.to_owned()) || lineage.len() > INDEX_NODE_CAP {
140            return Err(OwnedTreeError::InvalidLineage);
141        }
142        let parent = port.load(parent_id).await?;
143        if parent.id != parent_id {
144            return Err(OwnedTreeError::InvalidLineage);
145        }
146        edge(
147            &parent,
148            child,
149            &caller_actor.root_actor_id,
150            caller_actor.project_id.as_deref(),
151        )?;
152        lineage.push(parent);
153    }
154    let root = lineage.last().ok_or(OwnedTreeError::InvalidLineage)?;
155    let root_actor = actor(root)?;
156    if root.kind != SessionKind::Root
157        || root.id != caller_actor.root_actor_id
158        || root_actor.project_id != caller_actor.project_id
159        || lineage.len() != caller_actor.spawn_depth as usize + 1
160    {
161        return Err(OwnedTreeError::InvalidLineage);
162    }
163    lineage.reverse();
164    Ok(lineage)
165}
166
167fn proof_session(proof: &mut Sha256, session: &Session) -> Result<(), OwnedTreeError> {
168    let bytes = serde_json::to_vec(&json!({
169        "actor": actor(session)?,
170        "updated_at": session.updated_at,
171        "title": session.title,
172        "last_run_status": session.last_run_status(),
173    }))
174    .map_err(|_| OwnedTreeError::InvalidLineage)?;
175    proof.update((bytes.len() as u64).to_le_bytes());
176    proof.update(bytes);
177    Ok(())
178}
179
180fn lineage_proof(lineage: &[Session]) -> Result<String, OwnedTreeError> {
181    let mut proof = Sha256::new();
182    for session in lineage {
183        proof_session(&mut proof, session)?;
184    }
185    Ok(hex::encode(proof.finalize()))
186}
187
188async fn verified_tree(
189    port: &dyn OwnedTreePort,
190    lineage: &[Session],
191) -> Result<(SessionTreeNode, String), OwnedTreeError> {
192    let caller = lineage.last().ok_or(OwnedTreeError::InvalidLineage)?;
193    let caller_actor = actor(caller)?;
194    let mut proof = Sha256::new();
195    for ancestor in lineage {
196        proof_session(&mut proof, ancestor)?;
197    }
198    let mut seen: HashSet<String> = lineage.iter().map(|session| session.id.clone()).collect();
199    let mut adjacency = HashMap::new();
200    let indexed = port.child_index().await?.map(|edges| {
201        let mut by_parent: HashMap<String, Vec<String>> = HashMap::new();
202        for (parent, child) in edges {
203            by_parent.entry(parent).or_default().push(child);
204        }
205        by_parent
206    });
207    let mut queue = VecDeque::from([(caller.clone(), 0_u32)]);
208    while let Some((parent, depth)) = queue.pop_front() {
209        if depth >= DISPLAY_DEPTH {
210            continue;
211        }
212        let mut ids = if let Some(indexed) = &indexed {
213            indexed.get(&parent.id).cloned().unwrap_or_default()
214        } else {
215            port.child_ids(&parent.id).await?
216        };
217        if ids.len() > INDEX_NODE_CAP {
218            return Err(OwnedTreeError::InvalidLineage);
219        }
220        ids.sort_unstable();
221        let mut children = Vec::new();
222        for id in ids {
223            if !seen.insert(id.clone()) || seen.len() > INDEX_NODE_CAP {
224                return Err(OwnedTreeError::InvalidLineage);
225            }
226            let child = port.load(&id).await?;
227            if child.id != id {
228                return Err(OwnedTreeError::InvalidLineage);
229            }
230            edge(
231                &parent,
232                &child,
233                &caller_actor.root_actor_id,
234                caller_actor.project_id.as_deref(),
235            )?;
236            proof_session(&mut proof, &child)?;
237            children.push(ChildSessionEntry {
238                child_session_id: child.id.clone(),
239                title: child.title.clone(),
240                pinned: child.pinned,
241                message_count: child.messages.len(),
242                updated_at: child.updated_at.to_rfc3339(),
243                last_run_status: child.last_run_status(),
244                last_run_error: child.last_run_error(),
245            });
246            queue.push_back((child, depth + 1));
247        }
248        adjacency.insert(parent.id, children);
249    }
250    let tree = assemble_session_tree(&caller.id, &caller.title, &adjacency, DISPLAY_DEPTH);
251    Ok((tree, hex::encode(proof.finalize())))
252}
253
254fn observed_status(raw: Option<&str>) -> &'static str {
255    match raw {
256        Some("created") => "created",
257        Some("pending") => "pending",
258        Some("queued") => "queued",
259        Some("running") => "running",
260        Some("running_in_background") => "running_in_background",
261        Some("already_running") => "already_running",
262        Some("completed") => "completed",
263        Some("error") => "error",
264        Some("timeout") => "timeout",
265        Some("cancelled") => "cancelled",
266        Some("message_delivered_live") => "message_delivered_live",
267        Some("message_queued") => "message_queued",
268        Some("activation_pending") => "activation_pending",
269        Some("activation_retry_required") => "activation_retry_required",
270        _ => "unknown",
271    }
272}
273
274fn cursor(offset: usize, digest: &str) -> String {
275    format!("tp1:{offset}:{digest}")
276}
277
278fn page(
279    caller: &Session,
280    tree: &SessionTreeNode,
281    raw_cursor: Option<&str>,
282    scope_digest: &str,
283) -> Result<Value, OwnedTreeError> {
284    let max_depth = DISPLAY_DEPTH;
285    let mut pending = vec![(tree, None)];
286    let mut nodes = Vec::new();
287    let mut depth_limited = false;
288    while let Some((node, parent)) = pending.pop() {
289        let title: String = node.title.chars().take(80).collect();
290        nodes.push(json!({"actor_id":node.session_id, "parent_actor_id":parent,
291            "title":title, "depth":node.depth,
292            "observed_status":observed_status(node.last_run_status.as_deref())}));
293        depth_limited |= node.depth >= max_depth;
294        let mut children = node.children.iter().collect::<Vec<_>>();
295        children.sort_unstable_by(|a, b| a.session_id.cmp(&b.session_id));
296        for child in children.into_iter().rev() {
297            pending.push((child, Some(node.session_id.as_str())));
298        }
299    }
300    let fingerprint = json!({"caller":caller.id, "birth":caller.created_at,
301        "project":caller.project_id_meta(), "metadata_version":caller.metadata_version,
302        "canonical_scope":scope_digest, "nodes":nodes});
303    let digest = hex::encode(Sha256::digest(
304        serde_json::to_vec(&fingerprint).map_err(|_| OwnedTreeError::InvalidLineage)?,
305    ));
306    let start = match raw_cursor {
307        None => 0,
308        Some(raw) => {
309            if raw.len() > MAX_TREE_CURSOR_BYTES {
310                return Err(OwnedTreeError::InvalidCursor);
311            }
312            let mut fields = raw.split(':');
313            let (Some("tp1"), Some(offset), Some(proof), None) =
314                (fields.next(), fields.next(), fields.next(), fields.next())
315            else {
316                return Err(OwnedTreeError::InvalidCursor);
317            };
318            if offset.starts_with('0') || proof != digest {
319                return Err(OwnedTreeError::InvalidCursor);
320            }
321            let offset = offset
322                .parse::<usize>()
323                .map_err(|_| OwnedTreeError::InvalidCursor)?;
324            if offset >= nodes.len() {
325                return Err(OwnedTreeError::InvalidCursor);
326            }
327            offset
328        }
329    };
330    let mut slice = Vec::new();
331    for node in nodes.iter().skip(start).take(PAGE_NODES) {
332        slice.push(node.clone());
333        let end = start + slice.len();
334        let next = (end < nodes.len()).then(|| cursor(end, &digest));
335        let candidate = json!({"actor_id":caller.id, "nodes":slice,
336            "truncated":depth_limited || next.is_some(), "next_cursor":next,
337            "observation":"Durable index tree; run statuses are observations, not activation leases."});
338        if serde_json::to_vec(&ToolResult::text(true, candidate.to_string()))
339            .map_or(true, |bytes| bytes.len() > MAX_CHILD_RESULT_BYTES)
340        {
341            slice.pop();
342            if slice.is_empty() {
343                return Err(OwnedTreeError::ResultTooLarge);
344            }
345            break;
346        }
347    }
348    let end = start + slice.len();
349    let next = (end < nodes.len()).then(|| cursor(end, &digest));
350    Ok(json!({"actor_id":caller.id, "nodes":slice,
351        "truncated":depth_limited || next.is_some(), "next_cursor":next,
352        "observation":"Durable index tree; run statuses are observations, not activation leases."}))
353}
354
355pub async fn inspect_owned_tree(
356    port: &dyn OwnedTreePort,
357    caller_id: &str,
358    raw_cursor: Option<&str>,
359) -> Result<Value, OwnedTreeError> {
360    let chain = lineage(port, caller_id).await?;
361    let caller = chain.last().ok_or(OwnedTreeError::InvalidLineage)?;
362    let (tree, scope_digest) = verified_tree(port, &chain).await?;
363    let page = page(caller, &tree, raw_cursor, &scope_digest)?;
364    let current = lineage(port, caller_id).await?;
365    if lineage_proof(&current)? != lineage_proof(&chain)? {
366        return Err(OwnedTreeError::InvalidLineage);
367    }
368    Ok(page)
369}
370
371/// Resolve one descendant for read-only inspection. Both lineages come from
372/// canonical Sessions; matching a candidate index entry or a root id alone is
373/// never enough to authorize transcript access.
374pub async fn load_owned_descendant(
375    port: &dyn OwnedTreePort,
376    caller_id: &str,
377    target_id: &str,
378) -> Result<Session, OwnedTreeError> {
379    let caller_chain = lineage(port, caller_id).await?;
380    let target_chain = lineage(port, target_id).await?;
381    if target_chain.len() <= caller_chain.len() {
382        return Err(OwnedTreeError::InvalidLineage);
383    }
384    let same_authority = |left: &Session, right: &Session| -> Result<bool, OwnedTreeError> {
385        let left = actor(left)?;
386        let right = actor(right)?;
387        Ok(left.actor_id == right.actor_id
388            && left.session_created_at == right.session_created_at
389            && left.parent_actor_id == right.parent_actor_id
390            && left.root_actor_id == right.root_actor_id
391            && left.project_id == right.project_id
392            && left.spawn_depth == right.spawn_depth)
393    };
394    for (caller, target_ancestor) in caller_chain.iter().zip(&target_chain) {
395        if !same_authority(caller, target_ancestor)? {
396            return Err(OwnedTreeError::InvalidLineage);
397        }
398    }
399    // Recheck authority after reading the target. If an ancestor is replaced
400    // or reparented during this read, do not return its transcript snapshot.
401    let current_chain = lineage(port, target_id).await?;
402    if current_chain.len() != target_chain.len() {
403        return Err(OwnedTreeError::InvalidLineage);
404    }
405    for (before, after) in target_chain.iter().zip(&current_chain) {
406        if !same_authority(before, after)? {
407            return Err(OwnedTreeError::InvalidLineage);
408        }
409    }
410    target_chain
411        .into_iter()
412        .last()
413        .ok_or(OwnedTreeError::InvalidLineage)
414}
415
416#[cfg(test)]
417mod descendant_tests {
418    use super::*;
419    use chrono::{Duration, Utc};
420
421    struct Sessions(HashMap<String, Session>);
422
423    #[async_trait]
424    impl OwnedTreePort for Sessions {
425        async fn load(&self, id: &str) -> Result<Session, OwnedTreeError> {
426            self.0
427                .get(id)
428                .cloned()
429                .ok_or(OwnedTreeError::InvalidLineage)
430        }
431
432        async fn child_ids(&self, parent_id: &str) -> Result<Vec<String>, OwnedTreeError> {
433            Ok(self
434                .0
435                .values()
436                .filter(|session| session.parent_session_id.as_deref() == Some(parent_id))
437                .map(|session| session.id.clone())
438                .collect())
439        }
440    }
441
442    fn sessions() -> Sessions {
443        let now = Utc::now();
444        let mut root = Session::new("root", "model");
445        root.created_at = now;
446        let mut child = Session::new_child_of("child", &root, "model", "child");
447        child.created_at = now + Duration::seconds(1);
448        let mut grandchild = Session::new_child_of("grandchild", &child, "model", "grandchild");
449        grandchild.created_at = now + Duration::seconds(2);
450        let mut sibling = Session::new_child_of("sibling", &root, "model", "sibling");
451        sibling.created_at = now + Duration::seconds(1);
452        let mut outsider = Session::new("outsider", "model");
453        outsider.created_at = now;
454        Sessions(
455            [root, child, grandchild, sibling, outsider]
456                .into_iter()
457                .map(|session| (session.id.clone(), session))
458                .collect(),
459        )
460    }
461
462    #[tokio::test]
463    async fn root_and_ancestor_can_inspect_descendant_but_sibling_and_other_root_cannot() {
464        let store = sessions();
465        assert_eq!(
466            load_owned_descendant(&store, "root", "grandchild")
467                .await
468                .unwrap()
469                .id,
470            "grandchild"
471        );
472        assert_eq!(
473            load_owned_descendant(&store, "child", "grandchild")
474                .await
475                .unwrap()
476                .id,
477            "grandchild"
478        );
479        for caller in ["sibling", "outsider", "grandchild"] {
480            assert!(matches!(
481                load_owned_descendant(&store, caller, "grandchild").await,
482                Err(OwnedTreeError::InvalidLineage)
483            ));
484        }
485    }
486
487    #[tokio::test]
488    async fn inconsistent_ancestor_birth_fails_closed() {
489        let mut store = sessions();
490        let invalid_birth = store.0.get("root").unwrap().created_at - Duration::seconds(1);
491        store.0.get_mut("grandchild").unwrap().created_at = invalid_birth;
492        assert!(matches!(
493            load_owned_descendant(&store, "root", "grandchild").await,
494            Err(OwnedTreeError::InvalidLineage)
495        ));
496    }
497}