Agent workspaces: per-contract overlays with instant handoff
- WorkspaceManager: create/get/list workspaces with daily/weekly/monthly/pinned tiers
- Saved searches: agent stores SQL queries in workspace context
- Shortlist: tag candidates/records to a workspace with notes
- Activity log: track calls, emails, updates per workspace per agent
- Instant handoff: transfer workspace ownership with full history
Zero data copy — just a pointer swap, receiving agent sees everything
- Persistence: workspaces stored as JSON in object storage, rebuilt on startup
- Endpoints: /workspaces/create, /{id}, /{id}/handoff, /{id}/search,
/{id}/shortlist, /{id}/activity
- Tested: Sarah creates workspace, saves searches, shortlists 3 candidates,
logs activity, hands off to Mike who continues seamlessly
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,10 +30,16 @@ async fn main() {
|
||||
tracing::warn!("catalog rebuild failed (empty store?): {e}");
|
||||
}
|
||||
|
||||
// Query engine with 16GB memory cache (configurable)
|
||||
let cache = queryd::cache::MemCache::new(16 * 1024 * 1024 * 1024); // 16GB
|
||||
// Query engine with 16GB memory cache
|
||||
let cache = queryd::cache::MemCache::new(16 * 1024 * 1024 * 1024);
|
||||
let engine = queryd::context::QueryEngine::new(registry.clone(), store.clone(), cache);
|
||||
|
||||
// Workspace manager for agent-specific overlays
|
||||
let workspace_mgr = queryd::workspace::WorkspaceManager::new(store.clone());
|
||||
if let Err(e) = workspace_mgr.rebuild().await {
|
||||
tracing::warn!("workspace rebuild: {e}");
|
||||
}
|
||||
|
||||
// AI sidecar client
|
||||
let ai_client = aibridge::client::AiClient::new(&config.sidecar.url);
|
||||
|
||||
@@ -51,7 +57,8 @@ async fn main() {
|
||||
.nest("/vectors", vectord::service::router(vectord::service::VectorState {
|
||||
store: store.clone(),
|
||||
ai_client: ai_client.clone(),
|
||||
}));
|
||||
}))
|
||||
.nest("/workspaces", queryd::workspace_service::router(workspace_mgr));
|
||||
|
||||
// Auth middleware (if enabled)
|
||||
if config.auth.enabled {
|
||||
|
||||
@@ -2,3 +2,5 @@ pub mod cache;
|
||||
pub mod context;
|
||||
pub mod delta;
|
||||
pub mod service;
|
||||
pub mod workspace;
|
||||
pub mod workspace_service;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/// Agent workspaces — named overlays for contract/search-specific work.
|
||||
/// Each workspace tracks an agent's activity on a specific contract or search,
|
||||
/// with daily/weekly/monthly tiers and instant handoff capability.
|
||||
|
||||
use arrow::array::{ArrayRef, RecordBatch, StringArray, Int64Array};
|
||||
use arrow::datatypes::{DataType, Field, Schema};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::delta;
|
||||
use object_store::ObjectStore;
|
||||
|
||||
/// Retention tier for workspace data.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Tier {
|
||||
Daily, // expires end of day, active search scratch
|
||||
Weekly, // expires end of week, active contract work
|
||||
Monthly, // expires end of month, contract lifecycle
|
||||
Pinned, // never expires, manually managed
|
||||
}
|
||||
|
||||
/// A saved query/filter within a workspace.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedSearch {
|
||||
pub name: String,
|
||||
pub sql: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A shortlisted candidate or record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShortlistEntry {
|
||||
pub dataset: String,
|
||||
pub record_id: String,
|
||||
pub notes: String,
|
||||
pub added_at: DateTime<Utc>,
|
||||
pub added_by: String,
|
||||
}
|
||||
|
||||
/// Activity log entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActivityEntry {
|
||||
pub action: String, // "search", "shortlist", "call", "email", "update", "ingest"
|
||||
pub detail: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub agent: String,
|
||||
}
|
||||
|
||||
/// A workspace — an agent's working context for a contract or search.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Workspace {
|
||||
pub id: String,
|
||||
pub name: String, // "Apex Corp .NET Developers - Chicago"
|
||||
pub description: String,
|
||||
pub tier: Tier,
|
||||
pub owner: String, // current agent
|
||||
pub previous_owners: Vec<HandoffRecord>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
// Work content
|
||||
pub saved_searches: Vec<SavedSearch>,
|
||||
pub shortlist: Vec<ShortlistEntry>,
|
||||
pub activity: Vec<ActivityEntry>,
|
||||
pub ingested_datasets: Vec<String>, // datasets this workspace created
|
||||
pub delta_keys: Vec<String>, // delta files specific to this workspace
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Record of a handoff between agents.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HandoffRecord {
|
||||
pub from_agent: String,
|
||||
pub to_agent: String,
|
||||
pub reason: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Workspace manager — in-memory registry with persistence.
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceManager {
|
||||
workspaces: Arc<RwLock<HashMap<String, Workspace>>>,
|
||||
store: Arc<dyn ObjectStore>,
|
||||
}
|
||||
|
||||
impl WorkspaceManager {
|
||||
pub fn new(store: Arc<dyn ObjectStore>) -> Self {
|
||||
Self {
|
||||
workspaces: Arc::new(RwLock::new(HashMap::new())),
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild from persisted workspace files on startup.
|
||||
pub async fn rebuild(&self) -> Result<usize, String> {
|
||||
let keys = storaged::ops::list(&self.store, Some("workspaces/")).await?;
|
||||
let mut ws_map = self.workspaces.write().await;
|
||||
ws_map.clear();
|
||||
|
||||
for key in &keys {
|
||||
if !key.ends_with(".json") { continue; }
|
||||
let data = storaged::ops::get(&self.store, key).await?;
|
||||
match serde_json::from_slice::<Workspace>(&data) {
|
||||
Ok(ws) => { ws_map.insert(ws.id.clone(), ws); }
|
||||
Err(e) => tracing::warn!("failed to load workspace {key}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let count = ws_map.len();
|
||||
if count > 0 {
|
||||
tracing::info!("loaded {count} workspaces");
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Create a new workspace.
|
||||
pub async fn create(&self, name: String, description: String, owner: String, tier: Tier) -> Result<Workspace, String> {
|
||||
let now = Utc::now();
|
||||
let id = format!("ws-{}", now.timestamp_millis());
|
||||
|
||||
let ws = Workspace {
|
||||
id: id.clone(),
|
||||
name,
|
||||
description,
|
||||
tier,
|
||||
owner,
|
||||
previous_owners: vec![],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
saved_searches: vec![],
|
||||
shortlist: vec![],
|
||||
activity: vec![],
|
||||
ingested_datasets: vec![],
|
||||
delta_keys: vec![],
|
||||
tags: vec![],
|
||||
};
|
||||
|
||||
self.persist(&ws).await?;
|
||||
self.workspaces.write().await.insert(id.clone(), ws.clone());
|
||||
tracing::info!("created workspace: {} ({})", ws.name, ws.id);
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
/// Handoff workspace to another agent. Instant — no data copy.
|
||||
pub async fn handoff(&self, workspace_id: &str, to_agent: &str, reason: &str) -> Result<Workspace, String> {
|
||||
let mut ws_map = self.workspaces.write().await;
|
||||
let ws = ws_map.get_mut(workspace_id)
|
||||
.ok_or_else(|| format!("workspace not found: {workspace_id}"))?;
|
||||
|
||||
let record = HandoffRecord {
|
||||
from_agent: ws.owner.clone(),
|
||||
to_agent: to_agent.to_string(),
|
||||
reason: reason.to_string(),
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
|
||||
ws.previous_owners.push(record);
|
||||
ws.owner = to_agent.to_string();
|
||||
ws.updated_at = Utc::now();
|
||||
|
||||
ws.activity.push(ActivityEntry {
|
||||
action: "handoff".to_string(),
|
||||
detail: format!("handed off to {} — {}", to_agent, reason),
|
||||
timestamp: Utc::now(),
|
||||
agent: to_agent.to_string(),
|
||||
});
|
||||
|
||||
let ws_clone = ws.clone();
|
||||
drop(ws_map);
|
||||
self.persist(&ws_clone).await?;
|
||||
|
||||
tracing::info!("workspace '{}' handed off to {}", ws_clone.name, to_agent);
|
||||
Ok(ws_clone)
|
||||
}
|
||||
|
||||
/// Add a saved search to a workspace.
|
||||
pub async fn add_search(&self, workspace_id: &str, name: String, sql: String, agent: &str) -> Result<(), String> {
|
||||
let mut ws_map = self.workspaces.write().await;
|
||||
let ws = ws_map.get_mut(workspace_id)
|
||||
.ok_or_else(|| format!("workspace not found: {workspace_id}"))?;
|
||||
|
||||
ws.saved_searches.push(SavedSearch {
|
||||
name: name.clone(),
|
||||
sql,
|
||||
created_at: Utc::now(),
|
||||
});
|
||||
ws.activity.push(ActivityEntry {
|
||||
action: "search".into(),
|
||||
detail: format!("saved search: {name}"),
|
||||
timestamp: Utc::now(),
|
||||
agent: agent.to_string(),
|
||||
});
|
||||
ws.updated_at = Utc::now();
|
||||
|
||||
let ws_clone = ws.clone();
|
||||
drop(ws_map);
|
||||
self.persist(&ws_clone).await
|
||||
}
|
||||
|
||||
/// Add a candidate/record to the shortlist.
|
||||
pub async fn add_to_shortlist(&self, workspace_id: &str, dataset: String, record_id: String, notes: String, agent: &str) -> Result<(), String> {
|
||||
let mut ws_map = self.workspaces.write().await;
|
||||
let ws = ws_map.get_mut(workspace_id)
|
||||
.ok_or_else(|| format!("workspace not found: {workspace_id}"))?;
|
||||
|
||||
ws.shortlist.push(ShortlistEntry {
|
||||
dataset: dataset.clone(),
|
||||
record_id: record_id.clone(),
|
||||
notes,
|
||||
added_at: Utc::now(),
|
||||
added_by: agent.to_string(),
|
||||
});
|
||||
ws.activity.push(ActivityEntry {
|
||||
action: "shortlist".into(),
|
||||
detail: format!("added {record_id} from {dataset}"),
|
||||
timestamp: Utc::now(),
|
||||
agent: agent.to_string(),
|
||||
});
|
||||
ws.updated_at = Utc::now();
|
||||
|
||||
let ws_clone = ws.clone();
|
||||
drop(ws_map);
|
||||
self.persist(&ws_clone).await
|
||||
}
|
||||
|
||||
/// Log an activity.
|
||||
pub async fn log_activity(&self, workspace_id: &str, action: String, detail: String, agent: &str) -> Result<(), String> {
|
||||
let mut ws_map = self.workspaces.write().await;
|
||||
let ws = ws_map.get_mut(workspace_id)
|
||||
.ok_or_else(|| format!("workspace not found: {workspace_id}"))?;
|
||||
|
||||
ws.activity.push(ActivityEntry {
|
||||
action,
|
||||
detail,
|
||||
timestamp: Utc::now(),
|
||||
agent: agent.to_string(),
|
||||
});
|
||||
ws.updated_at = Utc::now();
|
||||
|
||||
let ws_clone = ws.clone();
|
||||
drop(ws_map);
|
||||
self.persist(&ws_clone).await
|
||||
}
|
||||
|
||||
/// Get a workspace.
|
||||
pub async fn get(&self, workspace_id: &str) -> Option<Workspace> {
|
||||
self.workspaces.read().await.get(workspace_id).cloned()
|
||||
}
|
||||
|
||||
/// List all workspaces, optionally filtered by owner or tier.
|
||||
pub async fn list(&self, owner: Option<&str>, tier: Option<&Tier>) -> Vec<Workspace> {
|
||||
let ws_map = self.workspaces.read().await;
|
||||
ws_map.values()
|
||||
.filter(|ws| {
|
||||
owner.map_or(true, |o| ws.owner == o) &&
|
||||
tier.map_or(true, |t| ws.tier == *t)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Persist workspace to object storage.
|
||||
async fn persist(&self, ws: &Workspace) -> Result<(), String> {
|
||||
let key = format!("workspaces/{}.json", ws.id);
|
||||
let json = serde_json::to_vec_pretty(ws).map_err(|e| e.to_string())?;
|
||||
storaged::ops::put(&self.store, &key, json.into()).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::workspace::{Tier, WorkspaceManager};
|
||||
|
||||
pub fn router(manager: WorkspaceManager) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(list_workspaces))
|
||||
.route("/create", post(create_workspace))
|
||||
.route("/{id}", get(get_workspace))
|
||||
.route("/{id}/handoff", post(handoff))
|
||||
.route("/{id}/search", post(add_search))
|
||||
.route("/{id}/shortlist", post(add_to_shortlist))
|
||||
.route("/{id}/activity", post(log_activity))
|
||||
.with_state(manager)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListQuery {
|
||||
owner: Option<String>,
|
||||
tier: Option<Tier>,
|
||||
}
|
||||
|
||||
async fn list_workspaces(
|
||||
State(mgr): State<WorkspaceManager>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let workspaces = mgr.list(q.owner.as_deref(), q.tier.as_ref()).await;
|
||||
Json(workspaces)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateRequest {
|
||||
name: String,
|
||||
description: String,
|
||||
owner: String,
|
||||
tier: Tier,
|
||||
}
|
||||
|
||||
async fn create_workspace(
|
||||
State(mgr): State<WorkspaceManager>,
|
||||
Json(req): Json<CreateRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match mgr.create(req.name, req.description, req.owner, req.tier).await {
|
||||
Ok(ws) => Ok((StatusCode::CREATED, Json(ws))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_workspace(
|
||||
State(mgr): State<WorkspaceManager>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match mgr.get(&id).await {
|
||||
Some(ws) => Ok(Json(ws)),
|
||||
None => Err((StatusCode::NOT_FOUND, format!("workspace not found: {id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HandoffRequest {
|
||||
to_agent: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
async fn handoff(
|
||||
State(mgr): State<WorkspaceManager>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<HandoffRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match mgr.handoff(&id, &req.to_agent, &req.reason).await {
|
||||
Ok(ws) => Ok(Json(ws)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchRequest {
|
||||
name: String,
|
||||
sql: String,
|
||||
agent: String,
|
||||
}
|
||||
|
||||
async fn add_search(
|
||||
State(mgr): State<WorkspaceManager>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SearchRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match mgr.add_search(&id, req.name, req.sql, &req.agent).await {
|
||||
Ok(()) => Ok((StatusCode::OK, "search saved")),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShortlistRequest {
|
||||
dataset: String,
|
||||
record_id: String,
|
||||
notes: String,
|
||||
agent: String,
|
||||
}
|
||||
|
||||
async fn add_to_shortlist(
|
||||
State(mgr): State<WorkspaceManager>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ShortlistRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match mgr.add_to_shortlist(&id, req.dataset, req.record_id, req.notes, &req.agent).await {
|
||||
Ok(()) => Ok((StatusCode::OK, "added to shortlist")),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ActivityRequest {
|
||||
action: String,
|
||||
detail: String,
|
||||
agent: String,
|
||||
}
|
||||
|
||||
async fn log_activity(
|
||||
State(mgr): State<WorkspaceManager>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ActivityRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match mgr.log_activity(&id, req.action, req.detail, &req.agent).await {
|
||||
Ok(()) => Ok((StatusCode::OK, "activity logged")),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user