feat: add workspace profile settings

This commit is contained in:
2026-07-08 21:38:23 +09:00
parent c903beee69
commit 3eefd33334
12 changed files with 1909 additions and 23 deletions
+1
View File
@@ -392,6 +392,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
);
+30 -12
View File
@@ -307,6 +307,8 @@ pub struct WorkerSpawnRequest {
pub resolved_working_directory_request: Option<WorkingDirectoryRequest>,
#[serde(skip, default)]
pub resolved_working_directory: Option<WorkingDirectoryClaim>,
#[serde(skip, default)]
pub resolved_config_bundle: Option<ConfigBundle>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1348,12 +1350,18 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
.clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone());
let config_bundle = match default_embedded_config_bundle(
&profile,
&self.host_id,
runtime_id.as_ref(),
&self.resource_broker,
)
let config_bundle = match request
.resolved_config_bundle
.clone()
.map(Ok)
.unwrap_or_else(|| {
default_embedded_config_bundle(
&profile,
&self.host_id,
runtime_id.as_ref(),
&self.resource_broker,
)
})
.and_then(|bundle| {
self.runtime
.store_config_bundle(bundle)
@@ -2048,12 +2056,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone());
let sync = match default_embedded_config_bundle(
&profile,
&self.host_id,
runtime_id.as_ref(),
&self.resource_broker,
) {
let sync = match request
.resolved_config_bundle
.clone()
.map(Ok)
.unwrap_or_else(|| {
default_embedded_config_bundle(
&profile,
&self.host_id,
runtime_id.as_ref(),
&self.resource_broker,
)
}) {
Ok(bundle) => self.sync_config_bundle(bundle),
Err(error) => ConfigBundleSyncResult {
state: WorkerOperationState::Rejected,
@@ -3398,6 +3412,7 @@ mod tests {
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
}
}
@@ -3530,6 +3545,7 @@ mod tests {
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
)
.unwrap();
@@ -3632,6 +3648,7 @@ mod tests {
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
)
.unwrap();
@@ -3663,6 +3680,7 @@ mod tests {
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
)
.unwrap();
+1
View File
@@ -8,6 +8,7 @@ pub mod companion;
pub mod config;
pub mod hosts;
pub mod identity;
pub mod profile_settings;
pub mod observation;
pub mod records;
pub mod repositories;
File diff suppressed because it is too large Load Diff
+180 -9
View File
@@ -6,7 +6,7 @@ use axum::extract::{Path as AxumPath, Query, State};
use axum::http::header::{CONTENT_TYPE, LOCATION};
use axum::http::{StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::routing::{delete, get, post, put};
use axum::{Json, Router};
use chrono::{SecondsFormat, Utc};
use futures::StreamExt;
@@ -36,6 +36,11 @@ use crate::observation::{
BackendObservationProxy, ClientWorkerEventWsFrame, ClientWorkerEventsWsQuery,
ObservationProxyError, RuntimeObservationClient, RuntimeObservationSourceConfig,
};
use crate::profile_settings::{
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
UpdateWorkspaceProfileSourceRequest,
};
use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
};
@@ -264,6 +269,24 @@ pub fn build_router(api: WorkspaceApi) -> Router {
Router::new()
.route("/api/workspace", get(get_workspace))
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
.route(
"/api/w/{workspace_id}/settings/workspace",
get(scoped_get_workspace_settings).put(scoped_update_workspace_settings),
)
.route(
"/api/w/{workspace_id}/settings/profiles",
get(scoped_get_profile_settings).post(scoped_create_profile_source),
)
.route(
"/api/w/{workspace_id}/settings/profiles/registry",
put(scoped_update_profile_registry),
)
.route(
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
get(scoped_get_profile_source)
.put(scoped_update_profile_source)
.delete(scoped_delete_profile_source),
)
.route("/api/tickets", get(list_tickets))
.route("/api/w/{workspace_id}/tickets", get(scoped_list_tickets))
.route("/api/tickets/{id}", get(get_ticket))
@@ -814,6 +837,113 @@ async fn scoped_get_workspace(
get_workspace(State(api)).await
}
async fn scoped_get_workspace_settings(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceMetadataSettingsResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(crate::profile_settings::workspace_metadata_settings(
&api.config.workspace_root,
&api.config.workspace_id,
&api.config.workspace_created_at,
&api.config.workspace_display_name,
)))
}
async fn scoped_update_workspace_settings(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<UpdateWorkspaceMetadataRequest>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceMetadataMutationResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let workspace = crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?;
Ok(Json(crate::profile_settings::WorkspaceMetadataMutationResponse {
workspace,
diagnostics: vec![RuntimeDiagnostic {
code: "workspace_metadata_updated".to_string(),
severity: DiagnosticSeverity::Info,
message: "Workspace display metadata was updated.".to_string(),
}],
}))
}
async fn scoped_get_profile_settings(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(crate::profile_settings::load_profile_settings(
&api.config.workspace_id,
&api.config.workspace_root,
)))
}
async fn scoped_create_profile_source(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<CreateWorkspaceProfileSourceRequest>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(crate::profile_settings::create_profile_source(
&api.config.workspace_id,
&api.config.workspace_root,
request,
)?))
}
async fn scoped_update_profile_registry(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<UpdateWorkspaceProfileRegistryRequest>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(crate::profile_settings::update_profile_registry(
&api.config.workspace_id,
&api.config.workspace_root,
request,
)?))
}
async fn scoped_get_profile_source(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceDetailResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::read_profile_source(
&api.config.workspace_id,
&api.config.workspace_root,
&profile_source_id,
)?))
}
async fn scoped_update_profile_source(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
Json(request): Json<UpdateWorkspaceProfileSourceRequest>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::update_profile_source(
&api.config.workspace_id,
&api.config.workspace_root,
&profile_source_id,
request,
)?))
}
async fn scoped_delete_profile_source(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
Json(request): Json<DeleteWorkspaceProfileSourceRequest>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::delete_profile_source(
&api.config.workspace_id,
&api.config.workspace_root,
&profile_source_id,
request,
)?))
}
async fn scoped_list_tickets(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -1639,12 +1769,22 @@ async fn create_workspace_worker(
State(api): State<WorkspaceApi>,
Json(request): Json<BrowserCreateWorkerRequest>,
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
let profile_selector = profile_selector_for_candidate(&request.profile).ok_or_else(|| {
let profile_selector = profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile).ok_or_else(|| {
settings_bad_request(
"unsupported_worker_profile",
"profile must be selected from Backend-published worker profile candidates",
)
})?;
let resolved_config_bundle = if request.profile.starts_with("project:") {
crate::profile_settings::build_workspace_profile_config_bundle(
&api.config.workspace_root,
&api.config.workspace_id,
&api.config.workspace_created_at,
&request.profile,
)?
} else {
None
};
let display_name = sanitize_worker_display_name(&request.display_name).ok_or_else(|| {
settings_bad_request(
"invalid_worker_display_name",
@@ -1702,6 +1842,7 @@ async fn create_workspace_worker(
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory,
resolved_config_bundle,
},
)
.map_err(|err| err.into_error())?;
@@ -2753,7 +2894,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
WorkerLaunchOptionsResponse {
workspace_id: api.config.workspace_id.clone(),
runtimes,
profiles: worker_profile_candidates(),
profiles: worker_profile_candidates_for_root(&api.config.workspace_root),
repositories: working_directory_repository_options(api),
working_directories: working_directory_summaries(api).unwrap_or_default(),
diagnostics: Vec::new(),
@@ -2823,8 +2964,16 @@ fn working_directory_request_for_browser(
})
}
#[cfg(test)]
fn worker_profile_candidates() -> Vec<WorkerLaunchProfileCandidate> {
vec![
worker_profile_candidates_for_root(Path::new("."))
.into_iter()
.filter(|candidate| candidate.id == "builtin:coder" || candidate.id == "runtime_default")
.collect()
}
fn worker_profile_candidates_for_root(workspace_root: &Path) -> Vec<WorkerLaunchProfileCandidate> {
let mut candidates = vec![
WorkerLaunchProfileCandidate {
id: "builtin:coder".to_string(),
label: "Coding Worker".to_string(),
@@ -2835,14 +2984,35 @@ fn worker_profile_candidates() -> Vec<WorkerLaunchProfileCandidate> {
label: "Runtime default".to_string(),
description: "Use the selected Runtime's default profile.".to_string(),
},
]
];
candidates.extend(
crate::profile_settings::project_profile_candidates(workspace_root)
.into_iter()
.filter(|profile| profile.diagnostics.is_empty())
.map(|profile| WorkerLaunchProfileCandidate {
id: profile.profile_id,
label: profile.label,
description: profile
.description
.unwrap_or_else(|| "Workspace Decodal profile source.".to_string()),
}),
);
candidates
}
fn profile_selector_for_candidate(profile: &str) -> Option<ProfileSelector> {
match profile {
"builtin:coder" => Some(ProfileSelector::Builtin("builtin:coder".to_string())),
"runtime_default" => Some(ProfileSelector::RuntimeDefault),
_ => None,
crate::profile_settings::selector_for_builtin_candidate(profile).filter(|_| {
matches!(profile, "builtin:coder" | "runtime_default")
})
}
fn profile_selector_for_candidate_with_root(workspace_root: &Path, profile: &str) -> Option<ProfileSelector> {
if profile_selector_for_candidate(profile).is_some() {
profile_selector_for_candidate(profile)
} else if crate::profile_settings::is_profile_candidate(workspace_root, profile) {
crate::profile_settings::selector_for_builtin_candidate(profile)
} else {
None
}
}
@@ -4599,6 +4769,7 @@ mod tests {
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
)
.expect("spawn worker");