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(),
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)?;
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(),
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 limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
@@ -216,7 +218,7 @@ mod tests {
// History recorded
assert!(
tracker
.scoped("workdir")
.scoped_attachment(&workdir::WorkdirAttachmentAlias::new("workdir").unwrap(), 0,)
.expected_workdir_hash(&WorkdirPath::new("a.txt").unwrap())
.is_ok()
);
@@ -247,7 +249,7 @@ mod tests {
#[tokio::test]
async fn read_tool_missing_file() {
let (dir, fs, tracker) = setup();
let (_dir, fs, tracker) = setup();
let def = read_tool(fs, tracker);
let (_, tool) = def();
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
/// Worker-local attachment alias while aggregate recency/change statistics
/// remain shared with the Worker tracker.
/// attachment incarnation (the Worker-local alias plus router generation),
/// while aggregate recency/change statistics remain shared with the Worker
/// tracker.
pub fn scoped(&self, namespace: impl Into<String>) -> Self {
Self {
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 {
TrackedPath {
namespace: self.namespace.clone(),
+3 -1
View File
@@ -49,7 +49,9 @@ impl Tool for WriteTool {
params.target_workdir.as_deref(),
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)?;
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();
}
#[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]
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
let dir = TempDir::new().unwrap();
+163 -14
View File
@@ -383,6 +383,7 @@ static EMBEDDED_RUNTIME_REQUEST_IDENTITY: std::sync::LazyLock<
#[derive(Clone)]
struct WorkdirCommandSession {
attachment_alias: String,
source: WorkdirSessionHandle,
provider_handle: CommandHandle,
}
@@ -421,6 +422,7 @@ impl WorkdirSessionRegistry {
fn register_command(
&mut self,
worker: RuntimeWorkerRef,
attachment_alias: String,
source: WorkdirSessionHandle,
provider_handle: CommandHandle,
) -> CommandHandle {
@@ -436,6 +438,7 @@ impl WorkdirSessionRegistry {
self.commands.insert(
(worker, external_handle.clone()),
WorkdirCommandSession {
attachment_alias,
source,
provider_handle,
},
@@ -446,13 +449,42 @@ impl WorkdirSessionRegistry {
fn command(
&self,
worker: &RuntimeWorkerRef,
attachment_alias: &str,
external_handle: &CommandHandle,
) -> Option<WorkdirCommandSession> {
self.commands
.get(&(worker.clone(), external_handle.clone()))
.filter(|session| session.attachment_alias == attachment_alias)
.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> {
let mut sessions = Vec::new();
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>>,
worker: &RuntimeWorkerRef,
registered: Vec<RegisteredWorkdirSession>,
) -> 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 errors = Vec::new();
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;
#[derive(Clone)]
@@ -8529,6 +8580,20 @@ async fn close_current_worker_attachment_session_locked(
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(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
@@ -8583,7 +8648,7 @@ async fn refresh_current_worker_session_locked(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
) -> Result<()> {
close_current_worker_session_locked(api, worker).await?;
close_current_worker_attachment_session_locked(api, worker).await?;
let remaining = api
.store
.list_worker_workdir_links(&api.config.workspace_id, worker)?
@@ -8653,7 +8718,7 @@ async fn scoped_attach_current_worker_workdir(
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 {
workspace_id: api.config.workspace_id.clone(),
worker: worker.clone(),
@@ -8734,7 +8799,8 @@ async fn scoped_detach_current_worker_workdir(
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.config.workspace_id,
&worker,
@@ -8903,7 +8969,12 @@ async fn scoped_execute_current_worker_workdir_operation(
.workdir_sessions
.lock()
.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)
}
WorkdirSessionOperation::CommandStatus(external_handle) => {
@@ -8966,7 +9037,7 @@ fn current_worker_command_session(
.workdir_sessions
.lock()
.expect("Workdir session registry lock poisoned")
.command(worker, external_handle)
.command(worker, target_workdir, external_handle)
.ok_or_else(|| {
WorkdirOperationApiError::Provider(current_worker_workdir_operation_error(
worker,
@@ -20100,8 +20171,12 @@ mod tests {
let mut registry = WorkdirSessionRegistry::default();
registry.insert_attachment(worker.clone(), source.clone());
let registered_source = registry.remove_attachment(&worker).unwrap();
let external_handle =
registry.register_command(worker.clone(), registered_source, provider_handle.clone());
let external_handle = registry.register_command(
worker.clone(),
"checkout".to_string(),
registered_source,
provider_handle.clone(),
);
assert_ne!(external_handle, provider_handle);
let refreshed: WorkdirSessionHandle = Arc::new(workdir::LocalWorkdirSession::new(
@@ -20116,7 +20191,14 @@ mod tests {
.await
.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
.source
.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 {
let account_id = format!("account-{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-status-dot") &&
workersNav.includes("worker-status-spinner") &&
workersNav.includes("worker.repository_key ?? '—'") &&
workersNav.includes("worker.working_directory_id ?? '—'") &&
workersNav.includes("workdirMeta(worker)") &&
workersNav.includes("attachment.alias") &&
workersNav.includes("attachment.repository_key ?? '—'") &&
workersNav.includes("attachment.working_directory_id") &&
!workersNav.includes('aria-disabled="true"'),
"Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata",
);
@@ -1,6 +1,7 @@
import {
buildCreateWorkspaceWorkerRequest,
defaultWorkerLaunchForm,
workerLaunchAttachmentError,
} from "./worker-launch.ts";
import type { WorkerLaunchOptionsResponse } from "./types.ts";
@@ -8,6 +9,262 @@ declare const Deno: {
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 {
const actualJson = JSON.stringify(actual);
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 = {
workspace_id: "workspace",
runtimes: [
@@ -66,180 +335,3 @@ const options: WorkerLaunchOptionsResponse = {
],
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 type { WorkerLaunchOptionsResponse } from "./types";
export type WorkerLaunchAttachmentFormState = {
alias: string;
working_directory_id: string;
relative_cwd: string;
};
export type WorkerLaunchFormState = {
runtime_id: string;
display_name: string;
profile: string;
initial_text: string;
working_directory_id: string;
workdir_attachments: WorkerLaunchAttachmentFormState[];
working_directory_repository_key: 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(
options: WorkerLaunchOptionsResponse | null,
current: WorkerLaunchFormState,
@@ -59,6 +69,27 @@ export function defaultWorkerLaunchForm(
repository.repository_key === current.working_directory_repository_key
) ??
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 {
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "",
@@ -68,22 +99,68 @@ export function defaultWorkerLaunchForm(
? current.profile
: preferredProfile?.id || "",
initial_text: current.initial_text,
working_directory_id:
!workdirlessRuntime && availableWorkingDirectories.some(
(directory) =>
directory.working_directory_id === current.working_directory_id,
)
? current.working_directory_id
: preferredWorkingDirectory?.working_directory_id || "",
workdir_attachments: workdirAttachments,
working_directory_repository_key:
current.working_directory_repository_key ||
preferredRepository?.repository_key || "",
working_directory_selector: current.working_directory_selector ||
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(
form: WorkerLaunchFormState,
): CreateWorkspaceWorkerRequest {
@@ -96,13 +173,7 @@ export function buildCreateWorkspaceWorkerRequest(
initial_submit: initialMessage
? [{ kind: "text", content: form.initial_text }]
: [],
workdir_attachments: form.working_directory_id
? [{
alias: "workdir",
working_directory_id: form.working_directory_id,
relative_cwd: form.relative_cwd.trim() || null,
}]
: [],
workdir_attachments: validatedAttachments(form.workdir_attachments),
control_operation_id: null,
});
}
@@ -143,19 +143,41 @@
min-width: 0;
max-width: 100%;
}
.worker-inline-select.wd-select {
min-width: 20rem;
.worker-attachment-heading {
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 {
display: grid;
gap: 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,
.worker-detail-grid {
display: grid;
@@ -182,15 +204,19 @@
flex-direction: column;
white-space: normal;
}
.worker-inline-select,
.worker-inline-select.wd-select {
.worker-inline-select {
width: 100%;
min-width: 0;
}
.new-working-directory-fields,
.worker-detail-grid {
.worker-detail-grid,
.worker-attachment-row {
grid-template-columns: 1fr;
}
.worker-attachment-remove {
justify-self: start;
margin-bottom: 0;
}
}
.worker-submit-error {
display: grid;
@@ -11,7 +11,12 @@
parseWorkerLaunchOptionsResponse,
} from '$lib/workspace/api/workers';
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 {
Diagnostic,
WorkerLaunchOptionsResponse,
@@ -62,18 +67,17 @@
: '',
);
let initialText = $state(ticketContext?.initialInput ?? '');
let workingDirectoryId = $state('');
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
let workingDirectoryDisplayName = $state('');
let workingDirectoryRepositoryKey = $state(ticketContext?.repositoryKey ?? '');
let workingDirectorySelector = $state(ticketContext?.refSelector ?? 'HEAD');
let relativeCwd = $state('');
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 selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
let hasSelectedExistingWorkdir = $derived(Boolean(
workingDirectoryId && !isNewWorkingDirectorySelected && !selectedRuntimeAllowsNoWorkdir,
));
let availableWorkingDirectories = $derived(
selectedRuntimeAllowsNoWorkdir
? []
@@ -83,10 +87,17 @@
directory.occupied_by == null
),
);
let attachmentValidationError = $derived(
isNewWorkingDirectorySelected
? 'Create the new Workdir before starting the Worker.'
: workerLaunchAttachmentError(workdirAttachments),
);
let canStartWorker = $derived(Boolean(
runtimeId &&
profile &&
(hasSelectedExistingWorkdir || (selectedRuntimeAllowsNoWorkdir && !isNewWorkingDirectorySelected)),
(selectedRuntimeAllowsNoWorkdir
? workdirAttachments.length === 0
: workdirAttachments.length > 0 && !attachmentValidationError),
));
function workerApiPath(path: string): string {
@@ -105,12 +116,49 @@
});
$effect(() => {
if (selectedRuntimeAllowsNoWorkdir && workingDirectoryId) {
workingDirectoryId = '';
relativeCwd = '';
if (selectedRuntimeAllowsNoWorkdir && workdirAttachments.length > 0) {
workdirAttachments = [];
} 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) {
loading = true;
optionsError = null;
@@ -126,19 +174,30 @@
display_name: displayName,
profile,
initial_text: initialText,
working_directory_id: workingDirectoryId,
workdir_attachments: workdirAttachments,
working_directory_repository_key: workingDirectoryRepositoryKey,
working_directory_selector: workingDirectorySelector,
relative_cwd: relativeCwd,
});
runtimeId = form.runtime_id;
displayName = form.display_name;
profile = form.profile;
workingDirectoryId = form.working_directory_id ||
(ticketContext?.repositoryKey ? NEW_WORKING_DIRECTORY_VALUE : '');
const runtimeRequiresWorkdir = payload.runtimes.find((runtime) =>
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;
workingDirectorySelector = form.working_directory_selector;
relativeCwd = form.relative_cwd;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
@@ -152,6 +211,11 @@
}
async function createWorkingDirectory() {
const attachmentIndex = newWorkingDirectoryAttachmentIndex;
if (attachmentIndex < 0) {
submitError = { message: 'select New workdir on an attachment first', diagnostics: [] };
return;
}
if (!runtimeId) {
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
return;
@@ -195,7 +259,11 @@
],
}
: 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) {
submitError = exceptionDisplayError(err, 'workdir create failed');
} finally {
@@ -208,8 +276,16 @@
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
return;
}
if (isNewWorkingDirectorySelected || (!workingDirectoryId && !selectedRuntimeAllowsNoWorkdir)) {
submitError = { message: 'select or create a workdir before starting a Worker; only embedded Runtime can start without one', diagnostics: [] };
if (selectedRuntimeAllowsNoWorkdir && workdirAttachments.length > 0) {
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;
}
@@ -224,10 +300,9 @@
display_name: displayName,
profile,
initial_text: initialText,
working_directory_id: workingDirectoryId,
workdir_attachments: workdirAttachments,
working_directory_repository_key: workingDirectoryRepositoryKey,
working_directory_selector: workingDirectorySelector,
relative_cwd: relativeCwd,
})),
});
if (!response.ok) {
@@ -304,21 +379,7 @@
<h2 id="worker-location-heading">Location</h2>
<div class="worker-launch-sentence">
<span>Run at</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>
<span>Run in</span>
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
{#if options?.runtimes.length}
{#each options.runtimes as runtime}
@@ -332,15 +393,73 @@
</select>
</div>
{#if !selectedRuntimeAllowsNoWorkdir && !workingDirectoryId && !isNewWorkingDirectorySelected}
<p class="worker-workdir-note">This Runtime requires a selected workdir before starting a Worker.</p>
{:else if selectedRuntimeAllowsNoWorkdir && !workingDirectoryId}
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a workdir.</p>
{#if selectedRuntimeAllowsNoWorkdir}
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a Workdir.</p>
{:else}
<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 isNewWorkingDirectorySelected}
<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">
<label>
<span>Display name</span>
@@ -364,17 +483,10 @@
</label>
</div>
<button type="button" disabled={creatingWorkingDirectory || !runtimeId || !workingDirectoryRepositoryKey} onclick={() => void createWorkingDirectory()}>
{creatingWorkingDirectory ? 'Creating…' : 'Create workdir'}
{creatingWorkingDirectory ? 'Creating…' : 'Create Workdir'}
</button>
</div>
{/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 class="worker-form-section" aria-labelledby="worker-details-heading">