feat: route worker workspace access through backend authority
This commit is contained in:
@@ -18,6 +18,8 @@ use memory::backend::{
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::worker::WorkspaceClient;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceHttpMemoryBackend {
|
||||
workspace_id: String,
|
||||
@@ -32,43 +34,92 @@ impl WorkspaceHttpMemoryBackend {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_operation(
|
||||
&self,
|
||||
operation: MemoryBackendOperation,
|
||||
) -> Result<MemoryBackendOperationResult, WorkspaceMemoryBackendError> {
|
||||
execute_http_memory_backend(&self.workspace_id, &self.base_url, operation)
|
||||
}
|
||||
|
||||
fn execute(&self, operation: MemoryBackendOperation) -> Result<ToolOutput, ToolError> {
|
||||
let url = format!(
|
||||
"{}/api/w/{}/memory/backend",
|
||||
self.base_url.trim_end_matches('/'),
|
||||
self.workspace_id
|
||||
);
|
||||
let response = reqwest::blocking::Client::new()
|
||||
.post(url)
|
||||
.json(&operation)
|
||||
.send()
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"workspace memory backend returned HTTP {status}: {body}"
|
||||
)));
|
||||
}
|
||||
let response: MemoryBackendHttpResponse = serde_json::from_str(&body).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!("decode memory backend response: {error}"))
|
||||
})?;
|
||||
match response {
|
||||
MemoryBackendHttpResponse::Ok {
|
||||
result: MemoryBackendOperationResult::ToolOutput(output),
|
||||
} => Ok(tool_output(output)),
|
||||
MemoryBackendHttpResponse::Ok { result } => Err(ToolError::ExecutionFailed(format!(
|
||||
match self.execute_operation(operation) {
|
||||
Ok(MemoryBackendOperationResult::ToolOutput(output)) => Ok(tool_output(output)),
|
||||
Ok(result) => Err(ToolError::ExecutionFailed(format!(
|
||||
"unexpected memory backend result for model-visible tool: {result:?}"
|
||||
))),
|
||||
MemoryBackendHttpResponse::Error { message } => {
|
||||
Err(ToolError::ExecutionFailed(message))
|
||||
Err(error) => Err(ToolError::ExecutionFailed(error.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkspaceMemoryBackendError {
|
||||
#[error("workspace memory backend is unavailable: {reason}")]
|
||||
Unavailable { reason: String },
|
||||
#[error("workspace memory backend request failed: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
#[error("workspace memory backend returned HTTP {status}: {body}")]
|
||||
Http {
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
},
|
||||
#[error("decode memory backend response: {0}")]
|
||||
Decode(#[from] serde_json::Error),
|
||||
#[error("workspace memory backend rejected operation: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl WorkspaceClient {
|
||||
pub fn execute_memory_backend_operation(
|
||||
&self,
|
||||
operation: MemoryBackendOperation,
|
||||
) -> Result<MemoryBackendOperationResult, WorkspaceMemoryBackendError> {
|
||||
match self {
|
||||
WorkspaceClient::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} => execute_http_memory_backend(workspace_id, base_url, operation),
|
||||
WorkspaceClient::Available { kind } => Err(WorkspaceMemoryBackendError::Unavailable {
|
||||
reason: format!(
|
||||
"workspace client kind `{kind}` does not expose the Backend Workspace API"
|
||||
),
|
||||
}),
|
||||
WorkspaceClient::Unavailable { reason } => {
|
||||
Err(WorkspaceMemoryBackendError::Unavailable {
|
||||
reason: reason.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_http_memory_backend(
|
||||
workspace_id: &str,
|
||||
base_url: &str,
|
||||
operation: MemoryBackendOperation,
|
||||
) -> Result<MemoryBackendOperationResult, WorkspaceMemoryBackendError> {
|
||||
let url = format!(
|
||||
"{}/api/w/{}/memory/backend",
|
||||
base_url.trim_end_matches('/'),
|
||||
workspace_id
|
||||
);
|
||||
let response = reqwest::blocking::Client::new()
|
||||
.post(url)
|
||||
.json(&operation)
|
||||
.send()?;
|
||||
let status = response.status();
|
||||
let body = response.text()?;
|
||||
if !status.is_success() {
|
||||
return Err(WorkspaceMemoryBackendError::Http { status, body });
|
||||
}
|
||||
match serde_json::from_str::<MemoryBackendHttpResponse>(&body)? {
|
||||
MemoryBackendHttpResponse::Ok { result } => Ok(result),
|
||||
MemoryBackendHttpResponse::Error { message } => {
|
||||
Err(WorkspaceMemoryBackendError::Backend(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace_http_memory_tools(
|
||||
workspace_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
|
||||
@@ -2,11 +2,11 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use memory::extract::{
|
||||
CandidateKind, ExtractedCandidate, StagingWriteResult, write_staging_candidate,
|
||||
use memory::backend::{
|
||||
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
|
||||
};
|
||||
use memory::extract::{CandidateKind, ExtractedCandidate};
|
||||
use memory::schema::SourceRef;
|
||||
use memory::workspace::WorkspaceLayout;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
@@ -19,6 +19,7 @@ use crate::session_reference::{
|
||||
ReadDetail, ReadOptions, ReadSelector, ReferenceKind, SearchOptions, SessionReferenceView,
|
||||
ToolPart,
|
||||
};
|
||||
use crate::worker::WorkspaceClient;
|
||||
|
||||
const SEARCH_EVIDENCE_DESCRIPTION: &str = "Search the host-created session evidence index. Use this to find stable evidence ids before staging a memory candidate. Supports kind=user|assistant|system|tool and tool_part=input|output|both.";
|
||||
const READ_EVIDENCE_DESCRIPTION: &str = "Read bounded session evidence by evidence_id or entry_range. Use compact mode for normal verification and full mode only when exact tool arguments or result content are necessary.";
|
||||
@@ -28,22 +29,22 @@ const FINISH_EXTRACTION_DESCRIPTION: &str = "Finish the extract worker run after
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SessionExploreState {
|
||||
view: Arc<SessionReferenceView>,
|
||||
layout: WorkspaceLayout,
|
||||
workspace_client: WorkspaceClient,
|
||||
source: SourceRef,
|
||||
extract_run_id: String,
|
||||
staged: Arc<Mutex<Vec<StagingWriteResult>>>,
|
||||
staged: Arc<Mutex<Vec<String>>>,
|
||||
finished: Arc<Mutex<Option<FinishExtractionParams>>>,
|
||||
}
|
||||
|
||||
impl SessionExploreState {
|
||||
pub(crate) fn new(
|
||||
view: SessionReferenceView,
|
||||
layout: WorkspaceLayout,
|
||||
workspace_client: WorkspaceClient,
|
||||
source: SourceRef,
|
||||
) -> Self {
|
||||
Self {
|
||||
view: Arc::new(view),
|
||||
layout,
|
||||
workspace_client,
|
||||
source,
|
||||
extract_run_id: Uuid::now_v7().to_string(),
|
||||
staged: Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -55,7 +56,7 @@ impl SessionExploreState {
|
||||
&self.view
|
||||
}
|
||||
|
||||
pub(crate) fn staged(&self) -> Vec<StagingWriteResult> {
|
||||
pub(crate) fn staged(&self) -> Vec<String> {
|
||||
self.staged
|
||||
.lock()
|
||||
.expect("session explore staged state poisoned")
|
||||
@@ -420,21 +421,45 @@ impl Tool for StageCandidateTool {
|
||||
staleness: params.staleness,
|
||||
evidence_ids: params.evidence_ids,
|
||||
};
|
||||
let written = write_staging_candidate(
|
||||
&self.state.layout,
|
||||
self.state.source.clone(),
|
||||
&self.state.extract_run_id,
|
||||
candidate,
|
||||
evidence,
|
||||
source_refs,
|
||||
)
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("write staging failed: {e}")))?;
|
||||
let id = written.id.to_string();
|
||||
let result = self
|
||||
.state
|
||||
.workspace_client
|
||||
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
|
||||
MemoryStageCandidateOperation {
|
||||
source: self.state.source.clone(),
|
||||
extract_run_id: self.state.extract_run_id.clone(),
|
||||
candidate,
|
||||
evidence,
|
||||
source_refs,
|
||||
},
|
||||
))
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("write staging failed: {e}")))?;
|
||||
let ids = match result {
|
||||
MemoryBackendOperationResult::StagingWritten(output) if output.staging_count == 1 => {
|
||||
output.staging_ids
|
||||
}
|
||||
MemoryBackendOperationResult::StagingWritten(output) => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"stage_candidate expected one staging record, backend wrote {}",
|
||||
output.staging_count
|
||||
)));
|
||||
}
|
||||
other => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"unexpected memory backend result for stage_candidate: {other:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let id = ids.into_iter().next().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(
|
||||
"stage_candidate backend did not return a staging id".to_string(),
|
||||
)
|
||||
})?;
|
||||
self.state
|
||||
.staged
|
||||
.lock()
|
||||
.expect("session explore staged state poisoned")
|
||||
.push(written);
|
||||
.push(id.clone());
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Staged memory candidate {id}."),
|
||||
content: Some(format!("staging_id: {id}")),
|
||||
@@ -583,14 +608,66 @@ fn truncate_line(text: &str, max_chars: usize) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llm_engine::Item;
|
||||
use tempfile::TempDir;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
|
||||
fn stub_memory_backend_response(
|
||||
body: &'static str,
|
||||
) -> (WorkspaceClient, mpsc::Receiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = Vec::new();
|
||||
let mut temp = [0_u8; 1024];
|
||||
let header_end = loop {
|
||||
let read = stream.read(&mut temp).unwrap();
|
||||
if read == 0 {
|
||||
break buffer.len();
|
||||
}
|
||||
buffer.extend_from_slice(&temp[..read]);
|
||||
if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
break pos + 4;
|
||||
}
|
||||
};
|
||||
let headers = String::from_utf8_lossy(&buffer[..header_end]);
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().ok())?
|
||||
})
|
||||
.unwrap_or(0);
|
||||
while buffer.len() < header_end + content_length {
|
||||
let read = stream.read(&mut temp).unwrap();
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&temp[..read]);
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buffer).into_owned();
|
||||
tx.send(request).unwrap();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
});
|
||||
(
|
||||
WorkspaceClient::http("test-workspace", format!("http://{addr}")),
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_declares_session_explore_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let state = SessionExploreState::new(
|
||||
SessionReferenceView::new("segment-1", vec![Item::user_message("remember this")]),
|
||||
WorkspaceLayout::new(temp.path()),
|
||||
WorkspaceClient::available("test-backend"),
|
||||
SourceRef {
|
||||
segment_id: "segment-1".to_string(),
|
||||
range: [0, 0],
|
||||
@@ -635,10 +712,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn stage_candidate_writes_staging_record_with_source_evidence() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (client, request_rx) = stub_memory_backend_response(
|
||||
r#"{"Ok":{"result":{"StagingWritten":{"staging_count":1,"staging_ids":["00000000-0000-7000-8000-000000000001"]}}}}"#,
|
||||
);
|
||||
let state = SessionExploreState::new(
|
||||
SessionReferenceView::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||
WorkspaceLayout::new(temp.path()),
|
||||
client,
|
||||
SourceRef {
|
||||
segment_id: "segment-1".to_string(),
|
||||
range: [0, 0],
|
||||
@@ -661,13 +740,14 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let staged = state.staged();
|
||||
assert_eq!(staged.len(), 1);
|
||||
let bytes = std::fs::read(&staged[0].path).unwrap();
|
||||
let record: memory::extract::StagingRecord = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(record.kind, CandidateKind::Decision);
|
||||
assert_eq!(record.evidence.len(), 1);
|
||||
assert_eq!(record.evidence[0].id, "M0000");
|
||||
assert_eq!(record.source_refs.len(), 1);
|
||||
assert_eq!(record.source_refs[0].evidence_id.as_deref(), Some("M0000"));
|
||||
assert_eq!(
|
||||
staged,
|
||||
vec!["00000000-0000-7000-8000-000000000001".to_string()]
|
||||
);
|
||||
let request = request_rx.recv().unwrap();
|
||||
assert!(request.contains("\"StageCandidate\""));
|
||||
assert!(request.contains("\"kind\":\"decision\""));
|
||||
assert!(request.contains("\"id\":\"M0000\""));
|
||||
assert!(request.contains("\"evidence_id\":\"M0000\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ const THREAD_TOOL_NAMES: &[&str] = &["TicketComment", "TicketReview"];
|
||||
|
||||
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
|
||||
|
||||
#[cfg(test)]
|
||||
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
@@ -145,6 +146,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
@@ -172,9 +174,6 @@ pub enum TicketFeatureBackend {
|
||||
Local {
|
||||
root: PathBuf,
|
||||
},
|
||||
LocalWorkspace {
|
||||
workspace_root: PathBuf,
|
||||
},
|
||||
WorkspaceHttp {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
@@ -224,9 +223,6 @@ impl TicketFeature {
|
||||
}
|
||||
|
||||
pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self {
|
||||
if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend {
|
||||
return Self::for_workspace_with_access(workspace_root, access);
|
||||
}
|
||||
Self {
|
||||
backend,
|
||||
record_language: None,
|
||||
@@ -266,7 +262,6 @@ impl TicketFeature {
|
||||
pub fn backend_root(&self) -> Option<&Path> {
|
||||
match &self.backend {
|
||||
TicketFeatureBackend::Local { root } => Some(root),
|
||||
TicketFeatureBackend::LocalWorkspace { workspace_root } => Some(workspace_root),
|
||||
TicketFeatureBackend::WorkspaceHttp { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -293,8 +288,7 @@ impl TicketFeature {
|
||||
}
|
||||
fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> {
|
||||
match &self.backend {
|
||||
TicketFeatureBackend::Local { root: _ }
|
||||
| TicketFeatureBackend::LocalWorkspace { workspace_root: _ } => {
|
||||
TicketFeatureBackend::Local { root: _ } => {
|
||||
let usable_root = match self.usable_backend_root() {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
|
||||
Reference in New Issue
Block a user