mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f80ddc22 | ||
|
|
d2098f1030 | ||
|
|
79a2c5d9dd | ||
|
|
d9358b0fa9 | ||
|
|
8f6999a074 | ||
|
|
4d7501a968 |
@@ -12,6 +12,9 @@
|
|||||||
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
|
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
|
||||||
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
|
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
|
||||||
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
|
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
|
||||||
|
<a href="https://gitcgr.com/nearai/ironclaw">
|
||||||
|
<img src="https://gitcgr.com/badge/nearai/ironclaw.svg" alt="gitcgr" />
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
+153
-31
@@ -992,8 +992,16 @@ impl Agent {
|
|||||||
{
|
{
|
||||||
// Put it back and return error
|
// Put it back and return error
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
match sess.threads.get_mut(&thread_id) {
|
||||||
thread.await_approval(pending);
|
Some(thread) => {
|
||||||
|
thread.await_approval(pending);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
%thread_id,
|
||||||
|
"Thread disappeared while restoring pending approval after request ID mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(SubmissionResult::error(
|
return Ok(SubmissionResult::error(
|
||||||
"Request ID mismatch. Use the correct request ID.",
|
"Request ID mismatch. Use the correct request ID.",
|
||||||
@@ -1015,8 +1023,19 @@ impl Agent {
|
|||||||
// Reset thread state to processing
|
// Reset thread state to processing
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
match sess.threads.get_mut(&thread_id) {
|
||||||
thread.state = ThreadState::Processing;
|
Some(thread) => {
|
||||||
|
thread.state = ThreadState::Processing;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::error!(
|
||||||
|
%thread_id,
|
||||||
|
"Thread disappeared while setting state to Processing during approval"
|
||||||
|
);
|
||||||
|
return Ok(SubmissionResult::error(
|
||||||
|
"Internal error: thread no longer exists",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,13 +1119,21 @@ impl Agent {
|
|||||||
// Record sanitized result in thread
|
// Record sanitized result in thread
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
match sess.threads.get_mut(&thread_id) {
|
||||||
&& let Some(turn) = thread.last_turn_mut()
|
Some(thread) => {
|
||||||
{
|
if let Some(turn) = thread.last_turn_mut() {
|
||||||
if is_tool_error {
|
if is_tool_error {
|
||||||
turn.record_tool_error(result_content.clone());
|
turn.record_tool_error(result_content.clone());
|
||||||
} else {
|
} else {
|
||||||
turn.record_tool_result(serde_json::json!(result_content));
|
turn.record_tool_result(serde_json::json!(result_content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::error!(
|
||||||
|
%thread_id,
|
||||||
|
"Thread disappeared while recording tool result during approval"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1354,13 +1381,22 @@ impl Agent {
|
|||||||
// Record sanitized result in thread
|
// Record sanitized result in thread
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
match sess.threads.get_mut(&thread_id) {
|
||||||
&& let Some(turn) = thread.last_turn_mut()
|
Some(thread) => {
|
||||||
{
|
if let Some(turn) = thread.last_turn_mut() {
|
||||||
if is_deferred_error {
|
if is_deferred_error {
|
||||||
turn.record_tool_error(deferred_content.clone());
|
turn.record_tool_error(deferred_content.clone());
|
||||||
} else {
|
} else {
|
||||||
turn.record_tool_result(serde_json::json!(deferred_content));
|
turn.record_tool_result(serde_json::json!(deferred_content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::error!(
|
||||||
|
%thread_id,
|
||||||
|
tool_name = %tc.name,
|
||||||
|
"Thread disappeared while recording deferred tool result during approval"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1413,8 +1449,19 @@ impl Agent {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
match sess.threads.get_mut(&thread_id) {
|
||||||
thread.await_approval(new_pending);
|
Some(thread) => {
|
||||||
|
thread.await_approval(new_pending);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::error!(
|
||||||
|
%thread_id,
|
||||||
|
"Thread disappeared while setting up deferred tool approval"
|
||||||
|
);
|
||||||
|
return Ok(SubmissionResult::error(
|
||||||
|
"Internal error: thread no longer exists",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1546,17 +1593,28 @@ impl Agent {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
match sess.threads.get_mut(&thread_id) {
|
||||||
thread.clear_pending_approval();
|
Some(thread) => {
|
||||||
thread.complete_turn(&rejection);
|
thread.clear_pending_approval();
|
||||||
// User message already persisted at turn start; save rejection response
|
thread.complete_turn(&rejection);
|
||||||
self.persist_assistant_response(
|
// User message already persisted at turn start; save rejection response
|
||||||
thread_id,
|
self.persist_assistant_response(
|
||||||
&message.channel,
|
thread_id,
|
||||||
&message.user_id,
|
&message.channel,
|
||||||
&rejection,
|
&message.user_id,
|
||||||
)
|
&rejection,
|
||||||
.await;
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::error!(
|
||||||
|
%thread_id,
|
||||||
|
"Thread disappeared during approval rejection"
|
||||||
|
);
|
||||||
|
return Ok(SubmissionResult::error(
|
||||||
|
"Internal error: thread no longer exists",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2098,6 +2156,70 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_approval_on_missing_thread_should_error() {
|
||||||
|
// Regression for #1487: when a thread disappears from the session
|
||||||
|
// during approval processing, the code must return a visible error
|
||||||
|
// rather than silently succeeding.
|
||||||
|
//
|
||||||
|
// We can't call process_approval() directly (requires full Agent),
|
||||||
|
// so we simulate the exact code pattern used in the rejection and
|
||||||
|
// state-setting paths: lock session, match on get_mut, verify the
|
||||||
|
// None arm produces an error.
|
||||||
|
use crate::agent::session::{Session, Thread, ThreadState};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
let thread_id = Uuid::new_v4();
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let session = Arc::new(Mutex::new(Session::new("test-user")));
|
||||||
|
|
||||||
|
// Scenario 1: Thread never existed
|
||||||
|
{
|
||||||
|
let sess = session.lock().await;
|
||||||
|
let result = match sess.threads.get(&thread_id) {
|
||||||
|
Some(_) => Ok("processed"),
|
||||||
|
None => Err("Internal error: thread no longer exists"),
|
||||||
|
};
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
"Internal error: thread no longer exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 2: Thread existed then was removed (simulates disappearance
|
||||||
|
// between lock acquisitions -- the TOCTOU window this fix addresses)
|
||||||
|
{
|
||||||
|
let mut sess = session.lock().await;
|
||||||
|
let mut thread = Thread::with_id(thread_id, session_id);
|
||||||
|
thread.start_turn("pending approval");
|
||||||
|
thread.state = ThreadState::AwaitingApproval;
|
||||||
|
sess.threads.insert(thread_id, thread);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut sess = session.lock().await;
|
||||||
|
// Simulate thread disappearing (e.g., pruned by another task)
|
||||||
|
sess.threads.remove(&thread_id);
|
||||||
|
|
||||||
|
// The rejection path must detect this and return an error
|
||||||
|
let result = match sess.threads.get_mut(&thread_id) {
|
||||||
|
Some(thread) => {
|
||||||
|
thread.clear_pending_approval();
|
||||||
|
thread.complete_turn("rejected");
|
||||||
|
Ok("rejection persisted")
|
||||||
|
}
|
||||||
|
None => Err("Internal error: thread no longer exists"),
|
||||||
|
};
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
"Internal error: thread no longer exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_queue_cap_rejects_at_capacity() {
|
fn test_queue_cap_rejects_at_capacity() {
|
||||||
use crate::agent::session::{MAX_PENDING_MESSAGES, Thread, ThreadState};
|
use crate::agent::session::{MAX_PENDING_MESSAGES, Thread, ThreadState};
|
||||||
|
|||||||
+11
@@ -325,9 +325,20 @@ impl AppBuilder {
|
|||||||
};
|
};
|
||||||
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
|
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
|
||||||
.with_search_config(&self.config.search);
|
.with_search_config(&self.config.search);
|
||||||
|
|
||||||
if let Some(ref emb) = embeddings {
|
if let Some(ref emb) = embeddings {
|
||||||
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
|
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire workspace-level settings (read scopes, memory layers)
|
||||||
|
if !self.config.workspace.read_scopes.is_empty() {
|
||||||
|
ws = ws.with_additional_read_scopes(self.config.workspace.read_scopes.clone());
|
||||||
|
tracing::info!(
|
||||||
|
user_id = workspace_user_id,
|
||||||
|
read_scopes = ?ws.read_user_ids(),
|
||||||
|
"Workspace configured with multi-scope reads"
|
||||||
|
);
|
||||||
|
}
|
||||||
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
||||||
let ws = Arc::new(ws);
|
let ws = Arc::new(ws);
|
||||||
tools.register_memory_tools(Arc::clone(&ws));
|
tools.register_memory_tools(Arc::clone(&ws));
|
||||||
|
|||||||
@@ -1822,7 +1822,13 @@ async fn memory_write_handler(
|
|||||||
"Workspace not available".to_string(),
|
"Workspace not available".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
// Route through layer-aware methods when a layer is specified
|
// Route through layer-aware methods when a layer is specified.
|
||||||
|
//
|
||||||
|
// Note: unlike MemoryWriteTool, this endpoint does NOT block writes to
|
||||||
|
// identity files (IDENTITY.md, SOUL.md, etc.). The HTTP API is an
|
||||||
|
// authenticated admin interface; the supervisor uses it to seed identity
|
||||||
|
// files at startup. Identity-file protection is enforced at the tool
|
||||||
|
// layer (LLM-facing) where the write originates from an untrusted agent.
|
||||||
if let Some(ref layer_name) = req.layer {
|
if let Some(ref layer_name) = req.layer {
|
||||||
let result = if req.append {
|
let result = if req.append {
|
||||||
workspace
|
workspace
|
||||||
|
|||||||
+8
-7
@@ -24,7 +24,7 @@ mod skills;
|
|||||||
mod transcription;
|
mod transcription;
|
||||||
mod tunnel;
|
mod tunnel;
|
||||||
mod wasm;
|
mod wasm;
|
||||||
mod workspace;
|
pub(crate) mod workspace;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{LazyLock, Mutex, Once};
|
use std::sync::{LazyLock, Mutex, Once};
|
||||||
@@ -178,9 +178,7 @@ impl Config {
|
|||||||
},
|
},
|
||||||
transcription: TranscriptionConfig::default(),
|
transcription: TranscriptionConfig::default(),
|
||||||
search: WorkspaceSearchConfig::default(),
|
search: WorkspaceSearchConfig::default(),
|
||||||
workspace: WorkspaceConfig {
|
workspace: WorkspaceConfig::default(),
|
||||||
memory_layers: vec![],
|
|
||||||
},
|
|
||||||
observability: crate::observability::ObservabilityConfig::default(),
|
observability: crate::observability::ObservabilityConfig::default(),
|
||||||
relay: None,
|
relay: None,
|
||||||
}
|
}
|
||||||
@@ -313,11 +311,14 @@ impl Config {
|
|||||||
|
|
||||||
let tunnel = TunnelConfig::resolve(settings)?;
|
let tunnel = TunnelConfig::resolve(settings)?;
|
||||||
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
|
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
|
||||||
|
|
||||||
|
// Resolve workspace config using the gateway user_id for default layers.
|
||||||
let workspace_user_id = channels
|
let workspace_user_id = channels
|
||||||
.gateway
|
.gateway
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|gw| gw.user_id.clone())
|
.map(|gw| gw.user_id.as_str())
|
||||||
.unwrap_or_else(|| "default".to_string());
|
.unwrap_or("default");
|
||||||
|
let workspace = WorkspaceConfig::resolve(workspace_user_id)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
owner_id: owner_id.clone(),
|
owner_id: owner_id.clone(),
|
||||||
@@ -339,7 +340,7 @@ impl Config {
|
|||||||
skills: SkillsConfig::resolve()?,
|
skills: SkillsConfig::resolve()?,
|
||||||
transcription: TranscriptionConfig::resolve(settings)?,
|
transcription: TranscriptionConfig::resolve(settings)?,
|
||||||
search: WorkspaceSearchConfig::resolve()?,
|
search: WorkspaceSearchConfig::resolve()?,
|
||||||
workspace: WorkspaceConfig::resolve(&workspace_user_id)?,
|
workspace,
|
||||||
observability: crate::observability::ObservabilityConfig {
|
observability: crate::observability::ObservabilityConfig {
|
||||||
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
||||||
},
|
},
|
||||||
|
|||||||
+68
-7
@@ -2,18 +2,29 @@ use crate::config::helpers::optional_env;
|
|||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::workspace::layer::MemoryLayer;
|
use crate::workspace::layer::MemoryLayer;
|
||||||
|
|
||||||
/// Workspace memory configuration.
|
/// Workspace-level configuration (memory layers, read scopes).
|
||||||
///
|
///
|
||||||
/// Controls memory layer definitions for privacy-aware writes.
|
/// Parsed from environment variables. Lives outside of `GatewayConfig`
|
||||||
/// Layers are parsed from the `MEMORY_LAYERS` env var (JSON array)
|
/// so that non-gateway channels can eventually use the same settings.
|
||||||
/// or default to a single private layer scoped to the gateway user.
|
#[derive(Debug, Clone, Default)]
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct WorkspaceConfig {
|
pub struct WorkspaceConfig {
|
||||||
|
/// Memory layer definitions (JSON in `MEMORY_LAYERS` env var, or defaults).
|
||||||
pub memory_layers: Vec<MemoryLayer>,
|
pub memory_layers: Vec<MemoryLayer>,
|
||||||
|
/// Additional user scopes for workspace reads.
|
||||||
|
///
|
||||||
|
/// When set, the workspace can read (search, read, list) from these
|
||||||
|
/// additional user scopes while writes remain isolated to the primary
|
||||||
|
/// `user_id`. Parsed from `WORKSPACE_READ_SCOPES` (comma-separated).
|
||||||
|
pub read_scopes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceConfig {
|
impl WorkspaceConfig {
|
||||||
pub(crate) fn resolve(user_id: &str) -> Result<Self, ConfigError> {
|
/// Resolve workspace config from environment variables.
|
||||||
|
///
|
||||||
|
/// `user_id` is used to derive default memory layers when `MEMORY_LAYERS`
|
||||||
|
/// is not set.
|
||||||
|
pub fn resolve(user_id: &str) -> Result<Self, ConfigError> {
|
||||||
|
// --- Memory layers ---
|
||||||
let memory_layers: Vec<MemoryLayer> = match optional_env("MEMORY_LAYERS")? {
|
let memory_layers: Vec<MemoryLayer> = match optional_env("MEMORY_LAYERS")? {
|
||||||
Some(json_str) => {
|
Some(json_str) => {
|
||||||
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
|
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
|
||||||
@@ -57,6 +68,20 @@ impl WorkspaceConfig {
|
|||||||
message: format!("layer '{}' has an empty scope", layer.name),
|
message: format!("layer '{}' has an empty scope", layer.name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if !layer
|
||||||
|
.scope
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "MEMORY_LAYERS".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"layer '{}' scope '{}' contains invalid characters \
|
||||||
|
(allowed: a-z, A-Z, 0-9, _, -)",
|
||||||
|
layer.name, layer.scope
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate layer names
|
// Check for duplicate layer names
|
||||||
@@ -72,7 +97,43 @@ impl WorkspaceConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self { memory_layers })
|
// --- Read scopes ---
|
||||||
|
let read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
|
||||||
|
.map(|s| {
|
||||||
|
s.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for scope in &read_scopes {
|
||||||
|
if scope.len() > 128 {
|
||||||
|
let prefix: String = scope.chars().take(32).collect();
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "WORKSPACE_READ_SCOPES".to_string(),
|
||||||
|
message: format!("scope '{prefix}...' exceeds 128 characters"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !scope
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "WORKSPACE_READ_SCOPES".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"scope '{}' contains invalid characters \
|
||||||
|
(allowed: a-z, A-Z, 0-9, _, -)",
|
||||||
|
scope
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
memory_layers,
|
||||||
|
read_scopes,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -644,6 +644,103 @@ pub trait WorkspaceStore: Send + Sync {
|
|||||||
embedding: Option<&[f32]>,
|
embedding: Option<&[f32]>,
|
||||||
config: &SearchConfig,
|
config: &SearchConfig,
|
||||||
) -> Result<Vec<SearchResult>, WorkspaceError>;
|
) -> Result<Vec<SearchResult>, WorkspaceError>;
|
||||||
|
|
||||||
|
// ==================== Multi-scope read methods ====================
|
||||||
|
//
|
||||||
|
// Default implementations loop over user_ids calling single-scope methods,
|
||||||
|
// then merge results. Backends can override with efficient SQL (e.g.,
|
||||||
|
// `WHERE user_id = ANY($1::text[])`).
|
||||||
|
|
||||||
|
/// Hybrid search across multiple user scopes, merging results by score.
|
||||||
|
///
|
||||||
|
/// **Note:** The default implementation calls `hybrid_search` per scope and
|
||||||
|
/// merges by raw score. Because RRF scores are normalized independently
|
||||||
|
/// within each scope, scores are not directly comparable across scopes.
|
||||||
|
/// The Postgres backend overrides this with a single combined query that
|
||||||
|
/// applies RRF once to the unified result set.
|
||||||
|
async fn hybrid_search_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
query: &str,
|
||||||
|
embedding: Option<&[f32]>,
|
||||||
|
config: &SearchConfig,
|
||||||
|
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
||||||
|
if user_ids.len() > 1 {
|
||||||
|
tracing::debug!(
|
||||||
|
scope_count = user_ids.len(),
|
||||||
|
"hybrid_search_multi: using default per-scope RRF merge; \
|
||||||
|
cross-scope score comparison may be unreliable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut all_results = Vec::new();
|
||||||
|
for uid in user_ids {
|
||||||
|
let results = self
|
||||||
|
.hybrid_search(uid, agent_id, query, embedding, config)
|
||||||
|
.await?;
|
||||||
|
all_results.extend(results);
|
||||||
|
}
|
||||||
|
// Re-sort by score descending and truncate to limit
|
||||||
|
all_results.sort_by(|a, b| {
|
||||||
|
b.score
|
||||||
|
.partial_cmp(&a.score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
all_results.truncate(config.limit);
|
||||||
|
Ok(all_results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all file paths across multiple user scopes.
|
||||||
|
async fn list_all_paths_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<String>, WorkspaceError> {
|
||||||
|
let mut all_paths = Vec::new();
|
||||||
|
for uid in user_ids {
|
||||||
|
let paths = self.list_all_paths(uid, agent_id).await?;
|
||||||
|
all_paths.extend(paths);
|
||||||
|
}
|
||||||
|
all_paths.sort();
|
||||||
|
all_paths.dedup();
|
||||||
|
Ok(all_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a document by path, searching across multiple user scopes.
|
||||||
|
///
|
||||||
|
/// Returns the first match found (tries each user_id in order).
|
||||||
|
async fn get_document_by_path_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<MemoryDocument, WorkspaceError> {
|
||||||
|
for uid in user_ids {
|
||||||
|
match self.get_document_by_path(uid, agent_id, path).await {
|
||||||
|
Ok(doc) => return Ok(doc),
|
||||||
|
Err(WorkspaceError::DocumentNotFound { .. }) => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(WorkspaceError::DocumentNotFound {
|
||||||
|
doc_type: path.to_string(),
|
||||||
|
user_id: format!("[{}]", user_ids.join(", ")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List directory contents across multiple user scopes.
|
||||||
|
async fn list_directory_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
directory: &str,
|
||||||
|
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
|
||||||
|
let mut all_entries = Vec::new();
|
||||||
|
for uid in user_ids {
|
||||||
|
all_entries.extend(self.list_directory(uid, agent_id, directory).await?);
|
||||||
|
}
|
||||||
|
Ok(crate::workspace::merge_workspace_entries(all_entries))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backend-agnostic database supertrait.
|
/// Backend-agnostic database supertrait.
|
||||||
|
|||||||
@@ -717,4 +717,49 @@ impl WorkspaceStore for PgBackend {
|
|||||||
.hybrid_search(user_id, agent_id, query, embedding, config)
|
.hybrid_search(user_id, agent_id, query, embedding, config)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimized multi-scope overrides using `ANY($1::text[])` SQL.
|
||||||
|
|
||||||
|
async fn hybrid_search_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
query: &str,
|
||||||
|
embedding: Option<&[f32]>,
|
||||||
|
config: &SearchConfig,
|
||||||
|
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
||||||
|
self.repo
|
||||||
|
.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all_paths_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<String>, WorkspaceError> {
|
||||||
|
self.repo.list_all_paths_multi(user_ids, agent_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_document_by_path_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<MemoryDocument, WorkspaceError> {
|
||||||
|
self.repo
|
||||||
|
.get_document_by_path_multi(user_ids, agent_id, path)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_directory_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
directory: &str,
|
||||||
|
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
|
||||||
|
self.repo
|
||||||
|
.list_directory_multi(user_ids, agent_id, directory)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,9 +304,6 @@ pub enum WorkspaceError {
|
|||||||
#[error("I/O error: {reason}")]
|
#[error("I/O error: {reason}")]
|
||||||
IoError { reason: String },
|
IoError { reason: String },
|
||||||
|
|
||||||
#[error("Not found: {path}")]
|
|
||||||
NotFound { path: String },
|
|
||||||
|
|
||||||
#[error("Layer not found: {name}")]
|
#[error("Layer not found: {name}")]
|
||||||
LayerNotFound { name: String },
|
LayerNotFound { name: String },
|
||||||
|
|
||||||
|
|||||||
+70
-1
@@ -28,7 +28,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::{Mutex as AsyncMutex, mpsc};
|
||||||
|
|
||||||
use crate::agent::AgentDeps;
|
use crate::agent::AgentDeps;
|
||||||
use crate::channels::{
|
use crate::channels::{
|
||||||
@@ -361,6 +361,75 @@ impl Channel for StubChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Captured broadcast deliveries keyed by the target user or chat identifier.
|
||||||
|
pub type BroadcastCapture = Arc<AsyncMutex<Vec<(String, OutgoingResponse)>>>;
|
||||||
|
|
||||||
|
/// A lightweight channel double that only records `broadcast()` traffic.
|
||||||
|
///
|
||||||
|
/// This is useful for unit tests that need to assert message routing without
|
||||||
|
/// spinning up a full interactive channel harness.
|
||||||
|
pub struct RecordingBroadcastChannel {
|
||||||
|
name: &'static str,
|
||||||
|
captures: BroadcastCapture,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingBroadcastChannel {
|
||||||
|
pub fn new(name: &'static str) -> (Self, BroadcastCapture) {
|
||||||
|
let captures = Arc::new(AsyncMutex::new(Vec::new()));
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
captures: Arc::clone(&captures),
|
||||||
|
},
|
||||||
|
captures,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Channel for RecordingBroadcastChannel {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||||
|
let (_tx, rx) = mpsc::channel::<IncomingMessage>(1);
|
||||||
|
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn respond(
|
||||||
|
&self,
|
||||||
|
_msg: &IncomingMessage,
|
||||||
|
_response: OutgoingResponse,
|
||||||
|
) -> Result<(), ChannelError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_status(
|
||||||
|
&self,
|
||||||
|
_status: StatusUpdate,
|
||||||
|
_metadata: &serde_json::Value,
|
||||||
|
) -> Result<(), ChannelError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
response: OutgoingResponse,
|
||||||
|
) -> Result<(), ChannelError> {
|
||||||
|
self.captures
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.push((user_id.to_string(), response));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Assembled test components.
|
/// Assembled test components.
|
||||||
pub struct TestHarness {
|
pub struct TestHarness {
|
||||||
/// The agent dependencies, ready for use.
|
/// The agent dependencies, ready for use.
|
||||||
|
|||||||
@@ -271,12 +271,13 @@ impl Tool for MemoryWriteTool {
|
|||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Parse timezone once for targets that need it (daily_log).
|
||||||
|
let tz = crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::Tz::UTC);
|
||||||
|
|
||||||
// Resolve the target to a workspace path
|
// Resolve the target to a workspace path
|
||||||
let resolved_path = match target {
|
let resolved_path = match target {
|
||||||
"memory" => paths::MEMORY.to_string(),
|
"memory" => paths::MEMORY.to_string(),
|
||||||
"daily_log" => {
|
"daily_log" => {
|
||||||
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
|
|
||||||
.unwrap_or(chrono_tz::Tz::UTC);
|
|
||||||
let now = chrono::Utc::now().with_timezone(&tz);
|
let now = chrono::Utc::now().with_timezone(&tz);
|
||||||
format!("daily/{}.md", now.format("%Y-%m-%d"))
|
format!("daily/{}.md", now.format("%Y-%m-%d"))
|
||||||
}
|
}
|
||||||
@@ -318,8 +319,6 @@ impl Tool for MemoryWriteTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"daily_log" => {
|
"daily_log" => {
|
||||||
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
|
|
||||||
.unwrap_or(chrono_tz::Tz::UTC);
|
|
||||||
self.workspace
|
self.workspace
|
||||||
.append_daily_log_tz(content, tz)
|
.append_daily_log_tz(content, tz)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ fn metadata_notify_user(metadata: &serde_json::Value) -> Option<String> {
|
|||||||
metadata_string(metadata, "notify_user").filter(|value| value != "default")
|
metadata_string(metadata, "notify_user").filter(|value| value != "default")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Autonomous runs include `owner_id` when the job is executing on behalf of a
|
||||||
|
// durable owner scope instead of an interactive channel actor.
|
||||||
|
fn metadata_owner_id(metadata: &serde_json::Value) -> Option<String> {
|
||||||
|
metadata_string(metadata, "owner_id")
|
||||||
|
}
|
||||||
|
|
||||||
fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option<&str>) -> bool {
|
fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option<&str>) -> bool {
|
||||||
match (resolved_channel, source_channel) {
|
match (resolved_channel, source_channel) {
|
||||||
(None, _) => true,
|
(None, _) => true,
|
||||||
@@ -91,11 +97,13 @@ fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option
|
|||||||
async fn resolve_channel_fallback_target(
|
async fn resolve_channel_fallback_target(
|
||||||
extension_manager: Option<&Arc<ExtensionManager>>,
|
extension_manager: Option<&Arc<ExtensionManager>>,
|
||||||
channel: Option<&str>,
|
channel: Option<&str>,
|
||||||
|
owner_scope_target: Option<&str>,
|
||||||
ctx_user_id: &str,
|
ctx_user_id: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let channel_name = channel?;
|
// Prefer an explicit channel binding when the extension manager knows the
|
||||||
|
// durable delivery target (for example, a bound Telegram chat ID).
|
||||||
if let Some(extension_manager) = extension_manager
|
if let Some(channel_name) = channel
|
||||||
|
&& let Some(extension_manager) = extension_manager
|
||||||
&& let Some(target) = extension_manager
|
&& let Some(target) = extension_manager
|
||||||
.notification_target_for_channel(channel_name)
|
.notification_target_for_channel(channel_name)
|
||||||
.await
|
.await
|
||||||
@@ -103,13 +111,19 @@ async fn resolve_channel_fallback_target(
|
|||||||
return Some(target);
|
return Some(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(ctx_user_id.to_string())
|
// `owner_id` is only present for autonomous owner-scoped executions.
|
||||||
|
// Interactive chat turns intentionally fall back to `ctx.user_id`, which is
|
||||||
|
// already the active conversation target for the current channel.
|
||||||
|
owner_scope_target
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| Some(ctx_user_id.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MessageTargetResolution<'a> {
|
struct MessageTargetResolution<'a> {
|
||||||
extension_manager: Option<&'a Arc<ExtensionManager>>,
|
extension_manager: Option<&'a Arc<ExtensionManager>>,
|
||||||
explicit_target: Option<String>,
|
explicit_target: Option<String>,
|
||||||
metadata_target: Option<String>,
|
metadata_target: Option<String>,
|
||||||
|
owner_scope_target: Option<String>,
|
||||||
default_target: Option<String>,
|
default_target: Option<String>,
|
||||||
channel: Option<&'a str>,
|
channel: Option<&'a str>,
|
||||||
metadata_channel: Option<&'a str>,
|
metadata_channel: Option<&'a str>,
|
||||||
@@ -133,6 +147,7 @@ async fn resolve_message_target(inputs: MessageTargetResolution<'_>) -> Option<S
|
|||||||
return resolve_channel_fallback_target(
|
return resolve_channel_fallback_target(
|
||||||
inputs.extension_manager,
|
inputs.extension_manager,
|
||||||
inputs.channel,
|
inputs.channel,
|
||||||
|
inputs.owner_scope_target.as_deref(),
|
||||||
inputs.ctx_user_id,
|
inputs.ctx_user_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -145,9 +160,12 @@ async fn resolve_message_target(inputs: MessageTargetResolution<'_>) -> Option<S
|
|||||||
}
|
}
|
||||||
|
|
||||||
if inputs.channel.is_some() {
|
if inputs.channel.is_some() {
|
||||||
|
// Shared per-turn conversation defaults are already scoped to the
|
||||||
|
// active interactive target, so owner scope metadata is irrelevant.
|
||||||
return resolve_channel_fallback_target(
|
return resolve_channel_fallback_target(
|
||||||
inputs.extension_manager,
|
inputs.extension_manager,
|
||||||
inputs.channel,
|
inputs.channel,
|
||||||
|
None,
|
||||||
inputs.ctx_user_id,
|
inputs.ctx_user_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -224,8 +242,9 @@ impl Tool for MessageTool {
|
|||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.clone();
|
.clone();
|
||||||
let metadata_target = metadata_notify_user(&ctx.metadata);
|
let metadata_target = metadata_notify_user(&ctx.metadata);
|
||||||
|
let owner_scope_target = metadata_owner_id(&ctx.metadata);
|
||||||
let has_execution_routing_metadata =
|
let has_execution_routing_metadata =
|
||||||
metadata_channel.is_some() || metadata_target.is_some();
|
metadata_channel.is_some() || metadata_target.is_some() || owner_scope_target.is_some();
|
||||||
|
|
||||||
// Job metadata is authoritative for autonomous executions. The shared
|
// Job metadata is authoritative for autonomous executions. The shared
|
||||||
// conversation defaults are only a legacy fallback when no execution-local
|
// conversation defaults are only a legacy fallback when no execution-local
|
||||||
@@ -250,6 +269,7 @@ impl Tool for MessageTool {
|
|||||||
extension_manager: self.extension_manager.as_ref(),
|
extension_manager: self.extension_manager.as_ref(),
|
||||||
explicit_target,
|
explicit_target,
|
||||||
metadata_target,
|
metadata_target,
|
||||||
|
owner_scope_target,
|
||||||
default_target,
|
default_target,
|
||||||
channel: channel.as_deref(),
|
channel: channel.as_deref(),
|
||||||
metadata_channel: metadata_channel.as_deref(),
|
metadata_channel: metadata_channel.as_deref(),
|
||||||
@@ -405,83 +425,13 @@ impl Tool for MessageTool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use async_trait::async_trait;
|
use crate::testing::{BroadcastCapture, RecordingBroadcastChannel};
|
||||||
use tokio::sync::{Mutex, mpsc};
|
|
||||||
|
|
||||||
use crate::channels::{
|
|
||||||
Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate,
|
|
||||||
};
|
|
||||||
use crate::error::ChannelError;
|
|
||||||
|
|
||||||
type BroadcastCapture = Arc<Mutex<Vec<(String, OutgoingResponse)>>>;
|
|
||||||
|
|
||||||
struct RecordingChannel {
|
|
||||||
name: &'static str,
|
|
||||||
captures: BroadcastCapture,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RecordingChannel {
|
|
||||||
fn new(name: &'static str) -> (Self, BroadcastCapture) {
|
|
||||||
let captures = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
name,
|
|
||||||
captures: Arc::clone(&captures),
|
|
||||||
},
|
|
||||||
captures,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Channel for RecordingChannel {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
self.name
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
|
||||||
let (_tx, rx) = mpsc::channel::<IncomingMessage>(1);
|
|
||||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn respond(
|
|
||||||
&self,
|
|
||||||
_msg: &IncomingMessage,
|
|
||||||
_response: OutgoingResponse,
|
|
||||||
) -> Result<(), ChannelError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_status(
|
|
||||||
&self,
|
|
||||||
_status: StatusUpdate,
|
|
||||||
_metadata: &serde_json::Value,
|
|
||||||
) -> Result<(), ChannelError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn broadcast(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
response: OutgoingResponse,
|
|
||||||
) -> Result<(), ChannelError> {
|
|
||||||
self.captures
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.push((user_id.to_string(), response));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn message_tool_with_recording_channels()
|
async fn message_tool_with_recording_channels()
|
||||||
-> (MessageTool, BroadcastCapture, BroadcastCapture) {
|
-> (MessageTool, BroadcastCapture, BroadcastCapture) {
|
||||||
let channel_manager = ChannelManager::new();
|
let channel_manager = ChannelManager::new();
|
||||||
let (gateway, gateway_captures) = RecordingChannel::new("gateway");
|
let (gateway, gateway_captures) = RecordingBroadcastChannel::new("gateway");
|
||||||
let (telegram, telegram_captures) = RecordingChannel::new("telegram");
|
let (telegram, telegram_captures) = RecordingBroadcastChannel::new("telegram");
|
||||||
channel_manager.add(Box::new(gateway)).await;
|
channel_manager.add(Box::new(gateway)).await;
|
||||||
channel_manager.add(Box::new(telegram)).await;
|
channel_manager.add(Box::new(telegram)).await;
|
||||||
|
|
||||||
@@ -870,28 +820,63 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn message_tool_falls_back_to_ctx_user_when_channel_known() {
|
async fn message_tool_falls_back_to_owner_scope_when_channel_known() {
|
||||||
// Regression for owner-scoped notifications: a channel can be known
|
let (tool, gateway_captures, telegram_captures) =
|
||||||
// even when the concrete delivery target is omitted, so the message
|
message_tool_with_recording_channels().await;
|
||||||
// tool should pass ctx.user_id through to the channel layer.
|
|
||||||
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
|
|
||||||
|
|
||||||
let mut ctx =
|
let mut ctx =
|
||||||
crate::context::JobContext::with_user("owner-scope", "routine-job", "price alert");
|
crate::context::JobContext::with_user("telegram", "routine-job", "price alert");
|
||||||
|
ctx.metadata = serde_json::json!({
|
||||||
|
"notify_channel": "telegram",
|
||||||
|
"owner_id": "owner-scope",
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
|
||||||
|
.await
|
||||||
|
.expect("message tool should use owner scope before ctx.user_id");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.result.as_str(),
|
||||||
|
Some("Sent message to telegram:owner-scope")
|
||||||
|
);
|
||||||
|
assert!(gateway_captures.lock().await.is_empty());
|
||||||
|
let telegram = telegram_captures.lock().await.clone();
|
||||||
|
assert_eq!(telegram.len(), 1);
|
||||||
|
assert_eq!(telegram[0].0, "owner-scope");
|
||||||
|
assert_eq!(telegram[0].1.content, "NEAR price is $5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn message_tool_falls_back_to_ctx_user_when_owner_scope_absent() {
|
||||||
|
let (tool, gateway_captures, telegram_captures) =
|
||||||
|
message_tool_with_recording_channels().await;
|
||||||
|
|
||||||
|
let mut ctx = crate::context::JobContext::with_user(
|
||||||
|
"interactive-chat-user",
|
||||||
|
"routine-job",
|
||||||
|
"price alert",
|
||||||
|
);
|
||||||
ctx.metadata = serde_json::json!({
|
ctx.metadata = serde_json::json!({
|
||||||
"notify_channel": "telegram",
|
"notify_channel": "telegram",
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
|
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
|
||||||
.await;
|
.await
|
||||||
|
.expect(
|
||||||
|
"message tool should fall back to ctx.user_id when owner scope metadata is absent",
|
||||||
|
);
|
||||||
|
|
||||||
assert!(result.is_err()); // safety: test-only assertion
|
assert_eq!(
|
||||||
let err = result.unwrap_err().to_string();
|
result.result.as_str(),
|
||||||
let mentions_missing_target = err.contains("No target specified");
|
Some("Sent message to telegram:interactive-chat-user")
|
||||||
assert!(!mentions_missing_target); // safety: test-only assertion
|
);
|
||||||
let mentions_missing_channel = err.contains("No channel specified");
|
assert!(gateway_captures.lock().await.is_empty());
|
||||||
assert!(!mentions_missing_channel); // safety: test-only assertion
|
let telegram = telegram_captures.lock().await.clone();
|
||||||
|
assert_eq!(telegram.len(), 1);
|
||||||
|
assert_eq!(telegram[0].0, "interactive-chat-user");
|
||||||
|
assert_eq!(telegram[0].1.content, "NEAR price is $5");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1438,6 +1438,9 @@ impl From<TaskOutput> for Result<String, Error> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::channels::ChannelManager;
|
||||||
use crate::llm::ToolSelection;
|
use crate::llm::ToolSelection;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1448,6 +1451,8 @@ mod tests {
|
|||||||
ToolCompletionResponse,
|
ToolCompletionResponse,
|
||||||
};
|
};
|
||||||
use crate::safety::SafetyLayer;
|
use crate::safety::SafetyLayer;
|
||||||
|
use crate::testing::{BroadcastCapture, RecordingBroadcastChannel};
|
||||||
|
use crate::tools::builtin::MessageTool;
|
||||||
use crate::tools::{Tool, ToolError as ToolExecError, ToolOutput};
|
use crate::tools::{Tool, ToolError as ToolExecError, ToolOutput};
|
||||||
|
|
||||||
/// A test tool that sleeps for a configurable duration before returning.
|
/// A test tool that sleeps for a configurable duration before returning.
|
||||||
@@ -1539,6 +1544,20 @@ mod tests {
|
|||||||
Worker::new(job_id, deps)
|
Worker::new(job_id, deps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn make_worker_with_message_tool()
|
||||||
|
-> (Worker, Arc<MessageTool>, BroadcastCapture, BroadcastCapture) {
|
||||||
|
let channel_manager = ChannelManager::new();
|
||||||
|
let (gateway, gateway_captures) = RecordingBroadcastChannel::new("gateway");
|
||||||
|
let (telegram, telegram_captures) = RecordingBroadcastChannel::new("telegram");
|
||||||
|
channel_manager.add(Box::new(gateway)).await;
|
||||||
|
channel_manager.add(Box::new(telegram)).await;
|
||||||
|
|
||||||
|
let message_tool = Arc::new(MessageTool::new(Arc::new(channel_manager)));
|
||||||
|
let worker = make_worker(vec![message_tool.clone()]).await;
|
||||||
|
|
||||||
|
(worker, message_tool, gateway_captures, telegram_captures)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tool_selection_preserves_call_id() {
|
fn test_tool_selection_preserves_call_id() {
|
||||||
let selection = ToolSelection {
|
let selection = ToolSelection {
|
||||||
@@ -2147,4 +2166,50 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(ctx.metadata, original); // safety: test
|
assert_eq!(ctx.metadata, original); // safety: test
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn autonomous_message_tool_ignores_stale_gateway_context_when_routine_metadata_targets_telegram()
|
||||||
|
{
|
||||||
|
let (worker, message_tool, gateway_captures, telegram_captures) =
|
||||||
|
make_worker_with_message_tool().await;
|
||||||
|
|
||||||
|
message_tool
|
||||||
|
.set_context(
|
||||||
|
Some("gateway".to_string()),
|
||||||
|
Some("stale-gateway-target".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
worker
|
||||||
|
.context_manager()
|
||||||
|
.update_context(worker.job_id, |ctx| {
|
||||||
|
ctx.user_id = "telegram".to_string();
|
||||||
|
ctx.metadata = serde_json::json!({
|
||||||
|
"notify_channel": "telegram",
|
||||||
|
"owner_id": "owner-scope",
|
||||||
|
});
|
||||||
|
Ok::<(), String>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap() // safety: test
|
||||||
|
.unwrap(); // safety: test
|
||||||
|
|
||||||
|
let result = worker
|
||||||
|
.execute_tool(
|
||||||
|
"message",
|
||||||
|
&serde_json::json!({"content": "hello from routine"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(); // safety: test
|
||||||
|
assert!(
|
||||||
|
result.contains("telegram:owner-scope"),
|
||||||
|
"expected telegram owner-scope routing, got: {result}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(gateway_captures.lock().await.is_empty());
|
||||||
|
let telegram = telegram_captures.lock().await.clone();
|
||||||
|
assert_eq!(telegram.len(), 1);
|
||||||
|
assert_eq!(telegram[0].0, "owner-scope");
|
||||||
|
assert_eq!(telegram[0].1.content, "hello from routine");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,27 @@ Default k=60. Results from both methods are combined, with documents appearing i
|
|||||||
- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
|
- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
|
||||||
- **libSQL:** FTS5 for keyword search + vector search via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index()` during startup)
|
- **libSQL:** FTS5 for keyword search + vector search via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index()` during startup)
|
||||||
|
|
||||||
|
## Multi-Scope Reads & Identity Isolation
|
||||||
|
|
||||||
|
When a workspace has additional read scopes (via `with_additional_read_scopes`), read operations can span multiple user scopes — a user with scopes `["alice", "shared"]` can read documents from both.
|
||||||
|
|
||||||
|
**Identity files are exempt from multi-scope reads.** The system prompt reads identity and configuration files from the **primary scope only** (`read_primary()`), never from secondary scopes:
|
||||||
|
|
||||||
|
| File | Read method | Rationale |
|
||||||
|
|------|------------|-----------|
|
||||||
|
| AGENTS.md | `read_primary()` | Agent instructions are per-user |
|
||||||
|
| SOUL.md | `read_primary()` | Core values are per-user |
|
||||||
|
| USER.md | `read_primary()` | User context is per-user |
|
||||||
|
| IDENTITY.md | `read_primary()` | Identity is per-user |
|
||||||
|
| TOOLS.md | `read_primary()` | Tool config is per-user |
|
||||||
|
| BOOTSTRAP.md | `read_primary()` | Onboarding is per-user |
|
||||||
|
| MEMORY.md | `read()` | Shared memory is a feature |
|
||||||
|
| daily/*.md | `read()` | Shared daily logs are a feature |
|
||||||
|
|
||||||
|
**Why:** Without this, a user with read access to another scope could silently inherit that scope's identity if their own copy is missing. The agent would present itself as the wrong user — a correctness and security issue.
|
||||||
|
|
||||||
|
**Design rule:** If you want shared identity across users, seed the same content into each user's scope at setup time. Don't rely on multi-scope fallback for identity files.
|
||||||
|
|
||||||
## Heartbeat System
|
## Heartbeat System
|
||||||
|
|
||||||
Proactive periodic execution (default: 30 minutes):
|
Proactive periodic execution (default: 30 minutes):
|
||||||
|
|||||||
+167
-4
@@ -37,6 +37,25 @@ pub mod paths {
|
|||||||
pub const ASSISTANT_DIRECTIVES: &str = "context/assistant-directives.md";
|
pub const ASSISTANT_DIRECTIVES: &str = "context/assistant-directives.md";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paths treated as identity documents for multi-scope isolation.
|
||||||
|
///
|
||||||
|
/// These files are always read from the primary scope only — never from
|
||||||
|
/// secondary read scopes. This prevents silent identity inheritance
|
||||||
|
/// (e.g., user A accidentally presenting as user B).
|
||||||
|
pub const IDENTITY_PATHS: &[&str] = &[
|
||||||
|
paths::IDENTITY,
|
||||||
|
paths::SOUL,
|
||||||
|
paths::AGENTS,
|
||||||
|
paths::USER,
|
||||||
|
paths::TOOLS,
|
||||||
|
paths::BOOTSTRAP,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Check if a path is an identity document that must be isolated to primary scope.
|
||||||
|
pub fn is_identity_path(path: &str) -> bool {
|
||||||
|
IDENTITY_PATHS.contains(&path)
|
||||||
|
}
|
||||||
|
|
||||||
/// A memory document stored in the database.
|
/// A memory document stored in the database.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MemoryDocument {
|
pub struct MemoryDocument {
|
||||||
@@ -101,10 +120,7 @@ impl MemoryDocument {
|
|||||||
|
|
||||||
/// Check if this is a well-known identity document.
|
/// Check if this is a well-known identity document.
|
||||||
pub fn is_identity_document(&self) -> bool {
|
pub fn is_identity_document(&self) -> bool {
|
||||||
matches!(
|
is_identity_path(&self.path)
|
||||||
self.path.as_str(),
|
|
||||||
paths::IDENTITY | paths::SOUL | paths::AGENTS | paths::USER
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +144,42 @@ impl WorkspaceEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge workspace entries from multiple scopes into a deduplicated, sorted list.
|
||||||
|
///
|
||||||
|
/// When the same path appears in multiple scopes:
|
||||||
|
/// - Keeps the most recent `updated_at`
|
||||||
|
/// - If any scope marks it as a directory, the merged entry is a directory
|
||||||
|
pub fn merge_workspace_entries(
|
||||||
|
entries: impl IntoIterator<Item = WorkspaceEntry>,
|
||||||
|
) -> Vec<WorkspaceEntry> {
|
||||||
|
let mut seen = std::collections::HashMap::new();
|
||||||
|
for entry in entries {
|
||||||
|
seen.entry(entry.path.clone())
|
||||||
|
.and_modify(|existing: &mut WorkspaceEntry| {
|
||||||
|
// Keep the most recent updated_at (and its content_preview)
|
||||||
|
if let (Some(existing_ts), Some(new_ts)) = (&existing.updated_at, &entry.updated_at)
|
||||||
|
{
|
||||||
|
if new_ts > existing_ts {
|
||||||
|
existing.updated_at = Some(*new_ts);
|
||||||
|
existing.content_preview = entry.content_preview.clone();
|
||||||
|
}
|
||||||
|
} else if existing.updated_at.is_none() {
|
||||||
|
existing.updated_at = entry.updated_at;
|
||||||
|
existing.content_preview = entry.content_preview.clone();
|
||||||
|
}
|
||||||
|
// If either is a directory, mark as directory
|
||||||
|
if entry.is_directory {
|
||||||
|
existing.is_directory = true;
|
||||||
|
existing.content_preview = None;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(entry);
|
||||||
|
}
|
||||||
|
let mut result: Vec<WorkspaceEntry> = seen.into_values().collect();
|
||||||
|
result.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// A chunk of a memory document for search indexing.
|
/// A chunk of a memory document for search indexing.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MemoryChunk {
|
pub struct MemoryChunk {
|
||||||
@@ -226,4 +278,115 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(entry.name(), "alpha");
|
assert_eq!(entry.name(), "alpha");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_workspace_entries_empty() {
|
||||||
|
let result = merge_workspace_entries(vec![]);
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_workspace_entries_keeps_newer_timestamp_and_preview() {
|
||||||
|
use chrono::TimeZone;
|
||||||
|
let old_ts = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
|
||||||
|
let new_ts = chrono::Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
|
||||||
|
|
||||||
|
let entries = vec![
|
||||||
|
WorkspaceEntry {
|
||||||
|
path: "notes.md".to_string(),
|
||||||
|
is_directory: false,
|
||||||
|
updated_at: Some(old_ts),
|
||||||
|
content_preview: Some("old".to_string()),
|
||||||
|
},
|
||||||
|
WorkspaceEntry {
|
||||||
|
path: "notes.md".to_string(),
|
||||||
|
is_directory: false,
|
||||||
|
updated_at: Some(new_ts),
|
||||||
|
content_preview: Some("new".to_string()),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = merge_workspace_entries(entries);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].updated_at, Some(new_ts));
|
||||||
|
assert_eq!(result[0].content_preview, Some("new".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_workspace_entries_directory_wins() {
|
||||||
|
let entries = vec![
|
||||||
|
WorkspaceEntry {
|
||||||
|
path: "projects".to_string(),
|
||||||
|
is_directory: false,
|
||||||
|
updated_at: None,
|
||||||
|
content_preview: Some("file content".to_string()),
|
||||||
|
},
|
||||||
|
WorkspaceEntry {
|
||||||
|
path: "projects".to_string(),
|
||||||
|
is_directory: true,
|
||||||
|
updated_at: None,
|
||||||
|
content_preview: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = merge_workspace_entries(entries);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert!(result[0].is_directory);
|
||||||
|
assert!(result[0].content_preview.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_workspace_entries_fills_missing_timestamp() {
|
||||||
|
use chrono::TimeZone;
|
||||||
|
let ts = chrono::Utc.with_ymd_and_hms(2025, 3, 1, 0, 0, 0).unwrap();
|
||||||
|
|
||||||
|
let entries = vec![
|
||||||
|
WorkspaceEntry {
|
||||||
|
path: "a.md".to_string(),
|
||||||
|
is_directory: false,
|
||||||
|
updated_at: None,
|
||||||
|
content_preview: None,
|
||||||
|
},
|
||||||
|
WorkspaceEntry {
|
||||||
|
path: "a.md".to_string(),
|
||||||
|
is_directory: false,
|
||||||
|
updated_at: Some(ts),
|
||||||
|
content_preview: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = merge_workspace_entries(entries);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].updated_at, Some(ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_workspace_entries_sorted_by_path() {
|
||||||
|
let entries = vec![
|
||||||
|
WorkspaceEntry {
|
||||||
|
path: "z.md".to_string(),
|
||||||
|
is_directory: false,
|
||||||
|
updated_at: None,
|
||||||
|
content_preview: None,
|
||||||
|
},
|
||||||
|
WorkspaceEntry {
|
||||||
|
path: "a.md".to_string(),
|
||||||
|
is_directory: false,
|
||||||
|
updated_at: None,
|
||||||
|
content_preview: None,
|
||||||
|
},
|
||||||
|
WorkspaceEntry {
|
||||||
|
path: "m.md".to_string(),
|
||||||
|
is_directory: false,
|
||||||
|
updated_at: None,
|
||||||
|
content_preview: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = merge_workspace_entries(entries);
|
||||||
|
assert_eq!(result.len(), 3);
|
||||||
|
assert_eq!(result[0].path, "a.md");
|
||||||
|
assert_eq!(result[1].path, "m.md");
|
||||||
|
assert_eq!(result[2].path, "z.md");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+362
-38
@@ -52,7 +52,10 @@ mod repository;
|
|||||||
mod search;
|
mod search;
|
||||||
|
|
||||||
pub use chunker::{ChunkConfig, chunk_document};
|
pub use chunker::{ChunkConfig, chunk_document};
|
||||||
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
|
pub use document::{
|
||||||
|
IDENTITY_PATHS, MemoryChunk, MemoryDocument, WorkspaceEntry, is_identity_path,
|
||||||
|
merge_workspace_entries, paths,
|
||||||
|
};
|
||||||
pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig};
|
pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig};
|
||||||
pub use embeddings::{
|
pub use embeddings::{
|
||||||
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
|
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
|
||||||
@@ -320,6 +323,48 @@ impl WorkspaceStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Multi-scope read methods ====================
|
||||||
|
|
||||||
|
async fn hybrid_search_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
query: &str,
|
||||||
|
embedding: Option<&[f32]>,
|
||||||
|
config: &SearchConfig,
|
||||||
|
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
||||||
|
match self {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
Self::Repo(repo) => {
|
||||||
|
repo.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Self::Db(db) => {
|
||||||
|
db.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_document_by_path_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<MemoryDocument, WorkspaceError> {
|
||||||
|
match self {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
Self::Repo(repo) => {
|
||||||
|
repo.get_document_by_path_multi(user_ids, agent_id, path)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Self::Db(db) => {
|
||||||
|
db.get_document_by_path_multi(user_ids, agent_id, path)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default template seeded into HEARTBEAT.md on first access.
|
/// Default template seeded into HEARTBEAT.md on first access.
|
||||||
@@ -340,9 +385,20 @@ const BOOTSTRAP_SEED: &str = include_str!("seeds/BOOTSTRAP.md");
|
|||||||
/// Each workspace is scoped to a user (and optionally an agent).
|
/// Each workspace is scoped to a user (and optionally an agent).
|
||||||
/// Documents are persisted to the database and indexed for search.
|
/// Documents are persisted to the database and indexed for search.
|
||||||
/// Supports both PostgreSQL (via Repository) and libSQL (via Database trait).
|
/// Supports both PostgreSQL (via Repository) and libSQL (via Database trait).
|
||||||
|
///
|
||||||
|
/// ## Multi-scope reads
|
||||||
|
///
|
||||||
|
/// By default, a workspace reads from and writes to a single `user_id`.
|
||||||
|
/// With `with_additional_read_scopes`, read operations (search, read, list)
|
||||||
|
/// can span multiple user scopes while writes remain isolated to the primary
|
||||||
|
/// `user_id`. This enables cross-tenant read access (e.g., a user reading
|
||||||
|
/// from both their own workspace and a "shared" workspace).
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
/// User identifier (from channel).
|
/// User identifier (from channel). All writes go to this scope.
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
/// User identifiers for read operations. Includes `user_id` as the first
|
||||||
|
/// element, plus any additional scopes added via `with_additional_read_scopes`.
|
||||||
|
read_user_ids: Vec<String>,
|
||||||
/// Optional agent ID for multi-agent isolation.
|
/// Optional agent ID for multi-agent isolation.
|
||||||
agent_id: Option<Uuid>,
|
agent_id: Option<Uuid>,
|
||||||
/// Database storage backend.
|
/// Database storage backend.
|
||||||
@@ -371,6 +427,7 @@ impl Workspace {
|
|||||||
let user_id_str = user_id.into();
|
let user_id_str = user_id.into();
|
||||||
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
|
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
|
||||||
Self {
|
Self {
|
||||||
|
read_user_ids: vec![user_id_str.clone()],
|
||||||
user_id: user_id_str,
|
user_id: user_id_str,
|
||||||
agent_id: None,
|
agent_id: None,
|
||||||
storage: WorkspaceStorage::Repo(Repository::new(pool)),
|
storage: WorkspaceStorage::Repo(Repository::new(pool)),
|
||||||
@@ -390,6 +447,7 @@ impl Workspace {
|
|||||||
let user_id_str = user_id.into();
|
let user_id_str = user_id.into();
|
||||||
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
|
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
|
||||||
Self {
|
Self {
|
||||||
|
read_user_ids: vec![user_id_str.clone()],
|
||||||
user_id: user_id_str,
|
user_id: user_id_str,
|
||||||
agent_id: None,
|
agent_id: None,
|
||||||
storage: WorkspaceStorage::Db(db),
|
storage: WorkspaceStorage::Db(db),
|
||||||
@@ -474,6 +532,12 @@ impl Workspace {
|
|||||||
///
|
///
|
||||||
/// Also updates read_user_ids to include all layer scopes.
|
/// Also updates read_user_ids to include all layer scopes.
|
||||||
pub fn with_memory_layers(mut self, layers: Vec<crate::workspace::layer::MemoryLayer>) -> Self {
|
pub fn with_memory_layers(mut self, layers: Vec<crate::workspace::layer::MemoryLayer>) -> Self {
|
||||||
|
// Add layer scopes to read_user_ids (same dedup logic as with_additional_read_scopes)
|
||||||
|
for layer in &layers {
|
||||||
|
if !self.read_user_ids.contains(&layer.scope) {
|
||||||
|
self.read_user_ids.push(layer.scope.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
self.memory_layers = layers;
|
self.memory_layers = layers;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -496,11 +560,37 @@ impl Workspace {
|
|||||||
&self.memory_layers
|
&self.memory_layers
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the user ID.
|
/// Add additional user scopes for read operations.
|
||||||
|
///
|
||||||
|
/// The primary `user_id` is always included. Additional scopes allow
|
||||||
|
/// read operations (search, read, list) to span multiple tenants while
|
||||||
|
/// writes remain isolated to the primary scope.
|
||||||
|
///
|
||||||
|
/// Duplicate scopes are ignored.
|
||||||
|
pub fn with_additional_read_scopes(mut self, scopes: Vec<String>) -> Self {
|
||||||
|
for scope in scopes {
|
||||||
|
if !self.read_user_ids.contains(&scope) {
|
||||||
|
self.read_user_ids.push(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the user ID (primary scope for writes).
|
||||||
pub fn user_id(&self) -> &str {
|
pub fn user_id(&self) -> &str {
|
||||||
&self.user_id
|
&self.user_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the user IDs used for read operations.
|
||||||
|
pub fn read_user_ids(&self) -> &[String] {
|
||||||
|
&self.read_user_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this workspace has multiple read scopes.
|
||||||
|
fn is_multi_scope(&self) -> bool {
|
||||||
|
self.read_user_ids.len() > 1
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the agent ID.
|
/// Get the agent ID.
|
||||||
pub fn agent_id(&self) -> Option<Uuid> {
|
pub fn agent_id(&self) -> Option<Uuid> {
|
||||||
self.agent_id
|
self.agent_id
|
||||||
@@ -518,6 +608,33 @@ impl Workspace {
|
|||||||
/// println!("{}", doc.content);
|
/// println!("{}", doc.content);
|
||||||
/// ```
|
/// ```
|
||||||
pub async fn read(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
|
pub async fn read(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
|
||||||
|
let path = normalize_path(path);
|
||||||
|
if self.is_multi_scope() && is_identity_path(&path) {
|
||||||
|
// Identity files must only come from the primary scope.
|
||||||
|
self.storage
|
||||||
|
.get_document_by_path(&self.user_id, self.agent_id, &path)
|
||||||
|
.await
|
||||||
|
} else if self.is_multi_scope() {
|
||||||
|
self.storage
|
||||||
|
.get_document_by_path_multi(&self.read_user_ids, self.agent_id, &path)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
self.storage
|
||||||
|
.get_document_by_path(&self.user_id, self.agent_id, &path)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file from the **primary scope only**, ignoring additional read scopes.
|
||||||
|
///
|
||||||
|
/// Use this for identity and configuration files (AGENTS.md, SOUL.md, USER.md,
|
||||||
|
/// IDENTITY.md, TOOLS.md, BOOTSTRAP.md) where inheriting content from another
|
||||||
|
/// scope would be a correctness/security issue — the agent must never silently
|
||||||
|
/// present itself as the wrong user.
|
||||||
|
///
|
||||||
|
/// For memory files that should span scopes (MEMORY.md, daily logs), use
|
||||||
|
/// [`read`] instead.
|
||||||
|
pub async fn read_primary(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
|
||||||
let path = normalize_path(path);
|
let path = normalize_path(path);
|
||||||
self.storage
|
self.storage
|
||||||
.get_document_by_path(&self.user_id, self.agent_id, &path)
|
.get_document_by_path(&self.user_id, self.agent_id, &path)
|
||||||
@@ -556,6 +673,9 @@ impl Workspace {
|
|||||||
/// Uses a single `\n` separator (suitable for log-style entries).
|
/// Uses a single `\n` separator (suitable for log-style entries).
|
||||||
/// For semantic separation (e.g., memory entries), use `append_memory()`
|
/// For semantic separation (e.g., memory entries), use `append_memory()`
|
||||||
/// which uses `\n\n`.
|
/// which uses `\n\n`.
|
||||||
|
///
|
||||||
|
/// Uses a read-modify-write pattern that is not concurrency-safe:
|
||||||
|
/// concurrent appends to the same path may lose writes.
|
||||||
pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> {
|
pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> {
|
||||||
let path = normalize_path(path);
|
let path = normalize_path(path);
|
||||||
// Scan system-prompt-injected files for prompt injection.
|
// Scan system-prompt-injected files for prompt injection.
|
||||||
@@ -676,6 +796,20 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write to a layer, with append semantics.
|
/// Write to a layer, with append semantics.
|
||||||
|
///
|
||||||
|
/// Note: privacy classification only examines the new `content`, not the
|
||||||
|
/// full document after concatenation. See [`PatternPrivacyClassifier`]
|
||||||
|
/// limitations for details.
|
||||||
|
///
|
||||||
|
/// When a privacy redirect occurs, the append targets a **separate
|
||||||
|
/// document** in the private scope at the same path — the shared-scope
|
||||||
|
/// document is left unmodified. Subsequent multi-scope reads will return
|
||||||
|
/// the private copy (primary scope wins), effectively shadowing the
|
||||||
|
/// shared document at that path. The `WriteResult::redirected` flag
|
||||||
|
/// indicates when this has happened.
|
||||||
|
///
|
||||||
|
/// Uses a read-modify-write pattern that is not concurrency-safe:
|
||||||
|
/// concurrent appends to the same path may lose writes.
|
||||||
pub async fn append_to_layer(
|
pub async fn append_to_layer(
|
||||||
&self,
|
&self,
|
||||||
layer_name: &str,
|
layer_name: &str,
|
||||||
@@ -706,13 +840,25 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a file exists.
|
/// Check if a file exists.
|
||||||
|
///
|
||||||
|
/// When multi-scope reads are configured, checks across all read scopes.
|
||||||
pub async fn exists(&self, path: &str) -> Result<bool, WorkspaceError> {
|
pub async fn exists(&self, path: &str) -> Result<bool, WorkspaceError> {
|
||||||
let path = normalize_path(path);
|
let path = normalize_path(path);
|
||||||
match self
|
let result = if self.is_multi_scope() && is_identity_path(&path) {
|
||||||
.storage
|
// Identity files only checked in primary scope.
|
||||||
.get_document_by_path(&self.user_id, self.agent_id, &path)
|
self.storage
|
||||||
.await
|
.get_document_by_path(&self.user_id, self.agent_id, &path)
|
||||||
{
|
.await
|
||||||
|
} else if self.is_multi_scope() {
|
||||||
|
self.storage
|
||||||
|
.get_document_by_path_multi(&self.read_user_ids, self.agent_id, &path)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
self.storage
|
||||||
|
.get_document_by_path(&self.user_id, self.agent_id, &path)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
match result {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(false),
|
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(false),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
@@ -747,16 +893,55 @@ impl Workspace {
|
|||||||
/// ```
|
/// ```
|
||||||
pub async fn list(&self, directory: &str) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
|
pub async fn list(&self, directory: &str) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
|
||||||
let directory = normalize_directory(directory);
|
let directory = normalize_directory(directory);
|
||||||
self.storage
|
if self.is_multi_scope() {
|
||||||
.list_directory(&self.user_id, self.agent_id, &directory)
|
// Iterate per-scope rather than using list_directory_multi because
|
||||||
.await
|
// we need to filter identity paths from secondary scopes only — the
|
||||||
|
// merged _multi result loses scope attribution.
|
||||||
|
let primary = self
|
||||||
|
.storage
|
||||||
|
.list_directory(&self.user_id, self.agent_id, &directory)
|
||||||
|
.await?;
|
||||||
|
let mut all_entries = primary;
|
||||||
|
for scope in &self.read_user_ids[1..] {
|
||||||
|
let entries = self
|
||||||
|
.storage
|
||||||
|
.list_directory(scope, self.agent_id, &directory)
|
||||||
|
.await?;
|
||||||
|
all_entries.extend(entries.into_iter().filter(|e| !is_identity_path(&e.path)));
|
||||||
|
}
|
||||||
|
Ok(merge_workspace_entries(all_entries))
|
||||||
|
} else {
|
||||||
|
self.storage
|
||||||
|
.list_directory(&self.user_id, self.agent_id, &directory)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all files recursively (flat list of all paths).
|
/// List all files recursively (flat list of all paths).
|
||||||
|
///
|
||||||
|
/// When multi-scope reads are configured, lists across all read scopes.
|
||||||
pub async fn list_all(&self) -> Result<Vec<String>, WorkspaceError> {
|
pub async fn list_all(&self) -> Result<Vec<String>, WorkspaceError> {
|
||||||
self.storage
|
if self.is_multi_scope() {
|
||||||
.list_all_paths(&self.user_id, self.agent_id)
|
// Iterate per-scope rather than using list_all_paths_multi because
|
||||||
.await
|
// we need to filter identity paths from secondary scopes only.
|
||||||
|
// Primary scope: all paths. Secondary scopes: filter identity paths.
|
||||||
|
let mut all_paths = self
|
||||||
|
.storage
|
||||||
|
.list_all_paths(&self.user_id, self.agent_id)
|
||||||
|
.await?;
|
||||||
|
for scope in &self.read_user_ids[1..] {
|
||||||
|
let paths = self.storage.list_all_paths(scope, self.agent_id).await?;
|
||||||
|
all_paths.extend(paths.into_iter().filter(|p| !is_identity_path(p)));
|
||||||
|
}
|
||||||
|
// Deduplicate and sort
|
||||||
|
all_paths.sort();
|
||||||
|
all_paths.dedup();
|
||||||
|
Ok(all_paths)
|
||||||
|
} else {
|
||||||
|
self.storage
|
||||||
|
.list_all_paths(&self.user_id, self.agent_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Convenience Methods ====================
|
// ==================== Convenience Methods ====================
|
||||||
@@ -791,7 +976,7 @@ impl Workspace {
|
|||||||
/// comments, which the heartbeat runner treats as "effectively empty"
|
/// comments, which the heartbeat runner treats as "effectively empty"
|
||||||
/// and skips the LLM call.
|
/// and skips the LLM call.
|
||||||
pub async fn heartbeat_checklist(&self) -> Result<Option<String>, WorkspaceError> {
|
pub async fn heartbeat_checklist(&self) -> Result<Option<String>, WorkspaceError> {
|
||||||
match self.read(paths::HEARTBEAT).await {
|
match self.read_primary(paths::HEARTBEAT).await {
|
||||||
Ok(doc) => Ok(Some(doc.content)),
|
Ok(doc) => Ok(Some(doc.content)),
|
||||||
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())),
|
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
@@ -799,7 +984,29 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to read or create a file.
|
/// Helper to read or create a file.
|
||||||
|
///
|
||||||
|
/// When multi-scope reads are configured, checks all read scopes before
|
||||||
|
/// creating. If the file exists in any scope, returns it. If not found in
|
||||||
|
/// any scope, creates it in the primary (write) scope.
|
||||||
|
///
|
||||||
|
/// **Important:** In multi-scope mode, the returned document may belong to
|
||||||
|
/// a secondary scope. Callers that intend to **write** to the document
|
||||||
|
/// (via `update_document(doc.id, ...)`) must NOT use this method — use
|
||||||
|
/// `storage.get_or_create_document_by_path(&self.user_id, ...)` instead
|
||||||
|
/// to guarantee writes target the primary scope. See `append_memory` for
|
||||||
|
/// the correct pattern.
|
||||||
async fn read_or_create(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
|
async fn read_or_create(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
|
||||||
|
if self.is_multi_scope() {
|
||||||
|
match self
|
||||||
|
.storage
|
||||||
|
.get_document_by_path_multi(&self.read_user_ids, self.agent_id, path)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(doc) => return Ok(doc),
|
||||||
|
Err(WorkspaceError::DocumentNotFound { .. }) => {}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
self.storage
|
self.storage
|
||||||
.get_or_create_document_by_path(&self.user_id, self.agent_id, path)
|
.get_or_create_document_by_path(&self.user_id, self.agent_id, path)
|
||||||
.await
|
.await
|
||||||
@@ -811,9 +1018,18 @@ impl Workspace {
|
|||||||
///
|
///
|
||||||
/// This is for important facts, decisions, and preferences worth
|
/// This is for important facts, decisions, and preferences worth
|
||||||
/// remembering long-term.
|
/// remembering long-term.
|
||||||
|
///
|
||||||
|
/// Uses `get_or_create_document_by_path` with the primary `user_id`
|
||||||
|
/// instead of `self.memory()` to guarantee writes always target the
|
||||||
|
/// primary (write) scope. `self.memory()` delegates to `read_or_create`,
|
||||||
|
/// which in multi-scope mode may return a document owned by a secondary
|
||||||
|
/// scope; writing to that document by UUID would violate write isolation.
|
||||||
pub async fn append_memory(&self, entry: &str) -> Result<(), WorkspaceError> {
|
pub async fn append_memory(&self, entry: &str) -> Result<(), WorkspaceError> {
|
||||||
// Use double newline for memory entries (semantic separation)
|
// Always get/create in the primary scope to preserve write isolation.
|
||||||
let doc = self.memory().await?;
|
let doc = self
|
||||||
|
.storage
|
||||||
|
.get_or_create_document_by_path(&self.user_id, self.agent_id, paths::MEMORY)
|
||||||
|
.await?;
|
||||||
let new_content = if doc.content.is_empty() {
|
let new_content = if doc.content.is_empty() {
|
||||||
entry.to_string()
|
entry.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -905,9 +1121,16 @@ impl Workspace {
|
|||||||
// Safety net: if `profile_onboarding_completed` was already set (the
|
// Safety net: if `profile_onboarding_completed` was already set (the
|
||||||
// LLM completed onboarding but forgot to delete BOOTSTRAP.md), skip
|
// LLM completed onboarding but forgot to delete BOOTSTRAP.md), skip
|
||||||
// injection to avoid repeating the first-run ritual.
|
// injection to avoid repeating the first-run ritual.
|
||||||
|
//
|
||||||
|
// Identity and config files use read_primary() to prevent cross-scope
|
||||||
|
// bleed in multi-scope workspaces. Without this, a user with read access
|
||||||
|
// to other scopes could silently inherit another user's identity if their
|
||||||
|
// own copy is missing — the agent would present as the wrong person.
|
||||||
|
// Memory files (MEMORY.md, daily logs) intentionally use multi-scope
|
||||||
|
// read() since sharing memory across scopes is a feature.
|
||||||
let bootstrap_injected = if self.is_bootstrap_completed() {
|
let bootstrap_injected = if self.is_bootstrap_completed() {
|
||||||
if self
|
if self
|
||||||
.read(paths::BOOTSTRAP)
|
.read_primary(paths::BOOTSTRAP)
|
||||||
.await
|
.await
|
||||||
.is_ok_and(|d| !d.content.is_empty())
|
.is_ok_and(|d| !d.content.is_empty())
|
||||||
{
|
{
|
||||||
@@ -917,7 +1140,7 @@ impl Workspace {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
} else if let Ok(doc) = self.read(paths::BOOTSTRAP).await
|
} else if let Ok(doc) = self.read_primary(paths::BOOTSTRAP).await
|
||||||
&& !doc.content.is_empty()
|
&& !doc.content.is_empty()
|
||||||
{
|
{
|
||||||
parts.push(format!("## First-Run Bootstrap\n\n{}", doc.content));
|
parts.push(format!("## First-Run Bootstrap\n\n{}", doc.content));
|
||||||
@@ -926,7 +1149,8 @@ impl Workspace {
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load identity files in order of importance
|
// Load identity files in order of importance.
|
||||||
|
// These MUST use read_primary() — see comment above.
|
||||||
let identity_files = [
|
let identity_files = [
|
||||||
(paths::AGENTS, "## Agent Instructions"),
|
(paths::AGENTS, "## Agent Instructions"),
|
||||||
(paths::SOUL, "## Core Values"),
|
(paths::SOUL, "## Core Values"),
|
||||||
@@ -935,7 +1159,7 @@ impl Workspace {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (path, header) in identity_files {
|
for (path, header) in identity_files {
|
||||||
if let Ok(doc) = self.read(path).await
|
if let Ok(doc) = self.read_primary(path).await
|
||||||
&& !doc.content.is_empty()
|
&& !doc.content.is_empty()
|
||||||
{
|
{
|
||||||
parts.push(format!("{}\n\n{}", header, doc.content));
|
parts.push(format!("{}\n\n{}", header, doc.content));
|
||||||
@@ -944,7 +1168,8 @@ impl Workspace {
|
|||||||
|
|
||||||
// Tool notes: environment-specific guidance the agent or user has written.
|
// Tool notes: environment-specific guidance the agent or user has written.
|
||||||
// TOOLS.md does not control tool availability; it is guidance only.
|
// TOOLS.md does not control tool availability; it is guidance only.
|
||||||
if let Ok(doc) = self.read(paths::TOOLS).await
|
// Uses read_primary() — tool config is per-user, not inherited.
|
||||||
|
if let Ok(doc) = self.read_primary(paths::TOOLS).await
|
||||||
&& !doc.content.is_empty()
|
&& !doc.content.is_empty()
|
||||||
{
|
{
|
||||||
parts.push(format!("## Tool Notes\n\n{}", doc.content));
|
parts.push(format!("## Tool Notes\n\n{}", doc.content));
|
||||||
@@ -1235,6 +1460,8 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search with custom configuration.
|
/// Search with custom configuration.
|
||||||
|
///
|
||||||
|
/// When multi-scope reads are configured, searches across all read scopes.
|
||||||
pub async fn search_with_config(
|
pub async fn search_with_config(
|
||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
@@ -1254,15 +1481,46 @@ impl Workspace {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
self.storage
|
if self.is_multi_scope() {
|
||||||
.hybrid_search(
|
let results = self
|
||||||
&self.user_id,
|
.storage
|
||||||
self.agent_id,
|
.hybrid_search_multi(
|
||||||
query,
|
&self.read_user_ids,
|
||||||
embedding.as_deref(),
|
self.agent_id,
|
||||||
&config,
|
query,
|
||||||
)
|
embedding.as_deref(),
|
||||||
.await
|
&config,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Post-filter: exclude identity documents from secondary scopes.
|
||||||
|
// Collect document IDs that are identity paths in secondary scopes.
|
||||||
|
let mut excluded_doc_ids = std::collections::HashSet::new();
|
||||||
|
for result in &results {
|
||||||
|
if is_identity_path(&result.document_path) {
|
||||||
|
// Check if this document belongs to a secondary scope
|
||||||
|
match self.storage.get_document_by_id(result.document_id).await {
|
||||||
|
Ok(doc) if doc.user_id != self.user_id => {
|
||||||
|
excluded_doc_ids.insert(result.document_id);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(results
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| !excluded_doc_ids.contains(&r.document_id))
|
||||||
|
.collect())
|
||||||
|
} else {
|
||||||
|
self.storage
|
||||||
|
.hybrid_search(
|
||||||
|
&self.user_id,
|
||||||
|
self.agent_id,
|
||||||
|
query,
|
||||||
|
embedding.as_deref(),
|
||||||
|
&config,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Indexing ====================
|
// ==================== Indexing ====================
|
||||||
@@ -1323,13 +1581,13 @@ impl Workspace {
|
|||||||
// Check freshness BEFORE seeding identity files, otherwise the
|
// Check freshness BEFORE seeding identity files, otherwise the
|
||||||
// seeded files make the workspace look non-fresh and BOOTSTRAP.md
|
// seeded files make the workspace look non-fresh and BOOTSTRAP.md
|
||||||
// never gets created.
|
// never gets created.
|
||||||
let is_fresh_workspace = if self.read(paths::BOOTSTRAP).await.is_ok() {
|
let is_fresh_workspace = if self.read_primary(paths::BOOTSTRAP).await.is_ok() {
|
||||||
false // BOOTSTRAP already exists
|
false // BOOTSTRAP already exists
|
||||||
} else {
|
} else {
|
||||||
let (agents_res, soul_res, user_res) = tokio::join!(
|
let (agents_res, soul_res, user_res) = tokio::join!(
|
||||||
self.read(paths::AGENTS),
|
self.read_primary(paths::AGENTS),
|
||||||
self.read(paths::SOUL),
|
self.read_primary(paths::SOUL),
|
||||||
self.read(paths::USER),
|
self.read_primary(paths::USER),
|
||||||
);
|
);
|
||||||
matches!(agents_res, Err(WorkspaceError::DocumentNotFound { .. }))
|
matches!(agents_res, Err(WorkspaceError::DocumentNotFound { .. }))
|
||||||
&& matches!(soul_res, Err(WorkspaceError::DocumentNotFound { .. }))
|
&& matches!(soul_res, Err(WorkspaceError::DocumentNotFound { .. }))
|
||||||
@@ -1338,8 +1596,10 @@ impl Workspace {
|
|||||||
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for (path, content) in seed_files {
|
for (path, content) in seed_files {
|
||||||
// Skip files that already exist (never overwrite user edits)
|
// Skip files that already exist in the primary scope (never overwrite user edits).
|
||||||
match self.read(path).await {
|
// Uses read_primary to avoid false positives from secondary scopes —
|
||||||
|
// a file in another scope should not suppress seeding in this scope.
|
||||||
|
match self.read_primary(path).await {
|
||||||
Ok(_) => continue,
|
Ok(_) => continue,
|
||||||
Err(WorkspaceError::DocumentNotFound { .. }) => {}
|
Err(WorkspaceError::DocumentNotFound { .. }) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1360,7 +1620,8 @@ impl Workspace {
|
|||||||
// may already have a profile from a previous install and doesn't need
|
// may already have a profile from a previous install and doesn't need
|
||||||
// onboarding). This prevents existing users from getting a spurious
|
// onboarding). This prevents existing users from getting a spurious
|
||||||
// first-run ritual after upgrading.
|
// first-run ritual after upgrading.
|
||||||
let has_profile = self.read(paths::PROFILE).await.is_ok_and(|d| {
|
// Uses read_primary() to avoid false positives from secondary scopes.
|
||||||
|
let has_profile = self.read_primary(paths::PROFILE).await.is_ok_and(|d| {
|
||||||
!d.content.trim().is_empty()
|
!d.content.trim().is_empty()
|
||||||
&& serde_json::from_str::<crate::profile::PsychographicProfile>(&d.content).is_ok()
|
&& serde_json::from_str::<crate::profile::PsychographicProfile>(&d.content).is_ok()
|
||||||
});
|
});
|
||||||
@@ -1791,4 +2052,67 @@ mod seed_tests {
|
|||||||
"BOOTSTRAP.md should NOT have been seeded with existing profile"
|
"BOOTSTRAP.md should NOT have been seeded with existing profile"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_single_scope() {
|
||||||
|
// Verify backward compatibility: default workspace has single read scope
|
||||||
|
// matching user_id.
|
||||||
|
let user_id = "alice";
|
||||||
|
let read_user_ids = [user_id.to_string()];
|
||||||
|
assert_eq!(read_user_ids.len(), 1);
|
||||||
|
assert_eq!(read_user_ids[0], user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_additional_read_scopes() {
|
||||||
|
// Verify that additional read scopes are added correctly.
|
||||||
|
let user_id = "alice".to_string();
|
||||||
|
let mut read_user_ids = Vec::from([user_id.clone()]);
|
||||||
|
|
||||||
|
// Simulate with_additional_read_scopes logic
|
||||||
|
let scopes = ["shared", "team"];
|
||||||
|
for scope in scopes {
|
||||||
|
let s = scope.to_string();
|
||||||
|
if !read_user_ids.contains(&s) {
|
||||||
|
read_user_ids.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(read_user_ids.len(), 3);
|
||||||
|
assert_eq!(read_user_ids[0], "alice");
|
||||||
|
assert_eq!(read_user_ids[1], "shared");
|
||||||
|
assert_eq!(read_user_ids[2], "team");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_additional_read_scopes_dedup() {
|
||||||
|
// Verify that duplicate scopes are ignored.
|
||||||
|
let user_id = "alice".to_string();
|
||||||
|
let mut read_user_ids = Vec::from([user_id.clone()]);
|
||||||
|
|
||||||
|
let scopes = ["shared", "alice", "shared"];
|
||||||
|
for scope in scopes {
|
||||||
|
let s = scope.to_string();
|
||||||
|
if !read_user_ids.contains(&s) {
|
||||||
|
read_user_ids.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(read_user_ids.len(), 2);
|
||||||
|
assert_eq!(read_user_ids[0], "alice");
|
||||||
|
assert_eq!(read_user_ids[1], "shared");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_multi_scope_logic() {
|
||||||
|
// Test the multi-scope detection logic: > 1 means multi-scope
|
||||||
|
let single_count = 1_usize;
|
||||||
|
let multi_count = 2_usize;
|
||||||
|
|
||||||
|
// Single scope: not multi
|
||||||
|
assert!(single_count <= 1);
|
||||||
|
|
||||||
|
// Multi scope: is multi
|
||||||
|
assert!(multi_count > 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,4 +502,203 @@ impl Repository {
|
|||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Multi-scope search (optimized SQL) ====================
|
||||||
|
|
||||||
|
/// Hybrid search across multiple user scopes with efficient SQL.
|
||||||
|
///
|
||||||
|
/// Uses `user_id = ANY($1::text[])` instead of N separate queries.
|
||||||
|
pub async fn hybrid_search_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
query: &str,
|
||||||
|
embedding: Option<&[f32]>,
|
||||||
|
config: &SearchConfig,
|
||||||
|
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
||||||
|
let fts_results = if config.use_fts {
|
||||||
|
self.fts_search_multi(user_ids, agent_id, query, config.pre_fusion_limit)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let vector_results = if config.use_vector {
|
||||||
|
if let Some(embedding) = embedding {
|
||||||
|
self.vector_search_multi(user_ids, agent_id, embedding, config.pre_fusion_limit)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(fuse_results(fts_results, vector_results, config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FTS search across multiple user scopes.
|
||||||
|
async fn fts_search_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<RankedResult>, WorkspaceError> {
|
||||||
|
let conn = self.conn().await?;
|
||||||
|
|
||||||
|
let rows = conn
|
||||||
|
.query(
|
||||||
|
r#"
|
||||||
|
SELECT c.id as chunk_id, c.document_id, d.path as document_path,
|
||||||
|
c.content,
|
||||||
|
ts_rank_cd(c.content_tsv, plainto_tsquery('english', $3)) as rank
|
||||||
|
FROM memory_chunks c
|
||||||
|
JOIN memory_documents d ON d.id = c.document_id
|
||||||
|
WHERE d.user_id = ANY($1::text[]) AND d.agent_id IS NOT DISTINCT FROM $2
|
||||||
|
AND c.content_tsv @@ plainto_tsquery('english', $3)
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT $4
|
||||||
|
"#,
|
||||||
|
&[&user_ids, &agent_id, &query, &(limit as i64)],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WorkspaceError::SearchFailed {
|
||||||
|
reason: format!("FTS multi-scope query failed: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, row)| RankedResult {
|
||||||
|
chunk_id: row.get("chunk_id"),
|
||||||
|
document_id: row.get("document_id"),
|
||||||
|
document_path: row.get("document_path"),
|
||||||
|
content: row.get("content"),
|
||||||
|
rank: (i + 1) as u32,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vector search across multiple user scopes.
|
||||||
|
async fn vector_search_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
embedding: &[f32],
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<RankedResult>, WorkspaceError> {
|
||||||
|
let conn = self.conn().await?;
|
||||||
|
let embedding_vec = Vector::from(embedding.to_vec());
|
||||||
|
|
||||||
|
let rows = conn
|
||||||
|
.query(
|
||||||
|
r#"
|
||||||
|
SELECT c.id as chunk_id, c.document_id, d.path as document_path,
|
||||||
|
c.content, 1 - (c.embedding <=> $3) as similarity
|
||||||
|
FROM memory_chunks c
|
||||||
|
JOIN memory_documents d ON d.id = c.document_id
|
||||||
|
WHERE d.user_id = ANY($1::text[]) AND d.agent_id IS NOT DISTINCT FROM $2
|
||||||
|
AND c.embedding IS NOT NULL
|
||||||
|
ORDER BY c.embedding <=> $3
|
||||||
|
LIMIT $4
|
||||||
|
"#,
|
||||||
|
&[&user_ids, &agent_id, &embedding_vec, &(limit as i64)],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WorkspaceError::SearchFailed {
|
||||||
|
reason: format!("Vector multi-scope query failed: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, row)| RankedResult {
|
||||||
|
chunk_id: row.get("chunk_id"),
|
||||||
|
document_id: row.get("document_id"),
|
||||||
|
document_path: row.get("document_path"),
|
||||||
|
content: row.get("content"),
|
||||||
|
rank: (i + 1) as u32,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all file paths across multiple user scopes with a single query.
|
||||||
|
pub async fn list_all_paths_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<String>, WorkspaceError> {
|
||||||
|
let conn = self.conn().await?;
|
||||||
|
|
||||||
|
let rows = conn
|
||||||
|
.query(
|
||||||
|
r#"
|
||||||
|
SELECT DISTINCT path FROM memory_documents
|
||||||
|
WHERE user_id = ANY($1::text[]) AND agent_id IS NOT DISTINCT FROM $2
|
||||||
|
ORDER BY path
|
||||||
|
"#,
|
||||||
|
&[&user_ids, &agent_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WorkspaceError::SearchFailed {
|
||||||
|
reason: format!("List paths multi-scope failed: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(rows.iter().map(|row| row.get("path")).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a document by path across multiple user scopes.
|
||||||
|
///
|
||||||
|
/// Returns the first match (ordered by the input user_ids priority).
|
||||||
|
pub async fn get_document_by_path_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<MemoryDocument, WorkspaceError> {
|
||||||
|
let conn = self.conn().await?;
|
||||||
|
|
||||||
|
let row = conn
|
||||||
|
.query_opt(
|
||||||
|
r#"
|
||||||
|
SELECT id, user_id, agent_id, path, content,
|
||||||
|
created_at, updated_at, metadata
|
||||||
|
FROM memory_documents
|
||||||
|
WHERE user_id = ANY($1::text[]) AND agent_id IS NOT DISTINCT FROM $2 AND path = $3
|
||||||
|
ORDER BY array_position($1::text[], user_id)
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
&[&user_ids, &agent_id, &path],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WorkspaceError::SearchFailed {
|
||||||
|
reason: format!("get_document_by_path_multi failed: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(row) => Ok(self.row_to_document(&row)),
|
||||||
|
None => Err(WorkspaceError::DocumentNotFound {
|
||||||
|
doc_type: path.to_string(),
|
||||||
|
user_id: format!("[{}]", user_ids.join(", ")),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List directory contents across multiple user scopes.
|
||||||
|
///
|
||||||
|
/// Iterates per scope and merges results. A future migration could add an
|
||||||
|
/// optimised SQL function, at which point this method can call it directly.
|
||||||
|
pub async fn list_directory_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
directory: &str,
|
||||||
|
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
|
||||||
|
let mut all_entries = Vec::new();
|
||||||
|
for uid in user_ids {
|
||||||
|
all_entries.extend(self.list_directory(uid, agent_id, directory).await?);
|
||||||
|
}
|
||||||
|
Ok(crate::workspace::merge_workspace_entries(all_entries))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! Tests for identity file scope isolation in multi-scope workspaces.
|
||||||
|
//!
|
||||||
|
//! When a workspace has multiple read scopes (e.g., Andrew can read from
|
||||||
|
//! "andrew", "grace", "household"), identity files (SOUL.md, USER.md,
|
||||||
|
//! IDENTITY.md, AGENTS.md) must ONLY come from the primary scope.
|
||||||
|
//!
|
||||||
|
//! Multi-scope reads are designed for memory sharing (MEMORY.md, daily logs),
|
||||||
|
//! not identity inheritance. Silently inheriting identity from another scope
|
||||||
|
//! is a correctness and security issue — the agent would present itself as
|
||||||
|
//! the wrong user.
|
||||||
|
//!
|
||||||
|
//! These tests verify that:
|
||||||
|
//! 1. Identity files are read from primary scope only
|
||||||
|
//! 2. If the primary scope's identity file is missing, it's absent from the
|
||||||
|
//! system prompt — never falls back to another scope
|
||||||
|
//! 3. Memory files (MEMORY.md) still benefit from multi-scope reads
|
||||||
|
#![cfg(feature = "libsql")]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ironclaw::db::Database;
|
||||||
|
use ironclaw::db::libsql::LibSqlBackend;
|
||||||
|
use ironclaw::workspace::{Workspace, paths};
|
||||||
|
|
||||||
|
async fn setup() -> (Arc<dyn Database>, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let db_path = dir.path().join("test.db");
|
||||||
|
let backend = LibSqlBackend::new_local(&db_path).await.expect("create db");
|
||||||
|
backend.run_migrations().await.expect("run migrations");
|
||||||
|
let db: Arc<dyn Database> = Arc::new(backend);
|
||||||
|
(db, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed a document into a specific user's workspace scope.
|
||||||
|
async fn seed(db: &Arc<dyn Database>, user_id: &str, path: &str, content: &str) {
|
||||||
|
let ws = Workspace::new_with_db(user_id, db.clone());
|
||||||
|
ws.write(path, content)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("Failed to seed {path} for {user_id}: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 1: Primary scope identity appears in system prompt ───────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn system_prompt_uses_primary_scope_identity() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Seed Alice's identity files in her own scope
|
||||||
|
seed(&db, "alice", paths::SOUL, "Alice is kind and curious.").await;
|
||||||
|
seed(
|
||||||
|
&db,
|
||||||
|
"alice",
|
||||||
|
paths::USER,
|
||||||
|
"You are talking to Alice, a software engineer.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Seed Bob's identity files in his scope
|
||||||
|
seed(&db, "bob", paths::SOUL, "Bob is analytical and precise.").await;
|
||||||
|
seed(
|
||||||
|
&db,
|
||||||
|
"bob",
|
||||||
|
paths::USER,
|
||||||
|
"You are talking to Bob, a marine biologist.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create Alice's workspace WITH multi-scope reads including Bob
|
||||||
|
let ws = Workspace::new_with_db("alice", db.clone())
|
||||||
|
.with_additional_read_scopes(vec!["bob".to_string()]);
|
||||||
|
|
||||||
|
let prompt = ws
|
||||||
|
.system_prompt_for_context(false)
|
||||||
|
.await
|
||||||
|
.expect("system_prompt_for_context failed");
|
||||||
|
|
||||||
|
// Alice's identity must appear
|
||||||
|
assert!(
|
||||||
|
prompt.contains("Alice is kind and curious"),
|
||||||
|
"Primary scope SOUL.md should appear in system prompt.\nPrompt:\n{prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("Alice, a software engineer"),
|
||||||
|
"Primary scope USER.md should appear in system prompt.\nPrompt:\n{prompt}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bob's identity must NOT appear
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("Bob is analytical"),
|
||||||
|
"Secondary scope SOUL.md must NOT appear in system prompt.\nPrompt:\n{prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("Bob, a marine biologist"),
|
||||||
|
"Secondary scope USER.md must NOT appear in system prompt.\nPrompt:\n{prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 2: Missing primary identity does NOT fall back to other scope ─
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_primary_identity_does_not_fallback_to_other_scope() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Only seed Bob's identity — Alice has no identity files
|
||||||
|
seed(&db, "bob", paths::SOUL, "Bob is analytical and precise.").await;
|
||||||
|
seed(
|
||||||
|
&db,
|
||||||
|
"bob",
|
||||||
|
paths::USER,
|
||||||
|
"You are talking to Bob, a marine biologist.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create Alice's workspace with multi-scope reads including Bob
|
||||||
|
let ws = Workspace::new_with_db("alice", db.clone())
|
||||||
|
.with_additional_read_scopes(vec!["bob".to_string()]);
|
||||||
|
|
||||||
|
let prompt = ws
|
||||||
|
.system_prompt_for_context(false)
|
||||||
|
.await
|
||||||
|
.expect("system_prompt_for_context failed");
|
||||||
|
|
||||||
|
// Bob's identity must NOT appear — Alice's missing identity should stay missing,
|
||||||
|
// not silently inherit from Bob's scope
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("Bob"),
|
||||||
|
"When primary scope identity is missing, must NOT fall back to secondary scope.\n\
|
||||||
|
This would cause the agent to present itself as the wrong user.\nPrompt:\n{prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 3: MEMORY.md still benefits from multi-scope reads ────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn memory_files_still_use_multi_scope_reads() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Seed shared memory in the "shared" scope (not Alice's primary)
|
||||||
|
seed(
|
||||||
|
&db,
|
||||||
|
"shared",
|
||||||
|
paths::MEMORY,
|
||||||
|
"Shared grocery list: milk, eggs, bread.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create Alice's workspace with read access to shared scope
|
||||||
|
let ws = Workspace::new_with_db("alice", db.clone())
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
|
||||||
|
let prompt = ws
|
||||||
|
.system_prompt_for_context(false)
|
||||||
|
.await
|
||||||
|
.expect("system_prompt_for_context failed");
|
||||||
|
|
||||||
|
// Shared memory SHOULD appear — multi-scope reads are correct for memory
|
||||||
|
assert!(
|
||||||
|
prompt.contains("grocery list"),
|
||||||
|
"MEMORY.md should still use multi-scope reads.\nPrompt:\n{prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 4: All identity files are scope-isolated ──────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn all_identity_files_are_scope_isolated() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Seed identity files ONLY in the "other" scope, not in Alice's
|
||||||
|
seed(&db, "other", paths::AGENTS, "You are Other's agent.").await;
|
||||||
|
seed(&db, "other", paths::SOUL, "Other's soul values.").await;
|
||||||
|
seed(&db, "other", paths::USER, "You are talking to Other.").await;
|
||||||
|
seed(&db, "other", paths::IDENTITY, "Other's identity.").await;
|
||||||
|
|
||||||
|
// Also seed BOOTSTRAP.md and TOOLS.md in other scope
|
||||||
|
seed(&db, "other", "BOOTSTRAP.md", "Other's bootstrap.").await;
|
||||||
|
seed(&db, "other", "TOOLS.md", "Other's tool notes.").await;
|
||||||
|
|
||||||
|
// Create Alice's workspace with read access to "other"
|
||||||
|
let ws = Workspace::new_with_db("alice", db.clone())
|
||||||
|
.with_additional_read_scopes(vec!["other".to_string()]);
|
||||||
|
|
||||||
|
let prompt = ws
|
||||||
|
.system_prompt_for_context(false)
|
||||||
|
.await
|
||||||
|
.expect("system_prompt_for_context failed");
|
||||||
|
|
||||||
|
// None of Other's identity/config files should appear
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("Other"),
|
||||||
|
"No identity or config files from secondary scope should appear.\n\
|
||||||
|
Every identity file (AGENTS.md, SOUL.md, USER.md, IDENTITY.md, \
|
||||||
|
BOOTSTRAP.md, TOOLS.md) must read from primary scope only.\nPrompt:\n{prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
#![cfg(feature = "libsql")]
|
||||||
|
//! Integration tests for multi-scope workspace reads using file-backed libSQL.
|
||||||
|
//!
|
||||||
|
//! Guards the PR2 contract: workspaces can read from multiple user scopes
|
||||||
|
//! while writes remain isolated to the primary scope.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ironclaw::db::Database;
|
||||||
|
use ironclaw::db::libsql::LibSqlBackend;
|
||||||
|
use ironclaw::workspace::Workspace;
|
||||||
|
|
||||||
|
async fn setup() -> (Arc<dyn Database>, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let db_path = dir.path().join("test.db");
|
||||||
|
let backend = LibSqlBackend::new_local(&db_path).await.expect("create db");
|
||||||
|
backend.run_migrations().await.expect("run migrations");
|
||||||
|
let db: Arc<dyn Database> = Arc::new(backend);
|
||||||
|
(db, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_across_scopes() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Write docs as the "shared" user
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write("docs/team-standup.md", "Team standup notes from Monday")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice's workspace with "shared" as an additional read scope
|
||||||
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
|
||||||
|
// Alice can read shared docs
|
||||||
|
let doc = ws_alice
|
||||||
|
.read("docs/team-standup.md")
|
||||||
|
.await
|
||||||
|
.expect("cross-scope read failed");
|
||||||
|
assert_eq!(doc.content, "Team standup notes from Monday");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_stays_in_primary_scope() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Alice has "shared" as a read scope
|
||||||
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
|
||||||
|
// Alice writes a personal note
|
||||||
|
ws_alice
|
||||||
|
.write("notes/personal.md", "Alice's private note")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
// The "shared" workspace should NOT see Alice's note
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
let result = ws_shared.read("notes/personal.md").await;
|
||||||
|
assert!(result.is_err(), "Shared scope should not see Alice's note");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_paths_merges_across_scopes() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Write as alice
|
||||||
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
||||||
|
ws_alice_plain
|
||||||
|
.write("notes/personal.md", "My notes")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
// Write as shared
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write("docs/shared-doc.md", "Shared document")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice with multi-scope should see both
|
||||||
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
|
||||||
|
let all_paths = ws_alice.list_all().await.expect("list_all failed");
|
||||||
|
assert!(
|
||||||
|
all_paths.contains(&"notes/personal.md".to_string()),
|
||||||
|
"Should contain alice's note: {:?}",
|
||||||
|
all_paths
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all_paths.contains(&"docs/shared-doc.md".to_string()),
|
||||||
|
"Should contain shared doc: {:?}",
|
||||||
|
all_paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_directory_merges_across_scopes() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Alice writes to docs/
|
||||||
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
||||||
|
ws_alice_plain
|
||||||
|
.write("docs/alice-doc.md", "Alice's doc")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
// Shared writes to docs/
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write("docs/shared-doc.md", "Shared doc")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice with multi-scope lists docs/
|
||||||
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
|
||||||
|
let entries = ws_alice.list("docs").await.expect("list failed");
|
||||||
|
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
paths.contains(&"docs/alice-doc.md"),
|
||||||
|
"Should contain alice's doc: {:?}",
|
||||||
|
paths
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
paths.contains(&"docs/shared-doc.md"),
|
||||||
|
"Should contain shared doc: {:?}",
|
||||||
|
paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_spans_scopes() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Write searchable content in shared scope
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write(
|
||||||
|
"docs/architecture.md",
|
||||||
|
"The microservice architecture uses gRPC for inter-service communication",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Write searchable content in alice scope
|
||||||
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
||||||
|
ws_alice_plain
|
||||||
|
.write("notes/ideas.md", "Consider switching to GraphQL federation")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
// Alice with multi-scope searches
|
||||||
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
|
||||||
|
// Search for content in the shared scope
|
||||||
|
let results = ws_alice
|
||||||
|
.search("microservice architecture gRPC", 10)
|
||||||
|
.await
|
||||||
|
.expect("search failed");
|
||||||
|
assert!(!results.is_empty(), "Should find results from shared scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_priority_primary_first() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Write same path in both scopes
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write("config/settings.md", "Shared settings v1")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
||||||
|
ws_alice_plain
|
||||||
|
.write("config/settings.md", "Alice's settings override")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
// Alice with multi-scope should get her own version (primary scope wins)
|
||||||
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
|
||||||
|
let doc = ws_alice
|
||||||
|
.read("config/settings.md")
|
||||||
|
.await
|
||||||
|
.expect("read failed");
|
||||||
|
assert_eq!(
|
||||||
|
doc.content, "Alice's settings override",
|
||||||
|
"Primary scope should take priority"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exists_spans_scopes() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Write a doc as "shared"
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write("docs/shared-only.md", "Shared content")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice without multi-scope should NOT see it
|
||||||
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
||||||
|
assert!(
|
||||||
|
!ws_alice_plain
|
||||||
|
.exists("docs/shared-only.md")
|
||||||
|
.await
|
||||||
|
.expect("exists failed"),
|
||||||
|
"Alice without multi-scope should not see shared doc"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice with multi-scope should see it
|
||||||
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
assert!(
|
||||||
|
ws_alice
|
||||||
|
.exists("docs/shared-only.md")
|
||||||
|
.await
|
||||||
|
.expect("exists failed"),
|
||||||
|
"Alice with multi-scope should see shared doc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn append_stays_in_primary_scope() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Write a document as "shared"
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write("notes/log.md", "shared original content")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice has "shared" as a read scope and appends to the same path
|
||||||
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
ws_alice
|
||||||
|
.append("notes/log.md", "alice appended line")
|
||||||
|
.await
|
||||||
|
.expect("alice append failed");
|
||||||
|
|
||||||
|
// Shared document must be unchanged (write isolation)
|
||||||
|
let shared_doc = ws_shared
|
||||||
|
.read("notes/log.md")
|
||||||
|
.await
|
||||||
|
.expect("shared read failed");
|
||||||
|
assert_eq!(
|
||||||
|
shared_doc.content, "shared original content",
|
||||||
|
"Append must not modify the secondary scope's document"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice should have her own copy with the appended content
|
||||||
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
||||||
|
let alice_doc = ws_alice_plain
|
||||||
|
.read("notes/log.md")
|
||||||
|
.await
|
||||||
|
.expect("alice read failed");
|
||||||
|
assert_eq!(
|
||||||
|
alice_doc.content, "alice appended line",
|
||||||
|
"Append should create a new document in alice's scope"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn append_memory_stays_in_primary_scope() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
// Write MEMORY.md as "shared"
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write("MEMORY.md", "shared memory baseline")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice has "shared" as a read scope and appends a memory entry
|
||||||
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||||
|
ws_alice
|
||||||
|
.append_memory("alice remembers this")
|
||||||
|
.await
|
||||||
|
.expect("alice append_memory failed");
|
||||||
|
|
||||||
|
// Shared MEMORY.md must be unchanged
|
||||||
|
let shared_doc = ws_shared
|
||||||
|
.read("MEMORY.md")
|
||||||
|
.await
|
||||||
|
.expect("shared read failed");
|
||||||
|
assert_eq!(
|
||||||
|
shared_doc.content, "shared memory baseline",
|
||||||
|
"append_memory must not modify the secondary scope's document"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice should have her own MEMORY.md
|
||||||
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
||||||
|
let alice_doc = ws_alice_plain
|
||||||
|
.read("MEMORY.md")
|
||||||
|
.await
|
||||||
|
.expect("alice read failed");
|
||||||
|
assert_eq!(
|
||||||
|
alice_doc.content, "alice remembers this",
|
||||||
|
"append_memory should create in alice's scope"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Identity isolation tests ====================
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn identity_files_not_readable_from_secondary_scope() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
|
||||||
|
ws_other
|
||||||
|
.write("IDENTITY.md", "I am the other user")
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
ws_other
|
||||||
|
.write("SOUL.md", "Other user soul overlay")
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
ws_other
|
||||||
|
.write("USER.md", "Other user profile")
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
ws_other
|
||||||
|
.write("AGENTS.md", "Other user agent config")
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
|
||||||
|
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["other-user".to_string()]);
|
||||||
|
|
||||||
|
for path in &["IDENTITY.md", "SOUL.md", "USER.md", "AGENTS.md"] {
|
||||||
|
let result = ws_primary.read(path).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Primary should NOT read other user's {} via secondary scope",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn identity_files_not_in_search_from_secondary_scope() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
|
||||||
|
ws_other
|
||||||
|
.write("SOUL.md", "Other user loves xylophone music passionately")
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
ws_other
|
||||||
|
.write(
|
||||||
|
"notes/music.md",
|
||||||
|
"Other user played xylophone at the concert",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
|
||||||
|
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["other-user".to_string()]);
|
||||||
|
|
||||||
|
let results = ws_primary
|
||||||
|
.search("xylophone", 10)
|
||||||
|
.await
|
||||||
|
.expect("search failed");
|
||||||
|
let has_concert = results.iter().any(|r| r.content.contains("concert"));
|
||||||
|
assert!(
|
||||||
|
has_concert,
|
||||||
|
"Should find non-identity content from secondary scope"
|
||||||
|
);
|
||||||
|
let has_soul = results.iter().any(|r| r.content.contains("passionately"));
|
||||||
|
assert!(
|
||||||
|
!has_soul,
|
||||||
|
"SOUL.md content from secondary scope should not appear in search results"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn identity_files_not_in_list_from_secondary_scope() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
|
||||||
|
ws_other
|
||||||
|
.write("IDENTITY.md", "I am the other user")
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
ws_other
|
||||||
|
.write("notes/shared-note.md", "A shared note")
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
|
||||||
|
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["other-user".to_string()]);
|
||||||
|
|
||||||
|
let paths = ws_primary.list_all().await.expect("list failed");
|
||||||
|
assert!(
|
||||||
|
!paths.contains(&"IDENTITY.md".to_string()),
|
||||||
|
"IDENTITY.md from secondary scope should not appear"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
paths.contains(&"notes/shared-note.md".to_string()),
|
||||||
|
"Non-identity files should be listed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_read_scopes_reads_primary_only() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write("docs/note.md", "Shared note")
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
|
||||||
|
let ws_primary =
|
||||||
|
Workspace::new_with_db("primary", Arc::clone(&db)).with_additional_read_scopes(vec![]);
|
||||||
|
|
||||||
|
let result = ws_primary.read("docs/note.md").await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Empty read scopes should not grant cross-scope access"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn duplicate_read_scopes_handled() {
|
||||||
|
let (db, _dir) = setup().await;
|
||||||
|
|
||||||
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
||||||
|
ws_shared
|
||||||
|
.write("docs/note.md", "One note")
|
||||||
|
.await
|
||||||
|
.expect("write failed");
|
||||||
|
|
||||||
|
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
|
||||||
|
.with_additional_read_scopes(vec!["shared".to_string(), "shared".to_string()]);
|
||||||
|
|
||||||
|
let doc = ws_primary.read("docs/note.md").await.expect("read failed");
|
||||||
|
assert_eq!(doc.content, "One note");
|
||||||
|
}
|
||||||
@@ -407,3 +407,333 @@ async fn test_workspace_system_prompt() {
|
|||||||
|
|
||||||
cleanup_user(&pool, user_id).await;
|
cleanup_user(&pool, user_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Multi-scope workspace read tests ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// These exercise the PostgreSQL-optimized `_multi` query paths
|
||||||
|
// (repository.rs) that the libSQL backend covers via default trait impls.
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_scope_read_across_scopes() {
|
||||||
|
let pool = get_pool();
|
||||||
|
if try_connect(&pool).await.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shared_id = "ms_shared_read";
|
||||||
|
let alice_id = "ms_alice_read";
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
|
||||||
|
// Write a doc as "shared"
|
||||||
|
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||||
|
ws_shared
|
||||||
|
.write("docs/team-standup.md", "Team standup notes from Monday")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice with "shared" as an additional read scope
|
||||||
|
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||||
|
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||||
|
|
||||||
|
let doc = ws_alice
|
||||||
|
.read("docs/team-standup.md")
|
||||||
|
.await
|
||||||
|
.expect("cross-scope read failed");
|
||||||
|
assert_eq!(doc.content, "Team standup notes from Monday");
|
||||||
|
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_scope_write_stays_in_primary() {
|
||||||
|
let pool = get_pool();
|
||||||
|
if try_connect(&pool).await.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shared_id = "ms_shared_write";
|
||||||
|
let alice_id = "ms_alice_write";
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
|
||||||
|
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||||
|
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||||
|
|
||||||
|
ws_alice
|
||||||
|
.write("notes/personal.md", "Alice's private note")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
// Shared workspace should NOT see Alice's note
|
||||||
|
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||||
|
let result = ws_shared.read("notes/personal.md").await;
|
||||||
|
assert!(result.is_err(), "Shared scope should not see Alice's note");
|
||||||
|
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_scope_list_all_merges() {
|
||||||
|
let pool = get_pool();
|
||||||
|
if try_connect(&pool).await.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shared_id = "ms_shared_list";
|
||||||
|
let alice_id = "ms_alice_list";
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
|
||||||
|
// Write as alice (plain, no multi-scope)
|
||||||
|
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||||
|
ws_alice_plain
|
||||||
|
.write("notes/personal.md", "My notes")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
// Write as shared
|
||||||
|
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||||
|
ws_shared
|
||||||
|
.write("docs/shared-doc.md", "Shared document")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice with multi-scope should see both
|
||||||
|
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||||
|
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||||
|
|
||||||
|
let all_paths = ws_alice.list_all().await.expect("list_all failed");
|
||||||
|
assert!(
|
||||||
|
all_paths.contains(&"notes/personal.md".to_string()),
|
||||||
|
"Should contain alice's note: {:?}",
|
||||||
|
all_paths
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all_paths.contains(&"docs/shared-doc.md".to_string()),
|
||||||
|
"Should contain shared doc: {:?}",
|
||||||
|
all_paths
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_scope_list_directory_merges() {
|
||||||
|
let pool = get_pool();
|
||||||
|
if try_connect(&pool).await.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shared_id = "ms_shared_dir";
|
||||||
|
let alice_id = "ms_alice_dir";
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
|
||||||
|
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||||
|
ws_alice_plain
|
||||||
|
.write("docs/alice-doc.md", "Alice's doc")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||||
|
ws_shared
|
||||||
|
.write("docs/shared-doc.md", "Shared doc")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||||
|
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||||
|
|
||||||
|
let entries = ws_alice.list("docs").await.expect("list failed");
|
||||||
|
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
paths.contains(&"docs/alice-doc.md"),
|
||||||
|
"Should contain alice's doc: {:?}",
|
||||||
|
paths
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
paths.contains(&"docs/shared-doc.md"),
|
||||||
|
"Should contain shared doc: {:?}",
|
||||||
|
paths
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_scope_read_priority_primary_first() {
|
||||||
|
let pool = get_pool();
|
||||||
|
if try_connect(&pool).await.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shared_id = "ms_shared_prio";
|
||||||
|
let alice_id = "ms_alice_prio";
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
|
||||||
|
// Write same path in both scopes
|
||||||
|
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||||
|
ws_shared
|
||||||
|
.write("config/settings.md", "Shared settings v1")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||||
|
ws_alice_plain
|
||||||
|
.write("config/settings.md", "Alice's settings override")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
// Alice with multi-scope should get her own version (primary scope wins)
|
||||||
|
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||||
|
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||||
|
|
||||||
|
let doc = ws_alice
|
||||||
|
.read("config/settings.md")
|
||||||
|
.await
|
||||||
|
.expect("read failed");
|
||||||
|
assert_eq!(
|
||||||
|
doc.content, "Alice's settings override",
|
||||||
|
"Primary scope should take priority"
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_scope_exists_spans_scopes() {
|
||||||
|
let pool = get_pool();
|
||||||
|
if try_connect(&pool).await.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shared_id = "ms_shared_exists";
|
||||||
|
let alice_id = "ms_alice_exists";
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
|
||||||
|
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||||
|
ws_shared
|
||||||
|
.write("docs/shared-only.md", "Shared content")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice without multi-scope should NOT see it
|
||||||
|
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||||
|
assert!(
|
||||||
|
!ws_alice_plain
|
||||||
|
.exists("docs/shared-only.md")
|
||||||
|
.await
|
||||||
|
.expect("exists failed"),
|
||||||
|
"Alice without multi-scope should not see shared doc"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice with multi-scope should see it
|
||||||
|
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||||
|
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||||
|
assert!(
|
||||||
|
ws_alice
|
||||||
|
.exists("docs/shared-only.md")
|
||||||
|
.await
|
||||||
|
.expect("exists failed"),
|
||||||
|
"Alice with multi-scope should see shared doc"
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_scope_search_spans_scopes() {
|
||||||
|
let pool = get_pool();
|
||||||
|
if try_connect(&pool).await.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shared_id = "ms_shared_search";
|
||||||
|
let alice_id = "ms_alice_search";
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
|
||||||
|
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||||
|
ws_shared
|
||||||
|
.write(
|
||||||
|
"docs/architecture.md",
|
||||||
|
"The microservice architecture uses gRPC for inter-service communication",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||||
|
ws_alice_plain
|
||||||
|
.write("notes/ideas.md", "Consider switching to GraphQL federation")
|
||||||
|
.await
|
||||||
|
.expect("alice write failed");
|
||||||
|
|
||||||
|
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||||
|
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||||
|
|
||||||
|
// Search for content in the shared scope
|
||||||
|
let results = ws_alice
|
||||||
|
.search_with_config(
|
||||||
|
"microservice gRPC architecture",
|
||||||
|
SearchConfig::default().fts_only(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("search failed");
|
||||||
|
assert!(!results.is_empty(), "Should find results from shared scope");
|
||||||
|
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_scope_append_stays_in_primary() {
|
||||||
|
let pool = get_pool();
|
||||||
|
if try_connect(&pool).await.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shared_id = "ms_shared_append";
|
||||||
|
let alice_id = "ms_alice_append";
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
|
||||||
|
// Write a document as "shared"
|
||||||
|
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||||
|
ws_shared
|
||||||
|
.write("notes/log.md", "shared original content")
|
||||||
|
.await
|
||||||
|
.expect("shared write failed");
|
||||||
|
|
||||||
|
// Alice has "shared" as a read scope and appends to the same path
|
||||||
|
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||||
|
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||||
|
ws_alice
|
||||||
|
.append("notes/log.md", "alice appended line")
|
||||||
|
.await
|
||||||
|
.expect("alice append failed");
|
||||||
|
|
||||||
|
// Shared document must be unchanged (write isolation)
|
||||||
|
let shared_doc = ws_shared
|
||||||
|
.read("notes/log.md")
|
||||||
|
.await
|
||||||
|
.expect("shared read failed");
|
||||||
|
assert_eq!(
|
||||||
|
shared_doc.content, "shared original content",
|
||||||
|
"Append must not modify the secondary scope's document"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice should have her own copy with the appended content
|
||||||
|
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||||
|
let alice_doc = ws_alice_plain
|
||||||
|
.read("notes/log.md")
|
||||||
|
.await
|
||||||
|
.expect("alice read failed");
|
||||||
|
assert_eq!(
|
||||||
|
alice_doc.content, "alice appended line",
|
||||||
|
"Append should create a new document in alice's scope"
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup_user(&pool, shared_id).await;
|
||||||
|
cleanup_user(&pool, alice_id).await;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user