merge: embedded no-workdir worker policy
This commit is contained in:
commit
2f7b809401
|
|
@ -753,6 +753,7 @@ pub struct WorkerLaunchRuntimeOption {
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub built_in: bool,
|
pub built_in: bool,
|
||||||
pub can_spawn_worker: bool,
|
pub can_spawn_worker: bool,
|
||||||
|
pub working_directory_required: bool,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
@ -2642,6 +2643,9 @@ async fn create_workspace_worker(
|
||||||
relative_cwd: selection.relative_cwd,
|
relative_cwd: selection.relative_cwd,
|
||||||
});
|
});
|
||||||
validate_working_directory_claim_for_browser(resolved_working_directory.as_ref())?;
|
validate_working_directory_claim_for_browser(resolved_working_directory.as_ref())?;
|
||||||
|
if resolved_working_directory.is_none() {
|
||||||
|
reject_no_workdir_for_non_embedded_runtime(&request.runtime_id)?;
|
||||||
|
}
|
||||||
let result = api
|
let result = api
|
||||||
.runtime
|
.runtime
|
||||||
.spawn_worker(
|
.spawn_worker(
|
||||||
|
|
@ -2811,11 +2815,36 @@ struct RuntimeConfigBundleAvailabilityQuery {
|
||||||
digest: String,
|
digest: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reject_no_workdir_for_non_embedded_runtime(
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> std::result::Result<(), ApiError> {
|
||||||
|
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(ApiError::with_diagnostics(
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code: "workspace_worker_workdir_required".to_string(),
|
||||||
|
message: "Only the embedded Runtime can launch a Worker without a working directory"
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
vec![RuntimeDiagnostic {
|
||||||
|
code: "workspace_worker_workdir_required".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Error,
|
||||||
|
message: "Select a working directory for this Runtime, or choose the embedded Runtime for a conversation-only Worker."
|
||||||
|
.to_string(),
|
||||||
|
}],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_runtime_worker(
|
async fn create_runtime_worker(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(runtime_id): AxumPath<String>,
|
AxumPath(runtime_id): AxumPath<String>,
|
||||||
Json(mut request): Json<WorkerSpawnRequest>,
|
Json(mut request): Json<WorkerSpawnRequest>,
|
||||||
) -> ApiResult<Json<WorkerSpawnResult>> {
|
) -> ApiResult<Json<WorkerSpawnResult>> {
|
||||||
|
if request.working_directory_request.is_none() && request.resolved_working_directory.is_none() {
|
||||||
|
reject_no_workdir_for_non_embedded_runtime(&runtime_id)?;
|
||||||
|
}
|
||||||
request.resolved_working_directory_request = request
|
request.resolved_working_directory_request = request
|
||||||
.working_directory_request
|
.working_directory_request
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -3803,6 +3832,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
||||||
display_name: runtime.label,
|
display_name: runtime.label,
|
||||||
built_in,
|
built_in,
|
||||||
can_spawn_worker: runtime.capabilities.can_spawn_worker,
|
can_spawn_worker: runtime.capabilities.can_spawn_worker,
|
||||||
|
working_directory_required: !built_in,
|
||||||
status: runtime.status,
|
status: runtime.status,
|
||||||
diagnostics: runtime.diagnostics,
|
diagnostics: runtime.diagnostics,
|
||||||
}
|
}
|
||||||
|
|
@ -4735,6 +4765,7 @@ impl IntoResponse for ApiError {
|
||||||
|| code.starts_with("unsupported_worker_profile")
|
|| code.starts_with("unsupported_worker_profile")
|
||||||
|| code.starts_with("working_directory_")
|
|| code.starts_with("working_directory_")
|
||||||
|| code.starts_with("workspace_cleanup_")
|
|| code.starts_with("workspace_cleanup_")
|
||||||
|
|| code == "workspace_worker_workdir_required"
|
||||||
|| code.ends_with("_already_exists")
|
|| code.ends_with("_already_exists")
|
||||||
|| code.ends_with("_not_config_managed")
|
|| code.ends_with("_not_config_managed")
|
||||||
|| code.ends_with("_unsupported") =>
|
|| code.ends_with("_unsupported") =>
|
||||||
|
|
@ -6080,13 +6111,17 @@ mod tests {
|
||||||
);
|
);
|
||||||
|
|
||||||
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
|
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
|
||||||
assert!(
|
let runtimes = launch_options["runtimes"].as_array().unwrap();
|
||||||
launch_options["runtimes"]
|
let embedded_runtime = runtimes
|
||||||
.as_array()
|
.iter()
|
||||||
.unwrap()
|
.find(|runtime| runtime["runtime_id"] == EMBEDDED_WORKER_RUNTIME_ID)
|
||||||
.iter()
|
.expect("embedded runtime launch option");
|
||||||
.any(|runtime| runtime["runtime_id"] == "team-runtime")
|
assert_eq!(embedded_runtime["working_directory_required"], false);
|
||||||
);
|
let team_runtime = runtimes
|
||||||
|
.iter()
|
||||||
|
.find(|runtime| runtime["runtime_id"] == "team-runtime")
|
||||||
|
.expect("team runtime launch option");
|
||||||
|
assert_eq!(team_runtime["working_directory_required"], true);
|
||||||
|
|
||||||
let deleted = request_json(
|
let deleted = request_json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
|
|
@ -6137,18 +6172,6 @@ mod tests {
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(added["restart_required"], false);
|
assert_eq!(added["restart_required"], false);
|
||||||
let created = post_json(
|
|
||||||
app.clone(),
|
|
||||||
"/api/workers",
|
|
||||||
serde_json::json!({
|
|
||||||
"runtime_id": "busy-runtime",
|
|
||||||
"display_name": "Remote Test Worker",
|
|
||||||
"profile": "runtime_default",
|
|
||||||
"initial_text": ""
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(created["runtime_id"], "busy-runtime");
|
|
||||||
let workers = get_json(app.clone(), "/api/workers").await;
|
let workers = get_json(app.clone(), "/api/workers").await;
|
||||||
assert!(
|
assert!(
|
||||||
workers["items"]
|
workers["items"]
|
||||||
|
|
@ -6352,6 +6375,38 @@ mod tests {
|
||||||
assert!(!projected.contains("http://"));
|
assert!(!projected.contains("http://"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browser_worker_create_rejects_non_embedded_no_workdir() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let app = test_app(dir.path()).await;
|
||||||
|
let response = request_json(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/workers",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"runtime_id": "remote-runtime",
|
||||||
|
"display_name": "Remote Worker",
|
||||||
|
"profile": "runtime_default",
|
||||||
|
"initial_text": ""
|
||||||
|
})),
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("workspace_worker_workdir_required")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
response["diagnostics"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|diagnostic| { diagnostic["code"] == "workspace_worker_workdir_required" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_worker_spawn_rejects_raw_working_directory_fields() {
|
async fn runtime_worker_spawn_rejects_raw_working_directory_fields() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ export type WorkerLaunchRuntimeOption = {
|
||||||
display_name: string;
|
display_name: string;
|
||||||
built_in: boolean;
|
built_in: boolean;
|
||||||
can_spawn_worker: boolean;
|
can_spawn_worker: boolean;
|
||||||
|
working_directory_required: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const options: WorkerLaunchOptionsResponse = {
|
||||||
status: "active",
|
status: "active",
|
||||||
can_spawn_worker: true,
|
can_spawn_worker: true,
|
||||||
built_in: false,
|
built_in: false,
|
||||||
|
working_directory_required: true,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -32,7 +33,8 @@ const options: WorkerLaunchOptionsResponse = {
|
||||||
display_name: "Embedded",
|
display_name: "Embedded",
|
||||||
status: "active",
|
status: "active",
|
||||||
can_spawn_worker: true,
|
can_spawn_worker: true,
|
||||||
built_in: false,
|
built_in: true,
|
||||||
|
working_directory_required: false,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -107,3 +109,23 @@ Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relati
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("buildBrowserCreateWorkerRequest omits working_directory for embedded no-workdir launches", () => {
|
||||||
|
const request = buildBrowserCreateWorkerRequest({
|
||||||
|
runtime_id: "embedded",
|
||||||
|
display_name: "Worker",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
initial_text: "chat",
|
||||||
|
working_directory_id: "",
|
||||||
|
working_directory_repository_id: "",
|
||||||
|
working_directory_selector: "",
|
||||||
|
relative_cwd: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(request, {
|
||||||
|
runtime_id: "embedded",
|
||||||
|
display_name: "Worker",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
initial_text: "chat",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,14 @@
|
||||||
let relativeCwd = $state('');
|
let relativeCwd = $state('');
|
||||||
let creatingWorkingDirectory = $state(false);
|
let creatingWorkingDirectory = $state(false);
|
||||||
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
|
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
|
||||||
let canStartWorker = $derived(Boolean(runtimeId && profile && workingDirectoryId && !isNewWorkingDirectorySelected));
|
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));
|
||||||
|
let canStartWorker = $derived(Boolean(
|
||||||
|
runtimeId &&
|
||||||
|
profile &&
|
||||||
|
(hasSelectedExistingWorkdir || (selectedRuntimeAllowsNoWorkdir && !isNewWorkingDirectorySelected)),
|
||||||
|
));
|
||||||
|
|
||||||
function workerApiPath(path: string): string {
|
function workerApiPath(path: string): string {
|
||||||
return workspaceApiPath(workspaceId, path);
|
return workspaceApiPath(workspaceId, path);
|
||||||
|
|
@ -81,7 +88,7 @@
|
||||||
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 || NEW_WORKING_DIRECTORY_VALUE;
|
workingDirectoryId = form.working_directory_id;
|
||||||
workingDirectoryRepositoryId = form.working_directory_repository_id;
|
workingDirectoryRepositoryId = form.working_directory_repository_id;
|
||||||
workingDirectorySelector = form.working_directory_selector;
|
workingDirectorySelector = form.working_directory_selector;
|
||||||
relativeCwd = form.relative_cwd;
|
relativeCwd = form.relative_cwd;
|
||||||
|
|
@ -149,8 +156,8 @@
|
||||||
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
|
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isNewWorkingDirectorySelected || !workingDirectoryId) {
|
if (isNewWorkingDirectorySelected || (!workingDirectoryId && !selectedRuntimeAllowsNoWorkdir)) {
|
||||||
submitError = { message: 'select or create a workdir before starting a Worker', diagnostics: [] };
|
submitError = { message: 'select or create a workdir before starting a Worker; only embedded Runtime can start without one', diagnostics: [] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,7 +227,7 @@
|
||||||
<header class="worker-new-page-header">
|
<header class="worker-new-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 id="new-worker-heading">New Worker</h1>
|
<h1 id="new-worker-heading">New Worker</h1>
|
||||||
<p>Create a Worker on a selected Runtime and workdir.</p>
|
<p>Create a Worker on a selected Runtime. Workdir-less conversation Workers are only available on embedded Runtime.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
|
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -237,7 +244,11 @@
|
||||||
<div class="worker-launch-sentence">
|
<div class="worker-launch-sentence">
|
||||||
<span>Run at</span>
|
<span>Run at</span>
|
||||||
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir">
|
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir">
|
||||||
<option value="">Select workdir</option>
|
{#if selectedRuntimeAllowsNoWorkdir}
|
||||||
|
<option value="">No workdir · embedded conversation only</option>
|
||||||
|
{:else}
|
||||||
|
<option value="" disabled>Select workdir</option>
|
||||||
|
{/if}
|
||||||
{#each options?.working_directories ?? [] as directory}
|
{#each options?.working_directories ?? [] as directory}
|
||||||
<option value={directory.working_directory_id} disabled={directory.status !== 'active'}>
|
<option value={directory.working_directory_id} disabled={directory.status !== 'active'}>
|
||||||
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'}
|
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'}
|
||||||
|
|
@ -259,6 +270,12 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
{#if isNewWorkingDirectorySelected}
|
{#if isNewWorkingDirectorySelected}
|
||||||
<div class="new-working-directory-panel">
|
<div class="new-working-directory-panel">
|
||||||
<h3>New workdir</h3>
|
<h3>New workdir</h3>
|
||||||
|
|
@ -286,10 +303,12 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label class="relative-cwd-field">
|
{#if hasSelectedExistingWorkdir || isNewWorkingDirectorySelected}
|
||||||
<span>Relative cwd inside workdir</span>
|
<label class="relative-cwd-field">
|
||||||
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside workdir" />
|
<span>Relative cwd inside workdir</span>
|
||||||
</label>
|
<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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user