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
@@ -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">