feat: add workspace memory backend for embedded workers

This commit is contained in:
Keisuke Hirata 2026-07-21 09:27:28 +09:00
parent 2e0cd3d161
commit 2572dde691
No known key found for this signature in database
15 changed files with 947 additions and 235 deletions

1
Cargo.lock generated
View File

@ -5850,6 +5850,7 @@ dependencies = [
"chrono", "chrono",
"futures", "futures",
"manifest", "manifest",
"memory",
"project-record", "project-record",
"protocol", "protocol",
"reqwest", "reqwest",

View File

@ -169,6 +169,13 @@ pub enum ScopeError {
} }
impl Scope { impl Scope {
pub fn empty() -> Self {
Self {
allow: Vec::new(),
deny: Vec::new(),
}
}
/// Build a [`Scope`] from a declarative [`ScopeConfig`]. /// Build a [`Scope`] from a declarative [`ScopeConfig`].
/// ///
/// Every `target` in `config` must already be absolute — per-layer /// Every `target` in `config` must already be absolute — per-layer

View 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())
}

View File

@ -6,6 +6,7 @@
//! Worker is responsible for denying it at the Scope level when memory is enabled. //! Worker is responsible for denying it at the Scope level when memory is enabled.
pub mod audit; pub mod audit;
pub mod backend;
pub mod consolidate; pub mod consolidate;
pub mod error; pub mod error;
pub mod extract; pub mod extract;

View File

