Skip to main content

harn_hostlib/
session.rs

1//! In-process adapter for the canonical Harn session/transcript store.
2//!
3//! This capability deliberately owns no persistence or ranking policy. It
4//! resolves one project root to the same `.harn/session-store.sqlite` used by
5//! the VM transcript journal, applies the VM redaction policy, and projects the
6//! typed `harn-session-store` interface into schema-checked hostlib builtins.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use harn_session_store::{
12    AppendEvent, CreateSession, Embedder, EventId, ListFilter, ReadRange, SearchMode, SearchQuery,
13    SessionStore, SessionType, SqliteSessionStore, StoreError, StoreHooks, UpdateSession,
14    MAX_READ_BATCH,
15};
16use harn_vm::process_sandbox::FsAccess;
17use harn_vm::VmValue;
18use serde::de::DeserializeOwned;
19use serde::Deserialize;
20
21use crate::error::HostlibError;
22use crate::json::vm_value_to_json;
23use crate::registry::{BuiltinRegistry, HostlibCapability};
24use crate::tools::permissions::enforce_path_scope;
25
26/// Open-or-create builtin.
27pub const OPEN_BUILTIN: &str = "hostlib_session_open";
28/// Typed session metadata update builtin.
29pub const UPDATE_BUILTIN: &str = "hostlib_session_update";
30/// Canonical event append builtin.
31pub const APPEND_BUILTIN: &str = "hostlib_session_append";
32/// Session close builtin.
33pub const CLOSE_BUILTIN: &str = "hostlib_session_close";
34/// Session metadata plus events builtin.
35pub const GET_BUILTIN: &str = "hostlib_session_get";
36/// Scoped session-list builtin.
37pub const LIST_BUILTIN: &str = "hostlib_session_list";
38/// Canonical lineage fork builtin.
39pub const FORK_BUILTIN: &str = "hostlib_session_fork";
40/// Project-scoped FTS search builtin.
41pub const SEARCH_FTS_BUILTIN: &str = "hostlib_search_fts";
42/// Project-scoped semantic search builtin.
43pub const SEARCH_SEMANTIC_BUILTIN: &str = "hostlib_search_semantic";
44/// Project-scoped hybrid search builtin.
45pub const SEARCH_HYBRID_BUILTIN: &str = "hostlib_search_hybrid";
46
47/// Thin in-process projection over [`harn_session_store::SessionStore`].
48#[derive(Clone)]
49pub struct SessionCapability {
50    embedder: Arc<dyn Embedder>,
51}
52
53impl SessionCapability {
54    /// Use the supplied backend for both vector indexing and semantic search.
55    pub fn with_embedder(embedder: Arc<dyn Embedder>) -> Self {
56        Self { embedder }
57    }
58
59    fn store(
60        &self,
61        builtin: &'static str,
62        root: &Path,
63    ) -> Result<SqliteSessionStore, HostlibError> {
64        enforce_path_scope(builtin, root, FsAccess::Write)?;
65        let hooks = StoreHooks {
66            redaction: Some(Arc::new(harn_vm::redact::current_policy())),
67            embedder: self.embedder.clone(),
68            ..StoreHooks::default()
69        };
70        SqliteSessionStore::open_with_hooks(database_path(root), hooks)
71            .map_err(|error| backend_error(builtin, error))
72    }
73
74    async fn open(&self, args: Vec<VmValue>) -> Result<VmValue, HostlibError> {
75        let mut request: OpenRequest = request(OPEN_BUILTIN, &args)?;
76        let root = normalized_root(OPEN_BUILTIN, &request.root)?;
77        if request.session.project_scope.is_none() {
78            request.session.project_scope = Some(project_scope(&root));
79        }
80        if request.session.cwd.is_none() {
81            request.session.cwd = Some(project_scope(&root));
82        }
83        if request.session.session_type.is_none() {
84            request.session.session_type = Some(SessionType::User);
85        }
86        let store = self.store(OPEN_BUILTIN, &root)?;
87        let meta = match request.session.id.as_deref() {
88            Some(id) => match store.describe(id).await {
89                Ok(meta) => meta,
90                Err(StoreError::NotFound(_)) => store
91                    .create(request.session)
92                    .await
93                    .map_err(|error| backend_error(OPEN_BUILTIN, error))?,
94                Err(error) => return Err(backend_error(OPEN_BUILTIN, error)),
95            },
96            None => store
97                .create(request.session)
98                .await
99                .map_err(|error| backend_error(OPEN_BUILTIN, error))?,
100        };
101        response(OPEN_BUILTIN, meta)
102    }
103
104    async fn append(&self, args: Vec<VmValue>) -> Result<VmValue, HostlibError> {
105        let request: AppendRequest = request(APPEND_BUILTIN, &args)?;
106        let root = normalized_root(APPEND_BUILTIN, &request.root)?;
107        let stored = self
108            .store(APPEND_BUILTIN, &root)?
109            .append(&request.session_id, request.event)
110            .await
111            .map_err(|error| backend_error(APPEND_BUILTIN, error))?;
112        response(APPEND_BUILTIN, stored)
113    }
114
115    async fn update(&self, args: Vec<VmValue>) -> Result<VmValue, HostlibError> {
116        let request: UpdateRequest = request(UPDATE_BUILTIN, &args)?;
117        let root = normalized_root(UPDATE_BUILTIN, &request.root)?;
118        let meta = self
119            .store(UPDATE_BUILTIN, &root)?
120            .update(&request.session_id, request.update)
121            .await
122            .map_err(|error| backend_error(UPDATE_BUILTIN, error))?;
123        response(UPDATE_BUILTIN, meta)
124    }
125
126    async fn close(&self, args: Vec<VmValue>) -> Result<VmValue, HostlibError> {
127        let request: SessionRequest = request(CLOSE_BUILTIN, &args)?;
128        let root = normalized_root(CLOSE_BUILTIN, &request.root)?;
129        let meta = self
130            .store(CLOSE_BUILTIN, &root)?
131            .close(&request.session_id)
132            .await
133            .map_err(|error| backend_error(CLOSE_BUILTIN, error))?;
134        response(CLOSE_BUILTIN, meta)
135    }
136
137    async fn get(&self, args: Vec<VmValue>) -> Result<VmValue, HostlibError> {
138        let request: SessionRequest = request(GET_BUILTIN, &args)?;
139        let root = normalized_root(GET_BUILTIN, &request.root)?;
140        let store = self.store(GET_BUILTIN, &root)?;
141        let session = store
142            .describe(&request.session_id)
143            .await
144            .map_err(|error| backend_error(GET_BUILTIN, error))?;
145        let mut events = Vec::new();
146        let mut cursor = None;
147        loop {
148            let page = store
149                .read(
150                    &request.session_id,
151                    ReadRange {
152                        from_event_id: cursor,
153                        limit: Some(MAX_READ_BATCH),
154                        ..ReadRange::default()
155                    },
156                )
157                .await
158                .map_err(|error| backend_error(GET_BUILTIN, error))?;
159            cursor = page.next_cursor;
160            events.extend(page.events);
161            if cursor.is_none() {
162                break;
163            }
164        }
165        response(
166            GET_BUILTIN,
167            serde_json::json!({"session": session, "events": events}),
168        )
169    }
170
171    async fn list(&self, args: Vec<VmValue>) -> Result<VmValue, HostlibError> {
172        let mut request: ListRequest = request(LIST_BUILTIN, &args)?;
173        let root = normalized_root(LIST_BUILTIN, &request.root)?;
174        if request.filter.project_scope.is_none() && request.filter.tenant_id.is_none() {
175            request.filter.project_scope = Some(project_scope(&root));
176        }
177        let sessions = self
178            .store(LIST_BUILTIN, &root)?
179            .list(request.filter)
180            .await
181            .map_err(|error| backend_error(LIST_BUILTIN, error))?;
182        response(LIST_BUILTIN, serde_json::json!({"sessions": sessions}))
183    }
184
185    async fn fork(&self, args: Vec<VmValue>) -> Result<VmValue, HostlibError> {
186        let request: ForkRequest = request(FORK_BUILTIN, &args)?;
187        let root = normalized_root(FORK_BUILTIN, &request.root)?;
188        let result = self
189            .store(FORK_BUILTIN, &root)?
190            .fork(
191                &request.session_id,
192                request.at_event_id,
193                request.child_session_id,
194            )
195            .await
196            .map_err(|error| backend_error(FORK_BUILTIN, error))?;
197        response(FORK_BUILTIN, result)
198    }
199
200    async fn search(
201        &self,
202        builtin: &'static str,
203        mode: SearchMode,
204        args: Vec<VmValue>,
205    ) -> Result<VmValue, HostlibError> {
206        let mut request: SearchRequest = request(builtin, &args)?;
207        let root = normalized_root(builtin, &request.root)?;
208        if request.query.filter.project_scope.is_none()
209            && request.query.filter.tenant_id.is_none()
210            && request.query.filter.session_id.is_none()
211        {
212            request.query.filter.project_scope = Some(project_scope(&root));
213        }
214        request.query.mode = mode;
215        let result = self
216            .store(builtin, &root)?
217            .search(request.query)
218            .await
219            .map_err(|error| backend_error(builtin, error))?;
220        response(builtin, result)
221    }
222}
223
224impl Default for SessionCapability {
225    fn default() -> Self {
226        Self::with_embedder(Arc::new(harn_session_store::LexicalEmbedder::default()))
227    }
228}
229
230impl HostlibCapability for SessionCapability {
231    fn module_name(&self) -> &'static str {
232        "session"
233    }
234
235    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
236        let capability = self.clone();
237        registry.register_async_fn("session", OPEN_BUILTIN, "open", move |args| {
238            let capability = capability.clone();
239            async move { capability.open(args).await }
240        });
241        let capability = self.clone();
242        registry.register_async_fn("session", UPDATE_BUILTIN, "update", move |args| {
243            let capability = capability.clone();
244            async move { capability.update(args).await }
245        });
246        let capability = self.clone();
247        registry.register_async_fn("session", APPEND_BUILTIN, "append", move |args| {
248            let capability = capability.clone();
249            async move { capability.append(args).await }
250        });
251        let capability = self.clone();
252        registry.register_async_fn("session", CLOSE_BUILTIN, "close", move |args| {
253            let capability = capability.clone();
254            async move { capability.close(args).await }
255        });
256        let capability = self.clone();
257        registry.register_async_fn("session", GET_BUILTIN, "get", move |args| {
258            let capability = capability.clone();
259            async move { capability.get(args).await }
260        });
261        let capability = self.clone();
262        registry.register_async_fn("session", LIST_BUILTIN, "list", move |args| {
263            let capability = capability.clone();
264            async move { capability.list(args).await }
265        });
266        let capability = self.clone();
267        registry.register_async_fn("session", FORK_BUILTIN, "fork", move |args| {
268            let capability = capability.clone();
269            async move { capability.fork(args).await }
270        });
271        for (builtin, method, mode) in [
272            (SEARCH_FTS_BUILTIN, "search_fts", SearchMode::Fts),
273            (
274                SEARCH_SEMANTIC_BUILTIN,
275                "search_semantic",
276                SearchMode::Semantic,
277            ),
278            (SEARCH_HYBRID_BUILTIN, "search_hybrid", SearchMode::Hybrid),
279        ] {
280            let capability = self.clone();
281            registry.register_async_fn("session", builtin, method, move |args| {
282                let capability = capability.clone();
283                async move { capability.search(builtin, mode, args).await }
284            });
285        }
286    }
287}
288
289#[derive(Deserialize)]
290struct OpenRequest {
291    root: String,
292    #[serde(flatten)]
293    session: CreateSession,
294}
295
296#[derive(Deserialize)]
297struct SessionRequest {
298    root: String,
299    session_id: String,
300}
301
302#[derive(Deserialize)]
303struct UpdateRequest {
304    root: String,
305    session_id: String,
306    #[serde(flatten)]
307    update: UpdateSession,
308}
309
310#[derive(Deserialize)]
311struct AppendRequest {
312    root: String,
313    session_id: String,
314    event: AppendEvent,
315}
316
317#[derive(Deserialize)]
318struct ListRequest {
319    root: String,
320    #[serde(default)]
321    filter: ListFilter,
322}
323
324#[derive(Deserialize)]
325struct ForkRequest {
326    root: String,
327    session_id: String,
328    at_event_id: EventId,
329    #[serde(default)]
330    child_session_id: Option<String>,
331}
332
333#[derive(Deserialize)]
334struct SearchRequest {
335    root: String,
336    #[serde(flatten)]
337    query: SearchQuery,
338}
339
340fn request<T: DeserializeOwned>(
341    builtin: &'static str,
342    args: &[VmValue],
343) -> Result<T, HostlibError> {
344    let value = args.first().ok_or(HostlibError::MissingParameter {
345        builtin,
346        param: "params",
347    })?;
348    serde_json::from_value(vm_value_to_json(value)).map_err(|error| {
349        HostlibError::InvalidParameter {
350            builtin,
351            param: "params",
352            message: error.to_string(),
353        }
354    })
355}
356
357fn response(builtin: &'static str, value: impl serde::Serialize) -> Result<VmValue, HostlibError> {
358    let value = serde_json::to_value(value).map_err(|error| HostlibError::Backend {
359        builtin,
360        message: format!("failed to encode response: {error}"),
361    })?;
362    Ok(harn_vm::json_to_vm_value(&value))
363}
364
365fn normalized_root(builtin: &'static str, root: &str) -> Result<PathBuf, HostlibError> {
366    let root = root.trim();
367    if root.is_empty() {
368        return Err(HostlibError::InvalidParameter {
369            builtin,
370            param: "root",
371            message: "must be non-empty".to_string(),
372        });
373    }
374    let path = crate::tools::args::resolve_host_path(root);
375    Ok(path.canonicalize().unwrap_or(path))
376}
377
378fn project_scope(root: &Path) -> String {
379    crate::tools::args::to_agent_path(root)
380}
381
382fn database_path(root: &Path) -> PathBuf {
383    root.join(".harn").join("session-store.sqlite")
384}
385
386fn backend_error(builtin: &'static str, error: StoreError) -> HostlibError {
387    HostlibError::Backend {
388        builtin,
389        message: error.to_string(),
390    }
391}