feat: route worker workspace access through backend authority

This commit is contained in:
2026-07-21 11:21:13 +09:00
parent 2572dde691
commit 1251edae04
10 changed files with 459 additions and 1206 deletions
+80 -29
View File
@@ -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\""));
}
}
+3 -9
View File
@@ -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) => {