fix: complete multi-workdir isolation and launch UI
This commit is contained in:
@@ -52,7 +52,9 @@ impl Tool for EditTool {
|
|||||||
params.target_workdir.as_deref(),
|
params.target_workdir.as_deref(),
|
||||||
workdir::WorkdirSessionCapability::Edit,
|
workdir::WorkdirSessionCapability::Edit,
|
||||||
)?;
|
)?;
|
||||||
let tracker = self.tracker.scoped(selected.alias.as_str());
|
let tracker = self
|
||||||
|
.tracker
|
||||||
|
.scoped_attachment(&selected.alias, selected.generation);
|
||||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||||
tracing::debug!(path = %path, replace_all = params.replace_all, "Edit");
|
tracing::debug!(path = %path, replace_all = params.replace_all, "Edit");
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ impl Tool for ReadTool {
|
|||||||
params.target_workdir.as_deref(),
|
params.target_workdir.as_deref(),
|
||||||
workdir::WorkdirSessionCapability::Read,
|
workdir::WorkdirSessionCapability::Read,
|
||||||
)?;
|
)?;
|
||||||
let tracker = self.tracker.scoped(selected.alias.as_str());
|
let tracker = self
|
||||||
|
.tracker
|
||||||
|
.scoped_attachment(&selected.alias, selected.generation);
|
||||||
let offset = params.offset.unwrap_or(0);
|
let offset = params.offset.unwrap_or(0);
|
||||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||||
|
|
||||||
@@ -216,7 +218,7 @@ mod tests {
|
|||||||
// History recorded
|
// History recorded
|
||||||
assert!(
|
assert!(
|
||||||
tracker
|
tracker
|
||||||
.scoped("workdir")
|
.scoped_attachment(&workdir::WorkdirAttachmentAlias::new("workdir").unwrap(), 0,)
|
||||||
.expected_workdir_hash(&WorkdirPath::new("a.txt").unwrap())
|
.expected_workdir_hash(&WorkdirPath::new("a.txt").unwrap())
|
||||||
.is_ok()
|
.is_ok()
|
||||||
);
|
);
|
||||||
@@ -247,7 +249,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_tool_missing_file() {
|
async fn read_tool_missing_file() {
|
||||||
let (dir, fs, tracker) = setup();
|
let (_dir, fs, tracker) = setup();
|
||||||
let def = read_tool(fs, tracker);
|
let def = read_tool(fs, tracker);
|
||||||
let (_, tool) = def();
|
let (_, tool) = def();
|
||||||
let input = serde_json::json!({
|
let input = serde_json::json!({
|
||||||
|
|||||||
@@ -161,8 +161,9 @@ impl Tracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return a view whose read-before-mutation state is isolated to one
|
/// Return a view whose read-before-mutation state is isolated to one
|
||||||
/// Worker-local attachment alias while aggregate recency/change statistics
|
/// attachment incarnation (the Worker-local alias plus router generation),
|
||||||
/// remain shared with the Worker tracker.
|
/// while aggregate recency/change statistics remain shared with the Worker
|
||||||
|
/// tracker.
|
||||||
pub fn scoped(&self, namespace: impl Into<String>) -> Self {
|
pub fn scoped(&self, namespace: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: self.inner.clone(),
|
inner: self.inner.clone(),
|
||||||
@@ -171,6 +172,14 @@ impl Tracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scoped_attachment(
|
||||||
|
&self,
|
||||||
|
alias: &workdir::WorkdirAttachmentAlias,
|
||||||
|
generation: u64,
|
||||||
|
) -> Self {
|
||||||
|
self.scoped(format!("{}#{generation}", alias.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
fn key(&self, path: PathBuf) -> TrackedPath {
|
fn key(&self, path: PathBuf) -> TrackedPath {
|
||||||
TrackedPath {
|
TrackedPath {
|
||||||
namespace: self.namespace.clone(),
|
namespace: self.namespace.clone(),
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ impl Tool for WriteTool {
|
|||||||
params.target_workdir.as_deref(),
|
params.target_workdir.as_deref(),
|
||||||
workdir::WorkdirSessionCapability::Write,
|
workdir::WorkdirSessionCapability::Write,
|
||||||
)?;
|
)?;
|
||||||
let tracker = self.tracker.scoped(selected.alias.as_str());
|
let tracker = self
|
||||||
|
.tracker
|
||||||
|
.scoped_attachment(&selected.alias, selected.generation);
|
||||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||||
tracing::debug!(path = %path, bytes = params.content.len(), "Write");
|
tracing::debug!(path = %path, bytes = params.content.len(), "Write");
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,66 @@ async fn routed_tools_observe_detach_after_registration() {
|
|||||||
router.close_all().await.unwrap();
|
router.close_all().await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
||||||
|
let first = TempDir::new().unwrap();
|
||||||
|
let second = TempDir::new().unwrap();
|
||||||
|
let spill = TempDir::new().unwrap();
|
||||||
|
std::fs::write(first.path().join("same.txt"), "same bytes\n").unwrap();
|
||||||
|
std::fs::write(second.path().join("same.txt"), "same bytes\n").unwrap();
|
||||||
|
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
alias.clone(),
|
||||||
|
Arc::new(LocalWorkdirSession::new(
|
||||||
|
scope_with_spill(first.path(), spill.path()),
|
||||||
|
first.path().to_path_buf(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let reg = Registry::new(routed_builtin_tools(
|
||||||
|
router.clone(),
|
||||||
|
Tracker::new(),
|
||||||
|
spill.path().to_path_buf(),
|
||||||
|
));
|
||||||
|
call(
|
||||||
|
®.get("Read"),
|
||||||
|
json!({ "target_workdir": "checkout", "file_path": "same.txt" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
router.detach(&alias).await.unwrap();
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
alias.clone(),
|
||||||
|
Arc::new(LocalWorkdirSession::new(
|
||||||
|
scope_with_spill(second.path(), spill.path()),
|
||||||
|
second.path().to_path_buf(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = call_err(
|
||||||
|
®.get("Edit"),
|
||||||
|
json!({
|
||||||
|
"target_workdir": "checkout",
|
||||||
|
"file_path": "same.txt",
|
||||||
|
"old_string": "same",
|
||||||
|
"new_string": "changed"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(error.to_string().contains("has not been read"), "{error}");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(second.path().join("same.txt")).unwrap(),
|
||||||
|
"same bytes\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
router.close_all().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ static EMBEDDED_RUNTIME_REQUEST_IDENTITY: std::sync::LazyLock<
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct WorkdirCommandSession {
|
struct WorkdirCommandSession {
|
||||||
|
attachment_alias: String,
|
||||||
source: WorkdirSessionHandle,
|
source: WorkdirSessionHandle,
|
||||||
provider_handle: CommandHandle,
|
provider_handle: CommandHandle,
|
||||||
}
|
}
|
||||||
@@ -421,6 +422,7 @@ impl WorkdirSessionRegistry {
|
|||||||
fn register_command(
|
fn register_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
worker: RuntimeWorkerRef,
|
worker: RuntimeWorkerRef,
|
||||||
|
attachment_alias: String,
|
||||||
source: WorkdirSessionHandle,
|
source: WorkdirSessionHandle,
|
||||||
provider_handle: CommandHandle,
|
provider_handle: CommandHandle,
|
||||||
) -> CommandHandle {
|
) -> CommandHandle {
|
||||||
@@ -436,6 +438,7 @@ impl WorkdirSessionRegistry {
|
|||||||
self.commands.insert(
|
self.commands.insert(
|
||||||
(worker, external_handle.clone()),
|
(worker, external_handle.clone()),
|
||||||
WorkdirCommandSession {
|
WorkdirCommandSession {
|
||||||
|
attachment_alias,
|
||||||
source,
|
source,
|
||||||
provider_handle,
|
provider_handle,
|
||||||
},
|
},
|
||||||
@@ -446,13 +449,42 @@ impl WorkdirSessionRegistry {
|
|||||||
fn command(
|
fn command(
|
||||||
&self,
|
&self,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
|
attachment_alias: &str,
|
||||||
external_handle: &CommandHandle,
|
external_handle: &CommandHandle,
|
||||||
) -> Option<WorkdirCommandSession> {
|
) -> Option<WorkdirCommandSession> {
|
||||||
self.commands
|
self.commands
|
||||||
.get(&(worker.clone(), external_handle.clone()))
|
.get(&(worker.clone(), external_handle.clone()))
|
||||||
|
.filter(|session| session.attachment_alias == attachment_alias)
|
||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn take_worker_commands_for_alias(
|
||||||
|
&mut self,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
attachment_alias: &str,
|
||||||
|
) -> Vec<RegisteredWorkdirSession> {
|
||||||
|
let command_handles = self
|
||||||
|
.commands
|
||||||
|
.iter()
|
||||||
|
.filter(|((owner, _), session)| {
|
||||||
|
owner == worker && session.attachment_alias == attachment_alias
|
||||||
|
})
|
||||||
|
.map(|((_, handle), _)| handle.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
command_handles
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|external_handle| {
|
||||||
|
self.commands
|
||||||
|
.remove(&(worker.clone(), external_handle.clone()))
|
||||||
|
.map(|session| RegisteredWorkdirSession::Command {
|
||||||
|
worker: worker.clone(),
|
||||||
|
external_handle,
|
||||||
|
session,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn take_worker(&mut self, worker: &RuntimeWorkerRef) -> Vec<RegisteredWorkdirSession> {
|
fn take_worker(&mut self, worker: &RuntimeWorkerRef) -> Vec<RegisteredWorkdirSession> {
|
||||||
let mut sessions = Vec::new();
|
let mut sessions = Vec::new();
|
||||||
if let Some(session) = self.attachments.remove(worker) {
|
if let Some(session) = self.attachments.remove(worker) {
|
||||||
@@ -498,14 +530,10 @@ impl WorkdirSessionRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn close_worker_workdir_sessions(
|
async fn close_registered_workdir_sessions(
|
||||||
registry: &Arc<Mutex<WorkdirSessionRegistry>>,
|
registry: &Arc<Mutex<WorkdirSessionRegistry>>,
|
||||||
worker: &RuntimeWorkerRef,
|
registered: Vec<RegisteredWorkdirSession>,
|
||||||
) -> std::result::Result<(), String> {
|
) -> std::result::Result<(), String> {
|
||||||
let registered = registry
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| "Workdir session registry was poisoned".to_string())?
|
|
||||||
.take_worker(worker);
|
|
||||||
let mut failed = Vec::new();
|
let mut failed = Vec::new();
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for item in registered {
|
for item in registered {
|
||||||
@@ -533,6 +561,29 @@ async fn close_worker_workdir_sessions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn close_worker_workdir_sessions(
|
||||||
|
registry: &Arc<Mutex<WorkdirSessionRegistry>>,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
) -> std::result::Result<(), String> {
|
||||||
|
let registered = registry
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Workdir session registry was poisoned".to_string())?
|
||||||
|
.take_worker(worker);
|
||||||
|
close_registered_workdir_sessions(registry, registered).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close_worker_workdir_command_sessions_for_alias(
|
||||||
|
registry: &Arc<Mutex<WorkdirSessionRegistry>>,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
attachment_alias: &str,
|
||||||
|
) -> std::result::Result<(), String> {
|
||||||
|
let registered = registry
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Workdir session registry was poisoned".to_string())?
|
||||||
|
.take_worker_commands_for_alias(worker, attachment_alias);
|
||||||
|
close_registered_workdir_sessions(registry, registered).await
|
||||||
|
}
|
||||||
|
|
||||||
const ATTACHMENT_UPLOAD_GRANT_TTL_MS: u64 = 5 * 60 * 1_000;
|
const ATTACHMENT_UPLOAD_GRANT_TTL_MS: u64 = 5 * 60 * 1_000;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -8529,6 +8580,20 @@ async fn close_current_worker_attachment_session_locked(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn close_current_worker_command_sessions_for_alias_locked(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
attachment_alias: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
close_worker_workdir_command_sessions_for_alias(&api.workdir_sessions, worker, attachment_alias)
|
||||||
|
.await
|
||||||
|
.map_err(|message| Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: worker.runtime_id.clone(),
|
||||||
|
code: "workdir_session_close_failed".to_string(),
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn close_current_worker_session_locked(
|
async fn close_current_worker_session_locked(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
@@ -8583,7 +8648,7 @@ async fn refresh_current_worker_session_locked(
|
|||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
close_current_worker_session_locked(api, worker).await?;
|
close_current_worker_attachment_session_locked(api, worker).await?;
|
||||||
let remaining = api
|
let remaining = api
|
||||||
.store
|
.store
|
||||||
.list_worker_workdir_links(&api.config.workspace_id, worker)?
|
.list_worker_workdir_links(&api.config.workspace_id, worker)?
|
||||||
@@ -8653,7 +8718,7 @@ async fn scoped_attach_current_worker_workdir(
|
|||||||
attached: true,
|
attached: true,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
close_current_worker_session_locked(&api, &worker).await?;
|
close_current_worker_attachment_session_locked(&api, &worker).await?;
|
||||||
let link = api.store.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
let link = api.store.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
@@ -8734,7 +8799,8 @@ async fn scoped_detach_current_worker_workdir(
|
|||||||
worker.runtime_id, worker.worker_id
|
worker.runtime_id, worker.worker_id
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
close_current_worker_session_locked(&api, &worker).await?;
|
close_current_worker_attachment_session_locked(&api, &worker).await?;
|
||||||
|
close_current_worker_command_sessions_for_alias_locked(&api, &worker, alias.as_str()).await?;
|
||||||
api.store.detach_worker_workdir(
|
api.store.detach_worker_workdir(
|
||||||
&api.config.workspace_id,
|
&api.config.workspace_id,
|
||||||
&worker,
|
&worker,
|
||||||
@@ -8903,7 +8969,12 @@ async fn scoped_execute_current_worker_workdir_operation(
|
|||||||
.workdir_sessions
|
.workdir_sessions
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Workdir session registry lock poisoned")
|
.expect("Workdir session registry lock poisoned")
|
||||||
.register_command(worker.clone(), registered_source, provider_handle);
|
.register_command(
|
||||||
|
worker.clone(),
|
||||||
|
target_workdir.clone(),
|
||||||
|
registered_source,
|
||||||
|
provider_handle,
|
||||||
|
);
|
||||||
WorkdirSessionOperationResult::CommandStart(external_handle)
|
WorkdirSessionOperationResult::CommandStart(external_handle)
|
||||||
}
|
}
|
||||||
WorkdirSessionOperation::CommandStatus(external_handle) => {
|
WorkdirSessionOperation::CommandStatus(external_handle) => {
|
||||||
@@ -8966,7 +9037,7 @@ fn current_worker_command_session(
|
|||||||
.workdir_sessions
|
.workdir_sessions
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Workdir session registry lock poisoned")
|
.expect("Workdir session registry lock poisoned")
|
||||||
.command(worker, external_handle)
|
.command(worker, target_workdir, external_handle)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
WorkdirOperationApiError::Provider(current_worker_workdir_operation_error(
|
WorkdirOperationApiError::Provider(current_worker_workdir_operation_error(
|
||||||
worker,
|
worker,
|
||||||
@@ -20100,8 +20171,12 @@ mod tests {
|
|||||||
let mut registry = WorkdirSessionRegistry::default();
|
let mut registry = WorkdirSessionRegistry::default();
|
||||||
registry.insert_attachment(worker.clone(), source.clone());
|
registry.insert_attachment(worker.clone(), source.clone());
|
||||||
let registered_source = registry.remove_attachment(&worker).unwrap();
|
let registered_source = registry.remove_attachment(&worker).unwrap();
|
||||||
let external_handle =
|
let external_handle = registry.register_command(
|
||||||
registry.register_command(worker.clone(), registered_source, provider_handle.clone());
|
worker.clone(),
|
||||||
|
"checkout".to_string(),
|
||||||
|
registered_source,
|
||||||
|
provider_handle.clone(),
|
||||||
|
);
|
||||||
assert_ne!(external_handle, provider_handle);
|
assert_ne!(external_handle, provider_handle);
|
||||||
|
|
||||||
let refreshed: WorkdirSessionHandle = Arc::new(workdir::LocalWorkdirSession::new(
|
let refreshed: WorkdirSessionHandle = Arc::new(workdir::LocalWorkdirSession::new(
|
||||||
@@ -20116,7 +20191,14 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let command = registry.command(&worker, &external_handle).unwrap();
|
let command = registry
|
||||||
|
.command(&worker, "checkout", &external_handle)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.command(&worker, "other", &external_handle)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
let output = command
|
let output = command
|
||||||
.source
|
.source
|
||||||
.command_output(workdir::CommandOutputRequest {
|
.command_output(workdir::CommandOutputRequest {
|
||||||
@@ -20176,6 +20258,73 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attachment_scoped_command_cleanup_preserves_other_aliases() {
|
||||||
|
let left_directory = tempfile::tempdir().unwrap();
|
||||||
|
let right_directory = tempfile::tempdir().unwrap();
|
||||||
|
let worker = RuntimeWorkerRef::new("runtime-command-alias", "worker-command-alias");
|
||||||
|
let make_session = |path: &std::path::Path| -> WorkdirSessionHandle {
|
||||||
|
Arc::new(workdir::LocalWorkdirSession::new(
|
||||||
|
manifest::Scope::writable(path).unwrap(),
|
||||||
|
path.to_path_buf(),
|
||||||
|
))
|
||||||
|
};
|
||||||
|
let left = make_session(left_directory.path());
|
||||||
|
let right = make_session(right_directory.path());
|
||||||
|
let request = || workdir::CommandRequest {
|
||||||
|
command: "sleep 30".to_string(),
|
||||||
|
timeout_secs: 60,
|
||||||
|
output_limit: 4096,
|
||||||
|
cwd: None,
|
||||||
|
spill_dir: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
};
|
||||||
|
let left_provider = left.start_command(request()).await.unwrap();
|
||||||
|
let right_provider = right.start_command(request()).await.unwrap();
|
||||||
|
|
||||||
|
let registry = Arc::new(Mutex::new(WorkdirSessionRegistry::default()));
|
||||||
|
let (left_external, right_external) = {
|
||||||
|
let mut sessions = registry.lock().unwrap();
|
||||||
|
let left_external =
|
||||||
|
sessions.register_command(worker.clone(), "left".to_string(), left, left_provider);
|
||||||
|
let right_external = sessions.register_command(
|
||||||
|
worker.clone(),
|
||||||
|
"right".to_string(),
|
||||||
|
right,
|
||||||
|
right_provider,
|
||||||
|
);
|
||||||
|
assert!(sessions.command(&worker, "right", &left_external).is_none());
|
||||||
|
assert!(sessions.command(&worker, "left", &right_external).is_none());
|
||||||
|
(left_external, right_external)
|
||||||
|
};
|
||||||
|
|
||||||
|
close_worker_workdir_command_sessions_for_alias(®istry, &worker, "right")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let left_command = {
|
||||||
|
let sessions = registry.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
sessions
|
||||||
|
.command(&worker, "right", &right_external)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
sessions.command(&worker, "left", &left_external).unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
left_command
|
||||||
|
.source
|
||||||
|
.command_status(left_command.provider_handle.clone())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
workdir::CommandStatus::Running
|
||||||
|
);
|
||||||
|
|
||||||
|
close_worker_workdir_sessions(®istry, &worker)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
fn seed_test_api_token(store: &dyn ControlPlaneStore, suffix: &str) -> String {
|
fn seed_test_api_token(store: &dyn ControlPlaneStore, suffix: &str) -> String {
|
||||||
let account_id = format!("account-{suffix}");
|
let account_id = format!("account-{suffix}");
|
||||||
let user_id = format!("user-{suffix}");
|
let user_id = format!("user-{suffix}");
|
||||||
|
|||||||
@@ -256,8 +256,10 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||||||
workersNav.includes("worker.display_name || worker.label") &&
|
workersNav.includes("worker.display_name || worker.label") &&
|
||||||
workersNav.includes("worker-status-dot") &&
|
workersNav.includes("worker-status-dot") &&
|
||||||
workersNav.includes("worker-status-spinner") &&
|
workersNav.includes("worker-status-spinner") &&
|
||||||
workersNav.includes("worker.repository_key ?? '—'") &&
|
workersNav.includes("workdirMeta(worker)") &&
|
||||||
workersNav.includes("worker.working_directory_id ?? '—'") &&
|
workersNav.includes("attachment.alias") &&
|
||||||
|
workersNav.includes("attachment.repository_key ?? '—'") &&
|
||||||
|
workersNav.includes("attachment.working_directory_id") &&
|
||||||
!workersNav.includes('aria-disabled="true"'),
|
!workersNav.includes('aria-disabled="true"'),
|
||||||
"Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata",
|
"Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
buildCreateWorkspaceWorkerRequest,
|
buildCreateWorkspaceWorkerRequest,
|
||||||
defaultWorkerLaunchForm,
|
defaultWorkerLaunchForm,
|
||||||
|
workerLaunchAttachmentError,
|
||||||
} from "./worker-launch.ts";
|
} from "./worker-launch.ts";
|
||||||
import type { WorkerLaunchOptionsResponse } from "./types.ts";
|
import type { WorkerLaunchOptionsResponse } from "./types.ts";
|
||||||
|
|
||||||
@@ -8,6 +9,262 @@ declare const Deno: {
|
|||||||
test(name: string, fn: () => Promise<void> | void): void;
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initial Workdir", () => {
|
||||||
|
const form = defaultWorkerLaunchForm(
|
||||||
|
options,
|
||||||
|
emptyForm({ initial_text: "hello" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(form.runtime_id, "remote");
|
||||||
|
assertEquals(form.display_name, "Worker");
|
||||||
|
assertEquals(form.profile, "builtin:coder");
|
||||||
|
assertEquals(form.initial_text, "hello");
|
||||||
|
assertEquals(form.workdir_attachments, [{
|
||||||
|
alias: "workdir",
|
||||||
|
working_directory_id: "wd-1-repo",
|
||||||
|
relative_cwd: "",
|
||||||
|
}]);
|
||||||
|
assertEquals(form.working_directory_repository_key, "repo");
|
||||||
|
assertEquals(form.working_directory_selector, "HEAD");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => {
|
||||||
|
const reviewerOptions = {
|
||||||
|
...options,
|
||||||
|
profiles: [
|
||||||
|
...options.profiles,
|
||||||
|
{ id: "builtin:reviewer", label: "Reviewer", description: "review" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const form = defaultWorkerLaunchForm(
|
||||||
|
reviewerOptions,
|
||||||
|
emptyForm({
|
||||||
|
display_name: "Review worker",
|
||||||
|
profile: "builtin:reviewer",
|
||||||
|
initial_text: "Review the ticket.",
|
||||||
|
working_directory_repository_key: "repo",
|
||||||
|
working_directory_selector: "HEAD",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(form.profile, "builtin:reviewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("defaultWorkerLaunchForm skips occupied Workdirs and leaves an editable attachment", () => {
|
||||||
|
const form = defaultWorkerLaunchForm(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
working_directories: [{
|
||||||
|
...options.working_directories[0],
|
||||||
|
occupied_by: {
|
||||||
|
runtime_id: "embedded",
|
||||||
|
worker_id: "0198f82e-6d90-7f15-a121-174a02e10e77",
|
||||||
|
display_name: "Worker 12",
|
||||||
|
linked_at: "2026-07-24T00:00:00Z",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
emptyForm({ initial_text: "hello" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(form.workdir_attachments, [{
|
||||||
|
alias: "workdir",
|
||||||
|
working_directory_id: "",
|
||||||
|
relative_cwd: "",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => {
|
||||||
|
const form = defaultWorkerLaunchForm(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
repositories: [
|
||||||
|
...options.repositories,
|
||||||
|
{ repository_key: "ticket-repo", default_selector: "main" },
|
||||||
|
],
|
||||||
|
working_directories: [
|
||||||
|
options.working_directories[0],
|
||||||
|
{
|
||||||
|
...options.working_directories[0],
|
||||||
|
working_directory_id: "ticket-workdir",
|
||||||
|
repository_key: "ticket-repo",
|
||||||
|
creation_selector: "work/ticket",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
emptyForm({
|
||||||
|
display_name: "Ticket worker",
|
||||||
|
profile: "builtin:coder",
|
||||||
|
initial_text: "Work on a ticket.",
|
||||||
|
working_directory_repository_key: "ticket-repo",
|
||||||
|
working_directory_selector: "work/ticket",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(form.workdir_attachments, [{
|
||||||
|
alias: "workdir",
|
||||||
|
working_directory_id: "ticket-workdir",
|
||||||
|
relative_cwd: "",
|
||||||
|
}]);
|
||||||
|
assertEquals(form.working_directory_repository_key, "ticket-repo");
|
||||||
|
assertEquals(form.working_directory_selector, "work/ticket");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("defaultWorkerLaunchForm clears attachments for a Workdir-less Runtime", () => {
|
||||||
|
const form = defaultWorkerLaunchForm(
|
||||||
|
options,
|
||||||
|
emptyForm({
|
||||||
|
runtime_id: "embedded",
|
||||||
|
workdir_attachments: [{
|
||||||
|
alias: "checkout",
|
||||||
|
working_directory_id: "wd-1-repo",
|
||||||
|
relative_cwd: "src",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(form.workdir_attachments, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("buildCreateWorkspaceWorkerRequest emits multiple aliased attachments", () => {
|
||||||
|
const request = buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||||
|
runtime_id: "remote",
|
||||||
|
display_name: "Worker",
|
||||||
|
profile: "builtin:coder",
|
||||||
|
initial_text: "go",
|
||||||
|
workdir_attachments: [
|
||||||
|
{
|
||||||
|
alias: " checkout ",
|
||||||
|
working_directory_id: "wd-1-repo",
|
||||||
|
relative_cwd: "crates/yoi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alias: "docs",
|
||||||
|
working_directory_id: "wd-2-docs",
|
||||||
|
relative_cwd: " ",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assertEquals(request, {
|
||||||
|
runtime_id: "remote",
|
||||||
|
display_name: "Worker",
|
||||||
|
profile: "builtin:coder",
|
||||||
|
ticket_assignment: null,
|
||||||
|
initial_submit: [{ kind: "text", content: "go" }],
|
||||||
|
workdir_attachments: [
|
||||||
|
{
|
||||||
|
alias: "checkout",
|
||||||
|
working_directory_id: "wd-1-repo",
|
||||||
|
relative_cwd: "crates/yoi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alias: "docs",
|
||||||
|
working_directory_id: "wd-2-docs",
|
||||||
|
relative_cwd: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
control_operation_id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("buildCreateWorkspaceWorkerRequest validates attachment aliases and selections", () => {
|
||||||
|
assertEquals(
|
||||||
|
workerLaunchAttachmentError([attachment("invalid/alias", "wd-1-repo")]),
|
||||||
|
"Attachment 1 alias must be 1–64 ASCII letters, digits, dots, underscores, or hyphens, and start with a letter or digit.",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||||
|
workdir_attachments: [
|
||||||
|
attachment("checkout", "wd-1-repo"),
|
||||||
|
attachment("checkout", "wd-2-docs"),
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
"Attachment alias “checkout” is used more than once.",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||||
|
workdir_attachments: [
|
||||||
|
attachment("checkout", "wd-1-repo"),
|
||||||
|
attachment("docs", "wd-1-repo"),
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
"A Workdir cannot be attached more than once to one Worker.",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||||
|
workdir_attachments: [attachment("checkout", "")],
|
||||||
|
})),
|
||||||
|
"Attachment 1 must select a Workdir.",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||||
|
workdir_attachments: [{
|
||||||
|
...attachment("checkout", "wd-1-repo"),
|
||||||
|
relative_cwd: "../outside",
|
||||||
|
}],
|
||||||
|
})),
|
||||||
|
"Attachment 1 relative cwd must stay inside the selected Workdir.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("buildCreateWorkspaceWorkerRequest sends no initial segments for an empty draft", () => {
|
||||||
|
const request = buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||||
|
runtime_id: "embedded",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
initial_text: " ",
|
||||||
|
}));
|
||||||
|
|
||||||
|
assertEquals(request.initial_submit, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for Workdir-less launches", () => {
|
||||||
|
const request = buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||||
|
runtime_id: "embedded",
|
||||||
|
display_name: "Worker",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
initial_text: "chat",
|
||||||
|
}));
|
||||||
|
|
||||||
|
assertEquals(request, {
|
||||||
|
runtime_id: "embedded",
|
||||||
|
display_name: "Worker",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
ticket_assignment: null,
|
||||||
|
initial_submit: [{ kind: "text", content: "chat" }],
|
||||||
|
workdir_attachments: [],
|
||||||
|
control_operation_id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function emptyForm(
|
||||||
|
overrides: Partial<Parameters<typeof buildCreateWorkspaceWorkerRequest>[0]> =
|
||||||
|
{},
|
||||||
|
): Parameters<typeof buildCreateWorkspaceWorkerRequest>[0] {
|
||||||
|
return {
|
||||||
|
runtime_id: "",
|
||||||
|
display_name: "",
|
||||||
|
profile: "",
|
||||||
|
initial_text: "",
|
||||||
|
workdir_attachments: [],
|
||||||
|
working_directory_repository_key: "",
|
||||||
|
working_directory_selector: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachment(alias: string, workingDirectoryId: string) {
|
||||||
|
return {
|
||||||
|
alias,
|
||||||
|
working_directory_id: workingDirectoryId,
|
||||||
|
relative_cwd: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function assertEquals<T>(actual: T, expected: T): void {
|
function assertEquals<T>(actual: T, expected: T): void {
|
||||||
const actualJson = JSON.stringify(actual);
|
const actualJson = JSON.stringify(actual);
|
||||||
const expectedJson = JSON.stringify(expected);
|
const expectedJson = JSON.stringify(expected);
|
||||||
@@ -16,6 +273,18 @@ function assertEquals<T>(actual: T, expected: T): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertThrows(fn: () => unknown, expectedMessage: string): void {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === expectedMessage) return;
|
||||||
|
throw new Error(
|
||||||
|
`Expected error ${JSON.stringify(expectedMessage)}, got ${String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`Expected error ${JSON.stringify(expectedMessage)}`);
|
||||||
|
}
|
||||||
|
|
||||||
const options: WorkerLaunchOptionsResponse = {
|
const options: WorkerLaunchOptionsResponse = {
|
||||||
workspace_id: "workspace",
|
workspace_id: "workspace",
|
||||||
runtimes: [
|
runtimes: [
|
||||||
@@ -66,180 +335,3 @@ const options: WorkerLaunchOptionsResponse = {
|
|||||||
],
|
],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
Deno.test("defaultWorkerLaunchForm uses the Backend-published Workspace default profile", () => {
|
|
||||||
const form = defaultWorkerLaunchForm(options, {
|
|
||||||
runtime_id: "",
|
|
||||||
display_name: "",
|
|
||||||
profile: "",
|
|
||||||
initial_text: "hello",
|
|
||||||
working_directory_id: "",
|
|
||||||
working_directory_repository_key: "",
|
|
||||||
working_directory_selector: "",
|
|
||||||
relative_cwd: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
assertEquals(form.runtime_id, "remote");
|
|
||||||
assertEquals(form.display_name, "Worker");
|
|
||||||
assertEquals(form.profile, "builtin:coder");
|
|
||||||
assertEquals(form.initial_text, "hello");
|
|
||||||
assertEquals(form.working_directory_id, "wd-1-repo");
|
|
||||||
assertEquals(form.working_directory_repository_key, "repo");
|
|
||||||
assertEquals(form.working_directory_selector, "HEAD");
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => {
|
|
||||||
const reviewerOptions = {
|
|
||||||
...options,
|
|
||||||
profiles: [
|
|
||||||
...options.profiles,
|
|
||||||
{ id: "builtin:reviewer", label: "Reviewer", description: "review" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const form = defaultWorkerLaunchForm(reviewerOptions, {
|
|
||||||
runtime_id: "",
|
|
||||||
display_name: "Review worker",
|
|
||||||
profile: "builtin:reviewer",
|
|
||||||
initial_text: "Review the ticket.",
|
|
||||||
working_directory_id: "",
|
|
||||||
working_directory_repository_key: "repo",
|
|
||||||
working_directory_selector: "HEAD",
|
|
||||||
relative_cwd: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
assertEquals(form.profile, "builtin:reviewer");
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => {
|
|
||||||
const form = defaultWorkerLaunchForm(
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
working_directories: [
|
|
||||||
{
|
|
||||||
...options.working_directories[0],
|
|
||||||
occupied_by: {
|
|
||||||
runtime_id: "embedded",
|
|
||||||
worker_id: "0198f82e-6d90-7f15-a121-174a02e10e77",
|
|
||||||
display_name: "Worker 12",
|
|
||||||
linked_at: "2026-07-24T00:00:00Z",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
runtime_id: "",
|
|
||||||
display_name: "",
|
|
||||||
profile: "",
|
|
||||||
initial_text: "hello",
|
|
||||||
working_directory_id: "",
|
|
||||||
working_directory_repository_key: "",
|
|
||||||
working_directory_selector: "",
|
|
||||||
relative_cwd: "",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(form.working_directory_id, "");
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => {
|
|
||||||
const form = defaultWorkerLaunchForm(
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
repositories: [
|
|
||||||
...options.repositories,
|
|
||||||
{
|
|
||||||
repository_key: "ticket-repo",
|
|
||||||
default_selector: "main",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
working_directories: [
|
|
||||||
options.working_directories[0],
|
|
||||||
{
|
|
||||||
...options.working_directories[0],
|
|
||||||
working_directory_id: "ticket-workdir",
|
|
||||||
repository_key: "ticket-repo",
|
|
||||||
creation_selector: "work/ticket",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
runtime_id: "",
|
|
||||||
display_name: "Ticket worker",
|
|
||||||
profile: "builtin:coder",
|
|
||||||
initial_text: "Work on a ticket.",
|
|
||||||
working_directory_id: "",
|
|
||||||
working_directory_repository_key: "ticket-repo",
|
|
||||||
working_directory_selector: "work/ticket",
|
|
||||||
relative_cwd: "",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(form.working_directory_id, "ticket-workdir");
|
|
||||||
assertEquals(form.working_directory_repository_key, "ticket-repo");
|
|
||||||
assertEquals(form.working_directory_selector, "work/ticket");
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("buildCreateWorkspaceWorkerRequest sends one aliased attachment", () => {
|
|
||||||
const request = buildCreateWorkspaceWorkerRequest({
|
|
||||||
runtime_id: "embedded",
|
|
||||||
display_name: "Worker",
|
|
||||||
profile: "builtin:coder",
|
|
||||||
initial_text: "go",
|
|
||||||
working_directory_id: "wd-1-repo",
|
|
||||||
working_directory_repository_key: "repo",
|
|
||||||
working_directory_selector: "main",
|
|
||||||
relative_cwd: "crates/yoi",
|
|
||||||
});
|
|
||||||
|
|
||||||
assertEquals(request, {
|
|
||||||
runtime_id: "embedded",
|
|
||||||
display_name: "Worker",
|
|
||||||
profile: "builtin:coder",
|
|
||||||
ticket_assignment: null,
|
|
||||||
initial_submit: [{ kind: "text", content: "go" }],
|
|
||||||
workdir_attachments: [{
|
|
||||||
alias: "workdir",
|
|
||||||
working_directory_id: "wd-1-repo",
|
|
||||||
relative_cwd: "crates/yoi",
|
|
||||||
}],
|
|
||||||
control_operation_id: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("buildCreateWorkspaceWorkerRequest sends no initial segments for an empty draft", () => {
|
|
||||||
const request = buildCreateWorkspaceWorkerRequest({
|
|
||||||
runtime_id: "embedded",
|
|
||||||
display_name: "Worker",
|
|
||||||
profile: "builtin:companion",
|
|
||||||
initial_text: " ",
|
|
||||||
working_directory_id: "",
|
|
||||||
working_directory_repository_key: "",
|
|
||||||
working_directory_selector: "",
|
|
||||||
relative_cwd: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
assertEquals(request.initial_submit, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for workdirless launches", () => {
|
|
||||||
const request = buildCreateWorkspaceWorkerRequest({
|
|
||||||
runtime_id: "embedded",
|
|
||||||
display_name: "Worker",
|
|
||||||
profile: "builtin:companion",
|
|
||||||
initial_text: "chat",
|
|
||||||
working_directory_id: "",
|
|
||||||
working_directory_repository_key: "",
|
|
||||||
working_directory_selector: "",
|
|
||||||
relative_cwd: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
assertEquals(request, {
|
|
||||||
runtime_id: "embedded",
|
|
||||||
display_name: "Worker",
|
|
||||||
profile: "builtin:companion",
|
|
||||||
ticket_assignment: null,
|
|
||||||
initial_submit: [{ kind: "text", content: "chat" }],
|
|
||||||
workdir_attachments: [],
|
|
||||||
control_operation_id: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import type { CreateWorkspaceWorkerRequest } from "$lib/generated/worker-launch-api";
|
import type {
|
||||||
|
BrowserWorkerWorkingDirectorySelection,
|
||||||
|
CreateWorkspaceWorkerRequest,
|
||||||
|
} from "$lib/generated/worker-launch-api";
|
||||||
import { parseCreateWorkspaceWorkerRequest } from "$lib/workspace/api/workers";
|
import { parseCreateWorkspaceWorkerRequest } from "$lib/workspace/api/workers";
|
||||||
|
|
||||||
import type { WorkerLaunchOptionsResponse } from "./types";
|
import type { WorkerLaunchOptionsResponse } from "./types";
|
||||||
|
|
||||||
|
export type WorkerLaunchAttachmentFormState = {
|
||||||
|
alias: string;
|
||||||
|
working_directory_id: string;
|
||||||
|
relative_cwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkerLaunchFormState = {
|
export type WorkerLaunchFormState = {
|
||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
initial_text: string;
|
initial_text: string;
|
||||||
working_directory_id: string;
|
workdir_attachments: WorkerLaunchAttachmentFormState[];
|
||||||
working_directory_repository_key: string;
|
working_directory_repository_key: string;
|
||||||
working_directory_selector: string;
|
working_directory_selector: string;
|
||||||
relative_cwd: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WORKDIR_ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||||
|
|
||||||
export function defaultWorkerLaunchForm(
|
export function defaultWorkerLaunchForm(
|
||||||
options: WorkerLaunchOptionsResponse | null,
|
options: WorkerLaunchOptionsResponse | null,
|
||||||
current: WorkerLaunchFormState,
|
current: WorkerLaunchFormState,
|
||||||
@@ -59,6 +69,27 @@ export function defaultWorkerLaunchForm(
|
|||||||
repository.repository_key === current.working_directory_repository_key
|
repository.repository_key === current.working_directory_repository_key
|
||||||
) ??
|
) ??
|
||||||
options?.repositories[0];
|
options?.repositories[0];
|
||||||
|
const availableWorkdirIds = new Set(
|
||||||
|
availableWorkingDirectories.map((directory) =>
|
||||||
|
directory.working_directory_id
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const currentAttachments = workdirlessRuntime
|
||||||
|
? []
|
||||||
|
: current.workdir_attachments.filter((attachment) =>
|
||||||
|
availableWorkdirIds.has(attachment.working_directory_id)
|
||||||
|
);
|
||||||
|
const workdirAttachments = currentAttachments.length > 0
|
||||||
|
? currentAttachments
|
||||||
|
: preferredWorkingDirectory
|
||||||
|
? [{
|
||||||
|
alias: "workdir",
|
||||||
|
working_directory_id: preferredWorkingDirectory.working_directory_id,
|
||||||
|
relative_cwd: "",
|
||||||
|
}]
|
||||||
|
: selectedRuntime?.working_directory_required === true
|
||||||
|
? [{ alias: "workdir", working_directory_id: "", relative_cwd: "" }]
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "",
|
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "",
|
||||||
@@ -68,22 +99,68 @@ export function defaultWorkerLaunchForm(
|
|||||||
? current.profile
|
? current.profile
|
||||||
: preferredProfile?.id || "",
|
: preferredProfile?.id || "",
|
||||||
initial_text: current.initial_text,
|
initial_text: current.initial_text,
|
||||||
working_directory_id:
|
workdir_attachments: workdirAttachments,
|
||||||
!workdirlessRuntime && availableWorkingDirectories.some(
|
|
||||||
(directory) =>
|
|
||||||
directory.working_directory_id === current.working_directory_id,
|
|
||||||
)
|
|
||||||
? current.working_directory_id
|
|
||||||
: preferredWorkingDirectory?.working_directory_id || "",
|
|
||||||
working_directory_repository_key:
|
working_directory_repository_key:
|
||||||
current.working_directory_repository_key ||
|
current.working_directory_repository_key ||
|
||||||
preferredRepository?.repository_key || "",
|
preferredRepository?.repository_key || "",
|
||||||
working_directory_selector: current.working_directory_selector ||
|
working_directory_selector: current.working_directory_selector ||
|
||||||
preferredRepository?.default_selector || "HEAD",
|
preferredRepository?.default_selector || "HEAD",
|
||||||
relative_cwd: current.relative_cwd,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function workerLaunchAttachmentError(
|
||||||
|
attachments: WorkerLaunchAttachmentFormState[],
|
||||||
|
): string | null {
|
||||||
|
const aliases = new Set<string>();
|
||||||
|
const workdirIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const [index, attachment] of attachments.entries()) {
|
||||||
|
const position = index + 1;
|
||||||
|
const alias = attachment.alias.trim();
|
||||||
|
const workdirId = attachment.working_directory_id.trim();
|
||||||
|
const relativeCwd = attachment.relative_cwd.trim();
|
||||||
|
|
||||||
|
if (!WORKDIR_ALIAS_PATTERN.test(alias)) {
|
||||||
|
return `Attachment ${position} alias must be 1–64 ASCII letters, digits, dots, underscores, or hyphens, and start with a letter or digit.`;
|
||||||
|
}
|
||||||
|
if (aliases.has(alias)) {
|
||||||
|
return `Attachment alias “${alias}” is used more than once.`;
|
||||||
|
}
|
||||||
|
aliases.add(alias);
|
||||||
|
|
||||||
|
if (!workdirId) {
|
||||||
|
return `Attachment ${position} must select a Workdir.`;
|
||||||
|
}
|
||||||
|
if (workdirIds.has(workdirId)) {
|
||||||
|
return "A Workdir cannot be attached more than once to one Worker.";
|
||||||
|
}
|
||||||
|
workdirIds.add(workdirId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
relativeCwd.startsWith("/") ||
|
||||||
|
relativeCwd.split("/").some((component) => component === "..")
|
||||||
|
) {
|
||||||
|
return `Attachment ${position} relative cwd must stay inside the selected Workdir.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatedAttachments(
|
||||||
|
attachments: WorkerLaunchAttachmentFormState[],
|
||||||
|
): BrowserWorkerWorkingDirectorySelection[] {
|
||||||
|
const error = workerLaunchAttachmentError(attachments);
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
return attachments.map((attachment) => ({
|
||||||
|
alias: attachment.alias.trim(),
|
||||||
|
working_directory_id: attachment.working_directory_id.trim(),
|
||||||
|
relative_cwd: attachment.relative_cwd.trim() || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCreateWorkspaceWorkerRequest(
|
export function buildCreateWorkspaceWorkerRequest(
|
||||||
form: WorkerLaunchFormState,
|
form: WorkerLaunchFormState,
|
||||||
): CreateWorkspaceWorkerRequest {
|
): CreateWorkspaceWorkerRequest {
|
||||||
@@ -96,13 +173,7 @@ export function buildCreateWorkspaceWorkerRequest(
|
|||||||
initial_submit: initialMessage
|
initial_submit: initialMessage
|
||||||
? [{ kind: "text", content: form.initial_text }]
|
? [{ kind: "text", content: form.initial_text }]
|
||||||
: [],
|
: [],
|
||||||
workdir_attachments: form.working_directory_id
|
workdir_attachments: validatedAttachments(form.workdir_attachments),
|
||||||
? [{
|
|
||||||
alias: "workdir",
|
|
||||||
working_directory_id: form.working_directory_id,
|
|
||||||
relative_cwd: form.relative_cwd.trim() || null,
|
|
||||||
}]
|
|
||||||
: [],
|
|
||||||
control_operation_id: null,
|
control_operation_id: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,19 +143,41 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.worker-inline-select.wd-select {
|
.worker-attachment-heading {
|
||||||
min-width: 20rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.worker-attachment-heading h3,
|
||||||
|
.new-working-directory-panel h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
}
|
||||||
|
.worker-attachment-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.worker-attachment-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(7rem, 0.7fr) minmax(13rem, 1.5fr) minmax(10rem, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
.worker-attachment-row:last-child {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.worker-attachment-remove {
|
||||||
|
margin-bottom: 1px;
|
||||||
}
|
}
|
||||||
.new-working-directory-panel {
|
.new-working-directory-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding-left: var(--space-3);
|
padding-left: var(--space-3);
|
||||||
}
|
}
|
||||||
.new-working-directory-panel h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
}
|
|
||||||
.new-working-directory-fields,
|
.new-working-directory-fields,
|
||||||
.worker-detail-grid {
|
.worker-detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -182,15 +204,19 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.worker-inline-select,
|
.worker-inline-select {
|
||||||
.worker-inline-select.wd-select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.new-working-directory-fields,
|
.new-working-directory-fields,
|
||||||
.worker-detail-grid {
|
.worker-detail-grid,
|
||||||
|
.worker-attachment-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.worker-attachment-remove {
|
||||||
|
justify-self: start;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.worker-submit-error {
|
.worker-submit-error {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
parseWorkerLaunchOptionsResponse,
|
parseWorkerLaunchOptionsResponse,
|
||||||
} from '$lib/workspace/api/workers';
|
} from '$lib/workspace/api/workers';
|
||||||
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
||||||
import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
|
import {
|
||||||
|
buildCreateWorkspaceWorkerRequest,
|
||||||
|
defaultWorkerLaunchForm,
|
||||||
|
workerLaunchAttachmentError,
|
||||||
|
} from '$lib/workspace/sidebar/worker-launch';
|
||||||
|
import type { WorkerLaunchAttachmentFormState } from '$lib/workspace/sidebar/worker-launch';
|
||||||
import type {
|
import type {
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
WorkerLaunchOptionsResponse,
|
WorkerLaunchOptionsResponse,
|
||||||
@@ -62,18 +67,17 @@
|
|||||||
: '',
|
: '',
|
||||||
);
|
);
|
||||||
let initialText = $state(ticketContext?.initialInput ?? '');
|
let initialText = $state(ticketContext?.initialInput ?? '');
|
||||||
let workingDirectoryId = $state('');
|
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
|
||||||
let workingDirectoryDisplayName = $state('');
|
let workingDirectoryDisplayName = $state('');
|
||||||
let workingDirectoryRepositoryKey = $state(ticketContext?.repositoryKey ?? '');
|
let workingDirectoryRepositoryKey = $state(ticketContext?.repositoryKey ?? '');
|
||||||
let workingDirectorySelector = $state(ticketContext?.refSelector ?? 'HEAD');
|
let workingDirectorySelector = $state(ticketContext?.refSelector ?? 'HEAD');
|
||||||
let relativeCwd = $state('');
|
|
||||||
let creatingWorkingDirectory = $state(false);
|
let creatingWorkingDirectory = $state(false);
|
||||||
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
|
let newWorkingDirectoryAttachmentIndex = $derived(
|
||||||
|
workdirAttachments.findIndex((attachment) => attachment.working_directory_id === NEW_WORKING_DIRECTORY_VALUE),
|
||||||
|
);
|
||||||
|
let isNewWorkingDirectorySelected = $derived(newWorkingDirectoryAttachmentIndex >= 0);
|
||||||
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
||||||
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
|
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
|
||||||
let hasSelectedExistingWorkdir = $derived(Boolean(
|
|
||||||
workingDirectoryId && !isNewWorkingDirectorySelected && !selectedRuntimeAllowsNoWorkdir,
|
|
||||||
));
|
|
||||||
let availableWorkingDirectories = $derived(
|
let availableWorkingDirectories = $derived(
|
||||||
selectedRuntimeAllowsNoWorkdir
|
selectedRuntimeAllowsNoWorkdir
|
||||||
? []
|
? []
|
||||||
@@ -83,10 +87,17 @@
|
|||||||
directory.occupied_by == null
|
directory.occupied_by == null
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
let attachmentValidationError = $derived(
|
||||||
|
isNewWorkingDirectorySelected
|
||||||
|
? 'Create the new Workdir before starting the Worker.'
|
||||||
|
: workerLaunchAttachmentError(workdirAttachments),
|
||||||
|
);
|
||||||
let canStartWorker = $derived(Boolean(
|
let canStartWorker = $derived(Boolean(
|
||||||
runtimeId &&
|
runtimeId &&
|
||||||
profile &&
|
profile &&
|
||||||
(hasSelectedExistingWorkdir || (selectedRuntimeAllowsNoWorkdir && !isNewWorkingDirectorySelected)),
|
(selectedRuntimeAllowsNoWorkdir
|
||||||
|
? workdirAttachments.length === 0
|
||||||
|
: workdirAttachments.length > 0 && !attachmentValidationError),
|
||||||
));
|
));
|
||||||
|
|
||||||
function workerApiPath(path: string): string {
|
function workerApiPath(path: string): string {
|
||||||
@@ -105,12 +116,49 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedRuntimeAllowsNoWorkdir && workingDirectoryId) {
|
if (selectedRuntimeAllowsNoWorkdir && workdirAttachments.length > 0) {
|
||||||
workingDirectoryId = '';
|
workdirAttachments = [];
|
||||||
relativeCwd = '';
|
} else if (selectedRuntime?.working_directory_required === true && workdirAttachments.length === 0) {
|
||||||
|
workdirAttachments = [{
|
||||||
|
alias: 'workdir',
|
||||||
|
working_directory_id: availableWorkingDirectories[0]?.working_directory_id ?? '',
|
||||||
|
relative_cwd: '',
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function nextAttachmentAlias(): string {
|
||||||
|
const aliases = new Set(workdirAttachments.map((attachment) => attachment.alias.trim()));
|
||||||
|
if (!aliases.has('workdir')) return 'workdir';
|
||||||
|
let suffix = 2;
|
||||||
|
while (aliases.has(`workdir${suffix}`)) suffix += 1;
|
||||||
|
return `workdir${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAttachment(): void {
|
||||||
|
const selectedWorkdirs = new Set(
|
||||||
|
workdirAttachments.map((attachment) => attachment.working_directory_id),
|
||||||
|
);
|
||||||
|
const available = availableWorkingDirectories.find((directory) =>
|
||||||
|
!selectedWorkdirs.has(directory.working_directory_id)
|
||||||
|
);
|
||||||
|
workdirAttachments = [...workdirAttachments, {
|
||||||
|
alias: nextAttachmentAlias(),
|
||||||
|
working_directory_id: available?.working_directory_id ?? '',
|
||||||
|
relative_cwd: '',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(index: number): void {
|
||||||
|
workdirAttachments = workdirAttachments.filter((_, candidateIndex) => candidateIndex !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workdirSelectedByAnotherAttachment(workdirId: string, index: number): boolean {
|
||||||
|
return workdirAttachments.some((attachment, candidateIndex) =>
|
||||||
|
candidateIndex !== index && attachment.working_directory_id === workdirId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLaunchOptions(signal?: AbortSignal) {
|
async function loadLaunchOptions(signal?: AbortSignal) {
|
||||||
loading = true;
|
loading = true;
|
||||||
optionsError = null;
|
optionsError = null;
|
||||||
@@ -126,19 +174,30 @@
|
|||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
profile,
|
profile,
|
||||||
initial_text: initialText,
|
initial_text: initialText,
|
||||||
working_directory_id: workingDirectoryId,
|
workdir_attachments: workdirAttachments,
|
||||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||||
working_directory_selector: workingDirectorySelector,
|
working_directory_selector: workingDirectorySelector,
|
||||||
relative_cwd: relativeCwd,
|
|
||||||
});
|
});
|
||||||
runtimeId = form.runtime_id;
|
runtimeId = form.runtime_id;
|
||||||
displayName = form.display_name;
|
displayName = form.display_name;
|
||||||
profile = form.profile;
|
profile = form.profile;
|
||||||
workingDirectoryId = form.working_directory_id ||
|
const runtimeRequiresWorkdir = payload.runtimes.find((runtime) =>
|
||||||
(ticketContext?.repositoryKey ? NEW_WORKING_DIRECTORY_VALUE : '');
|
runtime.runtime_id === form.runtime_id
|
||||||
|
)?.working_directory_required !== false;
|
||||||
|
workdirAttachments = form.workdir_attachments;
|
||||||
|
if (
|
||||||
|
runtimeRequiresWorkdir &&
|
||||||
|
ticketContext?.repositoryKey &&
|
||||||
|
workdirAttachments.every((attachment) => !attachment.working_directory_id)
|
||||||
|
) {
|
||||||
|
workdirAttachments = [{
|
||||||
|
alias: 'workdir',
|
||||||
|
working_directory_id: NEW_WORKING_DIRECTORY_VALUE,
|
||||||
|
relative_cwd: '',
|
||||||
|
}];
|
||||||
|
}
|
||||||
workingDirectoryRepositoryKey = form.working_directory_repository_key;
|
workingDirectoryRepositoryKey = form.working_directory_repository_key;
|
||||||
workingDirectorySelector = form.working_directory_selector;
|
workingDirectorySelector = form.working_directory_selector;
|
||||||
relativeCwd = form.relative_cwd;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
@@ -152,6 +211,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkingDirectory() {
|
async function createWorkingDirectory() {
|
||||||
|
const attachmentIndex = newWorkingDirectoryAttachmentIndex;
|
||||||
|
if (attachmentIndex < 0) {
|
||||||
|
submitError = { message: 'select New workdir on an attachment first', diagnostics: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!runtimeId) {
|
if (!runtimeId) {
|
||||||
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
|
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
|
||||||
return;
|
return;
|
||||||
@@ -195,7 +259,11 @@
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: options;
|
: options;
|
||||||
workingDirectoryId = payload.item.working_directory_id;
|
workdirAttachments = workdirAttachments.map((attachment, index) =>
|
||||||
|
index === attachmentIndex && attachment.working_directory_id === NEW_WORKING_DIRECTORY_VALUE
|
||||||
|
? { ...attachment, working_directory_id: payload.item.working_directory_id }
|
||||||
|
: attachment
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
submitError = exceptionDisplayError(err, 'workdir create failed');
|
submitError = exceptionDisplayError(err, 'workdir create failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -208,8 +276,16 @@
|
|||||||
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
|
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isNewWorkingDirectorySelected || (!workingDirectoryId && !selectedRuntimeAllowsNoWorkdir)) {
|
if (selectedRuntimeAllowsNoWorkdir && workdirAttachments.length > 0) {
|
||||||
submitError = { message: 'select or create a workdir before starting a Worker; only embedded Runtime can start without one', diagnostics: [] };
|
submitError = { message: 'the selected Runtime must start without Workdir attachments', diagnostics: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedRuntimeAllowsNoWorkdir && workdirAttachments.length === 0) {
|
||||||
|
submitError = { message: 'add at least one Workdir attachment before starting a Worker; only embedded Runtime can start without one', diagnostics: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attachmentValidationError) {
|
||||||
|
submitError = { message: attachmentValidationError, diagnostics: [] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,10 +300,9 @@
|
|||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
profile,
|
profile,
|
||||||
initial_text: initialText,
|
initial_text: initialText,
|
||||||
working_directory_id: workingDirectoryId,
|
workdir_attachments: workdirAttachments,
|
||||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||||
working_directory_selector: workingDirectorySelector,
|
working_directory_selector: workingDirectorySelector,
|
||||||
relative_cwd: relativeCwd,
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -304,21 +379,7 @@
|
|||||||
<h2 id="worker-location-heading">Location</h2>
|
<h2 id="worker-location-heading">Location</h2>
|
||||||
|
|
||||||
<div class="worker-launch-sentence">
|
<div class="worker-launch-sentence">
|
||||||
<span>Run at</span>
|
<span>Run in</span>
|
||||||
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir">
|
|
||||||
{#if selectedRuntimeAllowsNoWorkdir}
|
|
||||||
<option value="">No workdir</option>
|
|
||||||
{:else}
|
|
||||||
<option value="" disabled>Select workdir</option>
|
|
||||||
{#each availableWorkingDirectories as directory}
|
|
||||||
<option value={directory.working_directory_id}>
|
|
||||||
{workdirOptionLabel(directory)}
|
|
||||||
</option>
|
|
||||||
{/each}
|
|
||||||
<option value={NEW_WORKING_DIRECTORY_VALUE}>New workdir…</option>
|
|
||||||
{/if}
|
|
||||||
</select>
|
|
||||||
<span>in</span>
|
|
||||||
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
|
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
|
||||||
{#if options?.runtimes.length}
|
{#if options?.runtimes.length}
|
||||||
{#each options.runtimes as runtime}
|
{#each options.runtimes as runtime}
|
||||||
@@ -332,15 +393,73 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !selectedRuntimeAllowsNoWorkdir && !workingDirectoryId && !isNewWorkingDirectorySelected}
|
{#if selectedRuntimeAllowsNoWorkdir}
|
||||||
<p class="worker-workdir-note">This Runtime requires a selected workdir before starting a Worker.</p>
|
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a Workdir.</p>
|
||||||
{:else if selectedRuntimeAllowsNoWorkdir && !workingDirectoryId}
|
{:else}
|
||||||
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a workdir.</p>
|
<div class="worker-attachment-heading">
|
||||||
|
<h3>Initial Workdir attachments</h3>
|
||||||
|
<button type="button" class="secondary-button" onclick={addAttachment}>Add attachment</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="worker-attachment-list">
|
||||||
|
{#each workdirAttachments as attachment, index}
|
||||||
|
<div class="worker-attachment-row">
|
||||||
|
<label>
|
||||||
|
<span>Alias</span>
|
||||||
|
<input
|
||||||
|
bind:value={attachment.alias}
|
||||||
|
autocomplete="off"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="workdir"
|
||||||
|
aria-label={`Attachment ${index + 1} alias`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Workdir</span>
|
||||||
|
<select bind:value={attachment.working_directory_id} aria-label={`Attachment ${index + 1} Workdir`}>
|
||||||
|
<option value="" disabled>Select Workdir</option>
|
||||||
|
{#each availableWorkingDirectories as directory}
|
||||||
|
<option
|
||||||
|
value={directory.working_directory_id}
|
||||||
|
disabled={workdirSelectedByAnotherAttachment(directory.working_directory_id, index)}
|
||||||
|
>
|
||||||
|
{workdirOptionLabel(directory)}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
<option
|
||||||
|
value={NEW_WORKING_DIRECTORY_VALUE}
|
||||||
|
disabled={isNewWorkingDirectorySelected && newWorkingDirectoryAttachmentIndex !== index}
|
||||||
|
>New Workdir…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Relative cwd</span>
|
||||||
|
<input
|
||||||
|
bind:value={attachment.relative_cwd}
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Optional path inside Workdir"
|
||||||
|
aria-label={`Attachment ${index + 1} relative cwd`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="worker-attachment-remove"
|
||||||
|
onclick={() => removeAttachment(index)}
|
||||||
|
disabled={creatingWorkingDirectory && newWorkingDirectoryAttachmentIndex === index}
|
||||||
|
aria-label={`Remove attachment ${index + 1}`}
|
||||||
|
>Remove</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if attachmentValidationError}
|
||||||
|
<p class="worker-workdir-note error">{attachmentValidationError}</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isNewWorkingDirectorySelected}
|
{#if isNewWorkingDirectorySelected}
|
||||||
<div class="new-working-directory-panel">
|
<div class="new-working-directory-panel">
|
||||||
<h3>New workdir</h3>
|
<h3>New Workdir for “{workdirAttachments[newWorkingDirectoryAttachmentIndex]?.alias || `attachment ${newWorkingDirectoryAttachmentIndex + 1}`}”</h3>
|
||||||
<div class="new-working-directory-fields">
|
<div class="new-working-directory-fields">
|
||||||
<label>
|
<label>
|
||||||
<span>Display name</span>
|
<span>Display name</span>
|
||||||
@@ -364,17 +483,10 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" disabled={creatingWorkingDirectory || !runtimeId || !workingDirectoryRepositoryKey} onclick={() => void createWorkingDirectory()}>
|
<button type="button" disabled={creatingWorkingDirectory || !runtimeId || !workingDirectoryRepositoryKey} onclick={() => void createWorkingDirectory()}>
|
||||||
{creatingWorkingDirectory ? 'Creating…' : 'Create workdir'}
|
{creatingWorkingDirectory ? 'Creating…' : 'Create Workdir'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasSelectedExistingWorkdir || isNewWorkingDirectorySelected}
|
|
||||||
<label class="relative-cwd-field">
|
|
||||||
<span>Relative cwd inside workdir</span>
|
|
||||||
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside workdir" />
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="worker-form-section" aria-labelledby="worker-details-heading">
|
<section class="worker-form-section" aria-labelledby="worker-details-heading">
|
||||||
|
|||||||
Reference in New Issue
Block a user