merge: execution workspace materializer
This commit is contained in:
commit
c4cdf1c37e
|
|
@ -2,6 +2,7 @@ use crate::execution::WorkerExecutionStatus;
|
|||
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
||||
use crate::interaction::WorkerInput;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Profile selector boundary. This is a selector, not a resolved config bundle.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -25,21 +26,124 @@ pub struct ConfigBundleRef {
|
|||
pub digest: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySelector(pub String);
|
||||
|
||||
impl From<&str> for RepositorySelector {
|
||||
fn from(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for RepositorySelector {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for RepositorySelector {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for RepositorySelector {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionWorkspaceRepository {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub uri: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub local_path: Option<PathBuf>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<RepositorySelector>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaterializerKind {
|
||||
#[default]
|
||||
LocalGitWorktree,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DirtyStatePolicy {
|
||||
#[default]
|
||||
CleanPointOnly,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionWorkspaceRequest {
|
||||
pub repository: ExecutionWorkspaceRepository,
|
||||
#[serde(default)]
|
||||
pub materializer: MaterializerKind,
|
||||
#[serde(default)]
|
||||
pub dirty_state_policy: DirtyStatePolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionWorkspaceStatusKind {
|
||||
Active,
|
||||
Removed,
|
||||
CleanupPending,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionWorkspaceCleanupTarget {
|
||||
pub kind: String,
|
||||
pub allocation_id: String,
|
||||
pub repository_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionWorkspaceSummary {
|
||||
pub allocation_id: String,
|
||||
pub repository_id: String,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
pub dirty_state_policy: DirtyStatePolicy,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_commit: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<ExecutionWorkspaceCleanupTarget>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_policy: Option<String>,
|
||||
pub status: ExecutionWorkspaceStatusKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionWorkspaceStatus {
|
||||
pub summary: ExecutionWorkspaceSummary,
|
||||
}
|
||||
|
||||
/// Canonical Runtime Worker creation request.
|
||||
///
|
||||
/// Browser/product launch semantics are resolved by a backend before this
|
||||
/// request is built. The request contains only durable Runtime identity inputs:
|
||||
/// a backend-decided profile selector, a previously synced ConfigBundle identity,
|
||||
/// and optional initial user input that is committed in the same transaction as
|
||||
/// Worker catalog/transcript persistence. It carries no cwd/workspace path, tool
|
||||
/// scope, credential, socket/session path, raw config body, or execution binding
|
||||
/// internals.
|
||||
/// optional initial user input that is committed in the same transaction as
|
||||
/// Worker catalog/transcript persistence, and an optional execution workspace
|
||||
/// request that preserves RepositoryPoint-style semantics for runtime-side
|
||||
/// materialization. Browser-facing status for materialized workspaces is
|
||||
/// summarized without exposing raw host paths.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreateWorkerRequest {
|
||||
pub profile: ProfileSelector,
|
||||
pub config_bundle: ConfigBundleRef,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub initial_input: Option<WorkerInput>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub execution_workspace: Option<ExecutionWorkspaceRequest>,
|
||||
}
|
||||
|
||||
/// Worker lifecycle status for the in-memory embedded runtime.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::catalog::CreateWorkerRequest;
|
||||
use crate::catalog::{CreateWorkerRequest, ExecutionWorkspaceStatus};
|
||||
use crate::error::RuntimeError;
|
||||
use crate::execution_workspace::ExecutionWorkspaceBinding;
|
||||
use crate::identity::WorkerRef;
|
||||
use crate::interaction::WorkerInput;
|
||||
#[cfg(feature = "ws-server")]
|
||||
|
|
@ -153,6 +154,8 @@ pub struct WorkerExecutionStatus {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub binding: Option<WorkerExecutionBindingIdentity>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub execution_workspace: Option<ExecutionWorkspaceStatus>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_result: Option<WorkerExecutionResult>,
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +169,7 @@ impl WorkerExecutionStatus {
|
|||
backend: WorkerExecutionBackendKind::Connected,
|
||||
run_state,
|
||||
binding: None,
|
||||
execution_workspace: None,
|
||||
last_result: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -181,6 +185,11 @@ impl WorkerExecutionStatus {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_execution_workspace(mut self, status: ExecutionWorkspaceStatus) -> Self {
|
||||
self.execution_workspace = Some(status);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_result(mut self, result: WorkerExecutionResult) -> Self {
|
||||
self.run_state = result.run_state;
|
||||
self.last_result = Some(result);
|
||||
|
|
@ -277,6 +286,7 @@ pub struct WorkerExecutionSpawnRequest {
|
|||
pub worker_ref: WorkerRef,
|
||||
pub request: CreateWorkerRequest,
|
||||
pub context: WorkerExecutionContext,
|
||||
pub execution_workspace: Option<ExecutionWorkspaceBinding>,
|
||||
}
|
||||
|
||||
/// Result of backend Worker spawn/initialization.
|
||||
|
|
@ -285,6 +295,7 @@ pub enum WorkerExecutionSpawnResult {
|
|||
Connected {
|
||||
handle: WorkerExecutionHandle,
|
||||
run_state: WorkerExecutionRunState,
|
||||
execution_workspace: Option<ExecutionWorkspaceStatus>,
|
||||
},
|
||||
Rejected(WorkerExecutionResult),
|
||||
Errored(WorkerExecutionResult),
|
||||
|
|
|
|||
595
crates/worker-runtime/src/execution_workspace.rs
Normal file
595
crates/worker-runtime/src/execution_workspace.rs
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
use crate::catalog::{
|
||||
DirtyStatePolicy, ExecutionWorkspaceCleanupTarget, ExecutionWorkspaceRequest,
|
||||
ExecutionWorkspaceStatus, ExecutionWorkspaceStatusKind, ExecutionWorkspaceSummary,
|
||||
MaterializerKind,
|
||||
};
|
||||
use crate::identity::WorkerRef;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const EXECUTION_WORKSPACES_DIR: &str = "execution-workspaces";
|
||||
const MATERIALIZATION_RECORD: &str = "materialization.json";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionWorkspaceEvidence {
|
||||
pub repository_id: String,
|
||||
pub resolved_commit: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_tree: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
pub dirty_state_policy: DirtyStatePolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionWorkspaceAllocation {
|
||||
pub id: String,
|
||||
pub repository_id: String,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
pub dirty_state_policy: DirtyStatePolicy,
|
||||
pub evidence: ExecutionWorkspaceEvidence,
|
||||
pub cleanup_target: ExecutionWorkspaceCleanupTarget,
|
||||
pub cleanup_policy: String,
|
||||
pub status: ExecutionWorkspaceStatusKind,
|
||||
}
|
||||
|
||||
impl ExecutionWorkspaceAllocation {
|
||||
pub fn status_summary(&self) -> ExecutionWorkspaceSummary {
|
||||
ExecutionWorkspaceSummary {
|
||||
allocation_id: self.id.clone(),
|
||||
repository_id: self.repository_id.clone(),
|
||||
materializer_kind: self.materializer_kind.clone(),
|
||||
dirty_state_policy: self.dirty_state_policy.clone(),
|
||||
resolved_commit: Some(self.evidence.resolved_commit.clone()),
|
||||
resolved_tree: self.evidence.resolved_tree.clone(),
|
||||
cleanup_target: Some(self.cleanup_target.clone()),
|
||||
cleanup_policy: Some(self.cleanup_policy.clone()),
|
||||
status: self.status.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExecutionWorkspaceBinding {
|
||||
pub allocation: ExecutionWorkspaceAllocation,
|
||||
pub workspace_root: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
allocation_root: PathBuf,
|
||||
source_repository_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ExecutionWorkspaceBinding {
|
||||
pub fn workspace_root(&self) -> &Path {
|
||||
&self.workspace_root
|
||||
}
|
||||
|
||||
pub fn cwd(&self) -> &Path {
|
||||
&self.cwd
|
||||
}
|
||||
|
||||
pub fn allocation_root(&self) -> &Path {
|
||||
&self.allocation_root
|
||||
}
|
||||
|
||||
pub fn source_repository_path(&self) -> &Path {
|
||||
&self.source_repository_path
|
||||
}
|
||||
|
||||
pub fn status(&self) -> ExecutionWorkspaceStatus {
|
||||
ExecutionWorkspaceStatus {
|
||||
summary: self.allocation.status_summary(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExecutionWorkspaceDiagnostic {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ExecutionWorkspaceDiagnostic {
|
||||
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExecutionWorkspaceDiagnostic {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}: {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ExecutionWorkspaceDiagnostic {}
|
||||
|
||||
pub trait ExecutionWorkspaceMaterializer: Send + Sync + 'static {
|
||||
fn materialize(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
request: &ExecutionWorkspaceRequest,
|
||||
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic>;
|
||||
|
||||
fn cleanup(
|
||||
&self,
|
||||
binding: &ExecutionWorkspaceBinding,
|
||||
) -> Result<(), ExecutionWorkspaceDiagnostic>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalGitWorktreeMaterializer {
|
||||
runtime_root: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalGitWorktreeMaterializer {
|
||||
pub fn new(runtime_root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
runtime_root: runtime_root.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_root(&self) -> &Path {
|
||||
&self.runtime_root
|
||||
}
|
||||
|
||||
fn allocation_id(worker_ref: &WorkerRef, repository_id: &str) -> String {
|
||||
format!(
|
||||
"{}-{}-{}",
|
||||
sanitize_path_component(worker_ref.runtime_id.as_str()),
|
||||
sanitize_path_component(worker_ref.worker_id.as_str()),
|
||||
sanitize_path_component(repository_id)
|
||||
)
|
||||
}
|
||||
|
||||
fn allocation_root(&self, allocation_id: &str) -> PathBuf {
|
||||
self.runtime_root
|
||||
.join(EXECUTION_WORKSPACES_DIR)
|
||||
.join(allocation_id)
|
||||
}
|
||||
|
||||
fn write_record(
|
||||
&self,
|
||||
binding: &ExecutionWorkspaceBinding,
|
||||
) -> Result<(), ExecutionWorkspaceDiagnostic> {
|
||||
let record = ExecutionWorkspaceMaterializationRecord {
|
||||
allocation: binding.allocation.clone(),
|
||||
workspace_root: binding.workspace_root.clone(),
|
||||
source_repository_path: binding.source_repository_path.clone(),
|
||||
};
|
||||
let path = binding.allocation_root.join(MATERIALIZATION_RECORD);
|
||||
let raw = serde_json::to_vec_pretty(&record).map_err(|error| {
|
||||
ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_record_serialize_failed",
|
||||
format!("failed to serialize execution workspace record: {error}"),
|
||||
)
|
||||
})?;
|
||||
fs::write(&path, raw).map_err(|_| {
|
||||
ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_record_write_failed",
|
||||
"failed to write execution workspace record; backend-private path details were omitted",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutionWorkspaceMaterializer for LocalGitWorktreeMaterializer {
|
||||
fn materialize(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
request: &ExecutionWorkspaceRequest,
|
||||
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic> {
|
||||
if request.materializer != MaterializerKind::LocalGitWorktree {
|
||||
return Err(ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_materializer_unsupported",
|
||||
"only local_git_worktree execution workspace materialization is supported in v0",
|
||||
));
|
||||
}
|
||||
if request.repository.provider != "git" {
|
||||
return Err(ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_repository_provider_unsupported",
|
||||
format!(
|
||||
"repository provider `{}` is not supported by the v0 execution workspace materializer",
|
||||
request.repository.provider
|
||||
),
|
||||
));
|
||||
}
|
||||
if request.dirty_state_policy != DirtyStatePolicy::CleanPointOnly {
|
||||
return Err(ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_dirty_policy_unsupported",
|
||||
"only clean_point_only dirty-state policy is supported in v0",
|
||||
));
|
||||
}
|
||||
if is_remote_uri(&request.repository.uri) {
|
||||
return Err(ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_remote_repository_unsupported",
|
||||
"remote repository URI materialization is not implemented in v0",
|
||||
));
|
||||
}
|
||||
|
||||
let source_path = request
|
||||
.repository
|
||||
.local_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from(&request.repository.uri));
|
||||
let source_root = git_stdout(&source_path, ["rev-parse", "--show-toplevel"])
|
||||
.map(|value| PathBuf::from(value.trim()))
|
||||
.map_err(|_| {
|
||||
ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_git_repository_unavailable",
|
||||
"configured local repository is not an available Git worktree; backend-private path details were omitted",
|
||||
)
|
||||
})?;
|
||||
|
||||
let status = git_stdout(&source_root, ["status", "--porcelain"])?;
|
||||
if !status.trim().is_empty() {
|
||||
return Err(ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_dirty_source_rejected",
|
||||
"clean_point_only execution workspace materialization rejects dirty source repository state",
|
||||
));
|
||||
}
|
||||
|
||||
let selector = request
|
||||
.repository
|
||||
.selector
|
||||
.as_deref()
|
||||
.unwrap_or("HEAD")
|
||||
.to_string();
|
||||
let commit_spec = format!("{selector}^{{commit}}");
|
||||
let resolved_commit = git_stdout(&source_root, ["rev-parse", commit_spec.as_str()])?
|
||||
.trim()
|
||||
.to_string();
|
||||
let tree_spec = format!("{resolved_commit}^{{tree}}");
|
||||
let resolved_tree = git_stdout(&source_root, ["rev-parse", tree_spec.as_str()])
|
||||
.ok()
|
||||
.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")
|
||||
.join(sanitize_path_component(&request.repository.id));
|
||||
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",
|
||||
));
|
||||
}
|
||||
fs::create_dir_all(workspace_root.parent().ok_or_else(|| {
|
||||
ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_invalid_target",
|
||||
"execution workspace allocation target has no parent directory",
|
||||
)
|
||||
})?)
|
||||
.map_err(|_| {
|
||||
ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_create_failed",
|
||||
"failed to create execution workspace allocation directory; backend-private path details were omitted",
|
||||
)
|
||||
})?;
|
||||
|
||||
let workspace_root_arg = path_str(&workspace_root)?;
|
||||
git_status(
|
||||
&source_root,
|
||||
[
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
workspace_root_arg.as_str(),
|
||||
resolved_commit.as_str(),
|
||||
],
|
||||
)?;
|
||||
|
||||
let allocation = ExecutionWorkspaceAllocation {
|
||||
id: allocation_id,
|
||||
repository_id: request.repository.id.clone(),
|
||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||
evidence: ExecutionWorkspaceEvidence {
|
||||
repository_id: request.repository.id.clone(),
|
||||
resolved_commit,
|
||||
resolved_tree,
|
||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||
},
|
||||
cleanup_target: ExecutionWorkspaceCleanupTarget {
|
||||
kind: "git_worktree".to_string(),
|
||||
allocation_id: Self::allocation_id(worker_ref, &request.repository.id),
|
||||
repository_id: request.repository.id.clone(),
|
||||
},
|
||||
cleanup_policy: "remove_on_worker_stop".to_string(),
|
||||
status: ExecutionWorkspaceStatusKind::Active,
|
||||
};
|
||||
let binding = ExecutionWorkspaceBinding {
|
||||
allocation,
|
||||
workspace_root: workspace_root.clone(),
|
||||
cwd: workspace_root,
|
||||
allocation_root,
|
||||
source_repository_path: source_root,
|
||||
};
|
||||
self.write_record(&binding)?;
|
||||
Ok(binding)
|
||||
}
|
||||
|
||||
fn cleanup(
|
||||
&self,
|
||||
binding: &ExecutionWorkspaceBinding,
|
||||
) -> Result<(), ExecutionWorkspaceDiagnostic> {
|
||||
let mut allocation = binding.allocation.clone();
|
||||
let workspace_root_arg = path_str(binding.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(|_| {
|
||||
ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_cleanup_failed",
|
||||
"failed to remove execution workspace; backend-private path details were omitted",
|
||||
)
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
allocation.status = if remove_result.is_ok() {
|
||||
ExecutionWorkspaceStatusKind::Removed
|
||||
} else {
|
||||
ExecutionWorkspaceStatusKind::CleanupPending
|
||||
};
|
||||
let updated = ExecutionWorkspaceBinding {
|
||||
allocation,
|
||||
workspace_root: binding.workspace_root.clone(),
|
||||
cwd: binding.cwd.clone(),
|
||||
allocation_root: binding.allocation_root.clone(),
|
||||
source_repository_path: binding.source_repository_path.clone(),
|
||||
};
|
||||
let _ = self.write_record(&updated);
|
||||
remove_result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct ExecutionWorkspaceMaterializationRecord {
|
||||
allocation: ExecutionWorkspaceAllocation,
|
||||
workspace_root: PathBuf,
|
||||
source_repository_path: PathBuf,
|
||||
}
|
||||
|
||||
fn git_stdout<'a, I>(
|
||||
repository_path: &Path,
|
||||
args: I,
|
||||
) -> Result<String, ExecutionWorkspaceDiagnostic>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repository_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|_| {
|
||||
ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_git_unavailable",
|
||||
"Git command could not be executed; backend-private path details were omitted",
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_git_failed",
|
||||
"Git command failed; backend-private path details were omitted",
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn git_status<'a, I>(repository_path: &Path, args: I) -> Result<(), ExecutionWorkspaceDiagnostic>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
git_stdout(repository_path, args).map(|_| ())
|
||||
}
|
||||
|
||||
fn path_str(path: &Path) -> Result<String, ExecutionWorkspaceDiagnostic> {
|
||||
path.to_str().map(ToString::to_string).ok_or_else(|| {
|
||||
ExecutionWorkspaceDiagnostic::new(
|
||||
"execution_workspace_non_utf8_path",
|
||||
"execution workspace path is not valid UTF-8; backend-private path details were omitted",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_remote_uri(uri: &str) -> bool {
|
||||
uri.contains("://") || uri.starts_with("git@") || uri.starts_with("ssh:")
|
||||
}
|
||||
|
||||
fn sanitize_path_component(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let trimmed = sanitized.trim_matches(['-', '.']).to_string();
|
||||
if trimmed.is_empty() {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
format!("workspace-{now}")
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::catalog::{ExecutionWorkspaceRepository, RepositorySelector};
|
||||
use crate::identity::{RuntimeId, WorkerId};
|
||||
|
||||
fn git(path: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(args)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "git {:?} failed", args);
|
||||
}
|
||||
|
||||
fn create_clean_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
git(dir.path(), &["init"]);
|
||||
git(
|
||||
dir.path(),
|
||||
&["config", "user.email", "test@example.invalid"],
|
||||
);
|
||||
git(dir.path(), &["config", "user.name", "Yoi Test"]);
|
||||
fs::write(dir.path().join("README.md"), "clean\n").unwrap();
|
||||
git(dir.path(), &["add", "README.md"]);
|
||||
git(dir.path(), &["commit", "-m", "init"]);
|
||||
dir
|
||||
}
|
||||
|
||||
fn request(repo: &Path) -> ExecutionWorkspaceRequest {
|
||||
ExecutionWorkspaceRequest {
|
||||
repository: ExecutionWorkspaceRepository {
|
||||
id: "repo-main".to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: ".".to_string(),
|
||||
local_path: Some(repo.to_path_buf()),
|
||||
selector: Some(RepositorySelector::from("HEAD")),
|
||||
},
|
||||
materializer: MaterializerKind::LocalGitWorktree,
|
||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_ref(sequence: u64) -> WorkerRef {
|
||||
WorkerRef::new(
|
||||
RuntimeId::new("runtime-test").unwrap(),
|
||||
WorkerId::generated(sequence),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_git_repo_materializes_detached_worktree_under_runtime_root() {
|
||||
let repo = create_clean_repo();
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
let binding = materializer
|
||||
.materialize(&worker_ref(1), &request(repo.path()))
|
||||
.unwrap();
|
||||
|
||||
assert!(binding.workspace_root.starts_with(runtime_root.path()));
|
||||
assert!(binding.workspace_root.join("README.md").exists());
|
||||
let branch = git_stdout(binding.workspace_root(), ["branch", "--show-current"]).unwrap();
|
||||
assert!(
|
||||
branch.is_empty(),
|
||||
"worktree should be detached, got {branch}"
|
||||
);
|
||||
assert_eq!(
|
||||
binding.allocation.materializer_kind,
|
||||
MaterializerKind::LocalGitWorktree
|
||||
);
|
||||
assert_eq!(
|
||||
binding.allocation.dirty_state_policy,
|
||||
DirtyStatePolicy::CleanPointOnly
|
||||
);
|
||||
assert!(
|
||||
binding
|
||||
.allocation_root()
|
||||
.join(MATERIALIZATION_RECORD)
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_workers_materialize_distinct_paths_for_same_source_repo() {
|
||||
let repo = create_clean_repo();
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
let first = materializer
|
||||
.materialize(&worker_ref(1), &request(repo.path()))
|
||||
.unwrap();
|
||||
let second = materializer
|
||||
.materialize(&worker_ref(2), &request(repo.path()))
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(first.workspace_root, second.workspace_root);
|
||||
assert!(first.workspace_root.starts_with(runtime_root.path()));
|
||||
assert!(second.workspace_root.starts_with(runtime_root.path()));
|
||||
assert!(!first.workspace_root.starts_with(repo.path()));
|
||||
assert!(!second.workspace_root.starts_with(repo.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_source_is_rejected_by_clean_point_only_policy() {
|
||||
let repo = create_clean_repo();
|
||||
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
|
||||
let error = materializer
|
||||
.materialize(&worker_ref(1), &request(repo.path()))
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.code, "execution_workspace_dirty_source_rejected");
|
||||
assert!(error.message.contains("clean_point_only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_remote_and_non_git_provider_return_typed_diagnostics() {
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
let mut remote = request(Path::new("."));
|
||||
remote.repository.local_path = None;
|
||||
remote.repository.uri = "https://example.invalid/repo.git".to_string();
|
||||
let error = materializer
|
||||
.materialize(&worker_ref(1), &remote)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.code,
|
||||
"execution_workspace_remote_repository_unsupported"
|
||||
);
|
||||
|
||||
let mut non_git = remote;
|
||||
non_git.repository.provider = "archive".to_string();
|
||||
non_git.repository.uri = ".".to_string();
|
||||
let error = materializer
|
||||
.materialize(&worker_ref(2), &non_git)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.code,
|
||||
"execution_workspace_repository_provider_unsupported"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_worktree_and_updates_record() {
|
||||
let repo = create_clean_repo();
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
let binding = materializer
|
||||
.materialize(&worker_ref(1), &request(repo.path()))
|
||||
.unwrap();
|
||||
let workspace_root = binding.workspace_root.clone();
|
||||
|
||||
materializer.cleanup(&binding).unwrap();
|
||||
|
||||
assert!(!workspace_root.exists());
|
||||
let raw =
|
||||
fs::read_to_string(binding.allocation_root().join(MATERIALIZATION_RECORD)).unwrap();
|
||||
assert!(raw.contains("removed"));
|
||||
}
|
||||
}
|
||||
|
|
@ -863,6 +863,7 @@ mod tests {
|
|||
digest: bundle.metadata.digest,
|
||||
},
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -877,6 +878,10 @@ mod tests {
|
|||
WorkerExecutionSpawnResult::Connected {
|
||||
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
|
||||
run_state: WorkerExecutionRunState::Idle,
|
||||
execution_workspace: request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.status()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1092,6 +1097,10 @@ mod ws_tests {
|
|||
WorkerExecutionSpawnResult::Connected {
|
||||
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
|
||||
run_state: WorkerExecutionRunState::Idle,
|
||||
execution_workspace: request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.status()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1138,6 +1147,7 @@ mod ws_tests {
|
|||
digest: bundle.metadata.digest,
|
||||
},
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod config_bundle;
|
|||
pub mod diagnostics;
|
||||
pub mod error;
|
||||
pub mod execution;
|
||||
pub mod execution_workspace;
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub mod fs_store;
|
||||
#[cfg(feature = "http-server")]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use std::process::ExitCode;
|
|||
use std::sync::Arc;
|
||||
|
||||
use worker_runtime::error::RuntimeError;
|
||||
use worker_runtime::execution_workspace::LocalGitWorktreeMaterializer;
|
||||
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||
use worker_runtime::http_server::{
|
||||
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
||||
|
|
@ -80,8 +81,14 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
|||
if let Some(profile) = config.profile.clone() {
|
||||
factory = factory.with_profile(profile);
|
||||
}
|
||||
let backend =
|
||||
Arc::new(WorkerRuntimeExecutionBackend::new(factory).map_err(ProcessError::WorkerAdapter)?);
|
||||
let execution_workspace_root = execution_workspace_runtime_root(config);
|
||||
let backend = Arc::new(
|
||||
WorkerRuntimeExecutionBackend::new(factory)
|
||||
.map_err(ProcessError::WorkerAdapter)?
|
||||
.with_execution_workspace_materializer(LocalGitWorktreeMaterializer::new(
|
||||
execution_workspace_root,
|
||||
)),
|
||||
);
|
||||
|
||||
match &config.http.store {
|
||||
RuntimeHttpStoreSelection::Memory => {
|
||||
|
|
@ -102,6 +109,16 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn execution_workspace_runtime_root(config: &ProcessConfig) -> PathBuf {
|
||||
match &config.http.store {
|
||||
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
|
||||
_ => config
|
||||
.worker_runtime_base_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| env::temp_dir().join("yoi-worker-runtime")),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
||||
RuntimeOptions {
|
||||
runtime_id: config.runtime_id.clone(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use crate::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
|
||||
WorkerStatus, WorkerSummary,
|
||||
ConfigBundleRef, CreateWorkerRequest,
|
||||
ExecutionWorkspaceStatus as CatalogExecutionWorkspaceStatus, ProfileSelector, WorkerDetail,
|
||||
WorkerLifecycleAck, WorkerStatus, WorkerSummary,
|
||||
};
|
||||
use crate::config_bundle::{
|
||||
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
|
||||
|
|
@ -288,13 +289,18 @@ impl Runtime {
|
|||
worker_ref: worker_ref.clone(),
|
||||
request,
|
||||
context: self.execution_context(worker_ref.clone()),
|
||||
execution_workspace: None,
|
||||
};
|
||||
(backend, worker_ref, spawn_request)
|
||||
};
|
||||
|
||||
let spawn_result = backend.spawn_worker(spawn_request);
|
||||
let (handle, run_state) = match spawn_result {
|
||||
WorkerExecutionSpawnResult::Connected { handle, run_state } => (handle, run_state),
|
||||
let (handle, run_state, execution_workspace) = match spawn_result {
|
||||
WorkerExecutionSpawnResult::Connected {
|
||||
handle,
|
||||
run_state,
|
||||
execution_workspace,
|
||||
} => (handle, run_state, execution_workspace),
|
||||
WorkerExecutionSpawnResult::Rejected(result)
|
||||
| WorkerExecutionSpawnResult::Errored(result) => {
|
||||
self.rollback_failed_create(&worker_ref)?;
|
||||
|
|
@ -328,6 +334,7 @@ impl Runtime {
|
|||
&worker_ref,
|
||||
handle,
|
||||
WorkerExecutionRunState::Busy,
|
||||
execution_workspace,
|
||||
WorkerExecutionResult::accepted(
|
||||
WorkerExecutionOperation::Input,
|
||||
WorkerExecutionRunState::Busy,
|
||||
|
|
@ -338,6 +345,7 @@ impl Runtime {
|
|||
&worker_ref,
|
||||
handle,
|
||||
run_state,
|
||||
execution_workspace,
|
||||
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn, run_state),
|
||||
)
|
||||
}
|
||||
|
|
@ -433,6 +441,7 @@ impl Runtime {
|
|||
backend: WorkerExecutionBackendKind::Connected,
|
||||
run_state: dispatch_result.run_state,
|
||||
binding: worker.execution.binding.clone(),
|
||||
execution_workspace: worker.execution.execution_workspace.clone(),
|
||||
last_result: Some(dispatch_result),
|
||||
};
|
||||
worker.transcript.push(TranscriptEntry {
|
||||
|
|
@ -482,6 +491,7 @@ impl Runtime {
|
|||
worker_ref: &WorkerRef,
|
||||
handle: WorkerExecutionHandle,
|
||||
run_state: WorkerExecutionRunState,
|
||||
execution_workspace: Option<CatalogExecutionWorkspaceStatus>,
|
||||
result: WorkerExecutionResult,
|
||||
) -> Result<WorkerDetail, RuntimeError> {
|
||||
let mut state = self.lock()?;
|
||||
|
|
@ -490,9 +500,11 @@ impl Runtime {
|
|||
let binding = WorkerExecutionBindingIdentity::from_handle(&handle);
|
||||
let worker = state.worker_mut(worker_ref)?;
|
||||
worker.execution_handle = Some(handle);
|
||||
worker.execution = WorkerExecutionStatus::connected(run_state)
|
||||
.with_binding(binding)
|
||||
.with_result(result);
|
||||
let mut execution = WorkerExecutionStatus::connected(run_state).with_binding(binding);
|
||||
if let Some(status) = execution_workspace {
|
||||
execution = execution.with_execution_workspace(status);
|
||||
}
|
||||
worker.execution = execution.with_result(result);
|
||||
worker.detail(&runtime_id)
|
||||
};
|
||||
state.persist_runtime_snapshot()?;
|
||||
|
|
@ -526,6 +538,7 @@ impl Runtime {
|
|||
backend: WorkerExecutionBackendKind::Connected,
|
||||
run_state: result.run_state,
|
||||
binding: worker.execution.binding.clone(),
|
||||
execution_workspace: worker.execution.execution_workspace.clone(),
|
||||
last_result: Some(result),
|
||||
};
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
|
|
@ -1580,6 +1593,7 @@ mod tests {
|
|||
digest: bundle.metadata.digest,
|
||||
},
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1645,6 +1659,10 @@ mod tests {
|
|||
WorkerExecutionSpawnResult::Connected {
|
||||
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
|
||||
run_state: WorkerExecutionRunState::Idle,
|
||||
execution_workspace: request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.status()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1916,6 +1934,10 @@ mod tests {
|
|||
WorkerExecutionSpawnResult::Connected {
|
||||
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
|
||||
run_state: WorkerExecutionRunState::Idle,
|
||||
execution_workspace: request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.status()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use crate::execution::{
|
|||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
||||
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||
};
|
||||
use crate::execution_workspace::{ExecutionWorkspaceBinding, ExecutionWorkspaceMaterializer};
|
||||
use crate::interaction::{WorkerInput, WorkerInputKind};
|
||||
use async_trait::async_trait;
|
||||
use manifest::paths;
|
||||
|
|
@ -160,9 +161,19 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||
) -> Result<WorkerHandle, String> {
|
||||
let worker_name = Self::runtime_worker_name(&request);
|
||||
let profile = self.runtime_profile(&request);
|
||||
let workspace_root = request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.workspace_root().to_path_buf())
|
||||
.unwrap_or_else(|| self.workspace_root.clone());
|
||||
let cwd = request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.cwd().to_path_buf())
|
||||
.unwrap_or_else(|| self.cwd.clone());
|
||||
let (mut manifest, loader) = worker::entrypoint::resolve_runtime_profile_manifest(
|
||||
profile.as_deref(),
|
||||
&self.workspace_root,
|
||||
&workspace_root,
|
||||
&worker_name,
|
||||
)?;
|
||||
manifest.worker.name = worker_name;
|
||||
|
|
@ -183,13 +194,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||
})?;
|
||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
||||
|
||||
let worker = Worker::from_manifest_with_context(
|
||||
manifest,
|
||||
store,
|
||||
loader,
|
||||
self.workspace_root.clone(),
|
||||
self.cwd.clone(),
|
||||
)
|
||||
let worker =
|
||||
Worker::from_manifest_with_context(manifest, store, loader, workspace_root, cwd)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
|
||||
|
||||
|
|
@ -204,12 +210,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||
struct RuntimeWorkerExecution {
|
||||
handle: WorkerHandle,
|
||||
busy: Arc<AtomicBool>,
|
||||
execution_workspace: Option<ExecutionWorkspaceBinding>,
|
||||
}
|
||||
|
||||
/// `worker-runtime` execution backend backed by real `worker` crate Workers.
|
||||
pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
|
||||
backend_id: String,
|
||||
factory: Arc<F>,
|
||||
execution_workspace_materializer: Option<Arc<dyn ExecutionWorkspaceMaterializer>>,
|
||||
runtime: Mutex<Option<Runtime>>,
|
||||
workers: Mutex<HashMap<crate::identity::WorkerRef, RuntimeWorkerExecution>>,
|
||||
}
|
||||
|
|
@ -233,6 +241,7 @@ where
|
|||
Ok(Self {
|
||||
backend_id: DEFAULT_BACKEND_ID.to_string(),
|
||||
factory: Arc::new(factory),
|
||||
execution_workspace_materializer: None,
|
||||
runtime: Mutex::new(Some(runtime)),
|
||||
workers: Mutex::new(HashMap::new()),
|
||||
})
|
||||
|
|
@ -243,6 +252,14 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_execution_workspace_materializer(
|
||||
mut self,
|
||||
materializer: impl ExecutionWorkspaceMaterializer,
|
||||
) -> Self {
|
||||
self.execution_workspace_materializer = Some(Arc::new(materializer));
|
||||
self
|
||||
}
|
||||
|
||||
fn wait_for_runtime_task<T>(receiver: mpsc::Receiver<Result<T, String>>) -> Result<T, String> {
|
||||
receiver
|
||||
.recv_timeout(RUNTIME_TASK_TIMEOUT)
|
||||
|
|
@ -356,6 +373,33 @@ where
|
|||
));
|
||||
}
|
||||
|
||||
let mut request = request;
|
||||
let execution_workspace = match request.request.execution_workspace.as_ref() {
|
||||
Some(workspace_request) => {
|
||||
let Some(materializer) = self.execution_workspace_materializer.as_ref() else {
|
||||
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Spawn,
|
||||
"execution workspace materialization requested, but no materializer is configured for this runtime backend",
|
||||
));
|
||||
};
|
||||
match materializer.materialize(&request.worker_ref, workspace_request) {
|
||||
Ok(binding) => {
|
||||
request.execution_workspace = Some(binding.clone());
|
||||
Some(binding)
|
||||
}
|
||||
Err(error) => {
|
||||
return WorkerExecutionSpawnResult::Rejected(
|
||||
WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Spawn,
|
||||
error.to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let factory = self.factory.clone();
|
||||
let bridge_context = request.context.clone();
|
||||
let worker_ref = request.worker_ref.clone();
|
||||
|
|
@ -365,6 +409,12 @@ where
|
|||
let handle = match spawn_result {
|
||||
Ok(handle) => handle,
|
||||
Err(message) => {
|
||||
if let (Some(materializer), Some(binding)) = (
|
||||
self.execution_workspace_materializer.as_ref(),
|
||||
execution_workspace.as_ref(),
|
||||
) {
|
||||
let _ = materializer.cleanup(binding);
|
||||
}
|
||||
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
|
||||
WorkerExecutionOperation::Spawn,
|
||||
message,
|
||||
|
|
@ -405,11 +455,19 @@ where
|
|||
));
|
||||
}
|
||||
};
|
||||
workers.insert(worker_ref.clone(), RuntimeWorkerExecution { handle, busy });
|
||||
workers.insert(
|
||||
worker_ref.clone(),
|
||||
RuntimeWorkerExecution {
|
||||
handle,
|
||||
busy,
|
||||
execution_workspace: execution_workspace.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
WorkerExecutionSpawnResult::Connected {
|
||||
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
|
||||
run_state: WorkerExecutionRunState::Idle,
|
||||
execution_workspace: execution_workspace.map(|binding| binding.status()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -468,19 +526,44 @@ where
|
|||
}
|
||||
|
||||
fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||
let (worker, _busy) = match self.get_execution(handle) {
|
||||
Ok(execution) => execution,
|
||||
Err(mut result) => {
|
||||
result.operation = WorkerExecutionOperation::Stop;
|
||||
return result;
|
||||
if handle.backend_id() != self.backend_id() {
|
||||
return WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Stop,
|
||||
format!(
|
||||
"execution handle belongs to backend {}, not {}",
|
||||
handle.backend_id(),
|
||||
self.backend_id()
|
||||
),
|
||||
);
|
||||
}
|
||||
let execution = match self.workers.lock() {
|
||||
Ok(mut workers) => workers.remove(handle.worker_ref()),
|
||||
Err(_) => {
|
||||
return WorkerExecutionResult::errored(
|
||||
WorkerExecutionOperation::Stop,
|
||||
"worker adapter registry lock is poisoned",
|
||||
);
|
||||
}
|
||||
};
|
||||
self.send_method(
|
||||
let Some(execution) = execution else {
|
||||
return WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Stop,
|
||||
worker,
|
||||
"execution handle does not reference a live Worker",
|
||||
);
|
||||
};
|
||||
let result = self.send_method(
|
||||
WorkerExecutionOperation::Stop,
|
||||
execution.handle,
|
||||
Method::Shutdown,
|
||||
WorkerExecutionRunState::Stopped,
|
||||
)
|
||||
);
|
||||
if let (Some(materializer), Some(binding)) = (
|
||||
self.execution_workspace_materializer.as_ref(),
|
||||
execution.execution_workspace.as_ref(),
|
||||
) {
|
||||
let _ = materializer.cleanup(binding);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||
|
|
@ -503,11 +586,17 @@ where
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::pin::Pin;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use crate::Runtime as EmbeddedRuntime;
|
||||
use crate::catalog::{ConfigBundleRef, CreateWorkerRequest, ProfileSelector};
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, ExecutionWorkspaceRepository,
|
||||
ExecutionWorkspaceRequest, MaterializerKind, ProfileSelector, RepositorySelector,
|
||||
};
|
||||
use crate::execution_workspace::LocalGitWorktreeMaterializer;
|
||||
use crate::identity::RuntimeId;
|
||||
use crate::management::RuntimeOptions;
|
||||
use crate::observation::{TranscriptQuery, TranscriptRole};
|
||||
|
|
@ -559,13 +648,14 @@ mod tests {
|
|||
cwd: PathBuf,
|
||||
store_dir: PathBuf,
|
||||
worker_metadata_dir: PathBuf,
|
||||
observed_cwds: Arc<Mutex<Vec<PathBuf>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RuntimeWorkerFactory for MockFactory {
|
||||
async fn spawn_controller(
|
||||
&self,
|
||||
_request: WorkerExecutionSpawnRequest,
|
||||
request: WorkerExecutionSpawnRequest,
|
||||
) -> Result<WorkerHandle, String> {
|
||||
let manifest = WorkerManifest::from_toml(
|
||||
r#"
|
||||
|
|
@ -590,12 +680,18 @@ mod tests {
|
|||
FsStore::new(&self.store_dir).map_err(|err| err.to_string())?,
|
||||
FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?,
|
||||
);
|
||||
let scope = Scope::writable(&self.cwd).map_err(|err| err.to_string())?;
|
||||
let cwd = request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.cwd().to_path_buf())
|
||||
.unwrap_or_else(|| self.cwd.clone());
|
||||
self.observed_cwds.lock().unwrap().push(cwd.clone());
|
||||
let scope = Scope::writable(&cwd).map_err(|err| err.to_string())?;
|
||||
let worker = Worker::new(
|
||||
manifest,
|
||||
Engine::new(self.client.clone()),
|
||||
store,
|
||||
self.cwd.clone(),
|
||||
cwd,
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
|
|
@ -650,6 +746,45 @@ mod tests {
|
|||
digest: bundle.metadata.digest,
|
||||
},
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn git(path: &std::path::Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(args)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "git {:?} failed", args);
|
||||
}
|
||||
|
||||
fn create_clean_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
git(dir.path(), &["init"]);
|
||||
git(
|
||||
dir.path(),
|
||||
&["config", "user.email", "test@example.invalid"],
|
||||
);
|
||||
git(dir.path(), &["config", "user.name", "Yoi Test"]);
|
||||
fs::write(dir.path().join("README.md"), "clean\n").unwrap();
|
||||
git(dir.path(), &["add", "README.md"]);
|
||||
git(dir.path(), &["commit", "-m", "init"]);
|
||||
dir
|
||||
}
|
||||
|
||||
fn execution_workspace_request(repo: &std::path::Path) -> ExecutionWorkspaceRequest {
|
||||
ExecutionWorkspaceRequest {
|
||||
repository: ExecutionWorkspaceRepository {
|
||||
id: "repo-main".to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: ".".to_string(),
|
||||
local_path: Some(repo.to_path_buf()),
|
||||
selector: Some(RepositorySelector::from("HEAD")),
|
||||
},
|
||||
materializer: MaterializerKind::LocalGitWorktree,
|
||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -677,12 +812,14 @@ mod tests {
|
|||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let cwd = tempfile::tempdir().unwrap();
|
||||
let store = tempfile::tempdir().unwrap();
|
||||
let observed_cwds = Arc::new(Mutex::new(Vec::new()));
|
||||
let factory = MockFactory {
|
||||
client: client.clone(),
|
||||
runtime_base: runtime_base.path().to_path_buf(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
store_dir: store.path().join("sessions"),
|
||||
worker_metadata_dir: store.path().join("workers"),
|
||||
observed_cwds,
|
||||
};
|
||||
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
|
||||
let runtime = EmbeddedRuntime::with_execution_backend(
|
||||
|
|
@ -730,4 +867,47 @@ mod tests {
|
|||
.any(|event| matches!(event.payload, protocol::Event::TextDone { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_spawn_receives_materialized_workspace_cwd_instead_of_source_repo() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let repo = create_clean_repo();
|
||||
let store = tempfile::tempdir().unwrap();
|
||||
let observed_cwds = Arc::new(Mutex::new(Vec::new()));
|
||||
let factory = MockFactory {
|
||||
client,
|
||||
runtime_base: runtime_base.path().to_path_buf(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
store_dir: store.path().join("sessions"),
|
||||
worker_metadata_dir: store.path().join("workers"),
|
||||
observed_cwds: observed_cwds.clone(),
|
||||
};
|
||||
let backend = WorkerRuntimeExecutionBackend::new(factory)
|
||||
.unwrap()
|
||||
.with_execution_workspace_materializer(LocalGitWorktreeMaterializer::new(
|
||||
runtime_base.path(),
|
||||
));
|
||||
let runtime = EmbeddedRuntime::with_execution_backend(
|
||||
RuntimeOptions {
|
||||
runtime_id: RuntimeId::new("embedded-materialized"),
|
||||
..RuntimeOptions::default()
|
||||
},
|
||||
Arc::new(backend),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let mut request = create_request("chat");
|
||||
request.execution_workspace = Some(execution_workspace_request(repo.path()));
|
||||
|
||||
let detail = runtime.create_worker(request).unwrap();
|
||||
|
||||
assert!(detail.execution.execution_workspace.is_some());
|
||||
let cwds = observed_cwds.lock().unwrap();
|
||||
assert_eq!(cwds.len(), 1);
|
||||
let cwd = &cwds[0];
|
||||
assert!(cwd.starts_with(runtime_base.path()));
|
||||
assert!(!cwd.starts_with(repo.path()));
|
||||
assert!(cwd.join("README.md").exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -387,6 +387,8 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
|
|||
},
|
||||
profile: Some(selector),
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
resolved_execution_workspace: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -575,6 +577,10 @@ mod tests {
|
|||
"deterministic-companion-test",
|
||||
),
|
||||
run_state: WorkerExecutionRunState::Idle,
|
||||
execution_workspace: request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.status()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use std::{
|
|||
time::Duration,
|
||||
};
|
||||
use worker_runtime::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail as EmbeddedWorkerDetail,
|
||||
WorkerStatus as EmbeddedWorkerStatus,
|
||||
ConfigBundleRef, CreateWorkerRequest, ExecutionWorkspaceRequest, ProfileSelector,
|
||||
WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus,
|
||||
};
|
||||
use worker_runtime::config_bundle::{
|
||||
ConfigBundle, ConfigBundleAvailability, ConfigBundleMetadata, ConfigBundleProvenance,
|
||||
|
|
@ -263,13 +263,26 @@ pub struct WorkerLookupResult {
|
|||
/// Browser-safe worker spawn request shape.
|
||||
///
|
||||
/// The request carries Browser-facing launch semantics only: workspace intent,
|
||||
/// optional display identity, acceptance policy, optional profile selector, and
|
||||
/// optional initial input. Runtime execution authority is resolved by the host
|
||||
/// optional display identity, acceptance policy, optional profile selector,
|
||||
/// optional initial input, and optional configured Repository selector for
|
||||
/// execution-workspace materialization. Runtime execution authority is resolved by the host
|
||||
/// into a synced ConfigBundle before the canonical Runtime create request is
|
||||
/// built. Raw workspace roots, child cwd, executable paths, tool scope,
|
||||
/// credentials, raw config stores, sockets, sessions, and storage paths are not
|
||||
/// accepted from Workspace API callers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerSpawnExecutionWorkspaceRequest {
|
||||
/// Safe configured Repository id. The host resolves this id to repository
|
||||
/// authority from server-side config; browser callers cannot provide raw
|
||||
/// source paths or runtime-internal storage paths.
|
||||
pub repository_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerSpawnRequest {
|
||||
pub intent: WorkerSpawnIntent,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -279,6 +292,13 @@ pub struct WorkerSpawnRequest {
|
|||
pub profile: Option<ProfileSelector>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub initial_input: Option<EmbeddedWorkerInput>,
|
||||
/// Optional safe execution-workspace selector. The Workspace server resolves
|
||||
/// this into a runtime-internal `ExecutionWorkspaceRequest` from configured
|
||||
/// repositories before calling a host.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub execution_workspace: Option<WorkerSpawnExecutionWorkspaceRequest>,
|
||||
#[serde(skip, default)]
|
||||
pub resolved_execution_workspace: Option<ExecutionWorkspaceRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -1321,6 +1341,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||
profile,
|
||||
config_bundle,
|
||||
initial_input: request.initial_input.clone(),
|
||||
execution_workspace: request.resolved_execution_workspace.clone(),
|
||||
};
|
||||
match self.runtime.create_worker(create_request) {
|
||||
Ok(detail) => {
|
||||
|
|
@ -1993,6 +2014,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||
profile,
|
||||
config_bundle,
|
||||
initial_input: request.initial_input.clone(),
|
||||
execution_workspace: request.resolved_execution_workspace.clone(),
|
||||
};
|
||||
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
|
||||
Ok(response) => WorkerSpawnResult {
|
||||
|
|
@ -2916,6 +2938,10 @@ mod tests {
|
|||
self.backend_id(),
|
||||
),
|
||||
run_state: WorkerExecutionRunState::Idle,
|
||||
execution_workspace: request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.status()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3144,6 +3170,8 @@ mod tests {
|
|||
},
|
||||
profile: None,
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
resolved_execution_workspace: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3273,6 +3301,8 @@ mod tests {
|
|||
},
|
||||
profile: None,
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
resolved_execution_workspace: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -3372,6 +3402,8 @@ mod tests {
|
|||
},
|
||||
profile: Some(ProfileSelector::Builtin("builtin:coder".to_string())),
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
resolved_execution_workspace: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -3400,6 +3432,8 @@ mod tests {
|
|||
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
|
||||
profile: None,
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
resolved_execution_workspace: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use chrono::{SecondsFormat, Utc};
|
|||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::TcpListener;
|
||||
use worker_runtime::execution_workspace::LocalGitWorktreeMaterializer;
|
||||
use worker_runtime::worker_backend::WorkerRuntimeExecutionBackend;
|
||||
|
||||
use crate::companion::{
|
||||
|
|
@ -24,8 +25,8 @@ use crate::hosts::{
|
|||
HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry,
|
||||
RuntimeRegistryUnregisterResult, RuntimeSummary, WorkerInputRequest, WorkerInputResult,
|
||||
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState,
|
||||
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
|
||||
WorkerSummary, WorkerTranscriptProjection,
|
||||
WorkerSpawnAcceptanceRequirement, WorkerSpawnExecutionWorkspaceRequest, WorkerSpawnIntent,
|
||||
WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary, WorkerTranscriptProjection,
|
||||
};
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::observation::{
|
||||
|
|
@ -41,7 +42,10 @@ use crate::repositories::{
|
|||
};
|
||||
use crate::store::{ControlPlaneStore, WorkspaceRecord};
|
||||
use crate::{Error, Result};
|
||||
use worker_runtime::catalog::{ConfigBundleRef, ProfileSelector};
|
||||
use worker_runtime::catalog::{
|
||||
ConfigBundleRef, DirtyStatePolicy, ExecutionWorkspaceRepository, ExecutionWorkspaceRequest,
|
||||
MaterializerKind, ProfileSelector, RepositorySelector as RuntimeRepositorySelector,
|
||||
};
|
||||
use worker_runtime::config_bundle::ConfigBundle;
|
||||
use worker_runtime::http_server::{
|
||||
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundlesResponse,
|
||||
|
|
@ -151,14 +155,16 @@ pub struct WorkspaceApi {
|
|||
|
||||
impl WorkspaceApi {
|
||||
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
|
||||
let execution_backend = WorkerRuntimeExecutionBackend::from_workspace(
|
||||
config.workspace_root.clone(),
|
||||
)
|
||||
let execution_backend =
|
||||
WorkerRuntimeExecutionBackend::from_workspace(config.workspace_root.clone())
|
||||
.map_err(|err| {
|
||||
crate::Error::Store(format!(
|
||||
"failed to initialize embedded Worker backend: {err}"
|
||||
))
|
||||
})?;
|
||||
})?
|
||||
.with_execution_workspace_materializer(LocalGitWorktreeMaterializer::new(
|
||||
config.embedded_runtime_store_root.clone(),
|
||||
));
|
||||
Self::new_with_execution_backend(config, store, Arc::new(execution_backend)).await
|
||||
}
|
||||
|
||||
|
|
@ -442,6 +448,8 @@ pub struct BrowserCreateWorkerRequest {
|
|||
pub display_name: String,
|
||||
pub profile: String,
|
||||
pub initial_text: String,
|
||||
#[serde(default)]
|
||||
pub repository_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
|
@ -909,6 +917,80 @@ async fn get_worker_launch_options(
|
|||
Ok(Json(worker_launch_options_response(&api)))
|
||||
}
|
||||
|
||||
fn execution_workspace_request_from_repository(
|
||||
repository: &ConfiguredRepository,
|
||||
selector: Option<&str>,
|
||||
) -> ExecutionWorkspaceRequest {
|
||||
ExecutionWorkspaceRequest {
|
||||
repository: ExecutionWorkspaceRepository {
|
||||
id: repository.id.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
uri: repository.uri.clone(),
|
||||
local_path: Some(repository.path.clone()),
|
||||
selector: selector
|
||||
.map(|selector| RuntimeRepositorySelector::from(selector.to_string()))
|
||||
.or_else(|| {
|
||||
repository
|
||||
.default_selector
|
||||
.clone()
|
||||
.map(RuntimeRepositorySelector)
|
||||
})
|
||||
.or_else(|| Some(RuntimeRepositorySelector::from("HEAD"))),
|
||||
},
|
||||
materializer: MaterializerKind::LocalGitWorktree,
|
||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_execution_workspace_request(
|
||||
config: &ServerConfig,
|
||||
request: &WorkerSpawnExecutionWorkspaceRequest,
|
||||
) -> Result<ExecutionWorkspaceRequest> {
|
||||
let repository = config
|
||||
.repositories
|
||||
.iter()
|
||||
.find(|repository| repository.id == request.repository_id)
|
||||
.ok_or_else(|| {
|
||||
Error::Config(format!(
|
||||
"unknown repository id `{}` for Worker execution workspace",
|
||||
request.repository_id
|
||||
))
|
||||
})?;
|
||||
Ok(execution_workspace_request_from_repository(
|
||||
repository,
|
||||
request.selector.as_deref(),
|
||||
))
|
||||
}
|
||||
|
||||
fn default_execution_workspace_request(
|
||||
config: &ServerConfig,
|
||||
repository_id: Option<&str>,
|
||||
) -> Result<Option<ExecutionWorkspaceRequest>> {
|
||||
let repository = match repository_id {
|
||||
Some(id) => config
|
||||
.repositories
|
||||
.iter()
|
||||
.find(|repository| repository.id == id)
|
||||
.ok_or_else(|| {
|
||||
Error::Config(format!(
|
||||
"unknown repository id `{id}` for Worker execution workspace"
|
||||
))
|
||||
})?,
|
||||
None => match config.repositories.as_slice() {
|
||||
[] => return Ok(None),
|
||||
[repository] => repository,
|
||||
repositories => repositories
|
||||
.iter()
|
||||
.find(|repository| repository.id == "main")
|
||||
.unwrap_or(&repositories[0]),
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Some(execution_workspace_request_from_repository(
|
||||
repository, None,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn create_workspace_worker(
|
||||
State(api): State<WorkspaceApi>,
|
||||
Json(request): Json<BrowserCreateWorkerRequest>,
|
||||
|
|
@ -934,6 +1016,8 @@ async fn create_workspace_worker(
|
|||
content: initial_text,
|
||||
})
|
||||
};
|
||||
let execution_workspace =
|
||||
default_execution_workspace_request(&api.config, request.repository_id.as_deref())?;
|
||||
let result = api
|
||||
.runtime
|
||||
.spawn_worker(
|
||||
|
|
@ -946,6 +1030,8 @@ async fn create_workspace_worker(
|
|||
},
|
||||
profile: Some(profile_selector),
|
||||
initial_input,
|
||||
execution_workspace: None,
|
||||
resolved_execution_workspace: execution_workspace,
|
||||
},
|
||||
)
|
||||
.map_err(|err| err.into_error())?;
|
||||
|
|
@ -1030,8 +1116,15 @@ struct RuntimeConfigBundleAvailabilityQuery {
|
|||
async fn create_runtime_worker(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(runtime_id): AxumPath<String>,
|
||||
Json(request): Json<WorkerSpawnRequest>,
|
||||
Json(mut request): Json<WorkerSpawnRequest>,
|
||||
) -> ApiResult<Json<WorkerSpawnResult>> {
|
||||
request.resolved_execution_workspace = request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|execution_workspace| {
|
||||
configured_execution_workspace_request(&api.config, execution_workspace)
|
||||
})
|
||||
.transpose()?;
|
||||
let result = api
|
||||
.runtime
|
||||
.spawn_worker(&runtime_id, request)
|
||||
|
|
@ -2415,6 +2508,10 @@ mod tests {
|
|||
self.backend_id(),
|
||||
),
|
||||
run_state: worker_runtime::execution::WorkerExecutionRunState::Idle,
|
||||
execution_workspace: request
|
||||
.execution_workspace
|
||||
.as_ref()
|
||||
.map(|binding| binding.status()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2511,6 +2608,7 @@ mod tests {
|
|||
digest: bundle.metadata.digest,
|
||||
},
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2838,6 +2936,71 @@ mod tests {
|
|||
assert!(!projected.contains("http://"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_worker_spawn_rejects_raw_execution_workspace_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let response = request_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/runtimes/embedded-worker-runtime/workers",
|
||||
Some(serde_json::json!({
|
||||
"intent": {
|
||||
"kind": "ticket_role",
|
||||
"ticket_id": "00001KVZSGT0Q",
|
||||
"role": "coder"
|
||||
},
|
||||
"acceptance": {
|
||||
"kind": "run_accepted",
|
||||
"expected_segments": 0
|
||||
},
|
||||
"execution_workspace": {
|
||||
"repository_id": TEST_REPOSITORY_ID,
|
||||
"local_path": dir.path().display().to_string()
|
||||
}
|
||||
})),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("unknown field"),
|
||||
"raw execution workspace field should be rejected: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_worker_spawn_accepts_safe_repository_selector() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let response = request_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/runtimes/embedded-worker-runtime/workers",
|
||||
Some(serde_json::json!({
|
||||
"intent": {
|
||||
"kind": "ticket_role",
|
||||
"ticket_id": "00001KVZSGT0Q",
|
||||
"role": "coder"
|
||||
},
|
||||
"acceptance": {
|
||||
"kind": "run_accepted",
|
||||
"expected_segments": 0
|
||||
},
|
||||
"execution_workspace": {
|
||||
"repository_id": TEST_REPOSITORY_ID,
|
||||
"selector": "HEAD"
|
||||
}
|
||||
})),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let projected = serde_json::to_string(&response).unwrap();
|
||||
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_worker_create_rejects_extra_request_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -3250,6 +3413,8 @@ mod tests {
|
|||
},
|
||||
profile: None,
|
||||
initial_input: None,
|
||||
execution_workspace: None,
|
||||
resolved_execution_workspace: None,
|
||||
},
|
||||
)
|
||||
.expect("spawn worker");
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user