feat: add browser execution workspaces

This commit is contained in:
2026-07-07 23:21:20 +09:00
parent ecf10c72ab
commit 684b19e87c
12 changed files with 993 additions and 111 deletions
+11
View File
@@ -89,6 +89,13 @@ pub struct ExecutionWorkspaceRequest {
pub dirty_state_policy: DirtyStatePolicy,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceAllocationClaim {
pub allocation_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relative_cwd: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionWorkspaceStatusKind {
@@ -108,6 +115,8 @@ pub struct ExecutionWorkspaceCleanupTarget {
pub struct ExecutionWorkspaceSummary {
pub allocation_id: String,
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_selector: Option<String>,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -144,6 +153,8 @@ pub struct CreateWorkerRequest {
pub initial_input: Option<WorkerInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_workspace: Option<ExecutionWorkspaceRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_workspace_allocation: Option<ExecutionWorkspaceAllocationClaim>,
}
/// Worker lifecycle status for the in-memory embedded runtime.
+334 -14
View File
@@ -6,16 +6,20 @@ use crate::catalog::{
use crate::identity::WorkerRef;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const EXECUTION_WORKSPACES_DIR: &str = "execution-workspaces";
const MATERIALIZATION_RECORD: &str = "materialization.json";
static NEXT_ALLOCATION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceEvidence {
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_selector: Option<String>,
pub resolved_commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
@@ -40,6 +44,7 @@ impl ExecutionWorkspaceAllocation {
ExecutionWorkspaceSummary {
allocation_id: self.id.clone(),
repository_id: self.repository_id.clone(),
requested_selector: self.evidence.requested_selector.clone(),
materializer_kind: self.materializer_kind.clone(),
dirty_state_policy: self.dirty_state_policy.clone(),
resolved_commit: Some(self.evidence.resolved_commit.clone()),
@@ -114,6 +119,31 @@ pub trait ExecutionWorkspaceMaterializer: Send + Sync + 'static {
request: &ExecutionWorkspaceRequest,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic>;
fn preallocate(
&self,
request: &ExecutionWorkspaceRequest,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic>;
fn bind_allocation(
&self,
allocation_id: &str,
relative_cwd: Option<&str>,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic>;
fn list_allocations(
&self,
) -> Result<Vec<ExecutionWorkspaceStatus>, ExecutionWorkspaceDiagnostic>;
fn allocation_status(
&self,
allocation_id: &str,
) -> Result<ExecutionWorkspaceStatus, ExecutionWorkspaceDiagnostic>;
fn cleanup_allocation(
&self,
allocation_id: &str,
) -> Result<ExecutionWorkspaceStatus, ExecutionWorkspaceDiagnostic>;
fn cleanup(
&self,
binding: &ExecutionWorkspaceBinding,
@@ -174,14 +204,41 @@ impl LocalGitWorktreeMaterializer {
)
})
}
}
impl ExecutionWorkspaceMaterializer for LocalGitWorktreeMaterializer {
fn materialize(
fn read_binding(
&self,
worker_ref: &WorkerRef,
request: &ExecutionWorkspaceRequest,
allocation_id: &str,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic> {
let allocation_root = self.allocation_root(allocation_id);
let path = allocation_root.join(MATERIALIZATION_RECORD);
let raw = fs::read(&path).map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_allocation_not_found",
"execution workspace allocation was not found",
)
})?;
let record: ExecutionWorkspaceMaterializationRecord = serde_json::from_slice(&raw).map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_record_invalid",
"execution workspace allocation record is invalid; backend-private path details were omitted",
)
})?;
Ok(ExecutionWorkspaceBinding {
allocation: record.allocation,
workspace_root: record.workspace_root.clone(),
cwd: record.workspace_root,
allocation_root,
source_repository_path: record.source_repository_path,
})
}
fn materialize_with_allocation_id(
&self,
allocation_id: String,
request: &ExecutionWorkspaceRequest,
cleanup_policy: &str,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic> {
validate_allocation_id(&allocation_id)?;
if request.materializer != MaterializerKind::LocalGitWorktree {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_materializer_unsupported",
@@ -248,7 +305,6 @@ impl ExecutionWorkspaceMaterializer for LocalGitWorktreeMaterializer {
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let allocation_id = Self::allocation_id(worker_ref, &request.repository.id);
let allocation_root = self.allocation_root(&allocation_id);
let workspace_root = allocation_root
.join("root")
@@ -256,7 +312,7 @@ impl ExecutionWorkspaceMaterializer for LocalGitWorktreeMaterializer {
if workspace_root.exists() {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_allocation_exists",
"execution workspace allocation target already exists; cleanup or choose a new Worker allocation",
"execution workspace allocation target already exists; cleanup or choose a new allocation",
));
}
fs::create_dir_all(workspace_root.parent().ok_or_else(|| {
@@ -285,12 +341,17 @@ impl ExecutionWorkspaceMaterializer for LocalGitWorktreeMaterializer {
)?;
let allocation = ExecutionWorkspaceAllocation {
id: allocation_id,
id: allocation_id.clone(),
repository_id: request.repository.id.clone(),
materializer_kind: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
evidence: ExecutionWorkspaceEvidence {
repository_id: request.repository.id.clone(),
requested_selector: request
.repository
.selector
.as_ref()
.map(|selector| selector.as_ref().to_string()),
resolved_commit,
resolved_tree,
materializer_kind: MaterializerKind::LocalGitWorktree,
@@ -298,10 +359,10 @@ impl ExecutionWorkspaceMaterializer for LocalGitWorktreeMaterializer {
},
cleanup_target: ExecutionWorkspaceCleanupTarget {
kind: "git_worktree".to_string(),
allocation_id: Self::allocation_id(worker_ref, &request.repository.id),
allocation_id,
repository_id: request.repository.id.clone(),
},
cleanup_policy: "remove_on_worker_stop".to_string(),
cleanup_policy: cleanup_policy.to_string(),
status: ExecutionWorkspaceStatusKind::Active,
};
let binding = ExecutionWorkspaceBinding {
@@ -314,20 +375,128 @@ impl ExecutionWorkspaceMaterializer for LocalGitWorktreeMaterializer {
self.write_record(&binding)?;
Ok(binding)
}
}
impl ExecutionWorkspaceMaterializer for LocalGitWorktreeMaterializer {
fn materialize(
&self,
worker_ref: &WorkerRef,
request: &ExecutionWorkspaceRequest,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic> {
let allocation_id = Self::allocation_id(worker_ref, &request.repository.id);
self.materialize_with_allocation_id(allocation_id, request, "remove_on_worker_stop")
}
fn preallocate(
&self,
request: &ExecutionWorkspaceRequest,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic> {
let allocation_id = next_allocation_id(&request.repository.id);
self.materialize_with_allocation_id(allocation_id, request, "manual_or_worker_stop")
}
fn bind_allocation(
&self,
allocation_id: &str,
relative_cwd: Option<&str>,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic> {
validate_allocation_id(allocation_id)?;
let binding = self.read_binding(allocation_id)?;
if binding.allocation.status != ExecutionWorkspaceStatusKind::Active {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_not_active",
"execution workspace allocation is not active",
));
}
let cwd = validate_relative_cwd(binding.workspace_root(), relative_cwd)?;
Ok(ExecutionWorkspaceBinding { cwd, ..binding })
}
fn list_allocations(
&self,
) -> Result<Vec<ExecutionWorkspaceStatus>, ExecutionWorkspaceDiagnostic> {
let root = self.runtime_root.join(EXECUTION_WORKSPACES_DIR);
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(_) => {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_list_failed",
"failed to list execution workspace allocations; backend-private path details were omitted",
));
}
};
let mut statuses = Vec::new();
for entry in entries.flatten() {
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if !file_type.is_dir() {
continue;
}
let allocation_id = entry.file_name().to_string_lossy().to_string();
if validate_allocation_id(&allocation_id).is_err() {
continue;
}
if let Ok(status) = self.allocation_status(&allocation_id) {
statuses.push(status);
}
}
statuses
.sort_by(|left, right| left.summary.allocation_id.cmp(&right.summary.allocation_id));
Ok(statuses)
}
fn allocation_status(
&self,
allocation_id: &str,
) -> Result<ExecutionWorkspaceStatus, ExecutionWorkspaceDiagnostic> {
validate_allocation_id(allocation_id)?;
Ok(self.read_binding(allocation_id)?.status())
}
fn cleanup_allocation(
&self,
allocation_id: &str,
) -> Result<ExecutionWorkspaceStatus, ExecutionWorkspaceDiagnostic> {
validate_allocation_id(allocation_id)?;
let binding = self.read_binding(allocation_id)?;
self.cleanup(&binding)?;
self.allocation_status(allocation_id)
}
fn cleanup(
&self,
binding: &ExecutionWorkspaceBinding,
) -> Result<(), ExecutionWorkspaceDiagnostic> {
let mut allocation = binding.allocation.clone();
let workspace_root_arg = path_str(binding.workspace_root())?;
let allocation_root = binding.allocation_root.canonicalize().map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_cleanup_target_invalid",
"execution workspace allocation root is unavailable; backend-private path details were omitted",
)
})?;
let workspace_root = binding.workspace_root.canonicalize().map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_cleanup_target_invalid",
"execution workspace root is unavailable; backend-private path details were omitted",
)
})?;
if !workspace_root.starts_with(&allocation_root) {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_cleanup_escape_rejected",
"execution workspace cleanup target is outside the allocation root",
));
}
let workspace_root_arg = path_str(&workspace_root)?;
let remove_result = git_status(
binding.source_repository_path(),
["worktree", "remove", "--force", workspace_root_arg.as_str()],
)
.or_else(|_| {
if binding.workspace_root.exists() {
fs::remove_dir_all(binding.workspace_root()).map_err(|_| {
if workspace_root.exists() {
fs::remove_dir_all(&workspace_root).map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_cleanup_failed",
"failed to remove execution workspace; backend-private path details were omitted",
@@ -431,6 +600,80 @@ fn sanitize_path_component(value: &str) -> String {
}
}
fn next_allocation_id(repository_id: &str) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let sequence = NEXT_ALLOCATION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
format!(
"alloc-{now}-{sequence}-{}",
sanitize_path_component(repository_id)
)
}
fn validate_allocation_id(allocation_id: &str) -> Result<(), ExecutionWorkspaceDiagnostic> {
let sanitized = sanitize_path_component(allocation_id);
if allocation_id.is_empty()
|| allocation_id != sanitized
|| allocation_id.contains(std::path::MAIN_SEPARATOR)
|| allocation_id.contains('/')
|| allocation_id.contains('\\')
{
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_allocation_id_invalid",
"execution workspace allocation id is invalid",
));
}
Ok(())
}
fn validate_relative_cwd(
workspace_root: &Path,
relative_cwd: Option<&str>,
) -> Result<PathBuf, ExecutionWorkspaceDiagnostic> {
let workspace_root = workspace_root.canonicalize().map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_root_unavailable",
"execution workspace root is unavailable; backend-private path details were omitted",
)
})?;
let relative = relative_cwd.unwrap_or(".").trim();
if relative.is_empty() || relative == "." {
return Ok(workspace_root);
}
let relative_path = Path::new(relative);
if relative_path.is_absolute()
|| relative_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_relative_cwd_invalid",
"execution workspace relative_cwd must be a relative path inside the allocation root",
));
}
let target = workspace_root
.join(relative_path)
.canonicalize()
.map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_relative_cwd_unavailable",
"execution workspace relative_cwd does not identify an existing directory",
)
})?;
if !target.starts_with(&workspace_root) || !target.is_dir() {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_relative_cwd_escape_rejected",
"execution workspace relative_cwd must resolve to a directory inside the allocation root",
));
}
Ok(target)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -575,6 +818,83 @@ mod tests {
);
}
#[test]
fn preallocated_allocation_binds_safe_relative_cwd_and_lists_without_paths() {
let repo = create_clean_repo();
fs::create_dir_all(repo.path().join("crates/yoi")).unwrap();
fs::write(repo.path().join("crates/yoi/lib.rs"), "// ok\n").unwrap();
git(repo.path(), &["add", "crates/yoi/lib.rs"]);
git(repo.path(), &["commit", "-m", "add crate"]);
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let allocation = materializer.preallocate(&request(repo.path())).unwrap();
let bound = materializer
.bind_allocation(&allocation.allocation.id, Some("crates/yoi"))
.unwrap();
assert_eq!(bound.cwd.file_name().unwrap(), "yoi");
let listed = materializer.list_allocations().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].summary.allocation_id, allocation.allocation.id);
assert_eq!(
listed[0].summary.requested_selector.as_deref(),
Some("HEAD")
);
}
#[test]
fn relative_cwd_rejects_absolute_parent_nonexistent_file_and_symlink_escape() {
let repo = create_clean_repo();
fs::create_dir_all(repo.path().join("inside")).unwrap();
fs::write(repo.path().join("inside/file.txt"), "file\n").unwrap();
git(repo.path(), &["add", "inside/file.txt"]);
git(repo.path(), &["commit", "-m", "add inside"]);
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let allocation = materializer.preallocate(&request(repo.path())).unwrap();
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("/tmp"))
.unwrap_err()
.code,
"execution_workspace_relative_cwd_invalid"
);
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("../outside"))
.unwrap_err()
.code,
"execution_workspace_relative_cwd_invalid"
);
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("missing"))
.unwrap_err()
.code,
"execution_workspace_relative_cwd_unavailable"
);
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("inside/file.txt"))
.unwrap_err()
.code,
"execution_workspace_relative_cwd_escape_rejected"
);
#[cfg(unix)]
{
std::os::unix::fs::symlink("/tmp", allocation.workspace_root().join("escape")).unwrap();
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("escape"))
.unwrap_err()
.code,
"execution_workspace_relative_cwd_escape_rejected"
);
}
}
#[test]
fn cleanup_removes_worktree_and_updates_record() {
let repo = create_clean_repo();
+2
View File
@@ -864,6 +864,7 @@ mod tests {
},
initial_input: None,
execution_workspace: None,
execution_workspace_allocation: None,
}
}
@@ -1148,6 +1149,7 @@ mod ws_tests {
},
initial_input: None,
execution_workspace: None,
execution_workspace_allocation: None,
}
}
+1
View File
@@ -1594,6 +1594,7 @@ mod tests {
},
initial_input: None,
execution_workspace: None,
execution_workspace_allocation: None,
}
}
+38 -3
View File
@@ -374,8 +374,17 @@ where
}
let mut request = request;
let execution_workspace = match request.request.execution_workspace.as_ref() {
Some(workspace_request) => {
let execution_workspace = match (
request.request.execution_workspace.as_ref(),
request.request.execution_workspace_allocation.as_ref(),
) {
(Some(_), Some(_)) => {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"worker spawn cannot specify both execution_workspace and execution_workspace_allocation",
));
}
(Some(workspace_request), None) => {
let Some(materializer) = self.execution_workspace_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
@@ -397,7 +406,32 @@ where
}
}
}
None => None,
(None, Some(allocation)) => {
let Some(materializer) = self.execution_workspace_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"execution workspace allocation requested, but no materializer is configured for this runtime backend",
));
};
match materializer.bind_allocation(
&allocation.allocation_id,
allocation.relative_cwd.as_deref(),
) {
Ok(binding) => {
request.execution_workspace = Some(binding.clone());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
error.to_string(),
),
);
}
}
}
(None, None) => None,
};
let factory = self.factory.clone();
@@ -747,6 +781,7 @@ mod tests {
},
initial_input: None,
execution_workspace: None,
execution_workspace_allocation: None,
}
}