fix: complete multi-workdir isolation and launch UI

This commit is contained in:
2026-09-20 22:07:59 +09:00
parent c102119412
commit 74fbadb1cd
11 changed files with 804 additions and 277 deletions
+3 -1
View File
@@ -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(&params.file_path).map_err(ToolsError::from)?; let path = WorkdirPath::new(&params.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");
+5 -3
View File
@@ -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!({
+11 -2
View File
@@ -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(),
+3 -1
View File
@@ -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(&params.file_path).map_err(ToolsError::from)?; let path = WorkdirPath::new(&params.file_path).map_err(ToolsError::from)?;
tracing::debug!(path = %path, bytes = params.content.len(), "Write"); tracing::debug!(path = %path, bytes = params.content.len(), "Write");
+60
View File
@@ -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(
&reg.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(
&reg.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();
+163 -14
View File
@@ -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(&registry, &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(&registry, &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">