feat: add workspace memory backend for embedded workers
This commit is contained in:
parent
2e0cd3d161
commit
2572dde691
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -5850,6 +5850,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"futures",
|
||||
"manifest",
|
||||
"memory",
|
||||
"project-record",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
|
|
|
|||
|
|
@ -169,6 +169,13 @@ pub enum ScopeError {
|
|||
}
|
||||
|
||||
impl Scope {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
allow: Vec::new(),
|
||||
deny: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`Scope`] from a declarative [`ScopeConfig`].
|
||||
///
|
||||
/// Every `target` in `config` must already be absolute — per-layer
|
||||
|
|
|
|||
469
crates/memory/src/backend.rs
Normal file
469
crates/memory/src/backend.rs
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
//! Workspace-backend operations for Memory tools.
|
||||
//!
|
||||
//! These types are the typed HTTP boundary used by runtime workers that have
|
||||
//! Workspace authority but no direct local filesystem authority. The local
|
||||
//! executor intentionally lives in the memory crate so the workspace backend and
|
||||
//! future non-HTTP hosts share validation and path semantics.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::extract::{ExtractedPayload, write_staging};
|
||||
use crate::schema::SourceRef;
|
||||
use crate::tool::MemoryToolKind;
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", rename_all = "snake_case")]
|
||||
pub enum MemoryBackendOperation {
|
||||
Query(MemoryQueryOperation),
|
||||
Read(MemoryReadOperation),
|
||||
Write(MemoryWriteOperation),
|
||||
Edit(MemoryEditOperation),
|
||||
Delete(MemoryDeleteOperation),
|
||||
StageExtracted(MemoryStageExtractedOperation),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum MemoryBackendHttpResponse {
|
||||
Ok {
|
||||
result: MemoryBackendOperationResult,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum MemoryBackendOperationResult {
|
||||
ToolOutput(MemoryToolOutput),
|
||||
StagingWritten(MemoryStagingWriteOutput),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryToolOutput {
|
||||
pub summary: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryQueryOperation {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryReadOperation {
|
||||
pub kind: MemoryToolKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slug: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryWriteOperation {
|
||||
pub kind: MemoryToolKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slug: Option<String>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryEditOperation {
|
||||
pub kind: MemoryToolKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slug: Option<String>,
|
||||
pub old_string: String,
|
||||
pub new_string: String,
|
||||
#[serde(default)]
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryDeleteOperation {
|
||||
pub kind: MemoryToolKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryStageExtractedOperation {
|
||||
pub source: SourceRef,
|
||||
pub payload: ExtractedPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryStagingWriteOutput {
|
||||
pub staging_count: usize,
|
||||
pub staging_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn execute_memory_backend_operation(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryBackendOperation,
|
||||
) -> io::Result<MemoryBackendOperationResult> {
|
||||
match operation {
|
||||
MemoryBackendOperation::Query(operation) => {
|
||||
execute_query(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||
}
|
||||
MemoryBackendOperation::Read(operation) => {
|
||||
execute_read(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||
}
|
||||
MemoryBackendOperation::Write(operation) => {
|
||||
execute_write(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||
}
|
||||
MemoryBackendOperation::Edit(operation) => {
|
||||
execute_edit(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||
}
|
||||
MemoryBackendOperation::Delete(operation) => {
|
||||
execute_delete(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||
}
|
||||
MemoryBackendOperation::StageExtracted(operation) => {
|
||||
let written = write_staging(layout, operation.source, operation.payload)?;
|
||||
Ok(MemoryBackendOperationResult::StagingWritten(
|
||||
MemoryStagingWriteOutput {
|
||||
staging_count: written.len(),
|
||||
staging_ids: written.iter().map(|item| item.id.to_string()).collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_query(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryQueryOperation,
|
||||
) -> io::Result<MemoryToolOutput> {
|
||||
let mut entries = Vec::new();
|
||||
for kind in [
|
||||
MemoryToolKind::Summary,
|
||||
MemoryToolKind::Decision,
|
||||
MemoryToolKind::Request,
|
||||
] {
|
||||
entries.extend(query_kind(layout, kind, operation.query.as_deref())?);
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
let suffix = operation
|
||||
.query
|
||||
.as_deref()
|
||||
.map(|query| format!(" matching {query:?}"))
|
||||
.unwrap_or_default();
|
||||
return Ok(MemoryToolOutput {
|
||||
summary: format!("No memory records found{suffix}"),
|
||||
content: None,
|
||||
});
|
||||
}
|
||||
|
||||
let body = entries.join("\n\n");
|
||||
Ok(tool_output_from_string(body))
|
||||
}
|
||||
|
||||
fn query_kind(
|
||||
layout: &WorkspaceLayout,
|
||||
kind: MemoryToolKind,
|
||||
query: Option<&str>,
|
||||
) -> io::Result<Vec<String>> {
|
||||
let mut entries = Vec::new();
|
||||
match kind {
|
||||
MemoryToolKind::Summary => {
|
||||
let path = memory_path(layout, kind, None)?;
|
||||
if !path.exists() {
|
||||
return Ok(entries);
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
if matches_query(&content, query) {
|
||||
entries.push(format!("kind=summary\n{}", excerpt(&content, query)));
|
||||
}
|
||||
}
|
||||
MemoryToolKind::Decision | MemoryToolKind::Request => {
|
||||
let dir = record_dir(layout, kind);
|
||||
if !dir.exists() {
|
||||
return Ok(entries);
|
||||
}
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(&dir)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for path in files {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
if matches_query(&content, query) {
|
||||
let slug = path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("");
|
||||
entries.push(format!(
|
||||
"kind={} slug={}\n{}",
|
||||
kind.as_str(),
|
||||
slug,
|
||||
excerpt(&content, query)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn execute_read(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryReadOperation,
|
||||
) -> io::Result<MemoryToolOutput> {
|
||||
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let offset = operation.offset.unwrap_or(0);
|
||||
let limit = operation.limit.unwrap_or(2000);
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let selected = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|(index, line)| format!("{:>6}\t{}", index + 1, line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let total = lines.len();
|
||||
Ok(MemoryToolOutput {
|
||||
summary: format!(
|
||||
"Read {} lines from {}{}",
|
||||
selected.lines().count(),
|
||||
operation.kind.as_str(),
|
||||
operation
|
||||
.slug
|
||||
.as_deref()
|
||||
.map(|slug| format!("/{slug}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
content: Some(format!(
|
||||
"total_lines={total} offset={offset} limit={limit}\n{selected}"
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_write(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryWriteOperation,
|
||||
) -> io::Result<MemoryToolOutput> {
|
||||
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
||||
validate_slug_rules(operation.kind, operation.slug.as_deref())?;
|
||||
validate_memory_content(
|
||||
operation.kind,
|
||||
operation.slug.as_deref(),
|
||||
&operation.content,
|
||||
)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&path, operation.content)?;
|
||||
Ok(MemoryToolOutput {
|
||||
summary: format!("Wrote {} record", operation.kind.as_str()),
|
||||
content: Some(path.display().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_edit(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryEditOperation,
|
||||
) -> io::Result<MemoryToolOutput> {
|
||||
validate_slug_rules(operation.kind, operation.slug.as_deref())?;
|
||||
if operation.old_string == operation.new_string {
|
||||
return Err(invalid_input("old_string and new_string must differ"));
|
||||
}
|
||||
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
||||
let original = fs::read_to_string(&path)?;
|
||||
let count = original.matches(&operation.old_string).count();
|
||||
if count == 0 {
|
||||
return Err(invalid_input("old_string was not found"));
|
||||
}
|
||||
if count > 1 && !operation.replace_all {
|
||||
return Err(invalid_input(
|
||||
"old_string matched more than once; set replace_all=true to replace every occurrence",
|
||||
));
|
||||
}
|
||||
let updated = if operation.replace_all {
|
||||
original.replace(&operation.old_string, &operation.new_string)
|
||||
} else {
|
||||
original.replacen(&operation.old_string, &operation.new_string, 1)
|
||||
};
|
||||
validate_memory_content(operation.kind, operation.slug.as_deref(), &updated)?;
|
||||
fs::write(&path, updated)?;
|
||||
Ok(MemoryToolOutput {
|
||||
summary: format!(
|
||||
"Edited {} occurrence(s)",
|
||||
if operation.replace_all { count } else { 1 }
|
||||
),
|
||||
content: Some(path.display().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_delete(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryDeleteOperation,
|
||||
) -> io::Result<MemoryToolOutput> {
|
||||
validate_slug_rules(operation.kind, operation.slug.as_deref())?;
|
||||
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
||||
fs::remove_file(&path)?;
|
||||
Ok(MemoryToolOutput {
|
||||
summary: format!("Deleted {} record", operation.kind.as_str()),
|
||||
content: Some(path.display().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn memory_path(
|
||||
layout: &WorkspaceLayout,
|
||||
kind: MemoryToolKind,
|
||||
slug: Option<&str>,
|
||||
) -> io::Result<PathBuf> {
|
||||
match kind {
|
||||
MemoryToolKind::Summary => {
|
||||
if slug.is_some() {
|
||||
return Err(invalid_input("summary records do not accept slug"));
|
||||
}
|
||||
Ok(layout.memory_dir().join("summary.md"))
|
||||
}
|
||||
MemoryToolKind::Decision => {
|
||||
Ok(record_dir(layout, kind).join(format!("{}.md", required_slug(slug)?)))
|
||||
}
|
||||
MemoryToolKind::Request => {
|
||||
Ok(record_dir(layout, kind).join(format!("{}.md", required_slug(slug)?)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_dir(layout: &WorkspaceLayout, kind: MemoryToolKind) -> PathBuf {
|
||||
match kind {
|
||||
MemoryToolKind::Summary => layout.memory_dir(),
|
||||
MemoryToolKind::Decision => layout.memory_dir().join("decisions"),
|
||||
MemoryToolKind::Request => layout.memory_dir().join("requests"),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_slug(slug: Option<&str>) -> io::Result<&str> {
|
||||
slug.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| invalid_input("slug is required for this memory kind"))
|
||||
}
|
||||
|
||||
fn validate_slug_rules(kind: MemoryToolKind, slug: Option<&str>) -> io::Result<()> {
|
||||
match kind {
|
||||
MemoryToolKind::Summary => {
|
||||
if slug.is_some() {
|
||||
return Err(invalid_input("summary records do not accept slug"));
|
||||
}
|
||||
}
|
||||
MemoryToolKind::Decision | MemoryToolKind::Request => {
|
||||
validate_slug(required_slug(slug)?)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_slug(slug: &str) -> io::Result<()> {
|
||||
if slug.is_empty()
|
||||
|| slug.contains('/')
|
||||
|| slug.contains('\\')
|
||||
|| slug == "."
|
||||
|| slug == ".."
|
||||
|| slug.starts_with('.')
|
||||
|| !slug
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
||||
{
|
||||
return Err(invalid_input(
|
||||
"slug must use lowercase ASCII letters, digits, and '-' only",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_memory_content(
|
||||
kind: MemoryToolKind,
|
||||
slug: Option<&str>,
|
||||
content: &str,
|
||||
) -> io::Result<()> {
|
||||
if content.trim().is_empty() {
|
||||
return Err(invalid_input("content must not be empty"));
|
||||
}
|
||||
match kind {
|
||||
MemoryToolKind::Summary => {
|
||||
if !content.trim_start().starts_with("# Memory summary") {
|
||||
return Err(invalid_input(
|
||||
"summary content must start with '# Memory summary'",
|
||||
));
|
||||
}
|
||||
}
|
||||
MemoryToolKind::Decision | MemoryToolKind::Request => {
|
||||
validate_slug_rules(kind, slug)?;
|
||||
if !content.trim_start().starts_with("---") {
|
||||
return Err(invalid_input(
|
||||
"memory record content must start with YAML frontmatter",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches_query(content: &str, query: Option<&str>) -> bool {
|
||||
match query.map(str::trim).filter(|query| !query.is_empty()) {
|
||||
Some(query) => content.to_lowercase().contains(&query.to_lowercase()),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn excerpt(content: &str, query: Option<&str>) -> String {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
|
||||
return lines
|
||||
.iter()
|
||||
.take(20)
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
};
|
||||
let query_lower = query.to_lowercase();
|
||||
let hit = lines
|
||||
.iter()
|
||||
.position(|line| line.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(0);
|
||||
let start = hit.saturating_sub(2);
|
||||
let end = (hit + 3).min(lines.len());
|
||||
lines[start..end].join("\n")
|
||||
}
|
||||
|
||||
fn tool_output_from_string(value: String) -> MemoryToolOutput {
|
||||
const SUMMARY_THRESHOLD: usize = 200;
|
||||
if value.len() <= SUMMARY_THRESHOLD {
|
||||
MemoryToolOutput {
|
||||
summary: value,
|
||||
content: None,
|
||||
}
|
||||
} else {
|
||||
let lines = value.lines().count();
|
||||
let first_line: String = value
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(80)
|
||||
.collect();
|
||||
MemoryToolOutput {
|
||||
summary: format!("{lines} lines | {first_line}…"),
|
||||
content: Some(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_input(message: impl Into<String>) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, message.into())
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
//! Worker is responsible for denying it at the Scope level when memory is enabled.
|
||||
|
||||
pub mod audit;
|
||||
pub mod backend;
|
||||
pub mod consolidate;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ pub use write::write_tool;
|
|||
|
||||
/// Kinds the memory tools accept as input. `Workflow` is intentionally
|
||||
/// excluded — workflows are sub-Engine context, not agent-editable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize, schemars::JsonSchema,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MemoryToolKind {
|
||||
Summary,
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||
) -> Result<WorkerHandle, String> {
|
||||
let worker_name = Self::runtime_worker_name(&request);
|
||||
let profile = self.runtime_profile(&request);
|
||||
let has_local_filesystem = request.working_directory.is_some();
|
||||
let worker_root = request
|
||||
.working_directory
|
||||
.as_ref()
|
||||
|
|
@ -385,17 +386,24 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||
let archive = self
|
||||
.resolve_profile_source_archive(&request.request.profile_source)
|
||||
.await?;
|
||||
let (mut manifest, loader) = {
|
||||
let (manifest, loader) = {
|
||||
let manifest = archive
|
||||
.resolve_profile(selector, &worker_root, &worker_name)
|
||||
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
|
||||
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest(
|
||||
manifest,
|
||||
&worker_root,
|
||||
&worker_name,
|
||||
)?
|
||||
if has_local_filesystem {
|
||||
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest(
|
||||
manifest,
|
||||
&worker_root,
|
||||
&worker_name,
|
||||
)?
|
||||
} else {
|
||||
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest_without_filesystem(
|
||||
manifest,
|
||||
&worker_root,
|
||||
&worker_name,
|
||||
)?
|
||||
}
|
||||
};
|
||||
manifest.worker.name = worker_name;
|
||||
|
||||
let store_dir = self.store_dir()?;
|
||||
let session_store = FsStore::new(&store_dir).map_err(|err| {
|
||||
|
|
|
|||
|
|
@ -726,34 +726,46 @@ where
|
|||
}
|
||||
|
||||
{
|
||||
let workspace_client = worker.workspace_client().clone();
|
||||
let worker = worker.engine_mut();
|
||||
|
||||
// Memory tools require both explicit feature exposure and memory storage
|
||||
// configuration. This keeps resident-memory config separate from the
|
||||
// model-visible Memory* tool surface.
|
||||
// Memory tools require explicit feature exposure. Storage access may be
|
||||
// provided by local filesystem authority or the path-free Workspace HTTP API.
|
||||
if feature_config.memory.enabled {
|
||||
let mem = memory_config.as_ref().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
if let Some(workspace_root) = local_workspace_root.as_ref() {
|
||||
let mem = memory_config.as_ref().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"[feature.memory].enabled = true requires a [memory] configuration section",
|
||||
)
|
||||
})?;
|
||||
let layout = memory::WorkspaceLayout::resolve(mem, workspace_root);
|
||||
let query_cfg = memory::tool::QueryConfig::from(mem);
|
||||
worker.register_tool(memory::tool::read_tool_with_usage(
|
||||
layout.clone(),
|
||||
session_id_for_usage,
|
||||
));
|
||||
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
||||
worker.register_tool(memory::tool::edit_tool(layout.clone()));
|
||||
worker.register_tool(memory::tool::delete_tool(layout.clone()));
|
||||
worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg));
|
||||
} else if let WorkspaceClient::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} = workspace_client
|
||||
{
|
||||
for definition in crate::feature::builtin::memory::workspace_http_memory_tools(
|
||||
workspace_id,
|
||||
base_url,
|
||||
) {
|
||||
worker.register_tool(definition);
|
||||
}
|
||||
} else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"[feature.memory].enabled = true requires a [memory] configuration section",
|
||||
)
|
||||
})?;
|
||||
let workspace_root = local_workspace_root.as_ref().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"memory tools require local Worker filesystem authority",
|
||||
)
|
||||
})?;
|
||||
let layout = memory::WorkspaceLayout::resolve(mem, workspace_root);
|
||||
let query_cfg = memory::tool::QueryConfig::from(mem);
|
||||
worker.register_tool(memory::tool::read_tool_with_usage(
|
||||
layout.clone(),
|
||||
session_id_for_usage,
|
||||
));
|
||||
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
||||
worker.register_tool(memory::tool::edit_tool(layout.clone()));
|
||||
worker.register_tool(memory::tool::delete_tool(layout.clone()));
|
||||
worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg));
|
||||
"memory tools require Workspace HTTP API or local Worker filesystem authority",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Worker-orchestration tools (SpawnWorker + the four comm tools) share
|
||||
|
|
|
|||
|
|
@ -273,6 +273,20 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
|
|||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem(
|
||||
mut manifest: WorkerManifest,
|
||||
workspace_root: &Path,
|
||||
worker_name: &str,
|
||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||
if manifest.worker.name.is_empty() {
|
||||
manifest.worker.name = worker_name.to_string();
|
||||
}
|
||||
manifest.scope = ScopeConfig::default();
|
||||
manifest.delegation_scope = ScopeConfig::default();
|
||||
apply_plugin_resolution_plan(&mut manifest, workspace_root);
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
}
|
||||
|
||||
fn load_single_manifest(
|
||||
path: &Path,
|
||||
explicit_worker_name: Option<&str>,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
//! same descriptor-approved registry path used by feature modules. They are not
|
||||
//! an external plugin-loading surface.
|
||||
|
||||
pub mod memory;
|
||||
pub mod session_explore;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
|
|
|
|||
261
crates/worker/src/feature/builtin/memory.rs
Normal file
261
crates/worker/src/feature/builtin/memory.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
//! Workspace-HTTP backed Memory tools.
|
||||
//!
|
||||
//! Runtime workers may have Workspace authority without direct local filesystem
|
||||
//! authority. In that case model-visible Memory tools must go through the
|
||||
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
|
||||
};
|
||||
use memory::backend::{
|
||||
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult,
|
||||
MemoryDeleteOperation, MemoryEditOperation, MemoryQueryOperation, MemoryReadOperation,
|
||||
MemoryToolOutput, MemoryWriteOperation,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceHttpMemoryBackend {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl WorkspaceHttpMemoryBackend {
|
||||
pub fn new(workspace_id: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
workspace_id: workspace_id.into(),
|
||||
base_url: base_url.into(),
|
||||
}
|
||||
}
|
||||
|
||||
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!(
|
||||
"unexpected memory backend result for model-visible tool: {result:?}"
|
||||
))),
|
||||
MemoryBackendHttpResponse::Error { message } => {
|
||||
Err(ToolError::ExecutionFailed(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace_http_memory_tools(
|
||||
workspace_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
) -> Vec<ToolDefinition> {
|
||||
let backend = WorkspaceHttpMemoryBackend::new(workspace_id, base_url);
|
||||
vec![
|
||||
memory_tool(
|
||||
"MemoryRead",
|
||||
READ_DESCRIPTION,
|
||||
read_schema(),
|
||||
backend.clone(),
|
||||
|input| {
|
||||
Ok(MemoryBackendOperation::Read(parse_input::<
|
||||
MemoryReadOperation,
|
||||
>(input)?))
|
||||
},
|
||||
),
|
||||
memory_tool(
|
||||
"MemoryWrite",
|
||||
WRITE_DESCRIPTION,
|
||||
write_schema(),
|
||||
backend.clone(),
|
||||
|input| {
|
||||
Ok(MemoryBackendOperation::Write(parse_input::<
|
||||
MemoryWriteOperation,
|
||||
>(input)?))
|
||||
},
|
||||
),
|
||||
memory_tool(
|
||||
"MemoryEdit",
|
||||
EDIT_DESCRIPTION,
|
||||
edit_schema(),
|
||||
backend.clone(),
|
||||
|input| {
|
||||
Ok(MemoryBackendOperation::Edit(parse_input::<
|
||||
MemoryEditOperation,
|
||||
>(input)?))
|
||||
},
|
||||
),
|
||||
memory_tool(
|
||||
"MemoryDelete",
|
||||
DELETE_DESCRIPTION,
|
||||
delete_schema(),
|
||||
backend.clone(),
|
||||
|input| {
|
||||
Ok(MemoryBackendOperation::Delete(parse_input::<
|
||||
MemoryDeleteOperation,
|
||||
>(input)?))
|
||||
},
|
||||
),
|
||||
memory_tool(
|
||||
"MemoryQuery",
|
||||
QUERY_DESCRIPTION,
|
||||
query_schema(),
|
||||
backend,
|
||||
|input| {
|
||||
Ok(MemoryBackendOperation::Query(parse_input::<
|
||||
MemoryQueryOperation,
|
||||
>(input)?))
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
type OperationBuilder = fn(&str) -> Result<MemoryBackendOperation, ToolError>;
|
||||
|
||||
fn memory_tool(
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
schema: serde_json::Value,
|
||||
backend: WorkspaceHttpMemoryBackend,
|
||||
build: OperationBuilder,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(name)
|
||||
.description(description)
|
||||
.input_schema(schema.clone()),
|
||||
Arc::new(WorkspaceHttpMemoryTool {
|
||||
backend: backend.clone(),
|
||||
build,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WorkspaceHttpMemoryTool {
|
||||
backend: WorkspaceHttpMemoryBackend,
|
||||
build: OperationBuilder,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WorkspaceHttpMemoryTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let operation = (self.build)(input_json)?;
|
||||
self.backend.execute(operation)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_input<T: DeserializeOwned>(input: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
|
||||
}
|
||||
|
||||
fn tool_output(output: MemoryToolOutput) -> ToolOutput {
|
||||
ToolOutput {
|
||||
summary: output.summary,
|
||||
content: output.content,
|
||||
}
|
||||
}
|
||||
|
||||
const READ_DESCRIPTION: &str = "Read a durable memory record through Workspace authority.";
|
||||
const WRITE_DESCRIPTION: &str =
|
||||
"Create or overwrite a durable memory record through Workspace authority.";
|
||||
const EDIT_DESCRIPTION: &str =
|
||||
"Replace text in a durable memory record through Workspace authority.";
|
||||
const DELETE_DESCRIPTION: &str = "Delete a durable memory record through Workspace authority.";
|
||||
const QUERY_DESCRIPTION: &str = "Query durable memory records through Workspace authority.";
|
||||
|
||||
fn kind_schema() -> serde_json::Value {
|
||||
json!({"type":"string","enum":["summary","decision","request"]})
|
||||
}
|
||||
|
||||
fn read_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
"additionalProperties": false,
|
||||
"required":["kind"],
|
||||
"properties":{
|
||||
"kind": kind_schema(),
|
||||
"slug":{"type":["string","null"]},
|
||||
"offset":{"type":["integer","null"],"minimum":0},
|
||||
"limit":{"type":["integer","null"],"minimum":0}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn write_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
"additionalProperties": false,
|
||||
"required":["kind","content"],
|
||||
"properties":{
|
||||
"kind": kind_schema(),
|
||||
"slug":{"type":["string","null"]},
|
||||
"content":{"type":"string"}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
"additionalProperties": false,
|
||||
"required":["kind","old_string","new_string"],
|
||||
"properties":{
|
||||
"kind": kind_schema(),
|
||||
"slug":{"type":["string","null"]},
|
||||
"old_string":{"type":"string"},
|
||||
"new_string":{"type":"string"},
|
||||
"replace_all":{"type":"boolean","default":false}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
"additionalProperties": false,
|
||||
"required":["kind"],
|
||||
"properties":{
|
||||
"kind": kind_schema(),
|
||||
"slug":{"type":["string","null"]}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn query_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
"additionalProperties": false,
|
||||
"properties":{
|
||||
"query":{"type":["string","null"]}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -4971,7 +4971,11 @@ fn prepare_worker_common_with_context(
|
|||
let layout = memory::WorkspaceLayout::resolve(mem, &local.root);
|
||||
scope_config.deny.extend(memory::deny_write_rules(&layout));
|
||||
}
|
||||
let scope = Scope::from_config(&scope_config).map_err(WorkerError::Scope)?;
|
||||
let scope = if scope_config.allow.is_empty() && filesystem_authority.as_local().is_none() {
|
||||
Scope::empty()
|
||||
} else {
|
||||
Scope::from_config(&scope_config).map_err(WorkerError::Scope)?
|
||||
};
|
||||
prepare_worker_common_from_scope(
|
||||
manifest,
|
||||
loader,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ serde_yaml.workspace = true
|
|||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
ticket.workspace = true
|
||||
memory.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
worker.workspace = true
|
||||
|
|
|
|||
|
|
@ -1449,64 +1449,51 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||
|
||||
fn create_working_directory(
|
||||
&self,
|
||||
request: WorkingDirectoryRequest,
|
||||
_request: WorkingDirectoryRequest,
|
||||
) -> RuntimeWorkingDirectoryResult {
|
||||
match self.runtime.create_working_directory(request) {
|
||||
Ok(working_directory) => RuntimeWorkingDirectoryResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
working_directory: Some(working_directory),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(err) => RuntimeWorkingDirectoryResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
working_directory: None,
|
||||
diagnostics: vec![embedded_runtime_diagnostic(&err)],
|
||||
},
|
||||
RuntimeWorkingDirectoryResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
working_directory: None,
|
||||
diagnostics: vec![embedded_workdir_unsupported_diagnostic()],
|
||||
}
|
||||
}
|
||||
|
||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||
match self.runtime.list_working_directories() {
|
||||
Ok(items) => RuntimeList::new(items, Vec::new()),
|
||||
Err(err) => RuntimeList::new(Vec::new(), vec![embedded_runtime_diagnostic(&err)]),
|
||||
}
|
||||
RuntimeList::new(Vec::new(), Vec::new())
|
||||
}
|
||||
|
||||
fn working_directory(&self, working_directory_id: &str) -> RuntimeWorkingDirectoryResult {
|
||||
match self.runtime.working_directory(working_directory_id) {
|
||||
Ok(working_directory) => RuntimeWorkingDirectoryResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
working_directory: Some(working_directory),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(err) => RuntimeWorkingDirectoryResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
working_directory: None,
|
||||
diagnostics: vec![embedded_runtime_diagnostic(&err)],
|
||||
},
|
||||
fn working_directory(&self, _working_directory_id: &str) -> RuntimeWorkingDirectoryResult {
|
||||
RuntimeWorkingDirectoryResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
working_directory: None,
|
||||
diagnostics: vec![embedded_workdir_unsupported_diagnostic()],
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_working_directory(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
_working_directory_id: &str,
|
||||
) -> RuntimeWorkingDirectoryResult {
|
||||
match self.runtime.cleanup_working_directory(working_directory_id) {
|
||||
Ok(working_directory) => RuntimeWorkingDirectoryResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
working_directory: Some(working_directory),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(err) => RuntimeWorkingDirectoryResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
working_directory: None,
|
||||
diagnostics: vec![embedded_runtime_diagnostic(&err)],
|
||||
},
|
||||
RuntimeWorkingDirectoryResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
working_directory: None,
|
||||
diagnostics: vec![embedded_workdir_unsupported_diagnostic()],
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult {
|
||||
let mut diagnostics = Vec::new();
|
||||
if request.resolved_working_directory_request.is_some()
|
||||
|| request.resolved_working_directory.is_some()
|
||||
{
|
||||
diagnostics.push(embedded_workdir_unsupported_diagnostic());
|
||||
return WorkerSpawnResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
worker: None,
|
||||
acceptance_evidence: Vec::new(),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
if matches!(
|
||||
request.acceptance,
|
||||
WorkerSpawnAcceptanceRequirement::SocketReady
|
||||
|
|
@ -2647,12 +2634,15 @@ fn embedded_spawn_execution_failure_diagnostic(
|
|||
worker_runtime::execution::WorkerExecutionOutcome::Unsupported => "unsupported",
|
||||
worker_runtime::execution::WorkerExecutionOutcome::Errored => "errored",
|
||||
};
|
||||
let detail = result
|
||||
.message
|
||||
.as_deref()
|
||||
.map(|message| format!(": {message}"))
|
||||
.unwrap_or_default();
|
||||
Some(diagnostic(
|
||||
format!("embedded_worker_execution_spawn_{status}"),
|
||||
severity,
|
||||
format!(
|
||||
"Embedded Worker execution spawn was {status} during setup; check runtime configuration"
|
||||
),
|
||||
format!("Embedded Worker execution spawn was {status} during setup{detail}"),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -3006,6 +2996,14 @@ fn remote_lifecycle_rejected(
|
|||
}
|
||||
}
|
||||
|
||||
fn embedded_workdir_unsupported_diagnostic() -> RuntimeDiagnostic {
|
||||
diagnostic(
|
||||
"embedded_worker_workdir_unsupported",
|
||||
DiagnosticSeverity::Error,
|
||||
"Embedded Runtime is no-workdir only; choose a non-embedded Runtime for workspace-file Workers".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
|
||||
match error {
|
||||
EmbeddedRuntimeError::RuntimeStopped => diagnostic(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ use axum::routing::{delete, get, post, put};
|
|||
use axum::{Json, Router};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use futures::StreamExt;
|
||||
use memory::backend::{
|
||||
MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use ticket::{
|
||||
|
|
@ -21,7 +24,6 @@ use ticket::{
|
|||
use tokio::net::TcpListener;
|
||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
|
||||
|
||||
use crate::companion::{
|
||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||
|
|
@ -193,10 +195,7 @@ impl WorkspaceApi {
|
|||
crate::Error::Store(format!(
|
||||
"failed to initialize embedded Worker backend: {err}"
|
||||
))
|
||||
})?
|
||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
config.embedded_runtime_store_root.clone(),
|
||||
));
|
||||
})?;
|
||||
Self::new_with_execution_backend_and_broker(
|
||||
config,
|
||||
store,
|
||||
|
|
@ -330,6 +329,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||
"/api/w/{workspace_id}/tickets/backend",
|
||||
post(scoped_ticket_backend_operation),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/memory/backend",
|
||||
post(scoped_memory_backend_operation),
|
||||
)
|
||||
.route("/api/tickets/{id}", get(get_ticket))
|
||||
.route("/api/w/{workspace_id}/skills", get(scoped_list_skills))
|
||||
.route("/api/w/{workspace_id}/skills/lint", get(scoped_lint_skills))
|
||||
|
|
@ -1275,6 +1278,23 @@ async fn scoped_ticket_backend_operation(
|
|||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn scoped_memory_backend_operation(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(operation): Json<MemoryBackendOperation>,
|
||||
) -> ApiResult<Json<MemoryBackendHttpResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let memory_config = manifest::MemoryConfig::default();
|
||||
let layout = memory::WorkspaceLayout::resolve(&memory_config, &api.config.workspace_root);
|
||||
let response = match execute_memory_backend_operation(&layout, operation) {
|
||||
Ok(result) => MemoryBackendHttpResponse::Ok { result },
|
||||
Err(error) => MemoryBackendHttpResponse::Error {
|
||||
message: sanitize_backend_error(&error.to_string()),
|
||||
},
|
||||
};
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn scoped_list_skills(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
|
|
@ -1501,10 +1521,21 @@ async fn scoped_list_working_directories(
|
|||
async fn scoped_create_working_directory(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
|
||||
Json(_request): Json<BrowserWorkingDirectoryCreateRequest>,
|
||||
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
create_working_directory_for_runtime(api, request)
|
||||
Err(ApiError::with_diagnostics(
|
||||
Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-backend".to_string(),
|
||||
code: "backend_workdir_create_unsupported".to_string(),
|
||||
message: "Working directory creation must be scoped to a concrete Runtime".to_string(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
code: "backend_workdir_create_unsupported".to_string(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message: "Use runtime-scoped Worker creation or a runtime-scoped working-directory API; the backend does not own workdir lifecycle.".to_string(),
|
||||
}],
|
||||
))
|
||||
}
|
||||
|
||||
async fn scoped_working_directory_detail(
|
||||
|
|
@ -3128,6 +3159,27 @@ struct RuntimeConfigBundleAvailabilityQuery {
|
|||
digest: String,
|
||||
}
|
||||
|
||||
fn reject_workdir_for_embedded_runtime(
|
||||
runtime_id: &str,
|
||||
has_workdir: bool,
|
||||
) -> std::result::Result<(), ApiError> {
|
||||
if runtime_id != EMBEDDED_WORKER_RUNTIME_ID || !has_workdir {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ApiError::with_diagnostics(
|
||||
Error::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "embedded_worker_workdir_unsupported".to_string(),
|
||||
message: "The embedded Runtime does not accept working directories".to_string(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
code: "embedded_worker_workdir_unsupported".to_string(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message: "Choose a non-embedded Runtime for workspace-file Workers; embedded Workers are no-workdir Workspace-API workers.".to_string(),
|
||||
}],
|
||||
))
|
||||
}
|
||||
|
||||
fn reject_no_workdir_for_non_embedded_runtime(
|
||||
runtime_id: &str,
|
||||
) -> std::result::Result<(), ApiError> {
|
||||
|
|
@ -3155,6 +3207,10 @@ async fn create_runtime_worker(
|
|||
AxumPath(runtime_id): AxumPath<String>,
|
||||
Json(mut request): Json<WorkerSpawnRequest>,
|
||||
) -> ApiResult<Json<WorkerSpawnResult>> {
|
||||
reject_workdir_for_embedded_runtime(
|
||||
&runtime_id,
|
||||
request.working_directory_request.is_some() || request.resolved_working_directory.is_some(),
|
||||
)?;
|
||||
if request.working_directory_request.is_none() && request.resolved_working_directory.is_none() {
|
||||
reject_no_workdir_for_non_embedded_runtime(&runtime_id)?;
|
||||
}
|
||||
|
|
@ -6031,89 +6087,6 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
fn create_observed_workdir(api: &WorkspaceApi) -> String {
|
||||
let Json(response) = create_working_directory_for_runtime(
|
||||
api.clone(),
|
||||
BrowserWorkingDirectoryCreateRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
repository_id: TEST_REPOSITORY_ID.to_string(),
|
||||
selector: Some("HEAD".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("create observed workdir: {}", err.error));
|
||||
response.item.working_directory_id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observed_workdir_reports_verified_cleanliness_for_cleanup() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
let workdir_id = create_observed_workdir(&api);
|
||||
sync_runtime_workdir_observations(&api, EMBEDDED_WORKER_RUNTIME_ID)
|
||||
.unwrap_or_else(|err| panic!("sync runtime workdir observations: {}", err.error));
|
||||
|
||||
let stored = api
|
||||
.store
|
||||
.get_workdir_registry(&api.config.workspace_id, workdir_id.as_str())
|
||||
.unwrap()
|
||||
.expect("workdir registry row");
|
||||
assert_eq!(stored.materialization_status, "present");
|
||||
assert_eq!(stored.cleanliness, "clean");
|
||||
|
||||
let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID)
|
||||
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
|
||||
let candidate = plan
|
||||
.workdirs
|
||||
.iter()
|
||||
.find(|candidate| candidate.workdir_id == workdir_id)
|
||||
.expect("cleanup candidate");
|
||||
assert_eq!(candidate.cleanliness, CleanupWorkdirCleanliness::Clean);
|
||||
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
|
||||
assert!(candidate.reason.contains("clean"));
|
||||
|
||||
let target_id = candidate.target_id.clone();
|
||||
let request = ExecuteRuntimeCleanupRequest {
|
||||
expected_plan_revision: plan.revision,
|
||||
expected_plan_digest: plan.digest,
|
||||
worker_target_ids: Vec::new(),
|
||||
workdir_target_ids: vec![target_id],
|
||||
confirm_dirty_discard_target_ids: Vec::new(),
|
||||
};
|
||||
let response = execute_runtime_cleanup(&api, EMBEDDED_WORKER_RUNTIME_ID, request)
|
||||
.unwrap_or_else(|err| panic!("cleanup execution: {}", err.error));
|
||||
assert_eq!(
|
||||
response.results[0].action,
|
||||
CleanupTargetKind::WorkdirCleanCleanup
|
||||
);
|
||||
assert_eq!(response.results[0].status, "deleted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_clean_registry_row_is_downgraded_by_real_observation_before_cleanup() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
let workdir_id = create_observed_workdir(&api);
|
||||
let mut stale_clean = api
|
||||
.store
|
||||
.get_workdir_registry(&api.config.workspace_id, workdir_id.as_str())
|
||||
.unwrap()
|
||||
.expect("workdir registry row");
|
||||
stale_clean.cleanliness = "clean".to_string();
|
||||
api.store.upsert_workdir_registry(&stale_clean).unwrap();
|
||||
|
||||
let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID)
|
||||
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
|
||||
let candidate = plan
|
||||
.workdirs
|
||||
.iter()
|
||||
.find(|candidate| candidate.workdir_id == workdir_id)
|
||||
.expect("cleanup candidate");
|
||||
assert_eq!(candidate.cleanliness, CleanupWorkdirCleanliness::Clean);
|
||||
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn synthetic_verified_clean_workdir_can_still_use_clean_cleanup_path() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
|
|
@ -6478,71 +6451,33 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_working_directory_create_list_detail_and_cleanup_are_path_safe() {
|
||||
async fn browser_working_directory_create_is_rejected_as_backend_lifecycle() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let app = test_app(dir.path()).await;
|
||||
let workspace_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories");
|
||||
|
||||
let created = post_json(
|
||||
app.clone(),
|
||||
&workspace_path,
|
||||
serde_json::json!({
|
||||
"repository_id": TEST_REPOSITORY_ID,
|
||||
"selector": "HEAD",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let working_directory_id = created["item"]["working_directory_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert_eq!(created["item"]["repository_id"], TEST_REPOSITORY_ID);
|
||||
assert_eq!(created["item"]["requested_selector"], "HEAD");
|
||||
assert_eq!(created["item"]["status"], "active");
|
||||
let projected = serde_json::to_string(&created).unwrap();
|
||||
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
|
||||
|
||||
let list = get_json(app.clone(), &workspace_path).await;
|
||||
assert_eq!(list["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
list["items"][0]["working_directory_id"],
|
||||
working_directory_id
|
||||
);
|
||||
|
||||
let detail_path = format!("{workspace_path}/{working_directory_id}");
|
||||
let detail = get_json(app.clone(), &detail_path).await;
|
||||
assert_eq!(detail["item"]["working_directory_id"], working_directory_id);
|
||||
|
||||
let cleanup_plan_path = format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-plan"
|
||||
);
|
||||
let plan = get_json(app.clone(), &cleanup_plan_path).await;
|
||||
let target_id = plan["workdirs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|candidate| candidate["workdir_id"] == working_directory_id)
|
||||
.and_then(|candidate| candidate["target_id"].as_str())
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let removed = request_json(
|
||||
let response = request_json(
|
||||
app,
|
||||
"POST",
|
||||
&format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-executions"
|
||||
),
|
||||
&workspace_path,
|
||||
Some(serde_json::json!({
|
||||
"expected_plan_revision": plan["revision"],
|
||||
"expected_plan_digest": plan["digest"],
|
||||
"worker_target_ids": [],
|
||||
"workdir_target_ids": [target_id],
|
||||
"confirm_dirty_discard_target_ids": [target_id]
|
||||
"repository_id": TEST_REPOSITORY_ID,
|
||||
"selector": "HEAD",
|
||||
})),
|
||||
StatusCode::OK,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(removed["results"][0]["status"], "deleted");
|
||||
assert!(
|
||||
response["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic["code"] == "backend_workdir_create_unsupported"),
|
||||
"expected backend lifecycle diagnostic, got {response}"
|
||||
);
|
||||
let projected = serde_json::to_string(&response).unwrap();
|
||||
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -6550,24 +6485,14 @@ mod tests {
|
|||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let app = test_app(dir.path()).await;
|
||||
let working_directories_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories");
|
||||
let created = post_json(
|
||||
app.clone(),
|
||||
&working_directories_path,
|
||||
serde_json::json!({
|
||||
"repository_id": TEST_REPOSITORY_ID,
|
||||
"selector": "HEAD",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let working_directory_id = created["item"]["working_directory_id"].as_str().unwrap();
|
||||
let working_directory_id = "test-workdir";
|
||||
|
||||
let response = request_json(
|
||||
app,
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
||||
Some(serde_json::json!({
|
||||
"runtime_id": EMBEDDED_WORKER_RUNTIME_ID,
|
||||
"runtime_id": "remote-runtime",
|
||||
"display_name": "Coding Worker",
|
||||
"profile": "builtin:coder",
|
||||
"initial_text": "",
|
||||
|
|
@ -6987,7 +6912,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_worker_spawn_accepts_safe_repository_selector() {
|
||||
async fn runtime_worker_spawn_rejects_embedded_workdir_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let response = request_json(
|
||||
|
|
@ -7009,9 +6934,17 @@ mod tests {
|
|||
"selector": "HEAD"
|
||||
}
|
||||
})),
|
||||
StatusCode::OK,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic["code"] == "embedded_worker_workdir_unsupported"),
|
||||
"expected embedded workdir diagnostic, got {response}"
|
||||
);
|
||||
let projected = serde_json::to_string(&response).unwrap();
|
||||
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-Xlaq8/cyMGk3m4z6BNLtSgoRnWqVVnGaLyzgBlr70rk=";
|
||||
cargoHash = "sha256-MUEySmRu5Te8+GCwlt3qk7jxcumYIMvnuvj64o2XuDw=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user