@ -28,7 +28,9 @@ pub use write::write_tool;
/// Kinds the memory tools accept as input. `Workflow` is intentionally /// Kinds the memory tools accept as input. `Workflow` is intentionally
/// excluded — workflows are sub-Engine context, not agent-editable. /// 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")] #[serde(rename_all = "lowercase")]
pub enum MemoryToolKind { pub enum MemoryToolKind {
Summary, Summary,

View File

@ -361,6 +361,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
) -> Result<WorkerHandle, String> { ) -> Result<WorkerHandle, String> {
let worker_name = Self::runtime_worker_name(&request); let worker_name = Self::runtime_worker_name(&request);
let profile = self.runtime_profile(&request); let profile = self.runtime_profile(&request);
let has_local_filesystem = request.working_directory.is_some();
let worker_root = request let worker_root = request
.working_directory .working_directory
.as_ref() .as_ref()
@ -385,17 +386,24 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
let archive = self let archive = self
.resolve_profile_source_archive(&request.request.profile_source) .resolve_profile_source_archive(&request.request.profile_source)
.await?; .await?;
let (mut manifest, loader) = { let (manifest, loader) = {
let manifest = archive let manifest = archive
.resolve_profile(selector, &worker_root, &worker_name) .resolve_profile(selector, &worker_root, &worker_name)
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?; .map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest( if has_local_filesystem {
manifest, worker::entrypoint::resolve_runtime_profile_manifest_from_manifest(
&worker_root, manifest,
&worker_name, &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 store_dir = self.store_dir()?;
let session_store = FsStore::new(&store_dir).map_err(|err| { let session_store = FsStore::new(&store_dir).map_err(|err| {

View File

@ -726,34 +726,46 @@ where
} }
{ {
let workspace_client = worker.workspace_client().clone();
let worker = worker.engine_mut(); let worker = worker.engine_mut();
// Memory tools require both explicit feature exposure and memory storage // Memory tools require explicit feature exposure. Storage access may be
// configuration. This keeps resident-memory config separate from the // provided by local filesystem authority or the path-free Workspace HTTP API.
// model-visible Memory* tool surface.
if feature_config.memory.enabled { if feature_config.memory.enabled {
let mem = memory_config.as_ref().ok_or_else(|| { if let Some(workspace_root) = local_workspace_root.as_ref() {
std::io::Error::new( 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, std::io::ErrorKind::InvalidInput,
"[feature.memory].enabled = true requires a [memory] configuration section", "memory tools require Workspace HTTP API or local Worker filesystem authority",
) ));
})?; }
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));
} }
// Worker-orchestration tools (SpawnWorker + the four comm tools) share // Worker-orchestration tools (SpawnWorker + the four comm tools) share

View File

@ -273,6 +273,20 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
Ok((manifest, PromptLoader::builtins_only())) 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( fn load_single_manifest(
path: &Path, path: &Path,
explicit_worker_name: Option<&str>, explicit_worker_name: Option<&str>,

View File

@ -4,6 +4,7 @@
//! same descriptor-approved registry path used by feature modules. They are not //! same descriptor-approved registry path used by feature modules. They are not
//! an external plugin-loading surface. //! an external plugin-loading surface.
pub mod memory;
pub mod session_explore; pub mod session_explore;
pub mod task; pub mod task;
pub mod ticket; pub mod ticket;

View 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"]}
}
})
}

View File

@ -4971,7 +4971,11 @@ fn prepare_worker_common_with_context(
let layout = memory::WorkspaceLayout::resolve(mem, &local.root); let layout = memory::WorkspaceLayout::resolve(mem, &local.root);
scope_config.deny.extend(memory::deny_write_rules(&layout)); 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( prepare_worker_common_from_scope(
manifest, manifest,
loader, loader,

View File

@ -21,6 +21,7 @@ serde_yaml.workspace = true
sha2.workspace = true sha2.workspace = true
thiserror.workspace = true thiserror.workspace = true
ticket.workspace = true ticket.workspace = true
memory.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
tokio-tungstenite.workspace = true tokio-tungstenite.workspace = true
worker.workspace = true worker.workspace = true

View File

@ -1449,64 +1449,51 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
fn create_working_directory( fn create_working_directory(
&self, &self,
request: WorkingDirectoryRequest, _request: WorkingDirectoryRequest,
) -> RuntimeWorkingDirectoryResult { ) -> RuntimeWorkingDirectoryResult {
match self.runtime.create_working_directory(request) { RuntimeWorkingDirectoryResult {
Ok(working_directory) => RuntimeWorkingDirectoryResult { state: WorkerOperationState::Rejected,
state: WorkerOperationState::Accepted, working_directory: None,
working_directory: Some(working_directory), diagnostics: vec![embedded_workdir_unsupported_diagnostic()],
diagnostics: Vec::new(),
},
Err(err) => RuntimeWorkingDirectoryResult {
state: WorkerOperationState::Rejected,
working_directory: None,
diagnostics: vec![embedded_runtime_diagnostic(&err)],
},
} }
} }
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> { fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
match self.runtime.list_working_directories() { RuntimeList::new(Vec::new(), Vec::new())
Ok(items) => RuntimeList::new(items, Vec::new()),
Err(err) => RuntimeList::new(Vec::new(), vec![embedded_runtime_diagnostic(&err)]),
}
} }
fn working_directory(&self, working_directory_id: &str) -> RuntimeWorkingDirectoryResult { fn working_directory(&self, _working_directory_id: &str) -> RuntimeWorkingDirectoryResult {
match self.runtime.working_directory(working_directory_id) { RuntimeWorkingDirectoryResult {
Ok(working_directory) => RuntimeWorkingDirectoryResult { state: WorkerOperationState::Rejected,
state: WorkerOperationState::Accepted, working_directory: None,
working_directory: Some(working_directory), diagnostics: vec![embedded_workdir_unsupported_diagnostic()],
diagnostics: Vec::new(),
},
Err(err) => RuntimeWorkingDirectoryResult {
state: WorkerOperationState::Rejected,
working_directory: None,
diagnostics: vec![embedded_runtime_diagnostic(&err)],
},
} }
} }
fn cleanup_working_directory( fn cleanup_working_directory(
&self, &self,
working_directory_id: &str, _working_directory_id: &str,
) -> RuntimeWorkingDirectoryResult { ) -> RuntimeWorkingDirectoryResult {
match self.runtime.cleanup_working_directory(working_directory_id) { RuntimeWorkingDirectoryResult {
Ok(working_directory) => RuntimeWorkingDirectoryResult { state: WorkerOperationState::Rejected,
state: WorkerOperationState::Accepted, working_directory: None,
working_directory: Some(working_directory), diagnostics: vec![embedded_workdir_unsupported_diagnostic()],
diagnostics: Vec::new(),
},
Err(err) => RuntimeWorkingDirectoryResult {
state: WorkerOperationState::Rejected,
working_directory: None,
diagnostics: vec![embedded_runtime_diagnostic(&err)],
},
} }
} }
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult { fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult {
let mut diagnostics = Vec::new(); 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!( if matches!(
request.acceptance, request.acceptance,
WorkerSpawnAcceptanceRequirement::SocketReady WorkerSpawnAcceptanceRequirement::SocketReady
@ -2647,12 +2634,15 @@ fn embedded_spawn_execution_failure_diagnostic(
worker_runtime::execution::WorkerExecutionOutcome::Unsupported => "unsupported", worker_runtime::execution::WorkerExecutionOutcome::Unsupported => "unsupported",
worker_runtime::execution::WorkerExecutionOutcome::Errored => "errored", worker_runtime::execution::WorkerExecutionOutcome::Errored => "errored",
}; };
let detail = result
.message
.as_deref()
.map(|message| format!(": {message}"))
.unwrap_or_default();
Some(diagnostic( Some(diagnostic(
format!("embedded_worker_execution_spawn_{status}"), format!("embedded_worker_execution_spawn_{status}"),
severity, severity,
format!( format!("Embedded Worker execution spawn was {status} during setup{detail}"),
"Embedded Worker execution spawn was {status} during setup; check runtime configuration"
),
)) ))
} }
@ -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 { fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
match error { match error {
EmbeddedRuntimeError::RuntimeStopped => diagnostic( EmbeddedRuntimeError::RuntimeStopped => diagnostic(

View File

@ -12,6 +12,9 @@ use axum::routing::{delete, get, post, put};
use axum::{Json, Router}; use axum::{Json, Router};
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use futures::StreamExt; use futures::StreamExt;
use memory::backend::{
MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use ticket::{ use ticket::{
@ -21,7 +24,6 @@ use ticket::{
use tokio::net::TcpListener; use tokio::net::TcpListener;
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
use crate::companion::{ use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
@ -193,10 +195,7 @@ impl WorkspaceApi {
crate::Error::Store(format!( crate::Error::Store(format!(
"failed to initialize embedded Worker backend: {err}" "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( Self::new_with_execution_backend_and_broker(
config, config,
store, store,
@ -330,6 +329,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/tickets/backend", "/api/w/{workspace_id}/tickets/backend",
post(scoped_ticket_backend_operation), 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/tickets/{id}", get(get_ticket))
.route("/api/w/{workspace_id}/skills", get(scoped_list_skills)) .route("/api/w/{workspace_id}/skills", get(scoped_list_skills))
.route("/api/w/{workspace_id}/skills/lint", get(scoped_lint_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)) 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( async fn scoped_list_skills(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
@ -1501,10 +1521,21 @@ async fn scoped_list_working_directories(
async fn scoped_create_working_directory( async fn scoped_create_working_directory(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<BrowserWorkingDirectoryCreateRequest>, Json(_request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> { ) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; 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( async fn scoped_working_directory_detail(
@ -3128,6 +3159,27 @@ struct RuntimeConfigBundleAvailabilityQuery {
digest: String, 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( fn reject_no_workdir_for_non_embedded_runtime(
runtime_id: &str, runtime_id: &str,
) -> std::result::Result<(), ApiError> { ) -> std::result::Result<(), ApiError> {
@ -3155,6 +3207,10 @@ async fn create_runtime_worker(
AxumPath(runtime_id): AxumPath<String>, AxumPath(runtime_id): AxumPath<String>,
Json(mut request): Json<WorkerSpawnRequest>, Json(mut request): Json<WorkerSpawnRequest>,
) -> ApiResult<Json<WorkerSpawnResult>> { ) -> 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() { if request.working_directory_request.is_none() && request.resolved_working_directory.is_none() {
reject_no_workdir_for_non_embedded_runtime(&runtime_id)?; reject_no_workdir_for_non_embedded_runtime(&runtime_id)?;
} }
@ -6031,89 +6087,6 @@ mod tests {
.unwrap(); .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] #[tokio::test]
async fn synthetic_verified_clean_workdir_can_still_use_clean_cleanup_path() { async fn synthetic_verified_clean_workdir_can_still_use_clean_cleanup_path() {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
@ -6478,71 +6451,33 @@ mod tests {
} }
#[tokio::test] #[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(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); init_clean_git_workspace(dir.path());
let app = test_app(dir.path()).await; let app = test_app(dir.path()).await;
let workspace_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"); let workspace_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories");
let created = post_json( let response = request_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(
app, app,
"POST", "POST",
&format!( &workspace_path,
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-executions"
),
Some(serde_json::json!({ Some(serde_json::json!({
"expected_plan_revision": plan["revision"], "repository_id": TEST_REPOSITORY_ID,
"expected_plan_digest": plan["digest"], "selector": "HEAD",
"worker_target_ids": [],
"workdir_target_ids": [target_id],
"confirm_dirty_discard_target_ids": [target_id]
})), })),
StatusCode::OK, StatusCode::BAD_REQUEST,
) )
.await; .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] #[tokio::test]
@ -6550,24 +6485,14 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); init_clean_git_workspace(dir.path());
let app = test_app(dir.path()).await; let app = test_app(dir.path()).await;
let working_directories_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"); let working_directory_id = "test-workdir";
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 response = request_json( let response = request_json(
app, app,
"POST", "POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"), &format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
Some(serde_json::json!({ Some(serde_json::json!({
"runtime_id": EMBEDDED_WORKER_RUNTIME_ID, "runtime_id": "remote-runtime",
"display_name": "Coding Worker", "display_name": "Coding Worker",
"profile": "builtin:coder", "profile": "builtin:coder",
"initial_text": "", "initial_text": "",
@ -6987,7 +6912,7 @@ mod tests {
} }
#[tokio::test] #[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 dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await; let app = test_app(dir.path()).await;
let response = request_json( let response = request_json(
@ -7009,9 +6934,17 @@ mod tests {
"selector": "HEAD" "selector": "HEAD"
} }
})), })),
StatusCode::OK, StatusCode::BAD_REQUEST,
) )
.await; .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(); let projected = serde_json::to_string(&response).unwrap();
assert!(!projected.contains(dir.path().to_string_lossy().as_ref())); assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
} }

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-Xlaq8/cyMGk3m4z6BNLtSgoRnWqVVnGaLyzgBlr70rk="; cargoHash = "sha256-MUEySmRu5Te8+GCwlt3qk7jxcumYIMvnuvj64o2XuDw=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,