From 4d7501a9684469998f2b518f6bd3da8bc95b266a Mon Sep 17 00:00:00 2001
From: Henry Park
Date: Sun, 22 Mar 2026 20:33:52 -0700
Subject: [PATCH 01/25] Fix owner-scoped message routing fallbacks (#1574)
* Fix owner-scoped message routing fallbacks
* Address PR feedback on routing regressions
* Address review notes on routing fallbacks
---
src/testing/mod.rs | 71 ++++++++++++++-
src/tools/builtin/message.rs | 167 ++++++++++++++++-------------------
src/worker/job.rs | 65 ++++++++++++++
3 files changed, 211 insertions(+), 92 deletions(-)
diff --git a/src/testing/mod.rs b/src/testing/mod.rs
index 953cbfcd..a633e91c 100644
--- a/src/testing/mod.rs
+++ b/src/testing/mod.rs
@@ -28,7 +28,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use async_trait::async_trait;
use rust_decimal::Decimal;
-use tokio::sync::mpsc;
+use tokio::sync::{Mutex as AsyncMutex, mpsc};
use crate::agent::AgentDeps;
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>>;
+
+/// 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 {
+ let (_tx, rx) = mpsc::channel::(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.
pub struct TestHarness {
/// The agent dependencies, ready for use.
diff --git a/src/tools/builtin/message.rs b/src/tools/builtin/message.rs
index 83041b80..08029d6f 100644
--- a/src/tools/builtin/message.rs
+++ b/src/tools/builtin/message.rs
@@ -80,6 +80,12 @@ fn metadata_notify_user(metadata: &serde_json::Value) -> Option {
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 {
+ metadata_string(metadata, "owner_id")
+}
+
fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option<&str>) -> bool {
match (resolved_channel, source_channel) {
(None, _) => true,
@@ -91,11 +97,13 @@ fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option
async fn resolve_channel_fallback_target(
extension_manager: Option<&Arc>,
channel: Option<&str>,
+ owner_scope_target: Option<&str>,
ctx_user_id: &str,
) -> Option {
- let channel_name = channel?;
-
- if let Some(extension_manager) = extension_manager
+ // Prefer an explicit channel binding when the extension manager knows the
+ // durable delivery target (for example, a bound Telegram chat ID).
+ if let Some(channel_name) = channel
+ && let Some(extension_manager) = extension_manager
&& let Some(target) = extension_manager
.notification_target_for_channel(channel_name)
.await
@@ -103,13 +111,19 @@ async fn resolve_channel_fallback_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> {
extension_manager: Option<&'a Arc>,
explicit_target: Option,
metadata_target: Option,
+ owner_scope_target: Option,
default_target: Option,
channel: Option<&'a str>,
metadata_channel: Option<&'a str>,
@@ -133,6 +147,7 @@ async fn resolve_message_target(inputs: MessageTargetResolution<'_>) -> Option) -> Option>>;
-
- 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 {
- let (_tx, rx) = mpsc::channel::(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(())
- }
- }
+ use crate::testing::{BroadcastCapture, RecordingBroadcastChannel};
async fn message_tool_with_recording_channels()
-> (MessageTool, BroadcastCapture, BroadcastCapture) {
let channel_manager = ChannelManager::new();
- let (gateway, gateway_captures) = RecordingChannel::new("gateway");
- let (telegram, telegram_captures) = RecordingChannel::new("telegram");
+ 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;
@@ -870,28 +820,63 @@ mod tests {
}
#[tokio::test]
- async fn message_tool_falls_back_to_ctx_user_when_channel_known() {
- // Regression for owner-scoped notifications: a channel can be known
- // even when the concrete delivery target is omitted, so the message
- // tool should pass ctx.user_id through to the channel layer.
- let tool = MessageTool::new(Arc::new(ChannelManager::new()));
+ async fn message_tool_falls_back_to_owner_scope_when_channel_known() {
+ let (tool, gateway_captures, telegram_captures) =
+ message_tool_with_recording_channels().await;
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!({
"notify_channel": "telegram",
});
let result = tool
.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
- let err = result.unwrap_err().to_string();
- let mentions_missing_target = err.contains("No target specified");
- assert!(!mentions_missing_target); // safety: test-only assertion
- let mentions_missing_channel = err.contains("No channel specified");
- assert!(!mentions_missing_channel); // safety: test-only assertion
+ assert_eq!(
+ result.result.as_str(),
+ Some("Sent message to telegram:interactive-chat-user")
+ );
+ assert!(gateway_captures.lock().await.is_empty());
+ 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]
diff --git a/src/worker/job.rs b/src/worker/job.rs
index 436a23ce..ba5d47b9 100644
--- a/src/worker/job.rs
+++ b/src/worker/job.rs
@@ -1438,6 +1438,9 @@ impl From for Result {
#[cfg(test)]
mod tests {
+ use std::sync::Arc;
+
+ use crate::channels::ChannelManager;
use crate::llm::ToolSelection;
use super::*;
@@ -1448,6 +1451,8 @@ mod tests {
ToolCompletionResponse,
};
use crate::safety::SafetyLayer;
+ use crate::testing::{BroadcastCapture, RecordingBroadcastChannel};
+ use crate::tools::builtin::MessageTool;
use crate::tools::{Tool, ToolError as ToolExecError, ToolOutput};
/// A test tool that sleeps for a configurable duration before returning.
@@ -1539,6 +1544,20 @@ mod tests {
Worker::new(job_id, deps)
}
+ async fn make_worker_with_message_tool()
+ -> (Worker, Arc, 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]
fn test_tool_selection_preserves_call_id() {
let selection = ToolSelection {
@@ -2147,4 +2166,50 @@ mod tests {
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");
+ }
}
From 8f6999a0740a0222ecb52ddafb54084a97c75490 Mon Sep 17 00:00:00 2001
From: Vitali Avagyan
Date: Mon, 23 Mar 2026 08:03:51 +0400
Subject: [PATCH 02/25] docs: add gitcgr code graph badge (#1563)
---
README.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/README.md b/README.md
index 6e14d9ea..cb759236 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,9 @@
+
+
+
From d9358b0fa9a551dbad13a55aeaeaee923683394f Mon Sep 17 00:00:00 2001
From: standardtoaster
Date: Mon, 23 Mar 2026 06:56:26 +0100
Subject: [PATCH 03/25] feat(workspace): multi-scope workspace reads (#1117)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(workspace): multi-scope workspace reads
Adds the ability for a workspace to read from multiple user scopes
while keeping writes isolated to the primary scope. Configuration
via WORKSPACE_READ_SCOPES env var (comma-separated user IDs).
Includes identity file isolation (read_primary), multi-scope search,
list, and read operations, WorkspaceConfig refactor, and comprehensive
integration tests.
* fix: address review feedback for multi-scope workspace reads
- fix(memory): deduplicate timezone parsing for daily_log target
parse_timezone was called twice when target was "daily_log" without a
layer — once in path resolution, again in the fallback. Now computed
once and reused.
- fix(config): add character validation for WORKSPACE_READ_SCOPES and
layer scopes — both enforce [a-zA-Z0-9_-] to prevent path traversal
or injection via scope strings used as user_id in SQL queries.
- fix(config): use chars().take(32) instead of byte-index slicing for
scope length error messages (UTF-8 safety).
- fix(error): remove unused WorkspaceError::NotFound variant
Co-Authored-By: Claude Opus 4.6 (1M context)
* style: downgrade search log to debug, add comments on list iteration
- Downgrade hybrid_search_multi tracing::info! to debug! — fires on
every multi-scope search with the default backend, too noisy for info
- Add comments explaining why list/list_all iterate per-scope instead
of using _multi trait methods (identity path filtering needs scope
attribution that merged results lose)
Co-Authored-By: Claude Opus 4.6 (1M context)
---------
Co-authored-by: ilblackdragon@gmail.com
Co-authored-by: Claude Opus 4.6 (1M context)
---
src/app.rs | 11 +
src/channels/web/server.rs | 8 +-
src/config/mod.rs | 15 +-
src/config/workspace.rs | 75 ++++-
src/db/mod.rs | 97 +++++++
src/db/postgres.rs | 45 +++
src/error.rs | 3 -
src/tools/builtin/memory.rs | 7 +-
src/workspace/README.md | 21 ++
src/workspace/document.rs | 171 ++++++++++-
src/workspace/mod.rs | 400 +++++++++++++++++++++++---
src/workspace/repository.rs | 199 +++++++++++++
tests/identity_scope_isolation.rs | 195 +++++++++++++
tests/multi_scope_functional.rs | 451 ++++++++++++++++++++++++++++++
tests/workspace_integration.rs | 330 ++++++++++++++++++++++
15 files changed, 1964 insertions(+), 64 deletions(-)
create mode 100644 tests/identity_scope_isolation.rs
create mode 100644 tests/multi_scope_functional.rs
diff --git a/src/app.rs b/src/app.rs
index b2520144..94d949be 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -325,9 +325,20 @@ impl AppBuilder {
};
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
.with_search_config(&self.config.search);
+
if let Some(ref emb) = embeddings {
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());
let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws));
diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs
index 7b24805c..7edaad67 100644
--- a/src/channels/web/server.rs
+++ b/src/channels/web/server.rs
@@ -1822,7 +1822,13 @@ async fn memory_write_handler(
"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 {
let result = if req.append {
workspace
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 68b23ab2..dcda0fe9 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -24,7 +24,7 @@ mod skills;
mod transcription;
mod tunnel;
mod wasm;
-mod workspace;
+pub(crate) mod workspace;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, Once};
@@ -178,9 +178,7 @@ impl Config {
},
transcription: TranscriptionConfig::default(),
search: WorkspaceSearchConfig::default(),
- workspace: WorkspaceConfig {
- memory_layers: vec![],
- },
+ workspace: WorkspaceConfig::default(),
observability: crate::observability::ObservabilityConfig::default(),
relay: None,
}
@@ -313,11 +311,14 @@ impl Config {
let tunnel = TunnelConfig::resolve(settings)?;
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
+
+ // Resolve workspace config using the gateway user_id for default layers.
let workspace_user_id = channels
.gateway
.as_ref()
- .map(|gw| gw.user_id.clone())
- .unwrap_or_else(|| "default".to_string());
+ .map(|gw| gw.user_id.as_str())
+ .unwrap_or("default");
+ let workspace = WorkspaceConfig::resolve(workspace_user_id)?;
Ok(Self {
owner_id: owner_id.clone(),
@@ -339,7 +340,7 @@ impl Config {
skills: SkillsConfig::resolve()?,
transcription: TranscriptionConfig::resolve(settings)?,
search: WorkspaceSearchConfig::resolve()?,
- workspace: WorkspaceConfig::resolve(&workspace_user_id)?,
+ workspace,
observability: crate::observability::ObservabilityConfig {
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
},
diff --git a/src/config/workspace.rs b/src/config/workspace.rs
index 5daa73eb..27bc06f0 100644
--- a/src/config/workspace.rs
+++ b/src/config/workspace.rs
@@ -2,18 +2,29 @@ use crate::config::helpers::optional_env;
use crate::error::ConfigError;
use crate::workspace::layer::MemoryLayer;
-/// Workspace memory configuration.
+/// Workspace-level configuration (memory layers, read scopes).
///
-/// Controls memory layer definitions for privacy-aware writes.
-/// Layers are parsed from the `MEMORY_LAYERS` env var (JSON array)
-/// or default to a single private layer scoped to the gateway user.
-#[derive(Debug, Clone)]
+/// Parsed from environment variables. Lives outside of `GatewayConfig`
+/// so that non-gateway channels can eventually use the same settings.
+#[derive(Debug, Clone, Default)]
pub struct WorkspaceConfig {
+ /// Memory layer definitions (JSON in `MEMORY_LAYERS` env var, or defaults).
pub memory_layers: Vec,
+ /// 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,
}
impl WorkspaceConfig {
- pub(crate) fn resolve(user_id: &str) -> Result {
+ /// 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 {
+ // --- Memory layers ---
let memory_layers: Vec = match optional_env("MEMORY_LAYERS")? {
Some(json_str) => {
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),
});
}
+ 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
@@ -72,7 +97,43 @@ impl WorkspaceConfig {
}
}
- Ok(Self { memory_layers })
+ // --- Read scopes ---
+ let read_scopes: Vec = 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,
+ })
}
}
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 900d1810..0c84d35d 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -644,6 +644,103 @@ pub trait WorkspaceStore: Send + Sync {
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result, 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,
+ query: &str,
+ embedding: Option<&[f32]>,
+ config: &SearchConfig,
+ ) -> Result, 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,
+ ) -> Result, 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,
+ path: &str,
+ ) -> Result {
+ 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,
+ directory: &str,
+ ) -> Result, 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.
diff --git a/src/db/postgres.rs b/src/db/postgres.rs
index e77452db..cfa10997 100644
--- a/src/db/postgres.rs
+++ b/src/db/postgres.rs
@@ -717,4 +717,49 @@ impl WorkspaceStore for PgBackend {
.hybrid_search(user_id, agent_id, query, embedding, config)
.await
}
+
+ // Optimized multi-scope overrides using `ANY($1::text[])` SQL.
+
+ async fn hybrid_search_multi(
+ &self,
+ user_ids: &[String],
+ agent_id: Option,
+ query: &str,
+ embedding: Option<&[f32]>,
+ config: &SearchConfig,
+ ) -> Result, 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,
+ ) -> Result, 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,
+ path: &str,
+ ) -> Result {
+ 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,
+ directory: &str,
+ ) -> Result, WorkspaceError> {
+ self.repo
+ .list_directory_multi(user_ids, agent_id, directory)
+ .await
+ }
}
diff --git a/src/error.rs b/src/error.rs
index 30ec58f4..e4f1b957 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -304,9 +304,6 @@ pub enum WorkspaceError {
#[error("I/O error: {reason}")]
IoError { reason: String },
- #[error("Not found: {path}")]
- NotFound { path: String },
-
#[error("Layer not found: {name}")]
LayerNotFound { name: String },
diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs
index 1c27b539..edbc4f1c 100644
--- a/src/tools/builtin/memory.rs
+++ b/src/tools/builtin/memory.rs
@@ -271,12 +271,13 @@ impl Tool for MemoryWriteTool {
.and_then(|v| v.as_bool())
.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
let resolved_path = match target {
"memory" => paths::MEMORY.to_string(),
"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);
format!("daily/{}.md", now.format("%Y-%m-%d"))
}
@@ -318,8 +319,6 @@ impl Tool for MemoryWriteTool {
}
}
"daily_log" => {
- let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
- .unwrap_or(chrono_tz::Tz::UTC);
self.workspace
.append_daily_log_tz(content, tz)
.await
diff --git a/src/workspace/README.md b/src/workspace/README.md
index 67b9907f..061a5564 100644
--- a/src/workspace/README.md
+++ b/src/workspace/README.md
@@ -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
- **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
Proactive periodic execution (default: 30 minutes):
diff --git a/src/workspace/document.rs b/src/workspace/document.rs
index 3396b677..b1fa176a 100644
--- a/src/workspace/document.rs
+++ b/src/workspace/document.rs
@@ -37,6 +37,25 @@ pub mod paths {
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.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryDocument {
@@ -101,10 +120,7 @@ impl MemoryDocument {
/// Check if this is a well-known identity document.
pub fn is_identity_document(&self) -> bool {
- matches!(
- self.path.as_str(),
- paths::IDENTITY | paths::SOUL | paths::AGENTS | paths::USER
- )
+ is_identity_path(&self.path)
}
}
@@ -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- ,
+) -> Vec {
+ 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 = seen.into_values().collect();
+ result.sort_by(|a, b| a.path.cmp(&b.path));
+ result
+}
+
/// A chunk of a memory document for search indexing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryChunk {
@@ -226,4 +278,115 @@ mod tests {
};
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");
+ }
}
diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs
index 5aac2500..0242047f 100644
--- a/src/workspace/mod.rs
+++ b/src/workspace/mod.rs
@@ -52,7 +52,10 @@ mod repository;
mod search;
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 embeddings::{
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,
+ query: &str,
+ embedding: Option<&[f32]>,
+ config: &SearchConfig,
+ ) -> Result, 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,
+ path: &str,
+ ) -> Result {
+ 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.
@@ -340,9 +385,20 @@ const BOOTSTRAP_SEED: &str = include_str!("seeds/BOOTSTRAP.md");
/// Each workspace is scoped to a user (and optionally an agent).
/// Documents are persisted to the database and indexed for search.
/// 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 {
- /// User identifier (from channel).
+ /// User identifier (from channel). All writes go to this scope.
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,
/// Optional agent ID for multi-agent isolation.
agent_id: Option,
/// Database storage backend.
@@ -371,6 +427,7 @@ impl Workspace {
let user_id_str = user_id.into();
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
Self {
+ read_user_ids: vec![user_id_str.clone()],
user_id: user_id_str,
agent_id: None,
storage: WorkspaceStorage::Repo(Repository::new(pool)),
@@ -390,6 +447,7 @@ impl Workspace {
let user_id_str = user_id.into();
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
Self {
+ read_user_ids: vec![user_id_str.clone()],
user_id: user_id_str,
agent_id: None,
storage: WorkspaceStorage::Db(db),
@@ -474,6 +532,12 @@ impl Workspace {
///
/// Also updates read_user_ids to include all layer scopes.
pub fn with_memory_layers(mut self, layers: Vec) -> 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
}
@@ -496,11 +560,37 @@ impl Workspace {
&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) -> 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 {
&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.
pub fn agent_id(&self) -> Option {
self.agent_id
@@ -518,6 +608,33 @@ impl Workspace {
/// println!("{}", doc.content);
/// ```
pub async fn read(&self, path: &str) -> Result {
+ 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 {
let path = normalize_path(path);
self.storage
.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).
/// For semantic separation (e.g., memory entries), use `append_memory()`
/// 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> {
let path = normalize_path(path);
// Scan system-prompt-injected files for prompt injection.
@@ -676,6 +796,20 @@ impl Workspace {
}
/// 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(
&self,
layer_name: &str,
@@ -706,13 +840,25 @@ impl Workspace {
}
/// Check if a file exists.
+ ///
+ /// When multi-scope reads are configured, checks across all read scopes.
pub async fn exists(&self, path: &str) -> Result {
let path = normalize_path(path);
- match self
- .storage
- .get_document_by_path(&self.user_id, self.agent_id, &path)
- .await
- {
+ let result = if self.is_multi_scope() && is_identity_path(&path) {
+ // Identity files only checked in 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
+ };
+ match result {
Ok(_) => Ok(true),
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(false),
Err(e) => Err(e),
@@ -747,16 +893,55 @@ impl Workspace {
/// ```
pub async fn list(&self, directory: &str) -> Result, WorkspaceError> {
let directory = normalize_directory(directory);
- self.storage
- .list_directory(&self.user_id, self.agent_id, &directory)
- .await
+ if self.is_multi_scope() {
+ // Iterate per-scope rather than using list_directory_multi because
+ // 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).
+ ///
+ /// When multi-scope reads are configured, lists across all read scopes.
pub async fn list_all(&self) -> Result, WorkspaceError> {
- self.storage
- .list_all_paths(&self.user_id, self.agent_id)
- .await
+ if self.is_multi_scope() {
+ // Iterate per-scope rather than using list_all_paths_multi because
+ // 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 ====================
@@ -791,7 +976,7 @@ impl Workspace {
/// comments, which the heartbeat runner treats as "effectively empty"
/// and skips the LLM call.
pub async fn heartbeat_checklist(&self) -> Result